chore: checkpoint current PMFlow workspace

This commit is contained in:
Albert 2026-04-26 09:07:16 +08:00
parent b52428f79e
commit e04ea4d1ee
260 changed files with 33460 additions and 1676 deletions

View File

@ -11,6 +11,7 @@ DB_PORT=5435
REDIS_PORT=6379 REDIS_PORT=6379
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=5174 FRONTEND_PORT=5174
FRONTEND_PROD_PORT=5175
PMFLOW_EXPECTED_DB_DIALECT=postgresql PMFLOW_EXPECTED_DB_DIALECT=postgresql
# Backend Runtime # Backend Runtime

12
.gitignore vendored
View File

@ -71,3 +71,15 @@ Pixelle-Video/
# Playwright browser profiles # Playwright browser profiles
frontend/.playwright-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

View File

@ -0,0 +1,496 @@
"""add ai project control tables
Revision ID: 20260421_add_ai_project_control_tables
Revises: 20260415_add_semantic_facts
Create Date: 2026-04-21
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260421_add_ai_project_control_tables"
down_revision = "20260415_add_semantic_facts"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _seed_model_catalog(bind) -> None:
table = sa.table(
"model_catalog_items",
sa.column("tenant_id", sa.BigInteger()),
sa.column("provider", sa.String()),
sa.column("model_key", sa.String()),
sa.column("display_name", sa.String()),
sa.column("version", sa.String()),
sa.column("modalities", _json_type(bind)),
sa.column("capability_tags", _json_type(bind)),
sa.column("strengths", _json_type(bind)),
sa.column("limitations", _json_type(bind)),
sa.column("compliance_status", sa.String()),
sa.column("filing_status", sa.String()),
sa.column("input_cost_per_mtok", sa.Numeric(12, 4)),
sa.column("output_cost_per_mtok", sa.Numeric(12, 4)),
sa.column("official_docs_url", sa.Text()),
sa.column("is_active", sa.Boolean()),
)
op.bulk_insert(
table,
[
{
"tenant_id": 1,
"provider": "阿里云",
"model_key": "qwen-plus",
"display_name": "通义千问 Plus",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "agent", "code"],
"strengths": ["中文通用能力强", "RAG 适配稳定", "工具调用成熟"],
"limitations": ["不适合视频生成"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 0.8,
"output_cost_per_mtok": 2.0,
"official_docs_url": "https://help.aliyun.com/zh/model-studio/",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "火山引擎",
"model_key": "doubao-pro",
"display_name": "豆包 Pro",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
"strengths": ["长文本稳", "中文体验好", "成本适中"],
"limitations": ["代码生成弱于专用模型"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 0.8,
"output_cost_per_mtok": 2.0,
"official_docs_url": "https://www.volcengine.com/product/doubao",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "百度智能云",
"model_key": "ernie-4.0-turbo",
"display_name": "文心一言 4.0 Turbo",
"version": "latest",
"modalities": ["text", "image"],
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
"strengths": ["企业落地常见", "多模态均衡"],
"limitations": ["综合成本偏高"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 1.2,
"output_cost_per_mtok": 3.2,
"official_docs_url": "https://cloud.baidu.com/product/wenxinworkshop",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "DeepSeek",
"model_key": "deepseek-chat",
"display_name": "DeepSeek Chat",
"version": "latest",
"modalities": ["text"],
"capability_tags": ["general", "reasoning", "code", "agent"],
"strengths": ["代码与推理性价比高", "适合技术团队"],
"limitations": ["需要留意合规和交付边界"],
"compliance_status": "review_required",
"filing_status": "unknown",
"input_cost_per_mtok": 0.27,
"output_cost_per_mtok": 1.1,
"official_docs_url": "https://platform.deepseek.com/",
"is_active": True,
},
{
"tenant_id": 1,
"provider": "Moonshot AI",
"model_key": "kimi-k2",
"display_name": "Kimi K2",
"version": "latest",
"modalities": ["text"],
"capability_tags": ["general", "long_context", "rag", "reasoning"],
"strengths": ["长上下文强", "资料理解稳定"],
"limitations": ["对多模态场景支持有限"],
"compliance_status": "ready",
"filing_status": "filed",
"input_cost_per_mtok": 1.0,
"output_cost_per_mtok": 3.0,
"official_docs_url": "https://platform.moonshot.cn/",
"is_active": True,
},
],
)
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if not _table_exists(bind, "model_catalog_items"):
op.create_table(
"model_catalog_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("provider", sa.String(length=120), nullable=False),
sa.Column("model_key", sa.String(length=120), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=False),
sa.Column("version", sa.String(length=120), nullable=False),
sa.Column("modalities", json_type, nullable=False),
sa.Column("capability_tags", json_type, nullable=False),
sa.Column("strengths", json_type, nullable=False),
sa.Column("limitations", json_type, nullable=False),
sa.Column("compliance_status", sa.String(length=50), nullable=False, server_default="review_required"),
sa.Column("filing_status", sa.String(length=50), nullable=False, server_default="unknown"),
sa.Column("input_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
sa.Column("output_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
sa.Column("official_docs_url", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.UniqueConstraint(
"tenant_id",
"provider",
"model_key",
"version",
name="uq_model_catalog_items_tenant_provider_key_version",
),
)
op.create_index("ix_model_catalog_items_tenant_id", "model_catalog_items", ["tenant_id"], unique=False)
op.create_index("ix_model_catalog_items_provider", "model_catalog_items", ["provider"], unique=False)
op.create_index("ix_model_catalog_items_model_key", "model_catalog_items", ["model_key"], unique=False)
op.create_index("ix_model_catalog_items_is_active", "model_catalog_items", ["is_active"], unique=False)
_seed_model_catalog(bind)
table_specs = {
"project_model_bindings": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=False),
sa.Column("selected_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("selection_reason", json_type, nullable=False),
sa.Column("estimated_daily_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("estimated_monthly_cost_usd", sa.DECIMAL(12, 2), nullable=True),
sa.Column("active_from", sa.TIMESTAMP(), nullable=True),
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
],
"model_recommendation_snapshots": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("recommendation_scope", sa.String(length=32), nullable=False, server_default="charter"),
sa.Column("snapshot_input", json_type, nullable=False),
sa.Column("recommendations", json_type, nullable=False),
sa.Column("top_model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=True),
sa.Column("generated_by", sa.String(length=32), nullable=False, server_default="rule_engine"),
],
"model_usage_events": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("event_key", sa.String(length=255), nullable=True),
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cost_usd", sa.DECIMAL(12, 4), nullable=False, server_default="0"),
sa.Column("event_payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
],
"model_eval_runs": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("success_criteria", json_type, nullable=False),
sa.Column("test_cases", json_type, nullable=False),
sa.Column("summary", json_type, nullable=False),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
],
"prompt_packages": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=False),
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="development"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("package_text", sa.Text(), nullable=False),
sa.Column("package_payload", json_type, nullable=False),
sa.Column("generated_from", json_type, nullable=False),
],
"dev_workspace_bindings": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("workspace_key", sa.String(length=255), nullable=False),
sa.Column("repo_root", sa.Text(), nullable=True),
sa.Column("editor_type", sa.String(length=32), nullable=False, server_default="vscode"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
],
"repo_bindings": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False, server_default="github"),
sa.Column("external_repo_id", sa.String(length=255), nullable=True),
sa.Column("owner", sa.String(length=255), nullable=False),
sa.Column("repo_name", sa.String(length=255), nullable=False),
sa.Column("default_branch", sa.String(length=255), nullable=False, server_default="main"),
sa.Column("installation_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("webhook_secret_hint", sa.String(length=255), nullable=True),
sa.UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
],
"task_code_links": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=False),
sa.Column("branch_name", sa.String(length=255), nullable=True),
sa.Column("pr_number", sa.Integer(), nullable=True),
sa.Column("last_commit_sha", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="linked"),
sa.UniqueConstraint(
"tenant_id",
"task_id",
"repo_binding_id",
"branch_name",
name="uq_task_code_links_tenant_task_repo_branch",
),
],
"progress_events": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
sa.Column("event_type", sa.String(length=32), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("derived_task_status", sa.String(length=32), nullable=True),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
],
"dev_sessions": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("heartbeat_at", sa.TIMESTAMP(), nullable=True),
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
sa.Column("session_payload", json_type, nullable=False),
],
"release_validation_runs": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
sa.Column("key_metrics", json_type, nullable=False),
sa.Column("risk_items", json_type, nullable=False),
sa.Column("rollback_plan", json_type, nullable=False),
sa.Column("validation_summary", json_type, nullable=False),
],
"feedback_ingests": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("ingested_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("metrics", json_type, nullable=False),
sa.Column("samples", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
],
"iteration_recommendations": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("feedback_ingest_id", sa.BigInteger(), sa.ForeignKey("feedback_ingests.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
sa.Column("recommendation_summary", json_type, nullable=False),
sa.Column("proposed_charter_patch", json_type, nullable=False),
sa.Column("proposed_tasks", json_type, nullable=False),
],
"revenue_events": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column("currency", sa.String(length=16), nullable=False, server_default="USD"),
sa.Column("amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("external_ref", sa.String(length=255), nullable=True),
sa.Column("event_payload", json_type, nullable=False),
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
],
"roi_snapshots": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("range_key", sa.String(length=32), nullable=False, server_default="monthly"),
sa.Column("window_start", sa.TIMESTAMP(), nullable=True),
sa.Column("window_end", sa.TIMESTAMP(), nullable=True),
sa.Column("total_revenue_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("total_cost_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
sa.Column("roi_ratio", sa.DECIMAL(12, 4), nullable=True),
sa.Column("snapshot_payload", json_type, nullable=False),
],
"compliance_check_runs": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
sa.Column("checklist", json_type, nullable=False),
sa.Column("issues", json_type, nullable=False),
sa.Column("summary", json_type, nullable=False),
],
"evidence_packs": [
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("compliance_check_run_id", sa.BigInteger(), sa.ForeignKey("compliance_check_runs.id"), nullable=True),
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="compliance_release"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("content", json_type, nullable=False),
sa.Column("export_payload", json_type, nullable=False),
],
}
for table_name, columns in table_specs.items():
if _table_exists(bind, table_name):
continue
op.create_table(table_name, *columns)
op.create_index(f"ix_{table_name}_tenant_id", table_name, ["tenant_id"], unique=False)
extra_indexes = {
"project_model_bindings": ["project_id", "charter_id", "model_catalog_item_id", "status"],
"model_recommendation_snapshots": ["project_id", "charter_id"],
"model_usage_events": ["project_id", "project_model_binding_id", "source_type", "occurred_at"],
"model_eval_runs": ["project_id", "charter_id", "project_model_binding_id", "status"],
"prompt_packages": ["project_id", "charter_id", "task_id", "project_model_binding_id", "status"],
"dev_workspace_bindings": ["project_id", "user_id", "status"],
"repo_bindings": ["project_id", "provider", "status"],
"task_code_links": ["task_id", "repo_binding_id", "pr_number", "status"],
"progress_events": ["task_id", "project_id", "user_id", "repo_binding_id", "workspace_binding_id", "event_type", "source", "occurred_at"],
"dev_sessions": ["project_id", "user_id", "workspace_binding_id", "status"],
"release_validation_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
"feedback_ingests": ["project_id", "charter_id", "ingested_by_user_id", "source_type", "status"],
"iteration_recommendations": ["project_id", "charter_id", "feedback_ingest_id", "status", "priority"],
"revenue_events": ["project_id", "source_type", "external_ref", "occurred_at"],
"roi_snapshots": ["project_id", "range_key"],
"compliance_check_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
"evidence_packs": ["project_id", "compliance_check_run_id", "package_kind", "status"],
}
for table_name, columns in extra_indexes.items():
for column_name in columns:
op.create_index(f"ix_{table_name}_{column_name}", table_name, [column_name], unique=False)
def downgrade() -> None:
bind = op.get_bind()
tables = [
"evidence_packs",
"compliance_check_runs",
"roi_snapshots",
"revenue_events",
"iteration_recommendations",
"feedback_ingests",
"release_validation_runs",
"dev_sessions",
"progress_events",
"task_code_links",
"repo_bindings",
"dev_workspace_bindings",
"prompt_packages",
"model_eval_runs",
"model_usage_events",
"model_recommendation_snapshots",
"project_model_bindings",
"model_catalog_items",
]
for table_name in tables:
if not _table_exists(bind, table_name):
continue
for index in sa.inspect(bind).get_indexes(table_name):
op.drop_index(index["name"], table_name=table_name)
op.drop_table(table_name)

View File

@ -0,0 +1,174 @@
"""finalize v1 productization
Revision ID: 20260421_finalize_v1_productization
Revises: 20260421_add_ai_project_control_tables
Create Date: 2026-04-21
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260421_finalize_v1_productization"
down_revision = "20260421_add_ai_project_control_tables"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
if not _table_exists(bind, table_name):
return False
return any(column.get("name") == column_name for column in sa.inspect(bind).get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if _table_exists(bind, "users") and _column_exists(bind, "users", "role"):
op.execute("UPDATE users SET role = 'OWNER' WHERE UPPER(role) = 'BOSS'")
op.execute("UPDATE users SET role = 'REVIEWER' WHERE UPPER(role) = 'ADMIN'")
op.execute("UPDATE users SET role = 'BUILDER' WHERE UPPER(role) IN ('AI', 'FE', 'BE')")
if _table_exists(bind, "revenue_events") and not _column_exists(bind, "revenue_events", "category"):
op.add_column(
"revenue_events",
sa.Column("category", sa.String(length=32), nullable=False, server_default="other"),
)
op.create_index("ix_revenue_events_category", "revenue_events", ["category"], unique=False)
if _table_exists(bind, "repo_bindings") and not _column_exists(bind, "repo_bindings", "installation_account_login"):
op.add_column(
"repo_bindings",
sa.Column("installation_account_login", sa.String(length=255), nullable=True),
)
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "delivery_id"):
op.add_column("progress_events", sa.Column("delivery_id", sa.String(length=255), nullable=True))
op.create_index("ix_progress_events_delivery_id", "progress_events", ["delivery_id"], unique=False)
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "event_source_id"):
op.add_column("progress_events", sa.Column("event_source_id", sa.String(length=255), nullable=True))
op.create_index("ix_progress_events_event_source_id", "progress_events", ["event_source_id"], unique=False)
if not _table_exists(bind, "github_app_installations"):
op.create_table(
"github_app_installations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("installation_id", sa.String(length=255), nullable=False),
sa.Column("account_login", sa.String(length=255), nullable=False),
sa.Column("account_type", sa.String(length=64), nullable=True),
sa.Column("app_slug", sa.String(length=255), nullable=True),
sa.Column("target_type", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("permissions", json_type, nullable=False),
sa.Column("events", json_type, nullable=False),
sa.Column("metadata", json_type, nullable=False),
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
)
op.create_index("ix_github_app_installations_tenant_id", "github_app_installations", ["tenant_id"], unique=False)
op.create_index("ix_github_app_installations_installation_id", "github_app_installations", ["installation_id"], unique=False)
op.create_index("ix_github_app_installations_account_login", "github_app_installations", ["account_login"], unique=False)
op.create_index("ix_github_app_installations_status", "github_app_installations", ["status"], unique=False)
if not _table_exists(bind, "github_app_repositories"):
op.create_table(
"github_app_repositories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("installation_id", sa.String(length=255), nullable=False),
sa.Column("external_repo_id", sa.String(length=255), nullable=False),
sa.Column("owner", sa.String(length=255), nullable=False),
sa.Column("repo_name", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("default_branch", sa.String(length=255), nullable=True),
sa.Column("is_private", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("permissions", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint(
"tenant_id",
"installation_id",
"external_repo_id",
name="uq_github_app_repositories_repo",
),
)
op.create_index("ix_github_app_repositories_tenant_id", "github_app_repositories", ["tenant_id"], unique=False)
op.create_index("ix_github_app_repositories_installation_id", "github_app_repositories", ["installation_id"], unique=False)
op.create_index("ix_github_app_repositories_external_repo_id", "github_app_repositories", ["external_repo_id"], unique=False)
op.create_index("ix_github_app_repositories_owner", "github_app_repositories", ["owner"], unique=False)
op.create_index("ix_github_app_repositories_repo_name", "github_app_repositories", ["repo_name"], unique=False)
op.create_index("ix_github_app_repositories_full_name", "github_app_repositories", ["full_name"], unique=False)
op.create_index("ix_github_app_repositories_status", "github_app_repositories", ["status"], unique=False)
if not _table_exists(bind, "github_webhook_deliveries"):
op.create_table(
"github_webhook_deliveries",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("delivery_id", sa.String(length=255), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("installation_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="received"),
sa.Column("failure_reason", sa.Text(), nullable=True),
sa.Column("payload", json_type, nullable=False),
sa.Column("received_at", sa.TIMESTAMP(), nullable=True),
sa.UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
)
op.create_index("ix_github_webhook_deliveries_tenant_id", "github_webhook_deliveries", ["tenant_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_delivery_id", "github_webhook_deliveries", ["delivery_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_event_type", "github_webhook_deliveries", ["event_type"], unique=False)
op.create_index("ix_github_webhook_deliveries_repo_binding_id", "github_webhook_deliveries", ["repo_binding_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_project_id", "github_webhook_deliveries", ["project_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_installation_id", "github_webhook_deliveries", ["installation_id"], unique=False)
op.create_index("ix_github_webhook_deliveries_status", "github_webhook_deliveries", ["status"], unique=False)
op.create_index("ix_github_webhook_deliveries_received_at", "github_webhook_deliveries", ["received_at"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
if _table_exists(bind, "github_webhook_deliveries"):
op.drop_table("github_webhook_deliveries")
if _table_exists(bind, "github_app_repositories"):
op.drop_table("github_app_repositories")
if _table_exists(bind, "github_app_installations"):
op.drop_table("github_app_installations")
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "event_source_id"):
op.drop_index("ix_progress_events_event_source_id", table_name="progress_events")
op.drop_column("progress_events", "event_source_id")
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "delivery_id"):
op.drop_index("ix_progress_events_delivery_id", table_name="progress_events")
op.drop_column("progress_events", "delivery_id")
if _table_exists(bind, "repo_bindings") and _column_exists(bind, "repo_bindings", "installation_account_login"):
op.drop_column("repo_bindings", "installation_account_login")
if _table_exists(bind, "revenue_events") and _column_exists(bind, "revenue_events", "category"):
op.drop_index("ix_revenue_events_category", table_name="revenue_events")
op.drop_column("revenue_events", "category")

View File

@ -0,0 +1,150 @@
"""add pmflow launch tables
Revision ID: 20260425_add_pmflow_launch_tables
Revises: 20260421_finalize_v1_productization
Create Date: 2026-04-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260425_add_pmflow_launch_tables"
down_revision = "20260421_finalize_v1_productization"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if not _table_exists(bind, "pmflow_launch_drafts"):
op.create_table(
"pmflow_launch_drafts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("draft_id", sa.String(length=64), nullable=False),
sa.Column("input_text", sa.Text(), nullable=False),
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
sa.Column("attachments", json_type, nullable=False),
sa.Column("draft_payload", json_type, nullable=False),
sa.Column("edited_payload", json_type, nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("warnings", json_type, nullable=False),
sa.Column("confirmed_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
sa.Column("confirmed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("confirm_request_hash", sa.String(length=128), nullable=True),
sa.UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
)
op.create_index("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts", ["user_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts", ["draft_id"], unique=False)
op.create_index("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts", ["status"], unique=False)
op.create_index(
"ix_pmflow_launch_drafts_confirmed_project_id",
"pmflow_launch_drafts",
["confirmed_project_id"],
unique=False,
)
op.create_index("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts", ["expires_at"], unique=False)
if not _table_exists(bind, "pmflow_threads"):
op.create_table(
"pmflow_threads",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("objective", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
sa.UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
)
op.create_index("ix_pmflow_threads_tenant_id", "pmflow_threads", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_threads_project_id", "pmflow_threads", ["project_id"], unique=False)
op.create_index("ix_pmflow_threads_launch_draft_id", "pmflow_threads", ["launch_draft_id"], unique=False)
op.create_index("ix_pmflow_threads_status", "pmflow_threads", ["status"], unique=False)
op.create_index("ix_pmflow_threads_source", "pmflow_threads", ["source"], unique=False)
if not _table_exists(bind, "pmflow_thread_messages"):
op.create_table(
"pmflow_thread_messages",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
sa.Column("role", sa.String(length=32), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
sa.Column("attachments", json_type, nullable=False),
sa.Column("message_metadata", json_type, nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="sent"),
)
op.create_index("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages", ["thread_id"], unique=False)
op.create_index("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages", ["project_id"], unique=False)
op.create_index(
"ix_pmflow_thread_messages_launch_draft_id",
"pmflow_thread_messages",
["launch_draft_id"],
unique=False,
)
op.create_index("ix_pmflow_thread_messages_status", "pmflow_thread_messages", ["status"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
for index_name, table_name in (
("ix_pmflow_thread_messages_status", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_launch_draft_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages"),
("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages"),
("ix_pmflow_threads_source", "pmflow_threads"),
("ix_pmflow_threads_status", "pmflow_threads"),
("ix_pmflow_threads_launch_draft_id", "pmflow_threads"),
("ix_pmflow_threads_project_id", "pmflow_threads"),
("ix_pmflow_threads_tenant_id", "pmflow_threads"),
("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_confirmed_project_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts"),
("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts"),
):
if _table_exists(bind, table_name):
try:
op.drop_index(index_name, table_name=table_name)
except Exception:
pass
for table_name in ("pmflow_thread_messages", "pmflow_threads", "pmflow_launch_drafts"):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -0,0 +1,173 @@
"""add pmflow change review tables
Revision ID: 20260426_add_pmflow_change_review_tables
Revises: 20260425_add_pmflow_launch_tables
Create Date: 2026-04-26
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260426_add_pmflow_change_review_tables"
down_revision = "20260425_add_pmflow_launch_tables"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def upgrade() -> None:
bind = op.get_bind()
json_type = _json_type(bind)
if not _table_exists(bind, "pmflow_change_sets"):
op.create_table(
"pmflow_change_sets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
sa.Column("source_message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("headline", sa.String(length=255), nullable=False),
sa.Column("extraction_scope", sa.String(length=64), nullable=False, server_default="latest_message"),
sa.Column("routing_meta", json_type, nullable=False),
sa.Column("llm_call_id", sa.String(length=128), nullable=True),
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
sa.Column("committed_at", sa.TIMESTAMP(), nullable=True),
)
op.create_index("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_change_sets_project_id", "pmflow_change_sets", ["project_id"], unique=False)
op.create_index("ix_pmflow_change_sets_thread_id", "pmflow_change_sets", ["thread_id"], unique=False)
op.create_index("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets", ["source_message_id"], unique=False)
op.create_index("ix_pmflow_change_sets_status", "pmflow_change_sets", ["status"], unique=False)
op.create_index("ix_pmflow_change_sets_created_by", "pmflow_change_sets", ["created_by"], unique=False)
if not _table_exists(bind, "pmflow_change_items"):
op.create_table(
"pmflow_change_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("group", sa.String(length=32), nullable=False),
sa.Column("object_type", sa.String(length=64), nullable=False),
sa.Column("object_id", sa.String(length=128), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("diff", sa.Text(), nullable=False),
sa.Column("payload", json_type, nullable=False),
sa.Column("edited_payload", json_type, nullable=True),
sa.Column("confidence", sa.Float(), nullable=True),
sa.Column("risk_level", sa.String(length=16), nullable=False, server_default="medium"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_review"),
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
)
op.create_index("ix_pmflow_change_items_tenant_id", "pmflow_change_items", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_change_items_change_set_id", "pmflow_change_items", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_change_items_project_id", "pmflow_change_items", ["project_id"], unique=False)
op.create_index("ix_pmflow_change_items_status", "pmflow_change_items", ["status"], unique=False)
if not _table_exists(bind, "pmflow_approvals"):
op.create_table(
"pmflow_approvals",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=False),
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("action", sa.String(length=32), nullable=False),
sa.Column("decision", sa.String(length=32), nullable=False),
sa.Column("before_status", sa.String(length=32), nullable=False),
sa.Column("after_status", sa.String(length=32), nullable=False),
sa.Column("before_payload", json_type, nullable=False),
sa.Column("after_payload", json_type, nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
)
op.create_index("ix_pmflow_approvals_tenant_id", "pmflow_approvals", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_approvals_project_id", "pmflow_approvals", ["project_id"], unique=False)
op.create_index("ix_pmflow_approvals_change_set_id", "pmflow_approvals", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_approvals_change_item_id", "pmflow_approvals", ["change_item_id"], unique=False)
op.create_index("ix_pmflow_approvals_actor_user_id", "pmflow_approvals", ["actor_user_id"], unique=False)
if not _table_exists(bind, "pmflow_evidence_links"):
op.create_table(
"pmflow_evidence_links",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=False),
sa.Column("source_id", sa.String(length=128), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("excerpt", sa.Text(), nullable=False),
sa.Column("display_label", sa.String(length=255), nullable=False),
sa.Column("evidence_metadata", json_type, nullable=False),
)
op.create_index("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links", ["tenant_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links", ["project_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links", ["change_set_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links", ["change_item_id"], unique=False)
op.create_index("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links", ["source_type"], unique=False)
op.create_index("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links", ["source_id"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
for index_name, table_name in (
("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links"),
("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links"),
("ix_pmflow_approvals_actor_user_id", "pmflow_approvals"),
("ix_pmflow_approvals_change_item_id", "pmflow_approvals"),
("ix_pmflow_approvals_change_set_id", "pmflow_approvals"),
("ix_pmflow_approvals_project_id", "pmflow_approvals"),
("ix_pmflow_approvals_tenant_id", "pmflow_approvals"),
("ix_pmflow_change_items_status", "pmflow_change_items"),
("ix_pmflow_change_items_project_id", "pmflow_change_items"),
("ix_pmflow_change_items_change_set_id", "pmflow_change_items"),
("ix_pmflow_change_items_tenant_id", "pmflow_change_items"),
("ix_pmflow_change_sets_created_by", "pmflow_change_sets"),
("ix_pmflow_change_sets_status", "pmflow_change_sets"),
("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_thread_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_project_id", "pmflow_change_sets"),
("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets"),
):
if _table_exists(bind, table_name):
try:
op.drop_index(index_name, table_name=table_name)
except Exception:
pass
for table_name in ("pmflow_evidence_links", "pmflow_approvals", "pmflow_change_items", "pmflow_change_sets"):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -24,7 +24,7 @@ async def get_executive_briefing(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response() return _forbidden_response()
try: try:
@ -40,7 +40,7 @@ async def get_executive_briefing_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response() return _forbidden_response()
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config() payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
return success_response(payload) return success_response(payload)
@ -53,7 +53,7 @@ async def update_executive_briefing_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}): if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
return _forbidden_response() return _forbidden_response()
service = ExecutiveBriefingService(db, tenant_id=tenant_id) service = ExecutiveBriefingService(db, tenant_id=tenant_id)

View File

@ -15,7 +15,7 @@ from ...services.auth import parse_role_values
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
from .shared import * from .shared import *
MANAGER_ROLES = {"ADMIN", "BOSS"} MANAGER_ROLES = {"REVIEWER", "OWNER"}
def _is_manager_user(current_user: User) -> bool: def _is_manager_user(current_user: User) -> bool:

View File

@ -42,8 +42,8 @@ async def list_audit_operations(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
header_tenant_id: int = Depends(get_tenant_id), header_tenant_id: int = Depends(get_tenant_id),
): ):
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]): 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: BOSS, ADMIN") raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
if current_user is None: if current_user is None:
effective_tenant_id = int(header_tenant_id or 1) effective_tenant_id = int(header_tenant_id or 1)
else: else:

View File

@ -80,7 +80,7 @@ async def list_budgets(
@router.post("/") @router.post("/")
async def create_budget( async def create_budget(
request: BudgetCreateRequest, request: BudgetCreateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@ -122,7 +122,7 @@ async def create_budget(
async def update_budget( async def update_budget(
budget_id: int, budget_id: int,
request: BudgetUpdateRequest, request: BudgetUpdateRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@ -163,7 +163,7 @@ async def update_budget(
async def override_budget( async def override_budget(
budget_id: int, budget_id: int,
request: BudgetOverrideRequest, request: BudgetOverrideRequest,
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):

302
backend/app/api/dev_sync.py Normal file
View File

@ -0,0 +1,302 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db
from ..core.response import success_response
from ..dependencies import get_current_user
from ..models import Project, User, UserProject
from ..services.ai_project_control_service import AIProjectControlService
from ..services.auth import has_any_role
router = APIRouter(tags=["dev-sync"])
class RepoBindingCreateRequest(BaseModel):
project_id: int
provider: str = "github"
owner: str
repo_name: str
default_branch: str = "main"
external_repo_id: str | None = None
installation_id: str | None = None
installation_account_login: str | None = None
webhook_secret_hint: str | None = None
class RepoBindingSelectRequest(BaseModel):
project_id: int
github_repository_id: int
class ProgressEventCreateRequest(BaseModel):
project_id: int
event_type: str
source: str = "manual"
summary: str | None = None
repo_binding_id: int | None = None
workspace_binding_id: int | None = None
payload: dict[str, object] | None = None
occurred_at: str | None = None
@router.get("/integrations/github/app/install-url")
async def get_github_app_install_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.get_github_app_install_center()
return success_response(
{
"install_url": payload.get("install_url"),
"configured": bool(payload.get("configured")),
"callback_url": payload.get("callback_url"),
"webhook_signature_configured": bool(payload.get("webhook_signature_configured")),
"app_slug": payload.get("app_slug"),
}
)
@router.get("/integrations/github/app/installations")
async def list_github_app_installations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.get_github_app_install_center()
return success_response(payload)
@router.post("/integrations/github/app/installations/sync")
async def sync_github_app_installations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await control.sync_github_installations()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.get("/integrations/github/repos/available")
async def list_available_github_repos(
project_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if project_id is not None:
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.list_available_github_repositories())
async def _ensure_project_member_or_manager(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
current_user: User,
) -> None:
project_result = await db.execute(
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
)
project = project_result.scalar_one_or_none()
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
return
member_result = await db.execute(
select(UserProject).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.project_id == int(project_id),
UserProject.user_id == int(current_user.id),
)
)
)
if member_result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="当前用户无权操作该项目")
@router.get("/me/dev-tasks")
async def list_my_dev_tasks(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.get_dev_tasks_for_user(user_id=int(current_user.id)))
@router.get("/projects/{project_id}/dev-sync/summary")
async def get_project_dev_sync_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(
await control.get_project_dev_sync_summary(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
)
@router.post("/tasks/{task_id}/progress-events")
async def create_progress_event(
task_id: int,
body: ProgressEventCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.create_or_apply_progress_event(
task_id=int(task_id),
project_id=int(body.project_id),
event_type=body.event_type,
source=body.source,
payload=dict(body.payload or {}),
summary=body.summary,
user_id=int(current_user.id),
repo_binding_id=int(body.repo_binding_id) if body.repo_binding_id is not None else None,
workspace_binding_id=int(body.workspace_binding_id) if body.workspace_binding_id is not None else None,
occurred_at=body.occurred_at,
)
return success_response(payload)
@router.post("/internal/debug/integrations/github/repos")
async def register_github_repo(
body: RepoBindingCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
payload = await control.register_repo_binding(
project_id=int(body.project_id),
provider=body.provider,
owner=body.owner,
repo_name=body.repo_name,
default_branch=body.default_branch,
external_repo_id=body.external_repo_id,
installation_id=body.installation_id,
installation_account_login=body.installation_account_login,
webhook_secret_hint=body.webhook_secret_hint,
)
return success_response(payload)
@router.post("/integrations/github/repos/bind")
async def bind_github_repo(
body: RepoBindingSelectRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(body.project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await control.bind_github_repository(
project_id=int(body.project_id),
github_repository_id=int(body.github_repository_id),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.get("/integrations/github/deliveries")
async def list_github_deliveries(
project_id: int | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if project_id is not None:
await _ensure_project_member_or_manager(
db,
tenant_id=int(current_user.tenant_id),
project_id=int(project_id),
current_user=current_user,
)
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
return success_response(await control.list_github_deliveries(project_id=project_id, limit=limit))
@router.post("/integrations/github/webhook")
async def ingest_github_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
delivery_id: str | None = Header(default=None, alias="X-GitHub-Delivery"),
):
raw_body = await request.body()
try:
payload = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="GitHub webhook body 不是合法 JSON") from exc
tenant_id = int((payload.get("tenant_id") or 1))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
result = await control.ingest_github_webhook(
event_type=str(event_type or payload.get("event_type") or "").strip(),
delivery_id=str(delivery_id or payload.get("delivery_id") or "").strip(),
signature_256=signature_256,
payload=payload,
raw_body=raw_body,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(result)
@router.post("/internal/debug/integrations/github/events")
async def ingest_github_event_debug(
payload: dict = Body(...),
db: AsyncSession = Depends(get_db),
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
):
tenant_id = int((payload.get("tenant_id") or 1))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
result = await control.ingest_github_event(
event_type=str(event_type or payload.get("event_type") or "").strip(),
payload=payload,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response({"debug": True, **result})

View File

@ -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: 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 current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]): if current_user.id != target_user_id and not has_any_role(str(current_role), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

View File

@ -40,7 +40,7 @@ async def list_skill_tags(
async def create_skill_tag( async def create_skill_tag(
body: SkillTagCreate, body: SkillTagCreate,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -78,7 +78,7 @@ async def update_skill_tag(
tag_id: UUID, tag_id: UUID,
body: SkillTagUpdate, body: SkillTagUpdate,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -103,7 +103,7 @@ async def update_skill_tag(
async def delete_skill_tag( async def delete_skill_tag(
tag_id: UUID, tag_id: UUID,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user

View File

@ -39,7 +39,7 @@ async def save_feishu_config(
4. 添加权限:im:message, im:message:group_at_msg, im:chat 4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1) 5. 订阅事件:接收消息 v1 (message.receive_v1)
""" """
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成") raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try: try:
@ -85,7 +85,7 @@ async def get_feishu_config(
service = get_feishu_service(db) service = get_feishu_service(db)
await service.load_config(tenant_id) await service.load_config(tenant_id)
manager = get_feishu_long_connection_manager() manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
return { return {
"is_configured": service.is_configured(), "is_configured": service.is_configured(),
@ -105,7 +105,7 @@ async def delete_feishu_config(
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
"""删除飞书配置。""" """删除飞书配置。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置") raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try: try:

View File

@ -38,7 +38,7 @@ async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""手动重启飞书长连接。""" """手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接") raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager() manager = get_feishu_long_connection_manager()

View File

@ -50,7 +50,7 @@ class VerifyContextConsumptionRequest(BaseModel):
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步") raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")

View File

@ -45,7 +45,7 @@ class LLMEngineSettingsUpdateRequest(BaseModel):
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置") raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")

View File

@ -56,7 +56,7 @@ async def update_model_routing(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
if not has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(current_user.role or ""), ["OWNER", "REVIEWER"]):
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置")) return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)

View File

@ -16,7 +16,7 @@ router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"])
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告") raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")

View File

@ -0,0 +1,49 @@
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.pmflow_launch import PMFlowLaunchConfirmRequest, PMFlowLaunchDraftRequest
from ...services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
router = APIRouter(prefix="/pmflow-launch", tags=["pmflow-launch"])
@router.post("/drafts")
async def create_pmflow_launch_draft(
body: PMFlowLaunchDraftRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await service.create_draft(body=body, user_id=int(current_user.id))
except PMFlowLaunchError as exc:
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
return success_response(payload.model_dump(mode="json"))
@router.post("/drafts/{draft_id}/confirm")
async def confirm_pmflow_launch_draft(
draft_id: str,
body: PMFlowLaunchConfirmRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not body.confirm:
return JSONResponse(status_code=400, content=error_response(400, "confirm_required: confirm must be true"))
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
try:
payload = await service.confirm_draft(
draft_id=draft_id,
edited_draft=body.edited_draft,
user_id=int(current_user.id),
)
except PMFlowLaunchError as exc:
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
return success_response(payload.model_dump(mode="json"))

View File

@ -0,0 +1,225 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import and_, select
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 Project, User, UserProject
from ...schemas.pmflow_os import (
PMFlowChangeItemDeferRequest,
PMFlowChangeItemEditRequest,
PMFlowExtractionCreateRequest,
PMFlowThreadMessageCreateRequest,
)
from ...services.auth import has_any_role
from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
from ...services.pmflow_os_projection_service import PMFlowOSProjectionService
router = APIRouter(prefix="/pmflow-os", tags=["pmflow-os"])
def _review_error(exc: PMFlowChangeReviewError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
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.id).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
)
if project_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="project_not_found")
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
return
member_result = await db.execute(
select(UserProject.user_id).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="forbidden")
@router.get("/projects/{project_id}/truth-state")
async def get_pmflow_os_truth_state(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_truth_state(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/threads/{thread_id}/messages")
async def create_pmflow_os_thread_message(
project_id: int,
thread_id: str,
body: PMFlowThreadMessageCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.create_thread_message(
project_id=int(project_id),
thread_id=thread_id,
body=body,
user_id=int(current_user.id),
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/projects/{project_id}/extractions")
async def create_pmflow_os_extraction(
project_id: int,
body: PMFlowExtractionCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.create_extraction(project_id=int(project_id), body=body, user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.get("/projects/{project_id}/change-sets/{change_set_id}")
async def get_pmflow_os_change_set(
project_id: int,
change_set_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.get_change_set_detail(project_id=int(project_id), change_set_id=int(change_set_id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/accept")
async def accept_pmflow_os_change_item(
change_item_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.accept_item(change_item_id=int(change_item_id), user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/edit")
async def edit_pmflow_os_change_item(
change_item_id: int,
body: PMFlowChangeItemEditRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.edit_item(change_item_id=int(change_item_id), body=body, user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/defer")
async def defer_pmflow_os_change_item(
change_item_id: int,
body: PMFlowChangeItemDeferRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.defer_item(
change_item_id=int(change_item_id),
user_id=int(current_user.id),
reason=body.reason if body is not None else None,
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.get("/projects/{project_id}/execution-summary")
async def get_pmflow_os_execution_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_execution_summary(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))

View File

@ -8,7 +8,7 @@ from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectU
async def create_project( async def create_project(
body: ProjectCreateRequest, body: ProjectCreateRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
title = compact_title(body.title, max_len=24) title = compact_title(body.title, max_len=24)
@ -88,7 +88,7 @@ async def assign_project_members(
project_id: int, project_id: int,
body: ProjectMembersAssignRequest, body: ProjectMembersAssignRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
@ -157,7 +157,7 @@ async def update_project(
project_id: int, project_id: int,
body: ProjectUpdateRequest, body: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
@ -185,7 +185,7 @@ async def update_project(
async def delete_project( async def delete_project(
project_id: int, project_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
_ = current_user _ = current_user

View File

@ -10,7 +10,7 @@ async def link_signal_to_project(
signal_id: int, signal_id: int,
body: LinkSignalRequest, body: LinkSignalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
@ -74,7 +74,7 @@ async def unlink_signal_from_project(
project_id: int, project_id: int,
signal_id: int, signal_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
_ = current_user _ = current_user

View File

@ -33,7 +33,7 @@ class PromptProfileUpdateRequest(BaseModel):
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置") raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")

View File

@ -207,7 +207,7 @@ async def export_qa_records_jsonl(
domain: str | None = Query(default=None), domain: str | None = Query(default=None),
date_from: datetime | None = Query(default=None), date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None), date_to: datetime | None = Query(default=None),
current_user: User = Depends(require_roles("ADMIN", "BOSS")), current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):

View File

@ -95,7 +95,7 @@ async def rag_multi_step_search(
@router.post("/symbols/sync") @router.post("/symbols/sync")
async def rag_symbols_sync( async def rag_symbols_sync(
request: SymbolSyncRequest, request: SymbolSyncRequest,
current_user: User = Depends(require_roles("ADMIN", "BOSS")), current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):

View File

@ -4,76 +4,74 @@ API 路由汇总
统一注册所有 API 路由,供 main.py 引入。 统一注册所有 API 路由,供 main.py 引入。
""" """
from importlib import import_module
from fastapi import APIRouter from fastapi import APIRouter
from .signals_api import router as signals_router from ..core.config import settings
from .feedbacks import router as feedbacks_router
from .experiments import router as experiments_router
from .context import router as context_router
from .tasks import router as tasks_router
from .webhooks import router as webhooks_router
from .auth import router as auth_router
from .users_api import router as users_router
from .endpoints.skill import router as skill_router
from .workloads import router as workloads_router
from .skill_tags_api import router as skill_tags_router
from .sstc_api import router as sstc_router
from .projects_api import router as projects_router
from .scheduler import router as scheduler_router
from .ai_steward_api import router as ai_steward_router
from .feishu_api import router as feishu_router
from .wecom_api import router as wecom_router
from .notifications import router as notifications_router
from .llm_engines import router as llm_engines_router
from .llm_providers import router as llm_providers_router
from .ai_logs_api import router as ai_logs_router
from .prompt_profiles import router as prompt_profiles_router
from .rag_api import router as rag_router
from .knowledge_graph import router as knowledge_graph_router
from .recipes import router as recipes_router
from .signal_sources_api import router as signal_sources_router
from .operation_catalog import router as operation_catalog_router
from .ai_pmo_api import router as ai_pmo_router
from .audit import router as audit_router
from .model_routing import router as model_routing_router
from .semantic_facts_api import router as semantic_facts_router
from .task_briefs import router as task_briefs_router
from .mcp_api import router as mcp_router
# 创建主 API 路由器 CORE_ROUTE_MODULES = (
api_router = APIRouter() "signals_api",
"feedbacks",
"experiments",
"context",
"tasks",
"webhooks",
"auth",
"users_api",
"endpoints.skill",
"workloads",
"skill_tags_api",
"sstc_api",
"projects_api",
"scheduler",
"notifications",
"recipes",
"operation_catalog",
"audit",
"task_briefs",
"pmflow_os_api",
"pmflow_launch_api",
)
# 注册各模块路由 OPTIONAL_ROUTE_GROUPS = {
api_router.include_router(signals_router) "integrations": ("feishu_api", "wecom_api", "mcp_api", "dev_sync"),
api_router.include_router(feedbacks_router) "knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
api_router.include_router(experiments_router) "ai_control": (
api_router.include_router(context_router) "ai_steward_api",
api_router.include_router(tasks_router) "llm_engines",
api_router.include_router(webhooks_router) "llm_providers",
api_router.include_router(auth_router) "ai_logs_api",
api_router.include_router(users_router) "prompt_profiles",
api_router.include_router(skill_router) "signal_sources_api",
api_router.include_router(workloads_router) "model_routing",
api_router.include_router(skill_tags_router) ),
api_router.include_router(sstc_router) }
api_router.include_router(projects_router)
api_router.include_router(scheduler_router)
api_router.include_router(ai_steward_router) def _include_module(router: APIRouter, module_path: str) -> None:
api_router.include_router(feishu_router) module = import_module(f"{__package__}.{module_path}")
api_router.include_router(wecom_router) router.include_router(module.router)
api_router.include_router(notifications_router)
api_router.include_router(llm_engines_router)
api_router.include_router(llm_providers_router) def _enabled_optional_groups() -> tuple[str, ...]:
api_router.include_router(ai_logs_router) if settings.api_route_profile == "lean":
api_router.include_router(prompt_profiles_router) return tuple(settings.api_optional_route_groups)
api_router.include_router(rag_router) if settings.api_optional_route_groups:
api_router.include_router(knowledge_graph_router) return tuple(dict.fromkeys(settings.api_optional_route_groups))
api_router.include_router(recipes_router) return tuple(OPTIONAL_ROUTE_GROUPS.keys())
api_router.include_router(signal_sources_router)
api_router.include_router(operation_catalog_router)
api_router.include_router(ai_pmo_router) def build_api_router() -> APIRouter:
api_router.include_router(audit_router) router = APIRouter()
api_router.include_router(model_routing_router) for module_path in CORE_ROUTE_MODULES:
api_router.include_router(semantic_facts_router) _include_module(router, module_path)
api_router.include_router(task_briefs_router)
api_router.include_router(mcp_router) for group in _enabled_optional_groups():
for module_path in OPTIONAL_ROUTE_GROUPS.get(group, ()):
_include_module(router, module_path)
return router
api_router = build_api_router()

View File

@ -56,7 +56,7 @@ async def list_skill_tags(
async def create_skill_tag( async def create_skill_tag(
body: SkillTagCreateRequest, body: SkillTagCreateRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -80,7 +80,7 @@ async def update_skill_tag(
tag_id: int, tag_id: int,
body: SkillTagUpdateRequest, body: SkillTagUpdateRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -109,7 +109,7 @@ async def update_skill_tag(
async def delete_skill_tag( async def delete_skill_tag(
tag_id: int, tag_id: int,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -164,7 +164,7 @@ async def bind_user_skill(
user_id: int, user_id: int,
body: UserSkillCreateRequest, body: UserSkillCreateRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user
@ -208,7 +208,7 @@ async def update_user_skill(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
current_role = str(current_user.role or "") current_role = str(current_user.role or "")
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"]) is_manager = has_any_role(current_role, ["OWNER", "REVIEWER"])
is_self = int(current_user.id) == user_id is_self = int(current_user.id) == user_id
if not is_manager and not is_self: if not is_manager and not is_self:
raise HTTPException(status_code=403, detail="No permission") raise HTTPException(status_code=403, detail="No permission")
@ -251,7 +251,7 @@ async def unbind_user_skill(
user_id: int, user_id: int,
skill_id: int, skill_id: int,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user

View File

@ -7,6 +7,7 @@ from . import (
charters_ai_alignment, charters_ai_alignment,
charters_detail_update, charters_detail_update,
charters_list_create, charters_list_create,
model_control,
overview, overview,
releases_list_create, releases_list_create,
releases_status_generate, releases_status_generate,
@ -34,6 +35,7 @@ for _module in [
charters_list_create, charters_list_create,
charters_detail_update, charters_detail_update,
charters_ai_alignment, charters_ai_alignment,
model_control,
tasks_create, tasks_create,
tasks_ai_breakdown, tasks_ai_breakdown,
tasks_board_status, tasks_board_status,

View File

@ -0,0 +1,289 @@
from __future__ import annotations
from .shared import *
from ...services.ai_project_control_service import AIProjectControlService
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id
router = APIRouter(tags=["sstc"])
class ModelBindingRequest(BaseModel):
catalog_item_id: int
charter_id: int | None = None
estimated_daily_tokens: int = 0
selection_reason: dict[str, Any] | None = None
class PromptPackageCreateRequest(BaseModel):
task_id: int | None = None
class ReleaseValidationRunRequest(BaseModel):
charter_id: int | None = None
release_node_id: int | None = None
class FeedbackIngestRequest(BaseModel):
charter_id: int | None = None
source_type: str = "manual"
title: str | None = None
content: str
metrics: dict[str, Any] | None = None
samples: list[dict[str, Any]] | None = None
class ComplianceCheckRunRequest(BaseModel):
charter_id: int | None = None
release_node_id: int | None = None
class RevenueEventCreateRequest(BaseModel):
source_type: str = "manual"
category: str = "other"
currency: str = "USD"
amount_usd: float
external_ref: str | None = None
event_payload: dict[str, Any] | None = None
occurred_at: str | None = None
@router.get("/projects/{project_id}/model-recommendations")
async def get_model_recommendations(
project_id: str,
charter_id: int | None = Query(default=None),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
if charter_id is not None:
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=int(charter_id))
control = AIProjectControlService(db, tenant_id=tenant_id)
return success_response(await control.recommend_models(project_id=pid, charter_id=charter_id))
@router.post("/projects/{project_id}/model-binding")
async def bind_project_model(
project_id: str,
body: ModelBindingRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
if body.charter_id is not None:
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=int(body.charter_id))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
payload = await control.bind_model(
project_id=pid,
catalog_item_id=int(body.catalog_item_id),
selected_by_user_id=int(current_user.id),
charter_id=int(body.charter_id) if body.charter_id is not None else None,
estimated_daily_tokens=max(int(body.estimated_daily_tokens or 0), 0),
selection_reason=dict(body.selection_reason or {}),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.get("/projects/{project_id}/model-usage")
async def get_project_model_usage(
project_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
control = AIProjectControlService(db, tenant_id=tenant_id)
return success_response(await control.get_model_usage(project_id=pid))
@router.post("/projects/{project_id}/charters/{charter_id}/prompt-packages")
async def create_prompt_package(
project_id: str,
charter_id: str,
body: PromptPackageCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
payload = await control.create_prompt_package(
project_id=pid,
charter_id=cid,
generated_by_user_id=int(current_user.id),
task_id=int(body.task_id) if body.task_id is not None else None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.post("/projects/{project_id}/release-validations/run")
async def run_release_validation(
project_id: str,
body: ReleaseValidationRunRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
if body.charter_id is not None:
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=int(body.charter_id))
control = AIProjectControlService(db, tenant_id=tenant_id)
payload = await control.run_release_validation(
project_id=pid,
actor_user_id=int(current_user.id),
charter_id=int(body.charter_id) if body.charter_id is not None else None,
release_node_id=int(body.release_node_id) if body.release_node_id is not None else None,
)
return success_response(payload)
@router.post("/projects/{project_id}/feedback-ingest")
async def ingest_project_feedback(
project_id: str,
body: FeedbackIngestRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
if body.charter_id is not None:
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=int(body.charter_id))
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
payload = await control.ingest_feedback(
project_id=pid,
actor_user_id=int(current_user.id),
charter_id=int(body.charter_id) if body.charter_id is not None else None,
source_type=body.source_type,
title=body.title,
content=body.content,
metrics=dict(body.metrics or {}),
samples=list(body.samples or []),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)
@router.post("/projects/{project_id}/revenue-events")
async def create_revenue_event(
project_id: str,
body: RevenueEventCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
control = AIProjectControlService(db, tenant_id=tenant_id)
payload = await control.record_revenue_event(
project_id=pid,
amount_usd=float(body.amount_usd or 0.0),
source_type=body.source_type,
category=body.category,
currency=body.currency,
external_ref=body.external_ref,
event_payload=dict(body.event_payload or {}),
occurred_at=body.occurred_at,
)
return success_response(payload)
@router.get("/projects/{project_id}/roi-snapshots")
async def get_roi_snapshots(
project_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
control = AIProjectControlService(db, tenant_id=tenant_id)
return success_response(await control.get_roi_snapshots(project_id=pid))
@router.post("/projects/{project_id}/compliance-checks/run")
async def run_compliance_check(
project_id: str,
body: ComplianceCheckRunRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
if body.charter_id is not None:
await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=int(body.charter_id))
control = AIProjectControlService(db, tenant_id=tenant_id)
payload = await control.run_compliance_check(
project_id=pid,
actor_user_id=int(current_user.id),
charter_id=int(body.charter_id) if body.charter_id is not None else None,
release_node_id=int(body.release_node_id) if body.release_node_id is not None else None,
)
return success_response(payload)
@router.get("/projects/{project_id}/compliance-checks/latest/export")
async def export_latest_compliance_evidence(
project_id: str,
format: str = Query(default="json"),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
control = AIProjectControlService(db, tenant_id=tenant_id)
try:
payload = await control.export_latest_compliance_evidence(project_id=pid, export_format=format)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(payload)

View File

@ -15,6 +15,7 @@ from ...services.sstc.governance import _assert_charter_aligned, _require_manage
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
from ...services.context_cache_service import context_cache_service from ...services.context_cache_service import context_cache_service
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
from ...services.ai_project_control_service import AIProjectControlService
from ...services.task_breakdown import ( from ...services.task_breakdown import (
build_task_breakdown_ai_payload, build_task_breakdown_ai_payload,
normalize_task_breakdown_payload, normalize_task_breakdown_payload,
@ -79,6 +80,10 @@ async def ai_breakdown_tasks(
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
control = AIProjectControlService(db, tenant_id=tenant_id)
active_binding = await control.get_active_binding_summary(project_id=pid)
payload = control.inject_model_tasks(payload=payload, active_binding=active_binding)
if not payload.tasks: if not payload.tasks:
raise HTTPException(status_code=500, detail="AI 拆解返回空结果") raise HTTPException(status_code=500, detail="AI 拆解返回空结果")

View File

@ -40,7 +40,7 @@ class GenerateTaskBriefRequest(BaseModel):
def _require_manager(current_user: User) -> Optional[JSONResponse]: def _require_manager(current_user: User) -> Optional[JSONResponse]:
if has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]): if has_any_role(str(current_user.role or ""), ["OWNER", "REVIEWER"]):
return None return None
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作")) return JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作"))

View File

@ -11,7 +11,7 @@ def _parse_role_values(raw: Any) -> list[str]:
if isinstance(raw, (list, tuple, set)): if isinstance(raw, (list, tuple, set)):
values = [str(_enum_value(item)).strip().upper() for item in raw] values = [str(_enum_value(item)).strip().upper() for item in raw]
else: else:
values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")] values = [str(token).strip().upper() for token in str(_enum_value(raw)).split(",")]
deduped: list[str] = [] deduped: list[str] = []
for value in values: for value in values:
if value and value not in deduped: if value and value not in deduped:
@ -32,7 +32,7 @@ def _pack_roles(raw: Any) -> str:
def _has_any_role(raw: Any, *roles: str) -> bool: def _has_any_role(raw: Any, *roles: str) -> bool:
values = set(_parse_role_values(raw)) values = set(_parse_role_values(raw))
expected = {str(role).upper() for role in roles} expected = {str(role).strip().upper() for role in roles}
return bool(values.intersection(expected)) return bool(values.intersection(expected))
def _build_role_filter(column: Any, roles: list[str]) -> Any: def _build_role_filter(column: Any, roles: list[str]) -> Any:
@ -61,4 +61,3 @@ def _parse_int_list(raw: Optional[str]) -> list[int]:
except ValueError: except ValueError:
continue continue
return ids return ids

View File

@ -225,7 +225,7 @@ def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]:
"password": str(lower_map.get("password", "")).strip(), "password": str(lower_map.get("password", "")).strip(),
"name": str(lower_map.get("name", "")).strip(), "name": str(lower_map.get("name", "")).strip(),
"email": str(lower_map.get("email", "")).strip() or None, "email": str(lower_map.get("email", "")).strip() or None,
"role": str(lower_map.get("role", "FE")).strip().upper(), "role": str(lower_map.get("role", "BUILDER")).strip().upper(),
"project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))), "project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))),
} }
@ -264,4 +264,3 @@ async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]:
return data_rows return data_rows
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported") raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")

View File

@ -9,7 +9,7 @@ from .schemas import BulkAssignUsersRequest, BulkDeleteUsersRequest
async def delete_user( async def delete_user(
user_id: int, user_id: int,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""删除用户(有未完成任务时禁止)。""" """删除用户(有未完成任务时禁止)。"""
@ -88,7 +88,7 @@ async def delete_user(
async def bulk_delete_users( async def bulk_delete_users(
body: BulkDeleteUsersRequest, body: BulkDeleteUsersRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""批量删除用户(带未完成任务保护)。""" """批量删除用户(带未完成任务保护)。"""
@ -175,7 +175,7 @@ async def bulk_delete_users(
async def bulk_assign_users( async def bulk_assign_users(
body: BulkAssignUsersRequest, body: BulkAssignUsersRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""批量分配项目/角色。""" """批量分配项目/角色。"""
@ -206,8 +206,8 @@ async def bulk_assign_users(
before_data = user.to_dict() before_data = user.to_dict()
if body.role is not None: if body.role is not None:
if not _has_any_role(_enum_value(current_user.role), "BOSS"): if not _has_any_role(_enum_value(current_user.role), "OWNER"):
skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"}) skipped.append({"user_id": user_id, "reason": "Only OWNER can bulk assign roles"})
continue continue
try: try:
user.role = _pack_roles(body.role) user.role = _pack_roles(body.role)
@ -239,4 +239,3 @@ async def bulk_assign_users(
await db.commit() await db.commit()
return {"updated_count": updated, "skipped": skipped} return {"updated_count": updated, "skipped": skipped}

View File

@ -31,7 +31,7 @@ async def update_user_workload(
async def import_users( async def import_users(
file: UploadFile = File(...), file: UploadFile = File(...),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""批量导入用户(最多 50 行)。""" """批量导入用户(最多 50 行)。"""
@ -93,4 +93,3 @@ async def import_users(
"failed": len(rows) - success_count, "failed": len(rows) - success_count,
"results": results, "results": results,
} }

View File

@ -8,7 +8,7 @@ from .helpers_user import _replace_user_projects, _serialize_user, _translate_us
async def create_user( async def create_user(
user_data: UserCreate, user_data: UserCreate,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""创建用户(自动初始化 user_workloads)。""" """创建用户(自动初始化 user_workloads)。"""
@ -161,7 +161,7 @@ async def patch_user_status(
user_id: int, user_id: int,
body: UserStatusPatchRequest, body: UserStatusPatchRequest,
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""禁用/启用用户,禁用时清空任务指派并归零负载。""" """禁用/启用用户,禁用时清空任务指派并归零负载。"""
@ -217,4 +217,3 @@ async def patch_user_status(
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
return {"message": "Status updated", "user": _serialize_user(user)} return {"message": "Status updated", "user": _serialize_user(user)}

View File

@ -45,5 +45,5 @@ from ...utils.timezone import now_cn_naive
router = APIRouter(tags=["users"]) router = APIRouter(tags=["users"])
COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
MANAGER_ROLE_SET = {"BOSS", "ADMIN"} MANAGER_ROLE_SET = {"OWNER", "REVIEWER"}
VALID_ROLE_VALUES = {r.value for r in UserRole} VALID_ROLE_VALUES = {r.value for r in UserRole}

View File

@ -24,7 +24,7 @@ async def save_wecom_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成") raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成")
try: try:
@ -61,7 +61,7 @@ async def get_wecom_config(
try: try:
service = get_wecom_service(db) service = get_wecom_service(db)
await service.load_config(tenant_id) await service.load_config(tenant_id)
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
return { return {
"is_configured": service.is_configured(), "is_configured": service.is_configured(),
@ -93,7 +93,7 @@ async def delete_wecom_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
raise HTTPException(status_code=403, detail="只有管理员可以删除配置") raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
try: try:

View File

@ -38,7 +38,7 @@ class AIOptimizeApplyRequest(BaseModel):
async def get_team_workloads( async def get_team_workloads(
project_id: Optional[int] = Query(None), project_id: Optional[int] = Query(None),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
current_user: User = Depends(require_roles("BOSS", "ADMIN")), current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ = current_user _ = current_user

View File

@ -61,6 +61,7 @@ from .ai_prompts_catalog import (
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
PROMPT_EXTERNAL_TREND_QA, PROMPT_EXTERNAL_TREND_QA,
PROMPT_INDUSTRY_TREND_QA, PROMPT_INDUSTRY_TREND_QA,
PROMPT_PMFLOW_LAUNCH_DRAFT,
) )
from .ai_prompt_registry import ( from .ai_prompt_registry import (
PROMPT_REGISTRY, PROMPT_REGISTRY,

View File

@ -17,6 +17,7 @@ from .part_scenes_06 import (
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA, PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
PROMPT_EXTERNAL_TREND_QA, PROMPT_EXTERNAL_TREND_QA,
PROMPT_INDUSTRY_TREND_QA, PROMPT_INDUSTRY_TREND_QA,
PROMPT_PMFLOW_LAUNCH_DRAFT,
) )
__all__ = [ __all__ = [
@ -73,4 +74,5 @@ __all__ = [
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
"PROMPT_PMFLOW_LAUNCH_DRAFT",
] ]

View File

@ -200,6 +200,43 @@ JSON schema:
TEXT_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL,
) )
PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt(
"你是 PMFlow 的新项目启动草案生成器。",
"""
用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建。
输出必须是 JSON 对象,不要 Markdown,不要解释。字段:
{{
"title": "string",
"one_liner": "string",
"target_user": "string",
"problem": "string",
"solution": "string",
"requirements": [{{"title": "string", "summary": "string", "acceptance_criteria": ["string"]}}],
"tasks": [{{"title": "string", "summary": "string", "group": "product|engineering|qa", "priority": "low|medium|high|urgent", "acceptance_criteria": ["string"], "requirement_index": 0}}],
"risks": [{{"title": "string", "summary": "string", "level": "low|medium|high", "mitigation": "string"}}],
"decisions": [{{"title": "string", "reason": "string", "owner": "string"}}],
"evidence": [{{"source": "user_input", "excerpt": "string"}}],
"confidence": 0.0,
"warnings": ["string"]
}}
硬约束:
1. requirements 目标 3-5 条,tasks 目标 3-7 条,risks 目标 2-4 条,decisions 目标 1-3 条。
2. 所有对象必须来自用户输入的合理结构化推断;信息不足时写入 warnings,不要编造具体事实。
3. confidence 用 0-1 数值。
4. AI 生成内容只能是待确认草案,不要使用“已确认”“已写入”等表述。
5. evidence.excerpt 必须引用或概括用户输入中的证据。
用户输入:
{input_text}
附件摘要:
{attachments_json}
""".strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = [ __all__ = [
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_EXTERNAL_TREND_QA",
"PROMPT_INDUSTRY_TREND_QA", "PROMPT_INDUSTRY_TREND_QA",
@ -208,4 +245,5 @@ __all__ = [
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA", "PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA", "PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
"PROMPT_PMFLOW_LAUNCH_DRAFT",
] ]

View File

@ -20,7 +20,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"], idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -34,7 +34,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
"properties": {"notification_id": {"type": "integer"}}, "properties": {"notification_id": {"type": "integer"}},
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
idempotency_fields=["tenant_id", "notification_id", "is_read"], idempotency_fields=["tenant_id", "notification_id", "is_read"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -48,7 +48,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
"properties": {"notification_id": {"type": "integer"}}, "properties": {"notification_id": {"type": "integer"}},
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "notification_id", "status"], idempotency_fields=["tenant_id", "notification_id", "status"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -66,7 +66,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "path", "content_hash"], idempotency_fields=["tenant_id", "path", "content_hash"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
allowed_paths=[ allowed_paths=[

View File

@ -19,7 +19,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "user_id", "role"], idempotency_fields=["tenant_id", "user_id", "role"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -36,7 +36,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"], idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -53,7 +53,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "user_id", "status", "date"], idempotency_fields=["tenant_id", "user_id", "status", "date"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -71,7 +71,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
idempotency_fields=["tenant_id", "doc_id", "content_hash"], idempotency_fields=["tenant_id", "doc_id", "content_hash"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -89,7 +89,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
idempotency_fields=["tenant_id", "doc_id", "chunk_hash"], idempotency_fields=["tenant_id", "doc_id", "chunk_hash"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -108,7 +108,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"], idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -126,7 +126,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "BUILDER"],
idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"], idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),

View File

@ -20,7 +20,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "title"], idempotency_fields=["tenant_id", "title"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -39,7 +39,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "project_id", "updated_at"], idempotency_fields=["tenant_id", "project_id", "updated_at"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -53,7 +53,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
"properties": {"project_id": {"type": "integer"}}, "properties": {"project_id": {"type": "integer"}},
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "project_id", "status"], idempotency_fields=["tenant_id", "project_id", "status"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -67,7 +67,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
"properties": {"project_id": {"type": "integer"}}, "properties": {"project_id": {"type": "integer"}},
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "project_id", "status"], idempotency_fields=["tenant_id", "project_id", "status"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -94,7 +94,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "project_id", "title"], idempotency_fields=["tenant_id", "project_id", "title"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -112,7 +112,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "task_id", "updated_at"], idempotency_fields=["tenant_id", "task_id", "updated_at"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -129,7 +129,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "task_id", "assignee_id"], idempotency_fields=["tenant_id", "task_id", "assignee_id"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -146,7 +146,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
idempotency_fields=["tenant_id", "task_id", "priority"], idempotency_fields=["tenant_id", "task_id", "priority"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -163,7 +163,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
}, },
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
idempotency_fields=["tenant_id", "task_id", "status"], idempotency_fields=["tenant_id", "task_id", "status"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),
@ -177,7 +177,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
"properties": {"task_id": {"type": "integer"}}, "properties": {"task_id": {"type": "integer"}},
"additionalProperties": False, "additionalProperties": False,
}, },
rbac_policy=["BOSS", "ADMIN"], rbac_policy=["OWNER", "REVIEWER"],
idempotency_fields=["tenant_id", "task_id"], idempotency_fields=["tenant_id", "task_id"],
audit_fields=COMMON_AUDIT_FIELDS, audit_fields=COMMON_AUDIT_FIELDS,
), ),

View File

@ -7,7 +7,12 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
try:
from dotenv import load_dotenv from dotenv import load_dotenv
except ModuleNotFoundError: # pragma: no cover - optional dependency in lightweight envs
def load_dotenv(*_args, **_kwargs):
return False
# 仅从项目根目录 .env 加载,作为唯一配置源 # 仅从项目根目录 .env 加载,作为唯一配置源
BACKEND_DIR = Path(__file__).resolve().parents[2] BACKEND_DIR = Path(__file__).resolve().parents[2]
@ -24,6 +29,20 @@ DEFAULT_DATABASE_URL = (
f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}" f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}"
f"@{DEFAULT_DB_HOST}:{DEFAULT_DB_PORT}/{DEFAULT_DB_NAME}" f"@{DEFAULT_DB_HOST}:{DEFAULT_DB_PORT}/{DEFAULT_DB_NAME}"
) )
TRUE_VALUES = {"true", "1", "yes", "on"}
DEBUG_ENABLED = os.getenv("DEBUG", "false").lower() in TRUE_VALUES
def _env_flag(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in TRUE_VALUES
def _env_csv(name: str) -> tuple[str, ...]:
raw = os.getenv(name, "")
return tuple(item.strip() for item in raw.split(",") if item.strip())
class Settings: class Settings:
@ -31,7 +50,14 @@ class Settings:
# 应用 # 应用
app_name: str = os.getenv("APP_NAME", "Neural Sync API") app_name: str = os.getenv("APP_NAME", "Neural Sync API")
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") debug: bool = DEBUG_ENABLED
startup_model_probe_enabled: bool = _env_flag("STARTUP_MODEL_PROBE_ENABLED", not DEBUG_ENABLED)
feishu_long_connection_enabled: bool = _env_flag("FEISHU_LONG_CONNECTION_ENABLED", not DEBUG_ENABLED)
world_model_warmup_enabled: bool = _env_flag("STARTUP_WORLD_MODEL_WARMUP_ENABLED", not DEBUG_ENABLED)
schedulers_enabled: bool = _env_flag("STARTUP_SCHEDULERS_ENABLED", True)
background_workers_enabled: bool = _env_flag("STARTUP_BACKGROUND_WORKERS_ENABLED", True)
api_route_profile: str = os.getenv("API_ROUTE_PROFILE", "full").strip().lower() or "full"
api_optional_route_groups: tuple[str, ...] = _env_csv("API_OPTIONAL_ROUTE_GROUPS")
# 数据库 # 数据库
database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
@ -59,6 +85,15 @@ class Settings:
# 多租户(预留) # 多租户(预留)
default_tenant_id: str = os.getenv("DEFAULT_TENANT_ID", "default") default_tenant_id: str = os.getenv("DEFAULT_TENANT_ID", "default")
# GitHub App
github_app_slug: Optional[str] = os.getenv("GITHUB_APP_SLUG")
github_app_install_url: Optional[str] = os.getenv("GITHUB_APP_INSTALL_URL")
github_app_callback_url: Optional[str] = os.getenv("GITHUB_APP_CALLBACK_URL")
github_app_webhook_secret: Optional[str] = os.getenv("GITHUB_APP_WEBHOOK_SECRET")
github_app_sync_endpoint: Optional[str] = os.getenv("GITHUB_APP_SYNC_ENDPOINT")
github_app_sync_token: Optional[str] = os.getenv("GITHUB_APP_SYNC_TOKEN")
github_app_installations_fixture: Optional[str] = os.getenv("GITHUB_APP_INSTALLATIONS_FIXTURE")
# 全局配置实例 # 全局配置实例
settings = Settings() settings = Settings()

View File

@ -10,14 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db from ..core.database import get_db
from ..models import User, UserProject from ..models import User, UserProject
from ..services.auth import parse_role_values from ..services.auth import MANAGER_ROLES, parse_role_values
from .auth import get_current_user from .auth import get_current_user
MANAGER_ROLES = {"BOSS", "ADMIN"}
def _role_value(role: object) -> str: def _role_value(role: object) -> str:
return role.value if isinstance(role, Enum) else str(role or "") return str(role.value if isinstance(role, Enum) else str(role or "")).strip().upper()
async def require_manager_user( async def require_manager_user(

View File

@ -23,13 +23,6 @@ from .core.startup.world_model_warmup import warmup_world_models
from .middleware import AuditContextMiddleware from .middleware import AuditContextMiddleware
from .services.ai_orchestration_run import AIOrchestrationRunService from .services.ai_orchestration_run import AIOrchestrationRunService
from .services.context_cache_service import register_context_cache_invalidation_hooks from .services.context_cache_service import register_context_cache_invalidation_hooks
from .services.integrations.feishu_long_connection_core import init_feishu_long_connection_manager
from .services.scheduler_center_core import (
AIPMOCadenceScheduler,
KnowledgeGraphIncrementalScheduler,
KnowledgeUrlRefreshScheduler,
WorkloadRecalcScheduler,
)
from .services.skill import init_system_skill_tags from .services.skill import init_system_skill_tags
from .services.semantic_fact_service import register_semantic_fact_hooks from .services.semantic_fact_service import register_semantic_fact_hooks
@ -39,6 +32,13 @@ register_context_cache_invalidation_hooks()
register_semantic_fact_hooks() register_semantic_fact_hooks()
def _startup_step_enabled(step: str, enabled: bool, env_name: str) -> bool:
if enabled:
return True
startup_log(f"跳过{step}", reason=f"{env_name}=false")
return False
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
"""应用生命周期:初始化依赖并启动后台任务。""" """应用生命周期:初始化依赖并启动后台任务。"""
@ -58,6 +58,7 @@ async def lifespan(_: FastAPI):
await conn.run_sync(check_database_dialect_guard) await conn.run_sync(check_database_dialect_guard)
startup_log("数据库连接成功") startup_log("数据库连接成功")
if _startup_step_enabled("模型连通性探测", settings.startup_model_probe_enabled, "STARTUP_MODEL_PROBE_ENABLED"):
await probe_model_connectivity_on_startup() await probe_model_connectivity_on_startup()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
@ -76,6 +77,18 @@ async def lifespan(_: FastAPI):
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc)) logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc))
scheduler = None
knowledge_scheduler = None
kg_scheduler = None
ai_pmo_cadence_scheduler = None
if _startup_step_enabled("定时调度器", settings.schedulers_enabled, "STARTUP_SCHEDULERS_ENABLED"):
from .services.scheduler_center_core import (
AIPMOCadenceScheduler,
KnowledgeGraphIncrementalScheduler,
KnowledgeUrlRefreshScheduler,
WorkloadRecalcScheduler,
)
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal) scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
scheduler.start() scheduler.start()
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal) knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
@ -85,45 +98,64 @@ async def lifespan(_: FastAPI):
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal) ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
ai_pmo_cadence_scheduler.start() ai_pmo_cadence_scheduler.start()
feishu_lc = None
if _startup_step_enabled("飞书长连接", settings.feishu_long_connection_enabled, "FEISHU_LONG_CONNECTION_ENABLED"):
from .services.integrations.feishu_long_connection_core import init_feishu_long_connection_manager
feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal) feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal)
await feishu_lc.start() await feishu_lc.start()
outbox_task = None
ingress_task = None
draft_expiry_task = None
if _startup_step_enabled("后台队列消费", settings.background_workers_enabled, "STARTUP_BACKGROUND_WORKERS_ENABLED"):
outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop") outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop")
ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop") ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop")
draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop") draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop")
wm_warmup_task = None
if _startup_step_enabled("World Model 预热", settings.world_model_warmup_enabled, "STARTUP_WORLD_MODEL_WARMUP_ENABLED"):
wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup") wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup")
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
yield yield
startup_log("服务关闭中") startup_log("服务关闭中")
if outbox_task is not None:
outbox_task.cancel() outbox_task.cancel()
try: try:
await outbox_task await outbox_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if ingress_task is not None:
ingress_task.cancel() ingress_task.cancel()
try: try:
await ingress_task await ingress_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if draft_expiry_task is not None:
draft_expiry_task.cancel() draft_expiry_task.cancel()
try: try:
await draft_expiry_task await draft_expiry_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if not wm_warmup_task.done(): if wm_warmup_task is not None and not wm_warmup_task.done():
wm_warmup_task.cancel() wm_warmup_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await wm_warmup_task await wm_warmup_task
if feishu_lc is not None:
await feishu_lc.stop() await feishu_lc.stop()
if ai_pmo_cadence_scheduler is not None:
await ai_pmo_cadence_scheduler.stop() await ai_pmo_cadence_scheduler.stop()
if kg_scheduler is not None:
await kg_scheduler.stop() await kg_scheduler.stop()
if knowledge_scheduler is not None:
await knowledge_scheduler.stop() await knowledge_scheduler.stop()
if scheduler is not None:
await scheduler.stop() await scheduler.stop()
await engine.dispose() await engine.dispose()
startup_log("服务已关闭") startup_log("服务已关闭")

View File

@ -84,6 +84,31 @@ from .mounted_ai_runtime import (
MountedToolCapabilityPolicy, MountedToolCapabilityPolicy,
MountedTopicWorkspace, MountedTopicWorkspace,
) )
from .ai_project_control import (
ModelCatalogItem,
ProjectModelBinding,
ModelRecommendationSnapshot,
ModelUsageEvent,
ModelEvalRun,
PromptPackage,
DevWorkspaceBinding,
RepoBinding,
GitHubAppInstallation,
GitHubAppRepository,
GitHubWebhookDelivery,
TaskCodeLink,
ProgressEvent,
DevSession,
ReleaseValidationRun,
FeedbackIngest,
IterationRecommendation,
RevenueEvent,
ROISnapshot,
ComplianceCheckRun,
EvidencePack,
)
from .pmflow_launch import PMFlowLaunchDraft, PMFlowThread, PMFlowThreadMessage
from .pmflow_change_review import PMFlowChangeSet, PMFlowChangeItem, PMFlowApproval, PMFlowEvidenceLink
__all__ = [ __all__ = [
"Base", "Base",
@ -173,4 +198,32 @@ __all__ = [
"MountedSourceActivation", "MountedSourceActivation",
"MountedToolCapabilityPolicy", "MountedToolCapabilityPolicy",
"MountedTopicWorkspace", "MountedTopicWorkspace",
"ModelCatalogItem",
"ProjectModelBinding",
"ModelRecommendationSnapshot",
"ModelUsageEvent",
"ModelEvalRun",
"PromptPackage",
"DevWorkspaceBinding",
"RepoBinding",
"GitHubAppInstallation",
"GitHubAppRepository",
"GitHubWebhookDelivery",
"TaskCodeLink",
"ProgressEvent",
"DevSession",
"ReleaseValidationRun",
"FeedbackIngest",
"IterationRecommendation",
"RevenueEvent",
"ROISnapshot",
"ComplianceCheckRun",
"EvidencePack",
"PMFlowLaunchDraft",
"PMFlowThread",
"PMFlowThreadMessage",
"PMFlowChangeSet",
"PMFlowChangeItem",
"PMFlowApproval",
"PMFlowEvidenceLink",
] ]

View File

@ -0,0 +1,426 @@
"""AI 应用项目控制面核心模型。"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DECIMAL, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class ModelCatalogItem(Base):
"""项目级模型目录条目。"""
__tablename__ = "model_catalog_items"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"provider",
"model_key",
"version",
name="uq_model_catalog_items_tenant_provider_key_version",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, index=True)
model_key: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, index=True)
display_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
version: Mapped[str] = mapped_column(VARCHAR(120), nullable=False)
modalities: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
capability_tags: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
strengths: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
limitations: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
compliance_status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="review_required")
filing_status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="unknown")
input_cost_per_mtok: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 4), nullable=True)
output_cost_per_mtok: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 4), nullable=True)
official_docs_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
class ProjectModelBinding(Base):
"""项目与模型绑定。"""
__tablename__ = "project_model_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
model_catalog_item_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("model_catalog_items.id"), nullable=False, index=True
)
selected_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
selection_reason: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
estimated_daily_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
estimated_monthly_cost_usd: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 2), nullable=True)
active_from: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
archived_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class ModelRecommendationSnapshot(Base):
"""推荐结果快照。"""
__tablename__ = "model_recommendation_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
recommendation_scope: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="charter")
snapshot_input: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
recommendations: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
top_model_catalog_item_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("model_catalog_items.id"), nullable=True
)
generated_by: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="rule_engine")
class ModelUsageEvent(Base):
"""模型用量事件。"""
__tablename__ = "model_usage_events"
__table_args__ = (
UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
project_model_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("project_model_bindings.id"), nullable=True, index=True
)
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="manual", index=True)
event_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_usd: Mapped[float] = mapped_column(DECIMAL(12, 4), nullable=False, default=0.0)
event_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
occurred_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
class ModelEvalRun(Base):
"""模型效果验证结果。"""
__tablename__ = "model_eval_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
project_model_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("project_model_bindings.id"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending", index=True)
success_criteria: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
test_cases: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
summary: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
score: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), nullable=True)
class PromptPackage(Base):
"""开发 Prompt 包。"""
__tablename__ = "prompt_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=False, index=True)
task_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("tasks.id"), nullable=True, index=True)
project_model_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("project_model_bindings.id"), nullable=True, index=True
)
package_kind: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="development")
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
package_text: Mapped[str] = mapped_column(Text, nullable=False)
package_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
generated_from: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class DevWorkspaceBinding(Base):
"""IDE 工作区绑定。"""
__tablename__ = "dev_workspace_bindings"
__table_args__ = (
UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
workspace_key: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
repo_root: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
editor_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="vscode")
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
last_seen_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class RepoBinding(Base):
"""项目仓库绑定。"""
__tablename__ = "repo_bindings"
__table_args__ = (
UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
provider: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="github", index=True)
external_repo_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
owner: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
repo_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
default_branch: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, default="main")
installation_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
installation_account_login: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
webhook_secret_hint: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
class GitHubAppInstallation(Base):
"""GitHub App 安装记录。"""
__tablename__ = "github_app_installations"
__table_args__ = (
UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
installation_id: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
account_login: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
account_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
app_slug: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
target_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
permissions: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
events: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
app_metadata: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict)
last_synced_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class GitHubAppRepository(Base):
"""GitHub App 安装下的仓库记录。"""
__tablename__ = "github_app_repositories"
__table_args__ = (
UniqueConstraint("tenant_id", "installation_id", "external_repo_id", name="uq_github_app_repositories_repo"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
installation_id: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
external_repo_id: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
owner: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
repo_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
full_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
default_branch: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
permissions: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
last_synced_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class GitHubWebhookDelivery(Base):
"""GitHub webhook 投递审计。"""
__tablename__ = "github_webhook_deliveries"
__table_args__ = (
UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
delivery_id: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
repo_binding_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("repo_bindings.id"), nullable=True, index=True)
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=True, index=True)
installation_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="received", index=True)
failure_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
received_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
class TaskCodeLink(Base):
"""任务与代码交付对象映射。"""
__tablename__ = "task_code_links"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"task_id",
"repo_binding_id",
"branch_name",
name="uq_task_code_links_tenant_task_repo_branch",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("tasks.id"), nullable=False, index=True)
repo_binding_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("repo_bindings.id"), nullable=False, index=True)
branch_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
pr_number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
last_commit_sha: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="linked", index=True)
class ProgressEvent(Base):
"""开发协同进度事件。"""
__tablename__ = "progress_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("tasks.id"), nullable=False, index=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True, index=True)
repo_binding_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("repo_bindings.id"), nullable=True, index=True)
workspace_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("dev_workspace_bindings.id"), nullable=True, index=True
)
event_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
source: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="manual", index=True)
derived_task_status: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True)
delivery_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
event_source_id: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
occurred_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
class DevSession(Base):
"""开发活跃会话。"""
__tablename__ = "dev_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
workspace_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("dev_workspace_bindings.id"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
started_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
heartbeat_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
ended_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
session_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class ReleaseValidationRun(Base):
"""发布验证运行记录。"""
__tablename__ = "release_validation_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
release_node_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
project_model_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("project_model_bindings.id"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending", index=True)
score: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), nullable=True)
key_metrics: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
risk_items: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
rollback_plan: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
validation_summary: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class FeedbackIngest(Base):
"""项目反馈摄取。"""
__tablename__ = "feedback_ingests"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
ingested_by_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True)
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="manual", index=True)
title: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
metrics: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
samples: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
class IterationRecommendation(Base):
"""基于反馈生成的下一轮建议。"""
__tablename__ = "iteration_recommendations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
feedback_ingest_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("feedback_ingests.id"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="draft", index=True)
priority: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="medium", index=True)
recommendation_summary: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_charter_patch: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_tasks: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
class RevenueEvent(Base):
"""项目经营收入事件。"""
__tablename__ = "revenue_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="manual", index=True)
category: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="other", index=True)
currency: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="USD")
amount_usd: Mapped[float] = mapped_column(DECIMAL(12, 2), nullable=False, default=0.0)
external_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
event_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
occurred_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
class ROISnapshot(Base):
"""ROI 观察快照。"""
__tablename__ = "roi_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
range_key: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="monthly", index=True)
window_start: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
window_end: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
total_revenue_usd: Mapped[float] = mapped_column(DECIMAL(12, 2), nullable=False, default=0.0)
total_cost_usd: Mapped[float] = mapped_column(DECIMAL(12, 2), nullable=False, default=0.0)
roi_ratio: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 4), nullable=True)
snapshot_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class ComplianceCheckRun(Base):
"""项目合规检查运行记录。"""
__tablename__ = "compliance_check_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
charter_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
release_node_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("truth_nodes.id"), nullable=True, index=True)
project_model_binding_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("project_model_bindings.id"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending", index=True)
score: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), nullable=True)
checklist: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
issues: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list)
summary: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
class EvidencePack(Base):
"""发布与合规证据包。"""
__tablename__ = "evidence_packs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
compliance_check_run_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("compliance_check_runs.id"), nullable=True, index=True
)
package_kind: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="compliance_release")
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="draft", index=True)
content: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
export_payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)

View File

@ -4,11 +4,10 @@ from enum import Enum
class UserRole(str, Enum): class UserRole(str, Enum):
"""用户角色""" """用户角色"""
BOSS = "BOSS" OWNER = "OWNER"
ADMIN = "ADMIN" BUILDER = "BUILDER"
AI = "AI" OPERATOR = "OPERATOR"
FE = "FE" REVIEWER = "REVIEWER"
BE = "BE"
class UserStatus(str, Enum): class UserStatus(str, Enum):

View File

@ -246,7 +246,7 @@ class Budget(Base):
VARCHAR(20), nullable=False, default="active" VARCHAR(20), nullable=False, default="active"
) )
# 超支解锁记录(BOSS/ADMIN 可以临时解锁) # 超支解锁记录(OWNER/REVIEWER 可以临时解锁)
override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)

View File

@ -0,0 +1,89 @@
"""PMFlow OS change review models."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import BigInteger, Float, ForeignKey, Integer, Text, TIMESTAMP, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class PMFlowChangeSet(Base):
__tablename__ = "pmflow_change_sets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
thread_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_threads.id"), nullable=False, index=True)
source_message_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("pmflow_thread_messages.id"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="open", index=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
headline: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
extraction_scope: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, default="latest_message")
routing_meta: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
llm_call_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
created_by: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
committed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class PMFlowChangeItem(Base):
__tablename__ = "pmflow_change_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
change_set_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_change_sets.id"), nullable=False, index=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
group: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
object_type: Mapped[str] = mapped_column(VARCHAR(64), nullable=False)
object_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
diff: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
edited_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
risk_level: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="medium")
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="pending_review", index=True)
reviewed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
class PMFlowApproval(Base):
__tablename__ = "pmflow_approvals"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
change_set_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_change_sets.id"), nullable=False, index=True)
change_item_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_change_items.id"), nullable=False, index=True)
actor_user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
action: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
decision: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
before_status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
after_status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
before_payload: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
after_payload: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class PMFlowEvidenceLink(Base):
__tablename__ = "pmflow_evidence_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
change_set_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_change_sets.id"), nullable=False, index=True)
change_item_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("pmflow_change_items.id"), nullable=True, index=True
)
source_type: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
excerpt: Mapped[str] = mapped_column(Text, nullable=False)
display_label: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
evidence_metadata: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
__all__ = ["PMFlowChangeSet", "PMFlowChangeItem", "PMFlowApproval", "PMFlowEvidenceLink"]

View File

@ -0,0 +1,75 @@
"""PMFlow Project Launch models."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import BigInteger, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class PMFlowLaunchDraft(Base):
__tablename__ = "pmflow_launch_drafts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False, index=True)
draft_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
input_text: Mapped[str] = mapped_column(Text, nullable=False)
input_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="text")
source: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="web")
attachments: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list)
draft_payload: Mapped[Any] = mapped_column(JSONB, nullable=False)
edited_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="draft", index=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
warnings: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list)
confirmed_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=True, index=True)
expires_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False, index=True)
confirmed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True)
confirm_request_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
)
class PMFlowThread(Base):
__tablename__ = "pmflow_threads"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
launch_draft_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("pmflow_launch_drafts.id"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
objective: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="active", index=True)
source: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="project_launch", index=True)
__table_args__ = (
UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
)
class PMFlowThreadMessage(Base):
__tablename__ = "pmflow_thread_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
thread_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("pmflow_threads.id"), nullable=False, index=True)
project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True)
launch_draft_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("pmflow_launch_drafts.id"), nullable=True, index=True
)
role: Mapped[str] = mapped_column(VARCHAR(32), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="project_launch")
attachments: Mapped[Any] = mapped_column(JSONB, nullable=False, default=list)
message_metadata: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="sent", index=True)
__all__ = ["PMFlowLaunchDraft", "PMFlowThread", "PMFlowThreadMessage"]

View File

@ -0,0 +1,105 @@
"""PMFlow Project Launch API schemas."""
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
class PMFlowLaunchAttachment(BaseModel):
type: str = "text"
name: Optional[str] = None
summary: Optional[str] = None
payload: Optional[dict[str, Any]] = None
class PMFlowLaunchDraftRequest(BaseModel):
input: str = Field(min_length=1, max_length=4000)
input_type: Literal["text", "file", "screenshot", "template"] = "text"
attachments: list[PMFlowLaunchAttachment] = Field(default_factory=list)
source: str = "web"
class PMFlowLaunchRequirement(BaseModel):
title: str
summary: str = ""
acceptance_criteria: list[str] = Field(default_factory=list)
class PMFlowLaunchRisk(BaseModel):
title: str
summary: str = ""
level: Literal["low", "medium", "high"] = "medium"
mitigation: str = ""
class PMFlowLaunchDecision(BaseModel):
title: str
reason: str = ""
owner: str = ""
class PMFlowLaunchTask(BaseModel):
title: str
summary: str = ""
group: Literal["product", "engineering", "qa"] = "product"
priority: Literal["low", "medium", "high", "urgent"] = "medium"
acceptance_criteria: list[str] = Field(default_factory=list)
requirement_index: Optional[int] = None
class PMFlowLaunchEvidence(BaseModel):
source: str = "user_input"
excerpt: str
class PMFlowLaunchDraftPayload(BaseModel):
title: str
one_liner: str
target_user: str
problem: str
solution: str
requirements: list[PMFlowLaunchRequirement] = Field(default_factory=list)
risks: list[PMFlowLaunchRisk] = Field(default_factory=list)
decisions: list[PMFlowLaunchDecision] = Field(default_factory=list)
tasks: list[PMFlowLaunchTask] = Field(default_factory=list)
evidence: list[PMFlowLaunchEvidence] = Field(default_factory=list)
confidence: float = 0.0
warnings: list[str] = Field(default_factory=list)
class PMFlowLaunchDraftResponse(PMFlowLaunchDraftPayload):
draft_id: str
status: str = "draft"
expires_at: str
class PMFlowLaunchConfirmRequest(BaseModel):
edited_draft: dict[str, Any] = Field(default_factory=dict)
confirm: bool = True
class PMFlowLaunchConfirmResponse(BaseModel):
project_id: int
redirect_url: str
created_truth_node_ids: list[int] = Field(default_factory=list)
created_task_ids: list[int] = Field(default_factory=list)
thread_id: Optional[int] = None
thread_message_id: Optional[int] = None
warnings: list[str] = Field(default_factory=list)
__all__ = [
"PMFlowLaunchAttachment",
"PMFlowLaunchConfirmRequest",
"PMFlowLaunchConfirmResponse",
"PMFlowLaunchDecision",
"PMFlowLaunchDraftPayload",
"PMFlowLaunchDraftRequest",
"PMFlowLaunchDraftResponse",
"PMFlowLaunchEvidence",
"PMFlowLaunchRequirement",
"PMFlowLaunchRisk",
"PMFlowLaunchTask",
]

View File

@ -0,0 +1,251 @@
"""PMFlow OS read-side projection schemas."""
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
PMFlowStage = Literal["idea", "validation", "planning", "development", "testing", "release", "growth"]
PMFlowObjectStatus = Literal["draft", "confirmed", "in_progress", "blocked", "done", "released"]
PMFlowRiskLevel = Literal["low", "medium", "high"]
PMFlowExecutionStatus = Literal["queued", "pushed", "coding", "pr_open", "testing", "synced", "failed"]
PMFlowReleaseStatus = Literal["check_pending", "ready", "shipping", "live", "watch"]
ProjectLoopStage = Literal["command_idle", "ai_extracting", "extraction_ready", "review_open", "writeback_success"]
PMFlowChangeGroup = Literal["new_object", "field_update", "risk", "decision"]
PMFlowChangeStatus = Literal["pending_review", "accepted", "edited", "deferred"]
PMFlowObjectType = Literal[
"Requirement",
"Decision",
"Task",
"Risk",
"ModelDecision",
"TestRun",
"Release",
"MetricSignal",
]
class PMFlowThreadMessageCreateRequest(BaseModel):
content: str = Field(min_length=1, max_length=12000)
source: str = "web"
attachments: list[dict[str, Any]] = Field(default_factory=list)
class PMFlowThreadMessageResponse(BaseModel):
id: str
threadId: str
projectId: str
role: str
author: str
content: str
createdAt: str
status: str = "sent"
source: str = "web"
metadata: dict[str, Any] = Field(default_factory=dict)
class PMFlowProposedChangeItem(BaseModel):
group: PMFlowChangeGroup
object_type: PMFlowObjectType
object_id: Optional[str] = None
title: str = Field(min_length=1, max_length=255)
summary: str = Field(min_length=1)
diff: str = Field(default="")
payload: dict[str, Any] = Field(default_factory=dict)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
risk_level: PMFlowRiskLevel = "medium"
class PMFlowExtractionCreateRequest(BaseModel):
thread_id: str
message_id: str
scope: str = "latest_message"
summary: Optional[str] = None
headline: Optional[str] = None
routing_meta: dict[str, Any] = Field(default_factory=dict)
llm_call_id: Optional[str] = None
proposed_items: list[PMFlowProposedChangeItem] = Field(min_length=1)
class PMFlowChangeItemEditRequest(BaseModel):
summary: Optional[str] = None
diff: Optional[str] = None
payload_patch: dict[str, Any] = Field(default_factory=dict)
reason: Optional[str] = None
class PMFlowChangeItemDeferRequest(BaseModel):
reason: Optional[str] = None
class PMFlowChangeSetDetailResponse(BaseModel):
changeSet: dict[str, Any]
items: list[dict[str, Any]] = Field(default_factory=list)
evidence: list[dict[str, Any]] = Field(default_factory=list)
approvals: list[dict[str, Any]] = Field(default_factory=list)
class PMFlowThread(BaseModel):
id: str
title: str
projectId: str
objective: str
extractionStatus: Literal["idle", "loading", "ready"] = "idle"
messageIds: list[str] = Field(default_factory=list)
class PMFlowProjectSummary(BaseModel):
id: str
name: str
goal: str
stage: PMFlowStage
threadId: str
class PMFlowRequirement(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "planning"
status: PMFlowObjectStatus
linkedTaskIds: list[str] = Field(default_factory=list)
class PMFlowDecision(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "planning"
status: PMFlowObjectStatus
owner: str = "未分配"
class PMFlowTask(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "development"
status: PMFlowObjectStatus
assignee: str = "未分配"
acceptanceCriteria: list[str] = Field(default_factory=list)
requirementId: Optional[str] = None
class PMFlowRisk(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "planning"
status: PMFlowObjectStatus
level: PMFlowRiskLevel = "medium"
mitigation: str = "待补充缓解策略"
class PMFlowModelDecision(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "planning"
status: PMFlowObjectStatus
rationale: str = ""
class PMFlowTestRun(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "testing"
status: PMFlowObjectStatus
result: Literal["passed", "failed"]
linkedTaskId: Optional[str] = None
class PMFlowRelease(BaseModel):
id: str
title: str
summary: str
status: PMFlowReleaseStatus
stage: PMFlowStage = "release"
gateItems: list[dict[str, Any]] = Field(default_factory=list)
class PMFlowMetricSignal(BaseModel):
id: str
title: str
summary: str
stage: PMFlowStage = "growth"
status: PMFlowObjectStatus = "confirmed"
value: str
direction: Literal["up", "down", "flat"] = "flat"
class PMFlowFeedEvent(BaseModel):
id: str
kind: Literal["extraction", "review", "execution", "test", "release", "growth", "agent"]
title: str
summary: str
timestamp: str
class PMFlowExecutionContext(BaseModel):
taskId: Optional[str] = None
status: PMFlowExecutionStatus = "queued"
prompt: str = ""
commitLog: list[str] = Field(default_factory=list)
prStatus: str = "pending"
testSummary: str = "pending"
writebackStatus: str = "pending"
class PMFlowTeamInbox(BaseModel):
product: list[str] = Field(default_factory=list)
engineering: list[str] = Field(default_factory=list)
qa: list[str] = Field(default_factory=list)
growth: list[str] = Field(default_factory=list)
class PMFlowProjectMemory(BaseModel):
truthNodeFacts: list[dict[str, Any]] = Field(default_factory=list)
semanticFacts: list[dict[str, Any]] = Field(default_factory=list)
sourceSummary: dict[str, int] = Field(default_factory=dict)
class ProjectTruthStateProjection(BaseModel):
loopStage: ProjectLoopStage = "command_idle"
lastWritebackAt: Optional[str] = None
project: PMFlowProjectSummary
thread: PMFlowThread
messages: list[dict[str, Any]] = Field(default_factory=list)
changeSets: list[dict[str, Any]] = Field(default_factory=list)
changeItems: list[dict[str, Any]] = Field(default_factory=list)
evidence: list[dict[str, Any]] = Field(default_factory=list)
requirements: list[PMFlowRequirement] = Field(default_factory=list)
decisions: list[PMFlowDecision] = Field(default_factory=list)
tasks: list[PMFlowTask] = Field(default_factory=list)
risks: list[PMFlowRisk] = Field(default_factory=list)
modelDecisions: list[PMFlowModelDecision] = Field(default_factory=list)
testRuns: list[PMFlowTestRun] = Field(default_factory=list)
releases: list[PMFlowRelease] = Field(default_factory=list)
metricSignals: list[PMFlowMetricSignal] = Field(default_factory=list)
approvals: list[dict[str, Any]] = Field(default_factory=list)
feed: list[PMFlowFeedEvent] = Field(default_factory=list)
teamInbox: PMFlowTeamInbox = Field(default_factory=PMFlowTeamInbox)
activeChangeSetId: Optional[str] = None
selectedTaskId: Optional[str] = None
execution: PMFlowExecutionContext = Field(default_factory=PMFlowExecutionContext)
projectMemory: PMFlowProjectMemory = Field(default_factory=PMFlowProjectMemory)
projectionWarnings: list[str] = Field(default_factory=list)
class ExecutionSummaryProjection(BaseModel):
projectId: str
execution: PMFlowExecutionContext
devSync: dict[str, Any] = Field(default_factory=dict)
promptPackage: Optional[dict[str, Any]] = None
repoBindings: list[dict[str, Any]] = Field(default_factory=list)
activeDevTasks: list[dict[str, Any]] = Field(default_factory=list)
recentProgressEvents: list[dict[str, Any]] = Field(default_factory=list)
recentDeliveries: list[dict[str, Any]] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)

View File

@ -460,7 +460,7 @@ class KernelIngressStorageMixin:
User.tenant_id == self.tenant_id, User.tenant_id == self.tenant_id,
User.is_active.is_(True), User.is_active.is_(True),
User.status == "active", User.status == "active",
User.role.in_(["BOSS", "ADMIN", "PM"]), User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
) )
).order_by(User.id.asc()).limit(1) ).order_by(User.id.asc()).limit(1)
) )

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
from .service import ( from .service import (
AuthService, AuthService,
MANAGER_ROLES,
can_assign_tasks, can_assign_tasks,
can_manage_users, can_manage_users,
has_any_role, has_any_role,
@ -11,6 +12,7 @@ from .service import (
__all__ = [ __all__ = [
"AuthService", "AuthService",
"MANAGER_ROLES",
"has_permission", "has_permission",
"has_any_role", "has_any_role",
"parse_role_values", "parse_role_values",

View File

@ -146,9 +146,18 @@ class AuthService:
return None return None
MANAGER_ROLES = {"OWNER", "REVIEWER"}
VALID_ROLE_TOKENS = {"OWNER", "BUILDER", "OPERATOR", "REVIEWER"}
def canonicalize_role_token(value: str | None) -> str:
token = str(value or "").strip().upper()
return token if token in VALID_ROLE_TOKENS else token
# Permission matrix for role-based access control # Permission matrix for role-based access control
PERMISSION_MATRIX = { PERMISSION_MATRIX = {
"BOSS": [ "OWNER": [
"view_all_signals", "view_all_signals",
"assign_tasks", "assign_tasks",
"manage_users", "manage_users",
@ -158,8 +167,11 @@ PERMISSION_MATRIX = {
"view_context", "view_context",
"view_settings", "view_settings",
"manage_staff", "manage_staff",
"manage_global_integrations",
"approve_release_gate",
"override_compliance_gate",
], ],
"ADMIN": [ "REVIEWER": [
"view_all_signals", "view_all_signals",
"assign_tasks", "assign_tasks",
"manage_domains", "manage_domains",
@ -167,19 +179,23 @@ PERMISSION_MATRIX = {
"view_context", "view_context",
"view_settings", "view_settings",
"manage_staff", "manage_staff",
"manage_global_integrations",
"approve_release_gate",
], ],
"AI": [ "BUILDER": [
"view_assigned_signals", "view_assigned_signals",
"create_experiments", "create_experiments",
"view_context", "view_context",
"work_on_dev_tasks",
"use_prompt_packages",
"sync_repo_progress",
], ],
"FE": [ "OPERATOR": [
"view_assigned_signals",
"view_assigned_projects",
],
"BE": [
"view_assigned_signals", "view_assigned_signals",
"view_assigned_projects", "view_assigned_projects",
"ingest_feedback",
"record_revenue_events",
"view_operating_metrics",
], ],
} }
@ -192,7 +208,7 @@ def has_permission(user_role: str, permission: str) -> bool:
def has_any_role(user_role: str, allowed_roles: list) -> bool: def has_any_role(user_role: str, allowed_roles: list) -> bool:
"""Check if user_role is in the list of allowed roles""" """Check if user_role is in the list of allowed roles"""
allowed = {str(role).upper() for role in allowed_roles} allowed = {canonicalize_role_token(str(role)) for role in allowed_roles}
return any(role in allowed for role in parse_role_values(user_role)) return any(role in allowed for role in parse_role_values(user_role))
@ -203,17 +219,17 @@ def parse_role_values(user_role: str | None) -> list[str]:
return [] return []
values: list[str] = [] values: list[str] = []
for token in raw.split(","): for token in raw.split(","):
role = token.strip().upper() role = canonicalize_role_token(token)
if role and role not in values: if role and role not in values:
values.append(role) values.append(role)
return values return values
def can_manage_users(user_role: str) -> bool: def can_manage_users(user_role: str) -> bool:
"""Check if user can manage other users (BOSS only)""" """Check if user can manage other users (OWNER only)"""
return has_any_role(user_role, ["BOSS"]) return has_any_role(user_role, ["OWNER"])
def can_assign_tasks(user_role: str) -> bool: def can_assign_tasks(user_role: str) -> bool:
"""Check if user can assign tasks to others""" """Check if user can assign tasks to others"""
return has_any_role(user_role, ["BOSS", "ADMIN"]) return has_any_role(user_role, ["OWNER", "REVIEWER"])

View File

@ -90,7 +90,7 @@ class BudgetEnforcementAlertsMixin:
note: str = "", note: str = "",
duration_hours: int = 24, duration_hours: int = 24,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""BOSS/ADMIN 超支解锁。""" """OWNER/REVIEWER 超支解锁。"""
result = await self.db.execute( result = await self.db.execute(
select(Budget).where( select(Budget).where(
and_( and_(

View File

@ -24,8 +24,8 @@ class DailyBriefingHelpersSummaryMixin:
role_code = self._role_code(user_role) role_code = self._role_code(user_role)
_ = signal.source.lower() _ = signal.source.lower()
if role_code == UserRole.AI.value: if role_code == UserRole.BUILDER.value:
ai_keywords = [ builder_keywords = [
"model", "model",
"llm", "llm",
"gpt", "gpt",
@ -35,12 +35,6 @@ class DailyBriefingHelpersSummaryMixin:
"推理", "推理",
"ollama", "ollama",
"langchain", "langchain",
]
if any(kw in content for kw in ai_keywords):
return True
elif role_code in {UserRole.BE.value, "BACKEND"}:
backend_keywords = [
"api", "api",
"backend", "backend",
"server", "server",
@ -49,12 +43,6 @@ class DailyBriefingHelpersSummaryMixin:
"后端", "后端",
"接口", "接口",
"服务", "服务",
]
if any(kw in content for kw in backend_keywords):
return True
elif role_code in {UserRole.FE.value, "FRONTEND"}:
frontend_keywords = [
"ui", "ui",
"frontend", "frontend",
"react", "react",
@ -64,10 +52,24 @@ class DailyBriefingHelpersSummaryMixin:
"界面", "界面",
"交互", "交互",
] ]
if any(kw in content for kw in frontend_keywords): if any(kw in content for kw in builder_keywords):
return True return True
elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}: elif role_code == UserRole.OPERATOR.value:
operator_keywords = [
"增长",
"留存",
"转化",
"渠道",
"运营",
"反馈",
"用户",
"推广",
]
if any(kw in content for kw in operator_keywords):
return True
elif role_code in {UserRole.REVIEWER.value, UserRole.OWNER.value}:
return signal.impact_score >= 0.8 return signal.impact_score >= 0.8
return signal.impact_score >= 0.7 return signal.impact_score >= 0.7

View File

@ -185,7 +185,7 @@ class DailyBriefingPersonalMixin:
for conflict in conflicts: for conflict in conflicts:
involved_users = parse_involved_user_ids(conflict.involved_user_ids) involved_users = parse_involved_user_ids(conflict.involved_user_ids)
if user.id in involved_users or role_code in [UserRole.ADMIN.value, UserRole.BOSS.value]: if user.id in involved_users or role_code in [UserRole.REVIEWER.value, UserRole.OWNER.value]:
data["conflicts"].append( data["conflicts"].append(
{ {
"id": conflict.id, "id": conflict.id,

View File

@ -43,7 +43,7 @@ class FeishuPipelineCoreBaseMixin:
def _can_execute_high_value_action(user: Optional[User]) -> bool: def _can_execute_high_value_action(user: Optional[User]) -> bool:
if user is None: if user is None:
return False return False
return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"} return str(getattr(user, "role", "") or "").upper() in {"OWNER", "REVIEWER", "OPERATOR"}
@staticmethod @staticmethod
def _stable_positive_int(*parts: Any) -> int: def _stable_positive_int(*parts: Any) -> int:

View File

@ -127,7 +127,7 @@ class FeishuPipelineFollowupBMixin:
User.tenant_id == self.tenant_id, User.tenant_id == self.tenant_id,
User.is_active.is_(True), User.is_active.is_(True),
User.status == "active", User.status == "active",
User.role.in_(["BOSS", "ADMIN", "PM"]), User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
) )
).order_by(User.id.asc()).limit(1) ).order_by(User.id.asc()).limit(1)
) )

View File

@ -258,4 +258,34 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"source_context": ("dict_or_none",), "source_context": ("dict_or_none",),
}, },
}, },
"pmflow_launch_draft_v1": {
"required_keys": (
"title",
"one_liner",
"target_user",
"problem",
"solution",
"requirements",
"tasks",
"risks",
"decisions",
"confidence",
"evidence",
"warnings",
),
"type_checks": {
"title": "str",
"one_liner": "str",
"target_user": "str",
"problem": "str",
"solution": "str",
"requirements": "list",
"tasks": "list",
"risks": "list",
"decisions": "list",
"confidence": "number",
"evidence": "list",
"warnings": "list",
},
},
} }

View File

@ -0,0 +1,463 @@
"""PMFlow OS change review state machine service."""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowThread,
PMFlowThreadMessage,
)
from ..schemas.pmflow_os import (
PMFlowChangeItemEditRequest,
PMFlowExtractionCreateRequest,
PMFlowThreadMessageCreateRequest,
)
from ..utils.timezone import now_cn_naive
class PMFlowChangeReviewError(RuntimeError):
def __init__(self, code: str, message: str, *, status_code: int = 400):
super().__init__(message)
self.code = code
self.message = message
self.status_code = int(status_code)
class PMFlowChangeReviewService:
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def create_thread_message(
self,
*,
project_id: int,
thread_id: str,
body: PMFlowThreadMessageCreateRequest,
user_id: int,
) -> dict[str, Any]:
thread = await self._resolve_or_create_thread(project_id=project_id, thread_id=thread_id)
row = PMFlowThreadMessage(
tenant_id=self.tenant_id,
thread_id=int(thread.id),
project_id=int(project_id),
role="user",
content=body.content.strip(),
source=(body.source or "web")[:32],
attachments=body.attachments,
message_metadata={"created_by": int(user_id), "source": body.source or "web"},
status="sent",
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self.serialize_message(row)
async def create_extraction(
self,
*,
project_id: int,
body: PMFlowExtractionCreateRequest,
user_id: int,
) -> dict[str, Any]:
thread, message = await self._load_thread_message(
project_id=project_id,
thread_id=body.thread_id,
message_id=body.message_id,
)
headline = (body.headline or "本轮结构化提取").strip()[:255]
summary = (body.summary or f"本轮对话生成 {len(body.proposed_items)} 个待审变更项。").strip()
change_set = PMFlowChangeSet(
tenant_id=self.tenant_id,
project_id=int(project_id),
thread_id=int(thread.id),
source_message_id=int(message.id),
status="open",
summary=summary,
headline=headline,
extraction_scope=(body.scope or "latest_message")[:64],
routing_meta=body.routing_meta,
llm_call_id=body.llm_call_id,
created_by=int(user_id),
)
self.db.add(change_set)
await self.db.flush()
items: list[PMFlowChangeItem] = []
for proposed in body.proposed_items:
payload = proposed.payload or {}
item = PMFlowChangeItem(
tenant_id=self.tenant_id,
change_set_id=int(change_set.id),
project_id=int(project_id),
group=proposed.group,
object_type=proposed.object_type,
object_id=proposed.object_id,
title=proposed.title.strip(),
summary=proposed.summary.strip(),
diff=(proposed.diff or proposed.summary).strip(),
payload=payload,
edited_payload=None,
confidence=float(proposed.confidence or 0.0),
risk_level=proposed.risk_level,
status="pending_review",
)
self.db.add(item)
items.append(item)
await self.db.flush()
evidence = PMFlowEvidenceLink(
tenant_id=self.tenant_id,
project_id=int(project_id),
change_set_id=int(change_set.id),
change_item_id=None,
source_type="thread_message",
source_id=str(message.id),
title="项目线程消息",
excerpt=message.content[:1200],
display_label="项目线程",
evidence_metadata={
"thread_id": int(thread.id),
"message_id": int(message.id),
"source": message.source,
},
)
self.db.add(evidence)
await self.db.commit()
return await self.get_change_set_detail(project_id=project_id, change_set_id=int(change_set.id))
async def get_change_set_detail(self, *, project_id: int, change_set_id: int) -> dict[str, Any]:
change_set = await self._load_change_set(project_id=project_id, change_set_id=change_set_id)
items = await self._load_items(project_id=project_id, change_set_id=int(change_set.id))
evidence = await self._load_evidence(project_id=project_id, change_set_id=int(change_set.id))
approvals = await self._load_approvals(project_id=project_id, change_set_id=int(change_set.id))
return {
"changeSet": self.serialize_change_set(change_set, items),
"items": [self.serialize_change_item(item, evidence) for item in items],
"evidence": [self.serialize_evidence(row) for row in evidence],
"approvals": [self.serialize_approval(row) for row in approvals],
}
async def get_item_project_id(self, *, change_item_id: int) -> Optional[int]:
result = await self.db.execute(
select(PMFlowChangeItem.project_id).where(
and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.id == int(change_item_id))
)
)
value = result.scalar_one_or_none()
return int(value) if value is not None else None
async def accept_item(self, *, change_item_id: int, user_id: int) -> dict[str, Any]:
item, change_set = await self._load_item_with_set(change_item_id=change_item_id)
await self._review_item(item=item, change_set=change_set, action="accept", decision="approved", user_id=user_id)
await self.db.commit()
return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id))
async def edit_item(self, *, change_item_id: int, body: PMFlowChangeItemEditRequest, user_id: int) -> dict[str, Any]:
item, change_set = await self._load_item_with_set(change_item_id=change_item_id)
before_payload = dict(item.edited_payload or item.payload or {})
base_payload = dict(item.edited_payload or item.payload or {})
base_payload.update(body.payload_patch or {})
if body.summary is not None:
item.summary = body.summary.strip()
base_payload["summary"] = item.summary
if body.diff is not None:
item.diff = body.diff.strip()
base_payload["diff"] = item.diff
item.edited_payload = base_payload
await self._review_item(
item=item,
change_set=change_set,
action="edit",
decision="edited",
user_id=user_id,
reason=body.reason,
before_payload_override=before_payload,
)
await self.db.commit()
return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id))
async def defer_item(self, *, change_item_id: int, user_id: int, reason: Optional[str] = None) -> dict[str, Any]:
item, change_set = await self._load_item_with_set(change_item_id=change_item_id)
await self._review_item(
item=item,
change_set=change_set,
action="defer",
decision="deferred",
user_id=user_id,
reason=reason,
)
await self.db.commit()
return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id))
async def _resolve_or_create_thread(self, *, project_id: int, thread_id: str) -> PMFlowThread:
cleaned = str(thread_id or "").strip()
if cleaned.isdigit():
result = await self.db.execute(
select(PMFlowThread).where(
and_(
PMFlowThread.tenant_id == self.tenant_id,
PMFlowThread.project_id == int(project_id),
PMFlowThread.id == int(cleaned),
)
)
)
thread = result.scalar_one_or_none()
if thread is None:
raise PMFlowChangeReviewError("thread_not_found", "thread_not_found", status_code=404)
return thread
if cleaned not in {"default", f"thread-project-{int(project_id)}"}:
raise PMFlowChangeReviewError("thread_not_found", "thread_not_found", status_code=404)
result = await self.db.execute(
select(PMFlowThread)
.where(and_(PMFlowThread.tenant_id == self.tenant_id, PMFlowThread.project_id == int(project_id), PMFlowThread.status == "active"))
.order_by(PMFlowThread.created_at.asc(), PMFlowThread.id.asc())
.limit(1)
)
thread = result.scalar_one_or_none()
if thread is not None:
return thread
thread = PMFlowThread(
tenant_id=self.tenant_id,
project_id=int(project_id),
title="项目默认线程",
objective="项目内 PMFlow OS 对话线程。",
status="active",
source="web",
)
self.db.add(thread)
await self.db.flush()
return thread
async def _load_thread_message(self, *, project_id: int, thread_id: str, message_id: str) -> tuple[PMFlowThread, PMFlowThreadMessage]:
if not str(thread_id).isdigit() or not str(message_id).isdigit():
raise PMFlowChangeReviewError("message_not_found", "message_not_found", status_code=404)
result = await self.db.execute(
select(PMFlowThread, PMFlowThreadMessage)
.join(PMFlowThreadMessage, PMFlowThreadMessage.thread_id == PMFlowThread.id)
.where(
and_(
PMFlowThread.tenant_id == self.tenant_id,
PMFlowThread.project_id == int(project_id),
PMFlowThread.id == int(thread_id),
PMFlowThreadMessage.tenant_id == self.tenant_id,
PMFlowThreadMessage.project_id == int(project_id),
PMFlowThreadMessage.id == int(message_id),
)
)
)
row = result.first()
if row is None:
raise PMFlowChangeReviewError("message_not_found", "message_not_found", status_code=404)
return row[0], row[1]
async def _load_change_set(self, *, project_id: int, change_set_id: int) -> PMFlowChangeSet:
result = await self.db.execute(
select(PMFlowChangeSet).where(
and_(
PMFlowChangeSet.tenant_id == self.tenant_id,
PMFlowChangeSet.project_id == int(project_id),
PMFlowChangeSet.id == int(change_set_id),
)
)
)
row = result.scalar_one_or_none()
if row is None:
raise PMFlowChangeReviewError("changeset_not_found", "changeset_not_found", status_code=404)
return row
async def _load_items(self, *, project_id: int, change_set_id: int) -> list[PMFlowChangeItem]:
result = await self.db.execute(
select(PMFlowChangeItem)
.where(
and_(
PMFlowChangeItem.tenant_id == self.tenant_id,
PMFlowChangeItem.project_id == int(project_id),
PMFlowChangeItem.change_set_id == int(change_set_id),
)
)
.order_by(PMFlowChangeItem.created_at.asc(), PMFlowChangeItem.id.asc())
)
return list(result.scalars().all())
async def _load_evidence(self, *, project_id: int, change_set_id: int) -> list[PMFlowEvidenceLink]:
result = await self.db.execute(
select(PMFlowEvidenceLink)
.where(
and_(
PMFlowEvidenceLink.tenant_id == self.tenant_id,
PMFlowEvidenceLink.project_id == int(project_id),
PMFlowEvidenceLink.change_set_id == int(change_set_id),
)
)
.order_by(PMFlowEvidenceLink.created_at.asc(), PMFlowEvidenceLink.id.asc())
)
return list(result.scalars().all())
async def _load_approvals(self, *, project_id: int, change_set_id: int) -> list[PMFlowApproval]:
result = await self.db.execute(
select(PMFlowApproval)
.where(
and_(
PMFlowApproval.tenant_id == self.tenant_id,
PMFlowApproval.project_id == int(project_id),
PMFlowApproval.change_set_id == int(change_set_id),
)
)
.order_by(PMFlowApproval.created_at.asc(), PMFlowApproval.id.asc())
)
return list(result.scalars().all())
async def _load_item_with_set(self, *, change_item_id: int) -> tuple[PMFlowChangeItem, PMFlowChangeSet]:
result = await self.db.execute(
select(PMFlowChangeItem, PMFlowChangeSet)
.join(PMFlowChangeSet, PMFlowChangeSet.id == PMFlowChangeItem.change_set_id)
.where(and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.id == int(change_item_id)))
.with_for_update()
)
row = result.first()
if row is None:
raise PMFlowChangeReviewError("change_item_not_found", "change_item_not_found", status_code=404)
return row[0], row[1]
async def _review_item(
self,
*,
item: PMFlowChangeItem,
change_set: PMFlowChangeSet,
action: str,
decision: str,
user_id: int,
reason: Optional[str] = None,
before_payload_override: Optional[dict[str, Any]] = None,
) -> None:
if change_set.status != "open":
raise PMFlowChangeReviewError("changeset_not_open", "changeset_not_open", status_code=409)
if item.status != "pending_review":
raise PMFlowChangeReviewError("item_already_reviewed", "item_already_reviewed", status_code=409)
before_status = item.status
before_payload = dict(before_payload_override or item.edited_payload or item.payload or {})
next_status = "accepted" if action == "accept" else ("edited" if action == "edit" else "deferred")
item.status = next_status
item.reviewed_at = now_cn_naive()
after_payload = dict(item.edited_payload or item.payload or {})
self.db.add(
PMFlowApproval(
tenant_id=self.tenant_id,
project_id=int(item.project_id),
change_set_id=int(item.change_set_id),
change_item_id=int(item.id),
actor_user_id=int(user_id),
action=action,
decision=decision,
before_status=before_status,
after_status=next_status,
before_payload=before_payload,
after_payload=after_payload,
reason=reason,
)
)
await self.db.flush()
pending_count = await self.db.scalar(
select(func.count(PMFlowChangeItem.id)).where(
and_(
PMFlowChangeItem.tenant_id == self.tenant_id,
PMFlowChangeItem.change_set_id == int(change_set.id),
PMFlowChangeItem.status == "pending_review",
)
)
)
if int(pending_count or 0) == 0:
change_set.status = "reviewed"
change_set.reviewed_at = now_cn_naive()
@staticmethod
def serialize_message(row: PMFlowThreadMessage) -> dict[str, Any]:
return {
"id": str(row.id),
"threadId": str(row.thread_id),
"projectId": str(row.project_id),
"role": row.role,
"author": "你" if row.role == "user" else "PMFlow",
"content": row.content,
"createdAt": row.created_at.isoformat() if row.created_at else "",
"status": "sent",
"source": row.source,
"metadata": row.message_metadata or {},
}
@staticmethod
def serialize_change_set(row: PMFlowChangeSet, items: list[PMFlowChangeItem]) -> dict[str, Any]:
return {
"id": str(row.id),
"threadId": str(row.thread_id),
"summary": row.summary,
"headline": row.headline,
"createdAt": row.created_at.isoformat() if row.created_at else "",
"status": "open" if row.status == "open" else "reviewed",
"itemIds": [str(item.id) for item in items],
"sourceMessageId": str(row.source_message_id),
"rawStatus": row.status,
}
@staticmethod
def serialize_change_item(row: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink]) -> dict[str, Any]:
evidence_ids = [str(item.id) for item in evidence if item.change_item_id in {None, row.id}]
return {
"id": str(row.id),
"changeSetId": str(row.change_set_id),
"group": row.group,
"objectType": row.object_type,
"objectId": str(row.object_id or f"pending-{row.id}"),
"title": row.title,
"summary": row.summary,
"diff": row.diff,
"confidence": float(row.confidence or 0.0),
"riskLevel": row.risk_level,
"status": row.status if row.status in {"pending_review", "accepted", "edited", "deferred"} else "deferred",
"evidenceIds": evidence_ids,
"payload": row.payload or {},
"editedPayload": row.edited_payload,
}
@staticmethod
def serialize_evidence(row: PMFlowEvidenceLink) -> dict[str, Any]:
kind = "conversation" if row.source_type == "thread_message" else "document"
return {
"id": str(row.id),
"kind": kind,
"title": row.title,
"excerpt": row.excerpt,
"sourceLabel": row.display_label,
"sourceType": row.source_type,
"sourceId": row.source_id,
"changeSetId": str(row.change_set_id),
"changeItemId": str(row.change_item_id) if row.change_item_id is not None else None,
"metadata": row.evidence_metadata or {},
}
@staticmethod
def serialize_approval(row: PMFlowApproval) -> dict[str, Any]:
return {
"id": str(row.id),
"title": f"{row.action} {row.change_item_id}",
"summary": row.reason or f"{row.before_status} -> {row.after_status}",
"stage": "planning",
"status": "confirmed" if row.decision in {"approved", "edited"} else "draft",
"owner": str(row.actor_user_id),
"changeSetId": str(row.change_set_id),
"changeItemId": str(row.change_item_id),
"action": row.action,
"decision": row.decision,
"createdAt": row.created_at.isoformat() if row.created_at else "",
}

View File

@ -0,0 +1,661 @@
"""PMFlow Project Launch draft and confirm service."""
from __future__ import annotations
import hashlib
import json
from datetime import timedelta
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import PROMPT_PMFLOW_LAUNCH_DRAFT
from ..models import (
PMFlowLaunchDraft,
PMFlowThread,
PMFlowThreadMessage,
Project,
SemanticFact,
TruthNode,
UserProject,
)
from ..schemas.pmflow_launch import (
PMFlowLaunchConfirmResponse,
PMFlowLaunchDraftPayload,
PMFlowLaunchDraftRequest,
PMFlowLaunchDraftResponse,
)
from ..services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
from ..services.context_cache_service import context_cache_service
from ..services.llm_service import llm_service
from ..services.operation_log_service import log_operation
from ..services.task_persistence_core import build_task_create_draft, persist_task
from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import now_cn_naive
from ..utils.title import compact_title
class PMFlowLaunchError(RuntimeError):
def __init__(self, code: str, message: str, *, status_code: int = 400):
super().__init__(message)
self.code = code
self.message = message
self.status_code = int(status_code)
class PMFlowLaunchService:
"""Owns the two-step Project Launch write chain."""
DRAFT_TTL_HOURS = 24
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def create_draft(
self,
*,
body: PMFlowLaunchDraftRequest,
user_id: int,
) -> PMFlowLaunchDraftResponse:
prompt = PROMPT_PMFLOW_LAUNCH_DRAFT.format(
input_text=str(body.input or "")[:4000],
attachments_json=json.dumps([item.model_dump(mode="json") for item in body.attachments], ensure_ascii=False),
)
try:
structured = await llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="pmflow_launch_draft_v1",
temperature=0.0,
max_tokens=2200,
response_mode="fast",
routing_meta={
"scene": "pmflow_project_launch",
"tier_alias": "low",
"response_contract": "structured",
},
)
except RuntimeError as exc:
raise PMFlowLaunchError("draft_generation_failed", str(exc), status_code=502) from exc
except Exception as exc:
raise PMFlowLaunchError("draft_generation_failed", f"项目草案生成失败: {exc}", status_code=502) from exc
payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {}
draft_payload = self._normalize_draft_payload(payload)
self._validate_draft_payload(draft_payload, require_confirmable=False)
warnings = self._draft_count_warnings(draft_payload)
merged_warnings = self._merge_warnings(draft_payload.warnings, warnings)
draft_payload.warnings = merged_warnings
expires_at = now_cn_naive() + timedelta(hours=self.DRAFT_TTL_HOURS)
draft_id = f"draft_{uuid4().hex[:16]}"
row = PMFlowLaunchDraft(
tenant_id=self.tenant_id,
user_id=int(user_id),
draft_id=draft_id,
input_text=str(body.input or "").strip(),
input_type=str(body.input_type or "text"),
source=str(body.source or "web")[:32],
attachments=[item.model_dump(mode="json") for item in body.attachments],
draft_payload=draft_payload.model_dump(mode="json"),
edited_payload=None,
status="draft",
confidence=float(draft_payload.confidence),
warnings=merged_warnings,
expires_at=expires_at,
)
self.db.add(row)
await log_operation(
self.db,
tenant_id=self.tenant_id,
user_id=int(user_id),
project_id=None,
entity_type="pmflow_launch_draft",
entity_id=draft_id,
action="pmflow_launch.draft.create",
before_data=None,
after_data={
"draft_id": draft_id,
"title": draft_payload.title,
"warnings": merged_warnings,
},
interface_path="/pmflow-launch/drafts",
caller="user",
)
try:
await self.db.commit()
except Exception:
await self.db.rollback()
raise
return PMFlowLaunchDraftResponse(
**draft_payload.model_dump(mode="json"),
draft_id=draft_id,
status="draft",
expires_at=expires_at.isoformat(),
)
async def confirm_draft(
self,
*,
draft_id: str,
edited_draft: dict[str, Any],
user_id: int,
) -> PMFlowLaunchConfirmResponse:
row = await self._load_draft_for_update(draft_id=draft_id, user_id=user_id)
if row.status == "confirmed" and row.confirmed_project_id is not None:
return PMFlowLaunchConfirmResponse(
project_id=int(row.confirmed_project_id),
redirect_url=f"/pmflow-os?projectId={int(row.confirmed_project_id)}",
warnings=self._safe_str_list(row.warnings),
)
self._assert_draft_confirmable(row)
payload = self._normalize_draft_payload(self._merge_draft(row.draft_payload, edited_draft))
self._validate_draft_payload(payload, require_confirmable=True)
warnings = self._merge_warnings(payload.warnings, self._draft_count_warnings(payload))
request_hash = self._confirm_hash(payload.model_dump(mode="json"))
created_truth_node_ids: list[int] = []
created_task_ids: list[int] = []
thread_id: Optional[int] = None
thread_message_id: Optional[int] = None
try:
await ensure_project_id_sequence(self.db)
title = compact_title(payload.title, max_len=24) or "新项目"
project = Project(
tenant_id=self.tenant_id,
name=title,
title=title,
description=self._project_description(payload),
owner_id=int(user_id),
status="active",
assigned_role="BUILDER",
signal_count=0,
)
self.db.add(project)
await self.db.flush()
self.db.add(UserProject(tenant_id=self.tenant_id, user_id=int(user_id), project_id=int(project.id)))
await self.db.flush()
thread = PMFlowThread(
tenant_id=self.tenant_id,
project_id=int(project.id),
launch_draft_id=int(row.id),
title=f"{title} 项目线程",
objective=payload.one_liner,
status="active",
source="project_launch",
)
self.db.add(thread)
await self.db.flush()
thread_id = int(thread.id)
message = PMFlowThreadMessage(
tenant_id=self.tenant_id,
thread_id=int(thread.id),
project_id=int(project.id),
launch_draft_id=int(row.id),
role="user",
content=row.input_text,
source=row.source,
attachments=row.attachments or [],
message_metadata={
"launch_draft_id": row.draft_id,
"input_type": row.input_type,
"source": row.source,
},
status="sent",
)
self.db.add(message)
await self.db.flush()
thread_message_id = int(message.id)
metadata = self._source_metadata(
launch_draft=row,
thread_id=thread_id,
thread_message_id=thread_message_id,
payload=payload,
)
requirement_nodes = await self._create_truth_nodes(
project_id=int(project.id),
user_id=int(user_id),
payload=payload,
metadata=metadata,
created_truth_node_ids=created_truth_node_ids,
)
await self._create_tasks(
project_id=int(project.id),
user_id=int(user_id),
payload=payload,
metadata=metadata,
requirement_nodes=requirement_nodes,
created_task_ids=created_task_ids,
)
self._create_semantic_facts(project_id=int(project.id), payload=payload, metadata=metadata)
row.status = "confirmed"
row.edited_payload = payload.model_dump(mode="json")
row.warnings = warnings
row.confidence = float(payload.confidence)
row.confirmed_project_id = int(project.id)
row.confirmed_at = now_cn_naive()
row.confirm_request_hash = request_hash
await log_operation(
self.db,
tenant_id=self.tenant_id,
user_id=int(user_id),
project_id=int(project.id),
entity_type="pmflow_launch_draft",
entity_id=row.draft_id,
action="pmflow_launch.confirm",
before_data={"status": "draft"},
after_data={
"status": "confirmed",
"project_id": int(project.id),
"thread_id": thread_id,
"thread_message_id": thread_message_id,
"created_truth_node_ids": created_truth_node_ids,
"created_task_ids": created_task_ids,
"confirm_request_hash": request_hash,
},
interface_path=f"/pmflow-launch/drafts/{row.draft_id}/confirm",
caller="user",
)
await self.db.commit()
except Exception:
await self.db.rollback()
raise
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=self.tenant_id, project_id=int(row.confirmed_project_id))
return PMFlowLaunchConfirmResponse(
project_id=int(row.confirmed_project_id),
redirect_url=f"/pmflow-os?projectId={int(row.confirmed_project_id)}",
created_truth_node_ids=created_truth_node_ids,
created_task_ids=created_task_ids,
thread_id=thread_id,
thread_message_id=thread_message_id,
warnings=warnings,
)
async def _load_draft_for_update(self, *, draft_id: str, user_id: int) -> PMFlowLaunchDraft:
result = await self.db.execute(
select(PMFlowLaunchDraft)
.where(
and_(
PMFlowLaunchDraft.tenant_id == self.tenant_id,
PMFlowLaunchDraft.user_id == int(user_id),
PMFlowLaunchDraft.draft_id == str(draft_id),
)
)
.with_for_update()
)
row = result.scalar_one_or_none()
if row is None:
raise PMFlowLaunchError("draft_not_found", "项目草案不存在或不属于当前用户", status_code=404)
return row
def _assert_draft_confirmable(self, row: PMFlowLaunchDraft) -> None:
if row.status in {"expired", "cancelled", "failed"}:
raise PMFlowLaunchError("draft_not_confirmable", f"项目草案状态不可确认: {row.status}", status_code=409)
if row.status != "draft":
raise PMFlowLaunchError("draft_not_confirmable", f"项目草案状态不可确认: {row.status}", status_code=409)
expires_at = row.expires_at
if expires_at is not None and expires_at <= now_cn_naive():
row.status = "expired"
raise PMFlowLaunchError("draft_expired", "项目草案已过期,请重新生成", status_code=409)
def _normalize_draft_payload(self, raw: dict[str, Any]) -> PMFlowLaunchDraftPayload:
payload = dict(raw or {})
payload["requirements"] = [
item for item in (self._normalize_requirement(row) for row in self._safe_dict_list(payload.get("requirements"))) if item["title"]
]
payload["tasks"] = [
item for item in (self._normalize_task(row) for row in self._safe_dict_list(payload.get("tasks"))) if item["title"]
]
payload["risks"] = [
item for item in (self._normalize_risk(row) for row in self._safe_dict_list(payload.get("risks"))) if item["title"]
]
payload["decisions"] = [
item for item in (self._normalize_decision(row) for row in self._safe_dict_list(payload.get("decisions"))) if item["title"]
]
payload["evidence"] = [
item for item in (self._normalize_evidence(row) for row in self._safe_dict_list(payload.get("evidence"))) if item["excerpt"]
]
payload["warnings"] = self._safe_str_list(payload.get("warnings"))
try:
payload["confidence"] = max(0.0, min(float(payload.get("confidence") or 0), 1.0))
except (TypeError, ValueError):
payload["confidence"] = 0.0
return PMFlowLaunchDraftPayload.model_validate(payload)
def _validate_draft_payload(self, payload: PMFlowLaunchDraftPayload, *, require_confirmable: bool) -> None:
missing = []
for field in ("title", "one_liner", "target_user", "problem", "solution"):
if not str(getattr(payload, field) or "").strip():
missing.append(field)
if not payload.evidence:
missing.append("evidence")
if require_confirmable:
if not payload.requirements:
missing.append("requirements")
if not payload.tasks:
missing.append("tasks")
if not payload.risks:
missing.append("risks")
if not payload.decisions:
missing.append("decisions")
if missing:
raise PMFlowLaunchError(
"draft_structured_failed",
f"项目草案结构化字段缺失: {', '.join(missing)}",
status_code=422,
)
def _draft_count_warnings(self, payload: PMFlowLaunchDraftPayload) -> list[str]:
warnings: list[str] = []
if not 3 <= len(payload.requirements) <= 5:
warnings.append(f"requirements_count_out_of_range: expected 3-5, got {len(payload.requirements)}")
if not 3 <= len(payload.tasks) <= 7:
warnings.append(f"tasks_count_out_of_range: expected 3-7, got {len(payload.tasks)}")
if not 2 <= len(payload.risks) <= 4:
warnings.append(f"risks_count_out_of_range: expected 2-4, got {len(payload.risks)}")
if not 1 <= len(payload.decisions) <= 3:
warnings.append(f"decisions_count_out_of_range: expected 1-3, got {len(payload.decisions)}")
return warnings
async def _create_truth_nodes(
self,
*,
project_id: int,
user_id: int,
payload: PMFlowLaunchDraftPayload,
metadata: dict[str, Any],
created_truth_node_ids: list[int],
) -> list[TruthNode]:
requirements: list[TruthNode] = []
all_nodes: list[TruthNode] = []
for item in payload.requirements:
node = TruthNode(
tenant_id=self.tenant_id,
node_type="requirement",
project_id=int(project_id),
created_by=int(user_id),
title=compact_title(item.title, max_len=120) or "初始需求",
content={
"summary": item.summary,
"acceptance_criteria": item.acceptance_criteria,
"stage": "planning",
"metadata": metadata,
},
version=1,
status="confirmed",
)
self.db.add(node)
requirements.append(node)
all_nodes.append(node)
for item in payload.risks:
node = TruthNode(
tenant_id=self.tenant_id,
node_type="risk",
project_id=int(project_id),
created_by=int(user_id),
title=compact_title(item.title, max_len=120) or "初始风险",
content={
"summary": item.summary,
"risk_level": item.level,
"mitigation": item.mitigation,
"stage": "planning",
"metadata": metadata,
},
version=1,
status="confirmed",
)
self.db.add(node)
all_nodes.append(node)
for item in payload.decisions:
node = TruthNode(
tenant_id=self.tenant_id,
node_type="decision",
project_id=int(project_id),
created_by=int(user_id),
title=compact_title(item.title, max_len=120) or "初始决策",
content={
"summary": item.reason,
"owner": item.owner or "项目负责人",
"stage": "planning",
"metadata": metadata,
},
version=1,
status="confirmed",
)
self.db.add(node)
all_nodes.append(node)
await self.db.flush()
created_truth_node_ids.extend([int(node.id) for node in all_nodes])
return requirements
async def _create_tasks(
self,
*,
project_id: int,
user_id: int,
payload: PMFlowLaunchDraftPayload,
metadata: dict[str, Any],
requirement_nodes: list[TruthNode],
created_task_ids: list[int],
) -> None:
if not requirement_nodes:
raise PMFlowLaunchError("draft_structured_failed", "项目草案缺少可绑定任务的需求", status_code=422)
for index, item in enumerate(payload.tasks):
req_index = item.requirement_index if item.requirement_index is not None else index % len(requirement_nodes)
if req_index is None or req_index < 0 or req_index >= len(requirement_nodes):
req_index = 0
task_payload = {
"source": "pmflow_launch_confirmed_draft",
"launch_metadata": metadata,
"task": item.model_dump(mode="json"),
"acceptance_criteria": item.acceptance_criteria,
}
task = await persist_task(
self.db,
tenant_id=self.tenant_id,
truth_node_id=int(requirement_nodes[int(req_index)].id),
project_id=int(project_id),
draft=build_task_create_draft(
title=item.title,
description=item.summary,
required_skill_tag_ids=[],
ai_breakdown_payload=task_payload,
manual_overrides={"launch_metadata": metadata, "acceptance_criteria": item.acceptance_criteria},
priority=item.priority,
status="todo",
),
actor_user_id=int(user_id),
action="pmflow_launch.task.create",
remark="pmflow_launch.confirm",
commit=False,
refresh_workload=False,
)
created_task_ids.append(int(task.id))
def _create_semantic_facts(
self,
*,
project_id: int,
payload: PMFlowLaunchDraftPayload,
metadata: dict[str, Any],
) -> None:
facts = {
"one_liner": payload.one_liner,
"target_user": payload.target_user,
"problem": payload.problem,
"solution": payload.solution,
}
for key, value in facts.items():
text = str(value or "").strip()
if not text:
continue
self.db.add(
SemanticFact(
tenant_id=self.tenant_id,
entity_type="project",
entity_id=int(project_id),
fact_namespace="pmflow_launch_seed",
fact_key=key,
fact_value_text=text,
fact_value_norm=text[:255],
value_type="text",
confidence=float(payload.confidence),
evidence_source_type="pmflow_thread_message",
evidence_source_id=metadata.get("thread_message_id"),
evidence_excerpt=self._evidence_excerpt(payload),
extract_version="pmflow_launch_v1",
freshness_at=now_cn_naive().isoformat(),
is_active=True,
)
)
@staticmethod
def _merge_draft(base: Any, edited: dict[str, Any]) -> dict[str, Any]:
merged = dict(base or {})
for key, value in dict(edited or {}).items():
if value is not None:
merged[key] = value
return merged
@staticmethod
def _safe_dict_list(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [dict(item) for item in value if isinstance(item, dict)]
@staticmethod
def _safe_str_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item or "").strip()]
@staticmethod
def _merge_warnings(*groups: list[str]) -> list[str]:
seen: set[str] = set()
merged: list[str] = []
for group in groups:
for item in group or []:
text = str(item or "").strip()
if text and text not in seen:
seen.add(text)
merged.append(text)
return merged
@staticmethod
def _normalize_requirement(item: dict[str, Any]) -> dict[str, Any]:
return {
"title": str(item.get("title") or "").strip(),
"summary": str(item.get("summary") or item.get("description") or "").strip(),
"acceptance_criteria": PMFlowLaunchService._safe_str_list(
item.get("acceptance_criteria") or item.get("acceptanceCriteria") or item.get("criteria")
),
}
@staticmethod
def _normalize_task(item: dict[str, Any]) -> dict[str, Any]:
group = str(item.get("group") or "product").strip().lower()
group_map = {"产品任务": "product", "开发任务": "engineering", "测试任务": "qa"}
group = group_map.get(group, group)
if group not in {"product", "engineering", "qa"}:
group = "product"
priority = str(item.get("priority") or "medium").strip().lower()
if priority not in {"low", "medium", "high", "urgent"}:
priority = "medium"
req_index = item.get("requirement_index")
try:
req_index = int(req_index) if req_index is not None else None
except (TypeError, ValueError):
req_index = None
return {
"title": str(item.get("title") or "").strip(),
"summary": str(item.get("summary") or item.get("description") or "").strip(),
"group": group,
"priority": priority,
"acceptance_criteria": PMFlowLaunchService._safe_str_list(
item.get("acceptance_criteria") or item.get("acceptanceCriteria") or item.get("criteria")
),
"requirement_index": req_index,
}
@staticmethod
def _normalize_risk(item: dict[str, Any]) -> dict[str, Any]:
level = str(item.get("level") or item.get("risk_level") or "medium").strip().lower()
level_map = {"高": "high", "中": "medium", "低": "low"}
level = level_map.get(level, level)
if level not in {"low", "medium", "high"}:
level = "medium"
return {
"title": str(item.get("title") or "").strip(),
"summary": str(item.get("summary") or item.get("description") or "").strip(),
"level": level,
"mitigation": str(item.get("mitigation") or item.get("mitigation_plan") or "").strip(),
}
@staticmethod
def _normalize_decision(item: dict[str, Any]) -> dict[str, Any]:
return {
"title": str(item.get("title") or "").strip(),
"reason": str(item.get("reason") or item.get("summary") or item.get("rationale") or "").strip(),
"owner": str(item.get("owner") or "").strip(),
}
@staticmethod
def _normalize_evidence(item: dict[str, Any]) -> dict[str, Any]:
return {
"source": str(item.get("source") or "user_input").strip() or "user_input",
"excerpt": str(item.get("excerpt") or item.get("summary") or "").strip(),
}
@staticmethod
def _project_description(payload: PMFlowLaunchDraftPayload) -> str:
return "\n".join(
[
payload.one_liner,
f"目标用户:{payload.target_user}",
f"核心问题:{payload.problem}",
f"解决方案:{payload.solution}",
]
)
@staticmethod
def _evidence_excerpt(payload: PMFlowLaunchDraftPayload) -> str:
if payload.evidence:
return payload.evidence[0].excerpt[:500]
return payload.one_liner[:500]
def _source_metadata(
self,
*,
launch_draft: PMFlowLaunchDraft,
thread_id: int,
thread_message_id: int,
payload: PMFlowLaunchDraftPayload,
) -> dict[str, Any]:
return {
"source": "pmflow_launch_confirmed_draft",
"launch_draft_id": launch_draft.draft_id,
"launch_draft_row_id": int(launch_draft.id),
"thread_id": int(thread_id),
"thread_message_id": int(thread_message_id),
"confidence": float(payload.confidence),
"evidence": [item.model_dump(mode="json") for item in payload.evidence],
}
@staticmethod
def _confirm_hash(payload: dict[str, Any]) -> str:
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
__all__ = ["PMFlowLaunchError", "PMFlowLaunchService"]

View File

@ -0,0 +1,966 @@
"""PMFlow OS read-only projection service."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
FeedbackIngest,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowThreadMessage,
PMFlowThread as PMFlowThreadModel,
ProgressEvent,
Project,
ProjectModelBinding,
PromptPackage,
ReleaseValidationRun,
RevenueEvent,
ROISnapshot,
SemanticFact,
Task,
TruthNode,
User,
)
from ..schemas.pmflow_os import (
ExecutionSummaryProjection,
PMFlowDecision,
PMFlowExecutionContext,
PMFlowFeedEvent,
PMFlowMetricSignal,
PMFlowModelDecision,
PMFlowProjectMemory,
PMFlowProjectSummary,
PMFlowRelease,
PMFlowRequirement,
PMFlowRisk,
PMFlowTask,
PMFlowTestRun,
PMFlowThread,
ProjectTruthStateProjection,
)
from .ai_project_control_service import AIProjectControlService
from .pmflow_change_review_service import PMFlowChangeReviewService
class PMFlowOSProjectionService:
"""Builds PMFlow OS compatible read models from existing production tables."""
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def get_truth_state(
self,
*,
project_id: int,
current_user_id: Optional[int] = None,
) -> ProjectTruthStateProjection:
project = await self._load_project(project_id=project_id)
truth_nodes = await self._load_truth_nodes(project_id=project_id)
tasks = await self._load_tasks(project_id=project_id)
task_items = self._project_tasks(tasks)
execution_summary = await self.get_execution_summary(
project_id=project_id,
current_user_id=current_user_id,
)
project_stage = self._infer_project_stage(truth_nodes=truth_nodes, tasks=tasks, execution=execution_summary.execution)
launch_thread, launch_messages = await self._load_project_thread(project_id=project_id)
thread_id = str(launch_thread.id) if launch_thread is not None else f"thread-project-{project.id}"
requirements = self._project_requirements(truth_nodes=truth_nodes, tasks=task_items)
releases = await self._project_releases(truth_nodes=truth_nodes, project_id=project_id)
test_runs = await self._project_test_runs(project_id=project_id)
metric_signals = await self._project_metric_signals(project_id=project_id)
memory = await self._project_memory(
project=project,
truth_nodes=truth_nodes,
task_ids=[int(task.id) for task, _ in tasks],
)
review_projection = await self._load_review_projection(project_id=project_id)
active_change_set_id = self._active_change_set_id(review_projection["change_sets"])
loop_stage = self._review_loop_stage(
change_sets=review_projection["change_sets"],
change_items=review_projection["change_items"],
)
warnings: list[str] = []
if launch_thread is None:
warnings.append("thread_projection_empty: no pmflow_threads row was found; returning an empty canonical thread.")
if not launch_messages:
warnings.append("thread_messages_empty: no pmflow_thread_messages rows were found for this project.")
if not truth_nodes:
warnings.append("truth_nodes_empty: no confirmed project truth nodes were found for this project.")
if not tasks:
warnings.append("tasks_empty: no task rows were found for this project.")
return ProjectTruthStateProjection(
loopStage=loop_stage,
lastWritebackAt=self._latest_iso([node.updated_at for node in truth_nodes] + [task.updated_at for task, _ in tasks]),
project=PMFlowProjectSummary(
id=str(project.id),
name=project.title or project.name,
goal=self._project_goal(project=project, truth_nodes=truth_nodes),
stage=project_stage,
threadId=thread_id,
),
thread=PMFlowThread(
id=thread_id,
title=launch_thread.title if launch_thread is not None else f"{project.title or project.name} 项目线程",
projectId=str(project.id),
objective=launch_thread.objective if launch_thread is not None else self._project_goal(project=project, truth_nodes=truth_nodes),
extractionStatus="idle",
messageIds=[str(message.id) for message in launch_messages],
),
messages=self._project_messages(launch_messages),
changeSets=review_projection["change_sets"],
changeItems=review_projection["change_items"],
evidence=review_projection["evidence"],
requirements=requirements,
decisions=self._project_decisions(truth_nodes),
tasks=task_items,
risks=self._project_risks(truth_nodes),
modelDecisions=await self._project_model_decisions(project_id=project_id),
testRuns=test_runs,
releases=releases,
metricSignals=metric_signals,
feed=self._project_feed(
execution_summary=execution_summary,
test_runs=test_runs,
releases=releases,
metric_signals=metric_signals,
),
approvals=review_projection["approvals"],
activeChangeSetId=active_change_set_id,
selectedTaskId=execution_summary.execution.taskId,
execution=execution_summary.execution,
projectMemory=memory,
projectionWarnings=warnings + execution_summary.warnings,
)
async def get_execution_summary(
self,
*,
project_id: int,
current_user_id: Optional[int] = None,
) -> ExecutionSummaryProjection:
await self._load_project(project_id=project_id)
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
dev_sync = await control.get_project_dev_sync_summary(
project_id=int(project_id),
current_user_id=current_user_id,
)
latest_package = await self._load_latest_prompt_package(project_id=project_id)
warnings: list[str] = []
events = list(dev_sync.get("recent_progress_events") or [])
active_tasks = list(dev_sync.get("active_dev_tasks") or [])
repos = list(dev_sync.get("repo_bindings") or [])
deliveries = list(dev_sync.get("recent_deliveries") or [])
if not repos:
warnings.append("repo_bindings_empty: no active repo binding is available for this project.")
if not active_tasks:
warnings.append("active_dev_tasks_empty: no active dev task is available in dev-sync summary.")
if not latest_package:
warnings.append("prompt_package_empty: no prompt package has been generated for this project.")
if not events:
warnings.append("progress_events_empty: no execution progress events have been recorded.")
task_id = self._pick_execution_task_id(active_tasks=active_tasks, latest_package=latest_package, events=events)
latest_event = events[0] if events else None
status = self._map_execution_status(
task_status=self._pick_task_status(active_tasks, task_id),
event=latest_event,
)
prompt = latest_package.package_text if latest_package is not None else ""
commit_log = self._event_commit_log(events)
pr_status = self._project_pr_status(active_tasks=active_tasks, task_id=task_id, events=events)
test_summary = self._project_test_summary(events)
writeback = self._project_writeback_status(latest_event)
return ExecutionSummaryProjection(
projectId=str(project_id),
execution=PMFlowExecutionContext(
taskId=task_id,
status=status,
prompt=prompt,
commitLog=commit_log,
prStatus=pr_status,
testSummary=test_summary,
writebackStatus=writeback,
),
devSync=dev_sync.get("summary") or {},
promptPackage=None if latest_package is None else self._serialize_prompt_package(latest_package),
repoBindings=repos,
activeDevTasks=active_tasks,
recentProgressEvents=events,
recentDeliveries=deliveries,
warnings=warnings,
)
async def _load_project(self, *, project_id: int) -> Project:
result = await self.db.execute(
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == self.tenant_id))
)
project = result.scalar_one_or_none()
if project is None:
raise ValueError("project_not_found")
return project
async def _load_truth_nodes(self, *, project_id: int) -> list[TruthNode]:
result = await self.db.execute(
select(TruthNode)
.where(and_(TruthNode.tenant_id == self.tenant_id, TruthNode.project_id == int(project_id)))
.order_by(TruthNode.updated_at.desc(), TruthNode.created_at.desc(), TruthNode.id.desc())
)
return list(result.scalars().all())
async def _load_tasks(self, *, project_id: int) -> list[tuple[Task, Optional[User]]]:
result = await self.db.execute(
select(Task, User)
.outerjoin(User, User.id == Task.assignee_id)
.where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
.order_by(Task.updated_at.desc(), Task.created_at.desc(), Task.id.desc())
)
return [(task, user) for task, user in result.all()]
async def _load_latest_prompt_package(self, *, project_id: int) -> Optional[PromptPackage]:
result = await self.db.execute(
select(PromptPackage)
.where(and_(PromptPackage.tenant_id == self.tenant_id, PromptPackage.project_id == int(project_id)))
.order_by(PromptPackage.updated_at.desc(), PromptPackage.created_at.desc(), PromptPackage.id.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def _load_project_thread(self, *, project_id: int) -> tuple[Optional[PMFlowThreadModel], list[PMFlowThreadMessage]]:
thread_result = await self.db.execute(
select(PMFlowThreadModel)
.where(
and_(
PMFlowThreadModel.tenant_id == self.tenant_id,
PMFlowThreadModel.project_id == int(project_id),
PMFlowThreadModel.status == "active",
)
)
.order_by(
(PMFlowThreadModel.source == "project_launch").desc(),
PMFlowThreadModel.created_at.asc(),
PMFlowThreadModel.id.asc(),
)
.limit(1)
)
thread = thread_result.scalar_one_or_none()
if thread is None:
return None, []
message_result = await self.db.execute(
select(PMFlowThreadMessage)
.where(
and_(
PMFlowThreadMessage.tenant_id == self.tenant_id,
PMFlowThreadMessage.project_id == int(project_id),
PMFlowThreadMessage.thread_id == int(thread.id),
)
)
.order_by(PMFlowThreadMessage.created_at.asc(), PMFlowThreadMessage.id.asc())
)
return thread, list(message_result.scalars().all())
def _project_messages(self, rows: list[PMFlowThreadMessage]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"role": row.role,
"author": "你" if row.role == "user" else "PMFlow",
"content": row.content,
"createdAt": row.created_at.isoformat() if row.created_at else "",
"status": "sent" if row.status == "sent" else ("analyzing" if row.status == "running" else "resolved"),
"source": row.source,
"metadata": row.message_metadata or {},
}
for row in rows
]
async def _load_review_projection(self, *, project_id: int) -> dict[str, list[dict[str, Any]]]:
change_set_result = await self.db.execute(
select(PMFlowChangeSet)
.where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id)))
.order_by(PMFlowChangeSet.updated_at.desc(), PMFlowChangeSet.created_at.desc(), PMFlowChangeSet.id.desc())
)
change_sets = list(change_set_result.scalars().all())
if not change_sets:
return {"change_sets": [], "change_items": [], "evidence": [], "approvals": []}
change_set_ids = [int(row.id) for row in change_sets]
item_result = await self.db.execute(
select(PMFlowChangeItem)
.where(
and_(
PMFlowChangeItem.tenant_id == self.tenant_id,
PMFlowChangeItem.project_id == int(project_id),
PMFlowChangeItem.change_set_id.in_(change_set_ids),
)
)
.order_by(PMFlowChangeItem.created_at.asc(), PMFlowChangeItem.id.asc())
)
item_rows = list(item_result.scalars().all())
evidence_result = await self.db.execute(
select(PMFlowEvidenceLink)
.where(
and_(
PMFlowEvidenceLink.tenant_id == self.tenant_id,
PMFlowEvidenceLink.project_id == int(project_id),
PMFlowEvidenceLink.change_set_id.in_(change_set_ids),
)
)
.order_by(PMFlowEvidenceLink.created_at.asc(), PMFlowEvidenceLink.id.asc())
)
evidence_rows = list(evidence_result.scalars().all())
approval_result = await self.db.execute(
select(PMFlowApproval)
.where(
and_(
PMFlowApproval.tenant_id == self.tenant_id,
PMFlowApproval.project_id == int(project_id),
PMFlowApproval.change_set_id.in_(change_set_ids),
)
)
.order_by(PMFlowApproval.created_at.asc(), PMFlowApproval.id.asc())
)
approval_rows = list(approval_result.scalars().all())
items_by_set: dict[int, list[PMFlowChangeItem]] = {}
evidence_by_set: dict[int, list[PMFlowEvidenceLink]] = {}
for item in item_rows:
items_by_set.setdefault(int(item.change_set_id), []).append(item)
for evidence in evidence_rows:
evidence_by_set.setdefault(int(evidence.change_set_id), []).append(evidence)
return {
"change_sets": [
PMFlowChangeReviewService.serialize_change_set(row, items_by_set.get(int(row.id), []))
for row in change_sets
],
"change_items": [
PMFlowChangeReviewService.serialize_change_item(item, evidence_by_set.get(int(item.change_set_id), []))
for item in item_rows
],
"evidence": [PMFlowChangeReviewService.serialize_evidence(row) for row in evidence_rows],
"approvals": [PMFlowChangeReviewService.serialize_approval(row) for row in approval_rows],
}
@staticmethod
def _active_change_set_id(change_sets: list[dict[str, Any]]) -> Optional[str]:
for row in change_sets:
if row.get("rawStatus") == "open" or row.get("status") == "open":
return str(row.get("id"))
return None
@staticmethod
def _review_loop_stage(*, change_sets: list[dict[str, Any]], change_items: list[dict[str, Any]]) -> str:
active = PMFlowOSProjectionService._active_change_set_id(change_sets)
if active is None:
return "command_idle"
if any(str(item.get("changeSetId")) == active and item.get("status") == "pending_review" for item in change_items):
return "review_open"
return "extraction_ready"
def _project_tasks(self, rows: list[tuple[Task, Optional[User]]]) -> list[PMFlowTask]:
return [
PMFlowTask(
id=str(task.id),
title=task.title,
summary=task.description or "",
stage=self._task_stage(task.status),
status=self._object_status(task.status),
assignee=user.name if user is not None else "未分配",
acceptanceCriteria=self._task_acceptance_criteria(task),
requirementId=str(task.truth_node_id) if task.truth_node_id is not None else None,
)
for task, user in rows
]
def _project_requirements(self, *, truth_nodes: list[TruthNode], tasks: list[PMFlowTask]) -> list[PMFlowRequirement]:
linked_by_node: dict[str, list[str]] = {}
for task in tasks:
if task.requirementId:
linked_by_node.setdefault(str(task.requirementId), []).append(task.id)
requirements: list[PMFlowRequirement] = []
for node in truth_nodes:
node_type = self._node_type(node)
if node_type not in {"requirement", "charter"}:
continue
requirements.append(
PMFlowRequirement(
id=str(node.id),
title=node.title,
summary=self._node_summary(node),
stage=self._stage_from_payload(node.content, default="planning"),
status=self._truth_status(node.status),
linkedTaskIds=linked_by_node.get(str(node.id), []),
)
)
return requirements
def _project_decisions(self, truth_nodes: list[TruthNode]) -> list[PMFlowDecision]:
decisions: list[PMFlowDecision] = []
for node in truth_nodes:
if self._node_type(node) != "decision":
continue
content = self._payload(node.content)
decisions.append(
PMFlowDecision(
id=str(node.id),
title=node.title,
summary=self._node_summary(node),
stage=self._stage_from_payload(content, default="planning"),
status=self._truth_status(node.status),
owner=str(content.get("owner") or content.get("decision_owner") or "未分配"),
)
)
return decisions
def _project_risks(self, truth_nodes: list[TruthNode]) -> list[PMFlowRisk]:
risks: list[PMFlowRisk] = []
for node in truth_nodes:
if self._node_type(node) != "risk":
continue
content = self._payload(node.content)
risks.append(
PMFlowRisk(
id=str(node.id),
title=node.title,
summary=self._node_summary(node),
stage=self._stage_from_payload(content, default="planning"),
status=self._truth_status(node.status),
level=self._risk_level(content.get("level") or content.get("risk_level") or node.status),
mitigation=str(content.get("mitigation") or content.get("mitigation_plan") or "待补充缓解策略"),
)
)
return risks
async def _project_model_decisions(self, *, project_id: int) -> list[PMFlowModelDecision]:
binding_rows = await self.db.execute(
select(ProjectModelBinding, ModelCatalogItem)
.join(ModelCatalogItem, ModelCatalogItem.id == ProjectModelBinding.model_catalog_item_id)
.where(
and_(
ProjectModelBinding.tenant_id == self.tenant_id,
ProjectModelBinding.project_id == int(project_id),
)
)
.order_by(ProjectModelBinding.updated_at.desc(), ProjectModelBinding.created_at.desc())
.limit(10)
)
decisions: list[PMFlowModelDecision] = []
for binding, catalog in binding_rows.all():
reason = self._payload(binding.selection_reason)
decisions.append(
PMFlowModelDecision(
id=f"model-binding-{binding.id}",
title=f"模型绑定:{catalog.display_name}",
summary=self._short_text(reason) or f"{catalog.provider} / {catalog.model_key}",
stage="planning",
status="confirmed" if str(binding.status).lower() == "active" else "draft",
rationale=self._short_text(reason) or "来自 project_model_bindings.selection_reason",
)
)
snapshot_rows = await self.db.execute(
select(ModelRecommendationSnapshot)
.where(
and_(
ModelRecommendationSnapshot.tenant_id == self.tenant_id,
ModelRecommendationSnapshot.project_id == int(project_id),
)
)
.order_by(ModelRecommendationSnapshot.updated_at.desc(), ModelRecommendationSnapshot.created_at.desc())
.limit(5)
)
for snapshot in snapshot_rows.scalars().all():
first = next(iter(snapshot.recommendations or []), {})
decisions.append(
PMFlowModelDecision(
id=f"model-recommendation-{snapshot.id}",
title=f"模型推荐快照:{snapshot.recommendation_scope}",
summary=self._short_text(first) or "模型推荐已生成,等待人工绑定确认。",
stage="planning",
status="draft",
rationale="来自 model_recommendation_snapshots;A3 不把推荐快照标记为已确认事实。",
)
)
return decisions
async def _project_test_runs(self, *, project_id: int) -> list[PMFlowTestRun]:
runs: list[PMFlowTestRun] = []
eval_result = await self.db.execute(
select(ModelEvalRun)
.where(and_(ModelEvalRun.tenant_id == self.tenant_id, ModelEvalRun.project_id == int(project_id)))
.order_by(ModelEvalRun.updated_at.desc(), ModelEvalRun.created_at.desc())
.limit(10)
)
for run in eval_result.scalars().all():
result = self._test_result(run.status, run.summary)
if result is None:
continue
runs.append(
PMFlowTestRun(
id=f"model-eval-{run.id}",
title="模型效果验证",
summary=self._short_text(run.summary) or str(run.status),
status="done" if result == "passed" else "blocked",
result=result,
)
)
progress_result = await self.db.execute(
select(ProgressEvent)
.where(
and_(
ProgressEvent.tenant_id == self.tenant_id,
ProgressEvent.project_id == int(project_id),
ProgressEvent.event_type.ilike("%test%"),
)
)
.order_by(ProgressEvent.occurred_at.desc(), ProgressEvent.created_at.desc())
.limit(10)
)
for event in progress_result.scalars().all():
result = self._test_result(event.derived_task_status or event.event_type, event.payload)
if result is None:
continue
runs.append(
PMFlowTestRun(
id=f"progress-test-{event.id}",
title=event.summary or "执行桥测试回写",
summary=event.summary or self._short_text(event.payload) or event.event_type,
status="done" if result == "passed" else "blocked",
result=result,
linkedTaskId=str(event.task_id),
)
)
return runs
async def _project_releases(self, *, truth_nodes: list[TruthNode], project_id: int) -> list[PMFlowRelease]:
validation_result = await self.db.execute(
select(ReleaseValidationRun)
.where(and_(ReleaseValidationRun.tenant_id == self.tenant_id, ReleaseValidationRun.project_id == int(project_id)))
.order_by(ReleaseValidationRun.updated_at.desc(), ReleaseValidationRun.created_at.desc())
)
validation_by_release = {
int(run.release_node_id): run
for run in validation_result.scalars().all()
if run.release_node_id is not None
}
releases: list[PMFlowRelease] = []
for node in truth_nodes:
if self._node_type(node) != "release":
continue
content = self._payload(node.content)
validation = validation_by_release.get(int(node.id))
releases.append(
PMFlowRelease(
id=str(node.id),
title=node.title,
summary=self._node_summary(node),
status=self._release_status(node.status, validation.status if validation else None),
gateItems=self._release_gate_items(content, validation),
)
)
return releases
async def _project_metric_signals(self, *, project_id: int) -> list[PMFlowMetricSignal]:
signals: list[PMFlowMetricSignal] = []
feedback_result = await self.db.execute(
select(FeedbackIngest)
.where(and_(FeedbackIngest.tenant_id == self.tenant_id, FeedbackIngest.project_id == int(project_id)))
.order_by(FeedbackIngest.updated_at.desc(), FeedbackIngest.created_at.desc())
.limit(10)
)
for feedback in feedback_result.scalars().all():
for key, value in self._payload(feedback.metrics).items():
signals.append(
PMFlowMetricSignal(
id=f"feedback-{feedback.id}-{key}",
title=str(key),
summary=feedback.title or feedback.content[:120],
value=str(value),
direction="flat",
)
)
revenue_result = await self.db.execute(
select(RevenueEvent)
.where(and_(RevenueEvent.tenant_id == self.tenant_id, RevenueEvent.project_id == int(project_id)))
.order_by(RevenueEvent.occurred_at.desc(), RevenueEvent.created_at.desc())
.limit(10)
)
for event in revenue_result.scalars().all():
signals.append(
PMFlowMetricSignal(
id=f"revenue-{event.id}",
title=f"收入事件:{event.category}",
summary=str(event.external_ref or event.source_type or "收入回写"),
value=f"{event.currency} {self._jsonable(event.amount_usd)}",
direction="up" if float(event.amount_usd or 0) > 0 else "flat",
)
)
roi_result = await self.db.execute(
select(ROISnapshot)
.where(and_(ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id)))
.order_by(ROISnapshot.updated_at.desc(), ROISnapshot.created_at.desc())
.limit(5)
)
for snapshot in roi_result.scalars().all():
signals.append(
PMFlowMetricSignal(
id=f"roi-{snapshot.id}",
title=f"ROI 观察:{snapshot.range_key}",
summary=self._short_text(snapshot.snapshot_payload) or "ROI 快照",
value=str(self._jsonable(snapshot.roi_ratio)),
direction="up" if float(snapshot.roi_ratio or 0) > 0 else "flat",
)
)
return signals
async def _project_memory(
self,
*,
project: Project,
truth_nodes: list[TruthNode],
task_ids: list[int],
) -> PMFlowProjectMemory:
truth_ids = [int(node.id) for node in truth_nodes]
clauses = [
and_(SemanticFact.entity_type == "project", SemanticFact.entity_id == int(project.id)),
]
if truth_ids:
clauses.append(and_(SemanticFact.entity_type.in_(["truth_node", "truth_nodes"]), SemanticFact.entity_id.in_(truth_ids)))
if task_ids:
clauses.append(and_(SemanticFact.entity_type == "task", SemanticFact.entity_id.in_(task_ids)))
result = await self.db.execute(
select(SemanticFact)
.where(and_(SemanticFact.tenant_id == self.tenant_id, SemanticFact.is_active.is_(True), or_(*clauses)))
.order_by(SemanticFact.updated_at.desc(), SemanticFact.created_at.desc())
.limit(100)
)
semantic_facts = [
{
"id": str(fact.id),
"source": "semantic_facts",
"entityType": fact.entity_type,
"entityId": str(fact.entity_id),
"namespace": fact.fact_namespace,
"key": fact.fact_key,
"value": fact.fact_value_text,
"confidence": fact.confidence,
"evidenceSourceType": fact.evidence_source_type,
"evidenceSourceId": None if fact.evidence_source_id is None else str(fact.evidence_source_id),
"evidenceExcerpt": fact.evidence_excerpt,
}
for fact in result.scalars().all()
]
truth_facts = [
{
"id": str(node.id),
"source": "truth_nodes",
"nodeType": node.node_type,
"title": node.title,
"summary": self._node_summary(node),
"status": node.status,
"updatedAt": node.updated_at.isoformat() if node.updated_at else None,
}
for node in truth_nodes
]
return PMFlowProjectMemory(
truthNodeFacts=truth_facts,
semanticFacts=semantic_facts,
sourceSummary={
"truth_nodes": len(truth_facts),
"semantic_facts": len(semantic_facts),
},
)
def _project_feed(
self,
*,
execution_summary: ExecutionSummaryProjection,
test_runs: list[PMFlowTestRun],
releases: list[PMFlowRelease],
metric_signals: list[PMFlowMetricSignal],
) -> list[PMFlowFeedEvent]:
feed: list[PMFlowFeedEvent] = []
for event in execution_summary.recentProgressEvents[:5]:
feed.append(
PMFlowFeedEvent(
id=f"progress-{event.get('id')}",
kind="execution",
title=event.get("event_type") or "执行进展",
summary=event.get("summary") or event.get("workflow_state") or "执行事件已回写",
timestamp=str(event.get("occurred_at") or ""),
)
)
for run in test_runs[:3]:
feed.append(PMFlowFeedEvent(id=f"feed-{run.id}", kind="test", title=run.title, summary=run.summary, timestamp=""))
for release in releases[:3]:
feed.append(PMFlowFeedEvent(id=f"feed-{release.id}", kind="release", title=release.title, summary=release.summary, timestamp=""))
for signal in metric_signals[:3]:
feed.append(PMFlowFeedEvent(id=f"feed-{signal.id}", kind="growth", title=signal.title, summary=signal.summary, timestamp=""))
return feed
def _project_goal(self, *, project: Project, truth_nodes: list[TruthNode]) -> str:
charter = next((node for node in truth_nodes if self._node_type(node) == "charter"), None)
if charter is not None:
content = self._payload(charter.content)
for key in ("business_goal", "goal", "objective", "summary"):
if content.get(key):
return self._short_text(content.get(key))
return project.description or project.title or project.name
def _infer_project_stage(
self,
*,
truth_nodes: list[TruthNode],
tasks: list[tuple[Task, Optional[User]]],
execution: PMFlowExecutionContext,
) -> str:
if any(self._node_type(node) == "release" for node in truth_nodes):
return "release"
if execution.status in {"coding", "pr_open", "testing", "synced"}:
return "development"
if any(str(task.status).lower() in {"in_progress", "review", "blocked"} for task, _ in tasks):
return "development"
if truth_nodes:
return "planning"
return "idea"
def _task_acceptance_criteria(self, task: Task) -> list[str]:
payloads = [self._payload(task.manual_overrides), self._payload(task.ai_breakdown_payload)]
for payload in payloads:
for key in ("acceptance_criteria", "acceptanceCriteria", "criteria"):
value = payload.get(key)
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
return []
def _node_summary(self, node: TruthNode) -> str:
return self._short_text(node.content) or node.title
def _node_type(self, node: TruthNode) -> str:
return str(node.node_type or "").strip().lower()
def _payload(self, value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _short_text(self, value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float, Decimal)):
return str(self._jsonable(value))
if isinstance(value, list):
return ";".join(str(item) for item in value[:5])
if isinstance(value, dict):
for key in ("summary", "description", "business_goal", "goal", "rationale", "reason", "conclusion", "content"):
if value.get(key):
return self._short_text(value.get(key))
pairs = []
for key, item in list(value.items())[:5]:
if isinstance(item, (dict, list)):
continue
pairs.append(f"{key}: {item}")
return ";".join(pairs)
return str(value)
def _stage_from_payload(self, payload: Any, *, default: str) -> str:
stage = str(self._payload(payload).get("stage") or default).strip().lower()
if stage in {"idea", "validation", "planning", "development", "testing", "release", "growth"}:
return stage
return default
def _truth_status(self, status: str) -> str:
value = str(status or "").strip().lower()
if value in {"aligned", "confirmed", "active", "verified", "approved"}:
return "confirmed"
if value in {"in_progress", "review", "aligning", "running"}:
return "in_progress"
if value in {"blocked", "failed"}:
return "blocked"
if value in {"done", "completed"}:
return "done"
if value in {"released", "live", "online"}:
return "released"
return "draft"
def _object_status(self, status: str) -> str:
value = str(status or "").strip().lower()
if value in {"done", "completed", "closed"}:
return "done"
if value in {"in_progress", "review", "running"}:
return "in_progress"
if value in {"blocked", "failed"}:
return "blocked"
if value in {"released", "live", "online"}:
return "released"
if value in {"confirmed", "aligned", "active"}:
return "confirmed"
return "draft"
def _task_stage(self, status: str) -> str:
value = str(status or "").strip().lower()
if value in {"done", "completed", "review"}:
return "testing"
return "development"
def _risk_level(self, value: Any) -> str:
text = str(value or "").strip().lower()
if text in {"high", "高", "critical", "urgent"}:
return "high"
if text in {"low", "低"}:
return "low"
return "medium"
def _release_status(self, node_status: str, validation_status: Optional[str]) -> str:
value = str(validation_status or node_status or "").strip().lower()
if value in {"live", "online", "released", "verified"}:
return "live"
if value in {"releasing", "shipping"}:
return "shipping"
if value in {"ready", "passed", "success", "done", "completed"}:
return "ready"
if value in {"watch", "observing"}:
return "watch"
return "check_pending"
def _release_gate_items(self, content: dict[str, Any], validation: Optional[ReleaseValidationRun]) -> list[dict[str, Any]]:
source = []
if validation is not None:
source = list(validation.risk_items or [])
summary = self._payload(validation.validation_summary)
hits = summary.get("success_criteria_hits")
if isinstance(hits, list):
source = hits
if not source:
source = list(content.get("gate_items") or content.get("verification_items") or [])
items = []
for item in source:
if not isinstance(item, dict):
continue
label = str(item.get("label") or item.get("title") or item.get("name") or item.get("criterion") or "发布检查项")
passed = bool(item.get("passed") or item.get("hit") or item.get("status") in {"done", "passed", "success"})
items.append({"label": label, "passed": passed})
return items
def _test_result(self, status: Any, payload: Any) -> Optional[str]:
text = f"{status} {self._short_text(payload)}".lower()
if any(token in text for token in ("passed", "pass", "success", "done", "completed", "通过")):
return "passed"
if any(token in text for token in ("failed", "fail", "blocked", "error", "失败", "阻塞")):
return "failed"
return None
def _map_execution_status(self, *, task_status: Optional[str], event: Optional[dict[str, Any]]) -> str:
event_type = str((event or {}).get("event_type") or "").lower()
workflow_state = str((event or {}).get("workflow_state") or "").lower()
derived = str((event or {}).get("derived_task_status") or task_status or "").lower()
combined = " ".join([event_type, workflow_state, derived])
if "test" in combined:
return "testing"
if "pull_request" in combined or "pr" in combined or "review" in combined:
return "pr_open"
if "commit" in combined or "coding" in combined or "in_progress" in combined:
return "coding"
if "pushed" in combined or "queued" in combined:
return "pushed"
if "done" in combined or "completed" in combined or "synced" in combined:
return "synced"
if "failed" in combined or "blocked" in combined:
return "failed"
return "queued"
def _pick_execution_task_id(
self,
*,
active_tasks: list[dict[str, Any]],
latest_package: Optional[PromptPackage],
events: list[dict[str, Any]],
) -> Optional[str]:
if latest_package is not None and latest_package.task_id is not None:
return str(latest_package.task_id)
if active_tasks:
return str(active_tasks[0].get("id"))
if events:
task = events[0].get("task")
if isinstance(task, dict) and task.get("id"):
return str(task.get("id"))
return None
def _pick_task_status(self, active_tasks: list[dict[str, Any]], task_id: Optional[str]) -> Optional[str]:
if task_id is None:
return None
for task in active_tasks:
if str(task.get("id")) == str(task_id):
return str(task.get("status") or "")
return None
def _event_commit_log(self, events: list[dict[str, Any]]) -> list[str]:
logs = []
for event in events[:8]:
commit = str(event.get("commit_sha") or "").strip()
summary = str(event.get("summary") or event.get("event_type") or "").strip()
if commit:
logs.append(f"{summary} · {commit[:12]}")
elif summary:
logs.append(summary)
return logs
def _project_pr_status(self, *, active_tasks: list[dict[str, Any]], task_id: Optional[str], events: list[dict[str, Any]]) -> str:
for task in active_tasks:
if task_id is not None and str(task.get("id")) != str(task_id):
continue
code_link = task.get("code_link")
if isinstance(code_link, dict) and code_link.get("pr_number"):
return f"PR #{code_link.get('pr_number')} · {code_link.get('status') or 'linked'}"
for event in events:
pr_number = event.get("pr_number")
if pr_number:
return f"PR #{pr_number}"
return "pending"
def _project_test_summary(self, events: list[dict[str, Any]]) -> str:
for event in events:
if "test" in str(event.get("event_type") or "").lower():
return str(event.get("summary") or event.get("workflow_state") or "test event recorded")
return "pending"
def _project_writeback_status(self, event: Optional[dict[str, Any]]) -> str:
if event is None:
return "pending"
return str(event.get("workflow_state") or event.get("summary") or event.get("event_type") or "progress event recorded")
def _serialize_prompt_package(self, package: PromptPackage) -> dict[str, Any]:
return {
"id": str(package.id),
"project_id": str(package.project_id),
"charter_id": str(package.charter_id),
"task_id": None if package.task_id is None else str(package.task_id),
"status": package.status,
"package_kind": package.package_kind,
"package_text": package.package_text,
"package_payload": package.package_payload,
"created_at": package.created_at.isoformat() if package.created_at else None,
}
def _latest_iso(self, values: list[Optional[datetime]]) -> Optional[str]:
valid = [value for value in values if value is not None]
if not valid:
return None
return max(valid).isoformat()
def _jsonable(self, value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
return value

View File

@ -13,14 +13,10 @@ from ..notification_service import NotificationService
_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+") _ROLE_SPLIT_RE = re.compile(r"[,/|\s]+")
_ROLE_ALIAS_MAP = { _ROLE_ALIAS_MAP = {
"AI": {"AI"}, "OWNER": {"OWNER"},
"AI_ENGINEER": {"AI"}, "REVIEWER": {"REVIEWER"},
"FRONTEND": {"FE"}, "OPERATOR": {"OPERATOR"},
"FE": {"FE"}, "BUILDER": {"BUILDER"},
"BACKEND": {"BE"},
"BE": {"BE"},
"BOSS": {"BOSS"},
"ADMIN": {"ADMIN"},
} }

View File

@ -43,7 +43,7 @@ class SignalCaptureNewProjectMixin:
and_( and_(
User.tenant_id == tenant_id, User.tenant_id == tenant_id,
User.status == "active", User.status == "active",
User.role.in_(["BOSS", "ADMIN"]), User.role.in_(["OWNER", "REVIEWER"]),
) )
).order_by(User.id.asc()).limit(1) ).order_by(User.id.asc()).limit(1)
) )

View File

@ -129,7 +129,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
User.tenant_id == tenant_id, User.tenant_id == tenant_id,
User.status == "active", User.status == "active",
User.is_active.is_(True), User.is_active.is_(True),
User.role.in_(["BOSS", "ADMIN"]), User.role.in_(["OWNER", "REVIEWER"]),
) )
).order_by(User.id.asc()).limit(1) ).order_by(User.id.asc()).limit(1)
) )
@ -177,7 +177,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
User.tenant_id == tenant_id, User.tenant_id == tenant_id,
User.status == "active", User.status == "active",
User.is_active.is_(True), User.is_active.is_(True),
User.role.in_(["BOSS", "ADMIN"]), User.role.in_(["OWNER", "REVIEWER"]),
) )
).limit(5) ).limit(5)
) )

View File

@ -28,7 +28,7 @@ class SignalSourceLookupParseMixin:
) )
) )
).scalar_one_or_none() ).scalar_one_or_none()
if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}: if row is not None and str(row.role or "").strip().upper() in {"OWNER", "REVIEWER"}:
return row return row
return ( return (
@ -37,7 +37,7 @@ class SignalSourceLookupParseMixin:
and_( and_(
User.tenant_id == tenant_id, User.tenant_id == tenant_id,
User.is_active.is_(True), User.is_active.is_(True),
User.role.in_(["BOSS", "ADMIN"]), User.role.in_(["OWNER", "REVIEWER"]),
) )
).order_by(User.id.asc()).limit(1) ).order_by(User.id.asc()).limit(1)
) )

View File

@ -221,7 +221,7 @@ def _assert_charter_aligned(charter: TruthNode) -> None:
) )
def _is_manager(user: User) -> bool: def _is_manager(user: User) -> bool:
return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]) return has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"])
def _require_manager(user: User) -> None: def _require_manager(user: User) -> None:
if not _is_manager(user): if not _is_manager(user):

View File

@ -2,6 +2,7 @@ from __future__ import annotations
from .shared_helpers import * from .shared_helpers import *
from .shared_helpers import _to_json from .shared_helpers import _to_json
from ..ai_project_control_service import AIProjectControlService
class SSTCBuildViewsMixin: class SSTCBuildViewsMixin:
async def build_overview(self, project_id: int) -> dict[str, Any]: async def build_overview(self, project_id: int) -> dict[str, Any]:
@ -17,6 +18,9 @@ class SSTCBuildViewsMixin:
member_ids = await self.get_project_workload_member_ids(project_id) member_ids = await self.get_project_workload_member_ids(project_id)
workload = await self.get_workload_health(member_ids) workload = await self.get_workload_health(member_ids)
chain_id = str(charters[0].id) if charters else None chain_id = str(charters[0].id) if charters else None
control_summary = await AIProjectControlService(self.db, tenant_id=self.tenant_id).build_project_control_summary(
project_id=project_id
)
return { return {
"project": { "project": {
"id": str(project.id), "id": str(project.id),
@ -36,6 +40,11 @@ class SSTCBuildViewsMixin:
"unresolved_conflicts": alignment["unresolved_conflicts"], "unresolved_conflicts": alignment["unresolved_conflicts"],
"task_progress": metrics, "task_progress": metrics,
"workload_health": workload, "workload_health": workload,
"model_binding": control_summary.get("model_binding"),
"prompt_package_status": control_summary.get("prompt_package_status"),
"release_validation_status": control_summary.get("release_validation_status"),
"roi_snapshot": control_summary.get("roi_snapshot"),
"compliance_status": control_summary.get("compliance_status"),
} }
async def build_stages(self, project_id: int) -> list[dict[str, Any]]: async def build_stages(self, project_id: int) -> list[dict[str, Any]]:
@ -295,4 +304,7 @@ class SSTCBuildViewsMixin:
for c in charters for c in charters
], ],
"tasks_by_charter": tasks_by_charter, "tasks_by_charter": tasks_by_charter,
"model_binding": await AIProjectControlService(self.db, tenant_id=self.tenant_id).get_active_binding_summary(
project_id=project_id
),
} }

View File

@ -11,7 +11,7 @@ from ..models import Project, User, UserProject, UserWorkload
from .auth.service import parse_role_values from .auth.service import parse_role_values
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
MANAGER_ROLES = {"BOSS", "ADMIN"} MANAGER_ROLES = {"OWNER", "REVIEWER"}
class TenantBriefingService(ExecutiveBriefingService): class TenantBriefingService(ExecutiveBriefingService):

View File

@ -98,7 +98,7 @@ async def ensure_seed_user(session: AsyncSession) -> User:
email="boss@pmflow.local", email="boss@pmflow.local",
password_hash=AuthService.hash_password("boss123"), password_hash=AuthService.hash_password("boss123"),
name="负责人", name="负责人",
role="BOSS", role="OWNER",
status="active", status="active",
) )
session.add(seed_user) session.add(seed_user)

View File

@ -25,3 +25,5 @@ python-docx>=1.1.2
openpyxl>=3.1.5 openpyxl>=3.1.5
lark-oapi>=1.4.10 lark-oapi>=1.4.10
duckduckgo-search>=8.1.1 duckduckgo-search>=8.1.1
pytest>=8.3.3
pytest-asyncio>=0.24.0

View File

@ -21,12 +21,7 @@ from app.services.auth import AuthService
async def seed_users(): async def seed_users():
"""Create default users with known passwords.""" """Create default users with known passwords."""
# Create initial users (with simple password hashes for demo) # Create initial users (with simple password hashes for demo)
# In production, these should be created with proper password hashing # In production, these should be created with proper password hashing.
# admin_hash = AuthService.hash_password("admin123")
# boss_hash = AuthService.hash_password("boss123")
# ai_hash = AuthService.hash_password("ai123")
# be_hash = AuthService.hash_password("be123")
# fe_hash = AuthService.hash_password("fe123")
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# Check if users already exist # Check if users already exist
@ -40,7 +35,7 @@ async def seed_users():
password_hash=AuthService.hash_password("admin123"), password_hash=AuthService.hash_password("admin123"),
name="系统管理员", name="系统管理员",
email="admin@pmflow.ai", email="admin@pmflow.ai",
role="BOSS", role="OWNER",
assigned_domains=[], assigned_domains=[],
workload=0, workload=0,
status="active", status="active",
@ -59,7 +54,7 @@ async def seed_users():
password_hash=AuthService.hash_password("boss123"), password_hash=AuthService.hash_password("boss123"),
name="老板", name="老板",
email="boss@pmflow.ai", email="boss@pmflow.ai",
role="BOSS", role="OWNER",
assigned_domains=[], assigned_domains=[],
workload=0, workload=0,
status="active", status="active",
@ -78,7 +73,7 @@ async def seed_users():
password_hash=AuthService.hash_password("ai123"), password_hash=AuthService.hash_password("ai123"),
name="AI工程师", name="AI工程师",
email="ai@pmflow.ai", email="ai@pmflow.ai",
role="AI", role="BUILDER",
assigned_domains=[], assigned_domains=[],
workload=0, workload=0,
status="active", status="active",
@ -97,7 +92,7 @@ async def seed_users():
password_hash=AuthService.hash_password("be123"), password_hash=AuthService.hash_password("be123"),
name="后端开发", name="后端开发",
email="backend@pmflow.ai", email="backend@pmflow.ai",
role="BE", role="BUILDER",
assigned_domains=[], assigned_domains=[],
workload=0, workload=0,
status="active", status="active",
@ -116,7 +111,7 @@ async def seed_users():
password_hash=AuthService.hash_password("fe123"), password_hash=AuthService.hash_password("fe123"),
name="前端开发", name="前端开发",
email="frontend@pmflow.ai", email="frontend@pmflow.ai",
role="FE", role="BUILDER",
assigned_domains=[], assigned_domains=[],
workload=0, workload=0,
status="active", status="active",
@ -190,11 +185,11 @@ async def main():
print("✅ Seeding completed!") print("✅ Seeding completed!")
print() print()
print("📝 Default users created:") print("📝 Default users created:")
print(" - admin / admin123 (BOSS)") print(" - admin / admin123 (OWNER)")
print(" - boss / boss123 (BOSS)") print(" - boss / boss123 (OWNER)")
print(" - ai_engineer / ai123 (AI)") print(" - ai_engineer / ai123 (BUILDER)")
print(" - backend / be123 (BE)") print(" - backend / be123 (BUILDER)")
print(" - frontend / fe123 (FE)") print(" - frontend / fe123 (BUILDER)")
print() print()
print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login") print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login")

View File

@ -0,0 +1,629 @@
from __future__ import annotations
import hashlib
import hmac
import os
import json
from decimal import Decimal
import pytest
import pytest_asyncio
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.models.ai_project_control import ( # noqa: E402
ComplianceCheckRun,
DevSession,
DevWorkspaceBinding,
EvidencePack,
FeedbackIngest,
GitHubAppInstallation,
GitHubAppRepository,
GitHubWebhookDelivery,
IterationRecommendation,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
ModelUsageEvent,
ProgressEvent,
ProjectModelBinding,
PromptPackage,
ReleaseValidationRun,
RevenueEvent,
RepoBinding,
ROISnapshot,
TaskCodeLink,
)
from app.models.base import Base # noqa: E402
from app.models.feishu_item import FeishuItem # noqa: E402
from app.models.model_capability_matrix import Budget # noqa: E402
from app.models.model_requirement_binding import ModelRequirementBinding # noqa: E402
from app.models.model_version import ModelVersion # noqa: E402
from app.models.project import Project # noqa: E402
from app.models.signal import Signal # noqa: E402
from app.models.task import Task # noqa: E402
from app.models.truth_node import TruthNode # noqa: E402
from app.models.user import User # noqa: E402
from app.schemas import TaskBreakdownBusinessObject, TaskBreakdownPayload # noqa: E402
from app.services.ai_project_control_service import AIProjectControlService # noqa: E402
from app.core.config import settings # noqa: E402
from app.utils.timezone import now_cn_naive # noqa: E402
TABLES = [
User.__table__,
FeishuItem.__table__,
Signal.__table__,
Project.__table__,
TruthNode.__table__,
Task.__table__,
Budget.__table__,
ModelVersion.__table__,
ModelRequirementBinding.__table__,
ModelCatalogItem.__table__,
ProjectModelBinding.__table__,
ModelRecommendationSnapshot.__table__,
ModelUsageEvent.__table__,
ModelEvalRun.__table__,
PromptPackage.__table__,
DevWorkspaceBinding.__table__,
RepoBinding.__table__,
TaskCodeLink.__table__,
ProgressEvent.__table__,
DevSession.__table__,
ReleaseValidationRun.__table__,
FeedbackIngest.__table__,
IterationRecommendation.__table__,
RevenueEvent.__table__,
ROISnapshot.__table__,
ComplianceCheckRun.__table__,
EvidencePack.__table__,
GitHubAppInstallation.__table__,
GitHubAppRepository.__table__,
GitHubWebhookDelivery.__table__,
]
def _use_sqlite_json_types() -> None:
for table in TABLES:
for column in table.columns:
type_name = column.type.__class__.__name__.upper()
if type_name in {"JSONB", "ARRAY"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_env():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
async def _seed_project_graph(session):
user = User(
tenant_id=1,
username="owner",
email="owner@example.com",
password_hash="hashed",
name="Owner",
role="REVIEWER",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.flush()
feishu_item = FeishuItem(
tenant_id=1,
raw_message="新建 AI 客服项目",
level="L1",
card_type="signal",
route_status="pending",
)
session.add(feishu_item)
await session.flush()
signal = Signal(
tenant_id=1,
source="manual",
content="需要一个支持知识库检索和客服回复的 AI 应用",
priority="high",
processed=False,
feishu_item_id=feishu_item.id,
title="AI 客服升级",
status="待处理",
planner_status="pending",
)
session.add(signal)
await session.flush()
project = Project(
tenant_id=1,
name="AI 客服升级",
title="AI 客服升级",
description="支持 RAG、客服对话和模型成本治理",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=1,
)
session.add(project)
await session.flush()
charter = TruthNode(
tenant_id=1,
node_type="charter",
parent_id=None,
project_id=project.id,
signal_id=signal.id,
created_by=user.id,
title="AI 客服升级宪章",
content={
"business_goal": "7 天内交付可验证 MVP",
"target_users": ["客服团队", "运营负责人"],
"success_criteria": ["首响低于 3 秒", "高频问题命中率达到 85%"],
"data_sources": ["客服 FAQ", "产品说明书"],
"content_labeling": "所有 AI 生成回复在前台显示 AI 标识",
"model_requirements": ["中文问答", "RAG", "代码集成"],
"delivery_boundary": ["仅做 MVP,不做 CRM 全量重构"],
},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
release = TruthNode(
tenant_id=1,
node_type="release",
parent_id=charter.id,
project_id=project.id,
signal_id=None,
created_by=user.id,
title="AI 客服 MVP 发布",
content={"charter_id": charter.id},
version=1,
status="pending",
)
session.add(release)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload=None,
manual_overrides=None,
title="接入 AI 模型与知识库",
description="实现问答主链、成本埋点和效果验证基线",
required_skill_tag_ids=[101],
status="todo",
priority="high",
estimated_hours=8,
actual_hours=None,
quality_score=None,
due_date=None,
completed_at=None,
)
session.add(task)
budget = Budget(
tenant_id=1,
budget_type="project",
project_id=project.id,
period="monthly",
budget_amount_usd=3000,
alert_threshold_percent=80,
hard_limit_enabled=True,
current_usage_usd=0,
current_period_start=None,
current_period_end=None,
status="active",
override_history={},
)
session.add(budget)
await session.commit()
return user, project, charter, release, task
@pytest.mark.asyncio
async def test_model_recommendation_binding_and_prompt_package_flow(db_env) -> None:
session = db_env
user, project, charter, _release, task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
recommendations = await service.recommend_models(project_id=project.id, charter_id=charter.id)
assert len(recommendations["recommendations"]) == 3
top = recommendations["recommendations"][0]
binding = await service.bind_model(
project_id=project.id,
catalog_item_id=int(top["catalog_item_id"]),
selected_by_user_id=user.id,
charter_id=charter.id,
estimated_daily_tokens=120_000,
selection_reason={"score": 93, "note": "适合 MVP 先跑通"},
)
assert binding is not None
assert binding["catalog_item_id"] == top["catalog_item_id"]
assert float(binding["estimated_monthly_cost_usd"] or 0.0) > 0
legacy_binding = (
await session.execute(
select(ModelRequirementBinding).where(ModelRequirementBinding.truth_node_id == charter.id)
)
).scalar_one_or_none()
assert legacy_binding is not None
payload = TaskBreakdownPayload(
tasks=[
TaskBreakdownBusinessObject(
task_key="baseline",
title="先补业务基线",
description="整理 MVP 的功能边界",
type="planning",
estimated_hours=2,
required_skill_names=["产品"],
priority="high",
)
]
)
enriched = service.inject_model_tasks(payload=payload, active_binding=binding)
task_keys = {item.task_key for item in enriched.tasks}
assert "model-integration" in task_keys
assert "prompt-optimization" in task_keys
assert "model-eval" in task_keys
prompt_package = await service.create_prompt_package(
project_id=project.id,
charter_id=charter.id,
generated_by_user_id=user.id,
task_id=task.id,
)
assert prompt_package["task_id"] == str(task.id)
assert "模型绑定" in prompt_package["package_text"]
assert prompt_package["package_payload"]["model_binding"]["catalog_item_id"] == top["catalog_item_id"]
@pytest.mark.asyncio
async def test_progress_events_and_github_ingest_follow_controlled_workflow(db_env) -> None:
session = db_env
user, project, charter, _release, task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
recommendations = await service.recommend_models(project_id=project.id, charter_id=charter.id)
selected_model = next(
(item for item in recommendations["recommendations"] if item["filing_status"] == "filed"),
recommendations["recommendations"][0],
)
await service.bind_model(
project_id=project.id,
catalog_item_id=int(selected_model["catalog_item_id"]),
selected_by_user_id=user.id,
charter_id=charter.id,
estimated_daily_tokens=80_000,
selection_reason={"score": 88},
)
repo_binding = await service.register_repo_binding(
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="control-plane",
default_branch="main",
external_repo_id="repo-1",
)
commit_event = await service.create_or_apply_progress_event(
task_id=task.id,
project_id=project.id,
event_type="commit",
source="ide",
payload={"branch_name": "feature/model-control", "commit_sha": "abc123"},
user_id=user.id,
repo_binding_id=int(repo_binding["id"]),
)
assert commit_event["task_status"] == "in_progress"
assert commit_event["workflow_state"] == "in_progress"
link = (
await session.execute(
select(TaskCodeLink).where(
TaskCodeLink.task_id == task.id,
TaskCodeLink.repo_binding_id == int(repo_binding["id"]),
)
)
).scalar_one_or_none()
assert link is not None
assert link.branch_name == "feature/model-control"
pr_result = await service.ingest_github_event(
event_type="pull_request",
payload={
"action": "opened",
"repository": {"name": "control-plane", "owner": {"login": "pmflow"}},
"pull_request": {"number": 17, "head": {"ref": "feature/model-control"}},
},
)
assert pr_result["matched_links"] == 1
assert pr_result["events_created"][0]["task_status"] == "review"
assert pr_result["events_created"][0]["workflow_state"] == "in_review"
ignored = await service.ingest_github_event(
event_type="check_run",
payload={
"action": "completed",
"repository": {"name": "control-plane", "owner": {"login": "pmflow"}},
},
)
assert ignored["matched_links"] == 0
assert ignored["events_created"] == []
assert ignored["ignored_reason"] == "missing_branch_or_pr"
summary = await service.get_project_dev_sync_summary(project_id=project.id, current_user_id=user.id)
assert summary["summary"]["repo_binding_count"] == 1
assert summary["summary"]["active_task_count"] >= 1
assert summary["summary"]["recent_event_count"] >= 2
assert summary["summary"]["my_active_task_count"] == 1
assert summary["active_dev_tasks"][0]["code_link"]["pr_number"] == 17
assert summary["recent_progress_events"][0]["task"]["id"] == str(task.id)
@pytest.mark.asyncio
async def test_validation_feedback_roi_and_compliance_chain(db_env) -> None:
session = db_env
user, project, charter, release, task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
recommendations = await service.recommend_models(project_id=project.id, charter_id=charter.id)
selected_model = next(
(item for item in recommendations["recommendations"] if item["filing_status"] == "filed"),
recommendations["recommendations"][0],
)
binding = await service.bind_model(
project_id=project.id,
catalog_item_id=int(selected_model["catalog_item_id"]),
selected_by_user_id=user.id,
charter_id=charter.id,
estimated_daily_tokens=100_000,
selection_reason={"score": 95},
)
prompt_package = await service.create_prompt_package(
project_id=project.id,
charter_id=charter.id,
generated_by_user_id=user.id,
task_id=task.id,
)
assert prompt_package["status"] == "active"
session.add(
ModelUsageEvent(
tenant_id=1,
project_id=project.id,
project_model_binding_id=int(binding["id"]),
source_type="manual",
event_key="usage-1",
input_tokens=120_000,
output_tokens=30_000,
cost_usd=Decimal("4.50"),
event_payload={"source": "qa"},
occurred_at=now_cn_naive(),
)
)
await session.commit()
validation = await service.run_release_validation(
project_id=project.id,
actor_user_id=user.id,
charter_id=charter.id,
release_node_id=release.id,
)
assert validation["status"] == "passed"
assert validation["score"] == 100.0
feedback = await service.ingest_feedback(
project_id=project.id,
actor_user_id=user.id,
charter_id=charter.id,
source_type="manual",
title="线上反馈",
content="回答有时偏慢,而且个别场景会出现幻觉。",
metrics={"csat": 3.8},
samples=[{"question": "退款规则", "issue": "答非所问"}],
)
focus_areas = feedback["recommendation"]["recommendation_summary"]["focus_areas"]
assert "performance" in focus_areas
assert "quality" in focus_areas
revenue = await service.record_revenue_event(
project_id=project.id,
amount_usd=180.0,
source_type="subscription",
currency="usd",
external_ref="sub-1",
event_payload={"plan": "pro"},
)
assert revenue["amount_usd"] == 180.0
roi = await service.get_roi_snapshots(project_id=project.id)
assert roi["latest"]["total_revenue_usd"] == 180.0
assert roi["latest"]["total_cost_usd"] == 4.5
assert roi["latest"]["roi_ratio"] > 0
compliance = await service.run_compliance_check(
project_id=project.id,
actor_user_id=user.id,
charter_id=charter.id,
release_node_id=release.id,
)
assert compliance["status"] == "passed"
assert compliance["score"] == 100.0
assert compliance["evidence_pack"]["package_kind"] == "compliance_release"
@pytest.mark.asyncio
async def test_github_webhook_uses_raw_body_signature_and_delivery_dedup(db_env) -> None:
session = db_env
user, project, charter, _release, task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
recommendations = await service.recommend_models(project_id=project.id, charter_id=charter.id)
await service.bind_model(
project_id=project.id,
catalog_item_id=int(recommendations["recommendations"][0]["catalog_item_id"]),
selected_by_user_id=user.id,
charter_id=charter.id,
estimated_daily_tokens=60_000,
selection_reason={"score": 91},
)
repo_binding = await service.register_repo_binding(
project_id=project.id,
provider="github",
owner="pmflow",
repo_name="control-plane",
default_branch="main",
external_repo_id="repo-1",
)
await service.create_or_apply_progress_event(
task_id=task.id,
project_id=project.id,
event_type="commit",
source="ide",
payload={"branch_name": "feature/model-control", "commit_sha": "abc123"},
user_id=user.id,
repo_binding_id=int(repo_binding["id"]),
)
previous_secret = settings.github_app_webhook_secret
settings.github_app_webhook_secret = "test-secret"
try:
payload = {
"action": "opened",
"repository": {"name": "control-plane", "owner": {"login": "pmflow"}},
"pull_request": {"number": 17, "head": {"ref": "feature/model-control"}},
}
raw_body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
signature = "sha256=" + hmac.new(
b"test-secret",
raw_body,
hashlib.sha256,
).hexdigest()
result = await service.ingest_github_webhook(
event_type="pull_request",
delivery_id="delivery-1",
signature_256=signature,
payload=payload,
raw_body=raw_body,
)
assert result["deduplicated"] is False
assert result["delivery"]["status"] == "processed"
dedup = await service.ingest_github_webhook(
event_type="pull_request",
delivery_id="delivery-1",
signature_256=signature,
payload=payload,
raw_body=raw_body,
)
assert dedup["deduplicated"] is True
with pytest.raises(ValueError, match="签名校验失败"):
await service.ingest_github_webhook(
event_type="pull_request",
delivery_id="delivery-2",
signature_256="sha256=bad-signature",
payload=payload,
raw_body=raw_body,
)
finally:
settings.github_app_webhook_secret = previous_secret
@pytest.mark.asyncio
async def test_bind_github_repository_requires_installation_repo(db_env) -> None:
session = db_env
_user, project, _charter, _release, _task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
installation = GitHubAppInstallation(
tenant_id=1,
installation_id="inst-1",
account_login="pmflow",
account_type="Organization",
app_slug="pmflow-dev",
status="active",
permissions={"pull_requests": "write"},
events=["pull_request"],
app_metadata={},
)
session.add(installation)
await session.flush()
allowed_repo = GitHubAppRepository(
tenant_id=1,
installation_id="inst-1",
external_repo_id="1001",
owner="pmflow",
repo_name="allowed-repo",
full_name="pmflow/allowed-repo",
default_branch="main",
permissions={"pull_requests": "write"},
is_private=True,
status="active",
)
blocked_repo = GitHubAppRepository(
tenant_id=1,
installation_id="inst-1",
external_repo_id="1002",
owner="pmflow",
repo_name="blocked-repo",
full_name="pmflow/blocked-repo",
default_branch="main",
permissions={"pull_requests": "write"},
is_private=True,
status="archived",
)
session.add_all([allowed_repo, blocked_repo])
await session.commit()
bound = await service.bind_github_repository(
project_id=project.id,
github_repository_id=allowed_repo.id,
)
assert bound["full_name"] == "pmflow/allowed-repo"
with pytest.raises(ValueError, match="已授权仓库列表"):
await service.bind_github_repository(
project_id=project.id,
github_repository_id=blocked_repo.id,
)
@pytest.mark.asyncio
async def test_release_validation_blocked_generates_fix_task_and_prompt(db_env) -> None:
session = db_env
user, project, charter, release, _task = await _seed_project_graph(session)
service = AIProjectControlService(session, tenant_id=1)
validation = await service.run_release_validation(
project_id=project.id,
actor_user_id=user.id,
charter_id=charter.id,
release_node_id=release.id,
)
assert validation["allow_release"] is False
assert validation["status"] == "blocked"
assert validation["blocking_reason"]
assert validation["remediation_task"] is not None
assert validation["remediation_prompt_package"] is not None

View File

@ -60,7 +60,7 @@ def _build_app(*, db_session) -> FastAPI:
tenant_id=1, tenant_id=1,
name="管理员", name="管理员",
username="admin", username="admin",
role="ADMIN", role="REVIEWER",
) )
return app return app
@ -73,7 +73,7 @@ async def test_cognitive_conflicts_return_display_type(db_session) -> None:
email="tester@example.com", email="tester@example.com",
password_hash="hash", password_hash="hash",
name="测试用户", name="测试用户",
role="ADMIN", role="REVIEWER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -70,7 +70,7 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
tenant_id=1, tenant_id=1,
name="管理员", name="管理员",
username="admin", username="admin",
role="ADMIN", role="REVIEWER",
) )
transport = ASGITransport(app=app) transport = ASGITransport(app=app)

View File

@ -81,7 +81,7 @@ async def test_generate_personalized_briefing_uses_naive_time_window(briefing_se
email="tester@example.com", email="tester@example.com",
password_hash="hash", password_hash="hash",
name="测试用户", name="测试用户",
role="BE", role="BUILDER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -52,7 +52,7 @@ async def db_session():
await engine.dispose() await engine.dispose()
def _build_app(*, db_session, role: str = "ADMIN", user_id: int = 9001) -> FastAPI: def _build_app(*, db_session, role: str = "REVIEWER", user_id: int = 9001) -> FastAPI:
app = FastAPI() app = FastAPI()
app.include_router(routes_brief_blockers.router) app.include_router(routes_brief_blockers.router)
@ -101,7 +101,7 @@ async def test_daily_briefing_v2_is_enveloped_for_any_authenticated_user(db_sess
monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload) monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload)
app = _build_app(db_session=db_session, role="BE", user_id=7) app = _build_app(db_session=db_session, role="BUILDER", user_id=7)
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"}) response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"})
@ -160,7 +160,7 @@ async def test_tenant_briefing_service_returns_full_scope_for_manager(db_session
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload) monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["BOSS"], mode="snapshot") payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["OWNER"], mode="snapshot")
assert payload["viewer_scope"] == "tenant_full" assert payload["viewer_scope"] == "tenant_full"
assert payload["hero"]["decision_count"] == 2 assert payload["hero"]["decision_count"] == 2
@ -177,7 +177,7 @@ async def test_tenant_briefing_service_filters_personal_scope_and_load(db_sessio
email="be@example.com", email="be@example.com",
password_hash="hash", password_hash="hash",
name="后端成员", name="后端成员",
role="BE", role="BUILDER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -262,7 +262,7 @@ async def test_tenant_briefing_service_filters_personal_scope_and_load(db_sessio
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload) monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BE"], mode="live") payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BUILDER"], mode="live")
assert payload["viewer_scope"] == "personal" assert payload["viewer_scope"] == "personal"
assert payload["hero"]["decision_count"] == 1 assert payload["hero"]["decision_count"] == 1
@ -288,7 +288,7 @@ async def test_tenant_briefing_service_personal_workload_ratio_matches_weekly_ho
email="be@example.com", email="be@example.com",
password_hash="hash", password_hash="hash",
name="后端成员", name="后端成员",
role="BE", role="BUILDER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -0,0 +1,203 @@
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import sys
from types import SimpleNamespace
import types
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
from app.core.database import get_db
from app.dependencies.auth import get_current_user
def _load_dev_sync_module():
package_name = "app.api"
module_name = "app.api.dev_sync"
module_path = Path(__file__).resolve().parents[1] / "app" / "api" / "dev_sync.py"
if package_name not in sys.modules:
package = types.ModuleType(package_name)
package.__path__ = [str(module_path.parent)] # type: ignore[attr-defined]
sys.modules[package_name] = package
if module_name in sys.modules:
return sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
raise RuntimeError("无法加载 dev_sync 模块")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
dev_sync = _load_dev_sync_module()
def _build_app(*, role: str = "OWNER") -> FastAPI:
app = FastAPI()
app.include_router(dev_sync.router)
async def _fake_get_db():
yield object()
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=7,
tenant_id=1,
username="owner",
role=role,
)
return app
@pytest.mark.asyncio
async def test_github_install_url_route_returns_install_center_payload(monkeypatch) -> None:
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db
self.tenant_id = tenant_id
async def get_github_app_install_center(self):
return {
"configured": True,
"install_url": "https://github.com/apps/pmflow/installations/new",
"callback_url": "https://pmflow.local/github/callback",
"webhook_signature_configured": True,
"app_slug": "pmflow",
}
monkeypatch.setattr(dev_sync, "AIProjectControlService", FakeService)
app = _build_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/integrations/github/app/install-url")
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["data"]["configured"] is True
assert payload["data"]["install_url"] == "https://github.com/apps/pmflow/installations/new"
@pytest.mark.asyncio
async def test_github_webhook_route_passes_raw_body_to_service(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db
captured["tenant_id"] = tenant_id
async def ingest_github_webhook(self, **kwargs):
captured.update(kwargs)
return {
"delivery_id": kwargs["delivery_id"],
"matched_links": 1,
}
monkeypatch.setattr(dev_sync, "AIProjectControlService", FakeService)
raw_body = json.dumps(
{"tenant_id": 9, "event_type": "pull_request", "repository": {"full_name": "pmflow/control-plane"}},
separators=(",", ":"),
).encode("utf-8")
app = _build_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(
"/integrations/github/webhook",
content=raw_body,
headers={
"Content-Type": "application/json",
"X-GitHub-Event": "pull_request",
"X-GitHub-Delivery": "delivery-1",
"X-Hub-Signature-256": "sha256=test",
},
)
assert response.status_code == 200
assert captured["tenant_id"] == 9
assert captured["event_type"] == "pull_request"
assert captured["delivery_id"] == "delivery-1"
assert captured["signature_256"] == "sha256=test"
assert captured["raw_body"] == raw_body
@pytest.mark.asyncio
async def test_github_installations_sync_route_returns_installations(monkeypatch) -> None:
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db, tenant_id
async def sync_github_installations(self):
return {
"installations": [
{
"id": "row-1",
"installation_id": "101",
"account_login": "pmflow",
}
],
"repository_count": 3,
}
monkeypatch.setattr(dev_sync, "AIProjectControlService", FakeService)
app = _build_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/integrations/github/app/installations/sync")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["repository_count"] == 3
assert payload["installations"][0]["account_login"] == "pmflow"
@pytest.mark.asyncio
async def test_github_repo_bind_route_surfaces_installation_repo_errors(monkeypatch) -> None:
class FakeService:
def __init__(self, db, tenant_id: int):
_ = db, tenant_id
async def bind_github_repository(self, *, project_id: int, github_repository_id: int):
if github_repository_id == 99:
raise ValueError("当前仓库不在 GitHub App 已授权仓库列表中")
return {
"id": "binding-1",
"project_id": str(project_id),
"provider": "github",
"full_name": "pmflow/control-plane",
"status": "active",
}
async def _allow_project(*args, **kwargs):
return None
monkeypatch.setattr(dev_sync, "AIProjectControlService", FakeService)
monkeypatch.setattr(dev_sync, "_ensure_project_member_or_manager", _allow_project)
app = _build_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
ok_response = await client.post(
"/integrations/github/repos/bind",
json={"project_id": 17, "github_repository_id": 1},
)
bad_response = await client.post(
"/integrations/github/repos/bind",
json={"project_id": 17, "github_repository_id": 99},
)
assert ok_response.status_code == 200
assert ok_response.json()["data"]["full_name"] == "pmflow/control-plane"
assert bad_response.status_code == 400
assert "已授权仓库列表" in bad_response.json()["detail"]

View File

@ -82,7 +82,7 @@ async def db_session():
await engine.dispose() await engine.dispose()
def _build_app(*, db_session, role: str = "ADMIN") -> FastAPI: def _build_app(*, db_session, role: str = "REVIEWER") -> FastAPI:
app = FastAPI() app = FastAPI()
app.include_router(routes_executive_briefing.router) app.include_router(routes_executive_briefing.router)
@ -205,7 +205,7 @@ async def test_executive_briefing_config_round_trip_is_enveloped(db_session) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None: async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None:
app = _build_app(db_session=db_session, role="BE") app = _build_app(db_session=db_session, role="BUILDER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put( response = await client.put(
@ -294,7 +294,7 @@ async def test_executive_briefing_pending_signal_sources_include_display_fields(
email="pm@example.com", email="pm@example.com",
password_hash="hash", password_hash="hash",
name="产品经理", name="产品经理",
role="ADMIN", role="REVIEWER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -372,7 +372,7 @@ async def test_executive_briefing_service_builds_detailed_signal_items_and_targe
email="boss@example.com", email="boss@example.com",
password_hash="hash", password_hash="hash",
name="李雷", name="李雷",
role="BOSS", role="OWNER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -439,7 +439,7 @@ async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics
email="boss@example.com", email="boss@example.com",
password_hash="hash", password_hash="hash",
name="李雷", name="李雷",
role="BOSS", role="OWNER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -541,7 +541,7 @@ async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_
email="boss@example.com", email="boss@example.com",
password_hash="hash", password_hash="hash",
name="李雷", name="李雷",
role="BOSS", role="OWNER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -637,7 +637,7 @@ async def test_executive_briefing_signal_source_target_does_not_apply_mismatched
email="boss@example.com", email="boss@example.com",
password_hash="hash", password_hash="hash",
name="李雷", name="李雷",
role="BOSS", role="OWNER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -689,7 +689,7 @@ async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_sess
email="owner@example.com", email="owner@example.com",
password_hash="hash", password_hash="hash",
name="杨宇", name="杨宇",
role="ADMIN", role="REVIEWER",
status="active", status="active",
is_active=True, is_active=True,
) )
@ -729,7 +729,7 @@ async def test_executive_briefing_localizes_conflict_and_signal_source_codes(db_
email="boss@example.com", email="boss@example.com",
password_hash="hash", password_hash="hash",
name="李雷", name="李雷",
role="BOSS", role="OWNER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -63,7 +63,7 @@ def _build_app(db_session):
tenant_id=1, tenant_id=1,
name="管理员", name="管理员",
username="admin", username="admin",
role="ADMIN", role="REVIEWER",
) )
return app return app

View File

@ -84,7 +84,7 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se
email="owner@example.com", email="owner@example.com",
password_hash="hash", password_hash="hash",
name="负责人", name="负责人",
role="ADMIN", role="REVIEWER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -34,7 +34,7 @@ def _build_app(*, db_session) -> FastAPI:
tenant_id=1, tenant_id=1,
name="管理员", name="管理员",
username="admin", username="admin",
role="ADMIN", role="REVIEWER",
) )
return app return app

View File

@ -58,7 +58,7 @@ def _build_app(db_session, *, role: str, tenant_id: int = 1) -> FastAPI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_admin_api_requires_manager_role(db_session) -> None: async def test_mcp_admin_api_requires_manager_role(db_session) -> None:
app = _build_app(db_session, role="AI") app = _build_app(db_session, role="BUILDER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -79,7 +79,7 @@ async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -
} }
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe) monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -140,8 +140,8 @@ async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None: async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
tenant1_app = _build_app(db_session, role="ADMIN", tenant_id=1) tenant1_app = _build_app(db_session, role="REVIEWER", tenant_id=1)
tenant2_app = _build_app(db_session, role="ADMIN", tenant_id=2) tenant2_app = _build_app(db_session, role="REVIEWER", tenant_id=2)
async with AsyncClient(transport=ASGITransport(app=tenant1_app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=tenant1_app), base_url="http://test") as client:
create_response = await client.post( create_response = await client.post(
@ -166,7 +166,7 @@ async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env_var_name(db_session) -> None: async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env_var_name(db_session) -> None:
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -217,7 +217,7 @@ async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None: async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -238,7 +238,7 @@ async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirmed(db_session) -> None: async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirmed(db_session) -> None:
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -283,7 +283,7 @@ async def test_duckduckgo_template_uses_backend_direct_preview_without_extra_par
} }
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe) monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:

View File

@ -127,7 +127,7 @@ def _build_app(db_session):
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001, id=9001,
tenant_id=1, tenant_id=1,
role="ADMIN", role="REVIEWER",
name="管理员", name="管理员",
username="admin", username="admin",
) )

View File

@ -0,0 +1,322 @@
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
from app.api import pmflow_os_api # noqa: E402
from app.core.database import get_db # noqa: E402
from app.dependencies.auth import get_current_user # noqa: E402
from app.models import ( # noqa: E402
DevWorkspaceBinding,
FeedbackIngest,
FeishuItem,
GitHubWebhookDelivery,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowLaunchDraft,
PMFlowThread,
PMFlowThreadMessage,
ProgressEvent,
Project,
ProjectModelBinding,
PromptPackage,
ReleaseValidationRun,
RepoBinding,
RevenueEvent,
ROISnapshot,
SemanticFact,
Signal,
Task,
TaskCodeLink,
TruthNode,
User,
UserProject,
)
from app.models.base import Base # noqa: E402
TABLES = [
User.__table__,
FeishuItem.__table__,
Signal.__table__,
Project.__table__,
UserProject.__table__,
PMFlowLaunchDraft.__table__,
PMFlowThread.__table__,
PMFlowThreadMessage.__table__,
PMFlowChangeSet.__table__,
PMFlowChangeItem.__table__,
PMFlowApproval.__table__,
PMFlowEvidenceLink.__table__,
TruthNode.__table__,
Task.__table__,
SemanticFact.__table__,
ModelCatalogItem.__table__,
ProjectModelBinding.__table__,
ModelRecommendationSnapshot.__table__,
ModelEvalRun.__table__,
PromptPackage.__table__,
DevWorkspaceBinding.__table__,
RepoBinding.__table__,
TaskCodeLink.__table__,
ProgressEvent.__table__,
ReleaseValidationRun.__table__,
FeedbackIngest.__table__,
RevenueEvent.__table__,
ROISnapshot.__table__,
GitHubWebhookDelivery.__table__,
]
def _use_sqlite_json_types() -> None:
for table in TABLES:
for column in table.columns:
type_name = column.type.__class__.__name__.upper()
if type_name in {"JSONB", "ARRAY"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_env():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
async def _seed_user_project(session, *, tenant_id: int = 1, role: str = "MEMBER", member: bool = True):
user = User(
tenant_id=tenant_id,
username=f"user-{tenant_id}-{role}-{member}",
email=f"user-{tenant_id}-{role}-{member}@example.com",
password_hash="hashed",
name="Owner",
role=role,
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.flush()
project = Project(
tenant_id=tenant_id,
name="真实项目",
title="真实项目",
description="用于 Change Review 测试",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(project)
await session.flush()
if member:
session.add(UserProject(tenant_id=tenant_id, user_id=user.id, project_id=project.id))
await session.commit()
return user, project
def _build_app(session, current_user) -> FastAPI:
app = FastAPI()
app.include_router(pmflow_os_api.router)
async def _fake_get_db():
yield session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: current_user
return app
def _proposed_items():
return [
{
"group": "new_object",
"object_type": "Requirement",
"title": "新增需求:审查真实落库",
"summary": "把 AI 候选变更写入待审状态机。",
"diff": "新增真实 ChangeItem。",
"payload": {"summary": "候选需求"},
"confidence": 0.91,
"risk_level": "medium",
},
{
"group": "risk",
"object_type": "Risk",
"title": "新增风险:高风险需记录审批",
"summary": "高风险项本轮先记录 approval。",
"diff": "新增高风险候选。",
"payload": {"summary": "候选风险"},
"confidence": 0.78,
"risk_level": "high",
},
{
"group": "decision",
"object_type": "Decision",
"title": "待确认决策:先审查后 commit",
"summary": "C1 不直接写项目真相层。",
"diff": "新增决策候选。",
"payload": {"summary": "候选决策"},
"confidence": 0.83,
"risk_level": "medium",
},
]
async def _create_message_and_extraction(client, project_id: int):
message = await client.post(
f"/pmflow-os/projects/{project_id}/threads/thread-project-{project_id}/messages",
json={"content": "请把本轮对话提取成待审变更", "source": "web", "attachments": []},
)
assert message.status_code == 200
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project_id}/extractions",
json={
"thread_id": msg["threadId"],
"message_id": msg["id"],
"scope": "latest_message",
"headline": "本轮待审变更",
"summary": "生成 3 个候选变更。",
"proposed_items": _proposed_items(),
},
)
assert extraction.status_code == 200
return msg, extraction.json()["data"]
@pytest.mark.asyncio
async def test_change_review_message_extraction_detail_and_projection(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
msg, detail = await _create_message_and_extraction(client, project.id)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert msg["threadId"].isdigit()
assert await session.scalar(select(func.count(PMFlowThread.id))) == 1
assert await session.scalar(select(func.count(PMFlowThreadMessage.id))) == 1
assert await session.scalar(select(func.count(PMFlowChangeSet.id))) == 1
assert await session.scalar(select(func.count(PMFlowChangeItem.id))) == 3
assert await session.scalar(select(func.count(PMFlowEvidenceLink.id))) == 1
assert detail["changeSet"]["status"] == "open"
assert len(detail["items"]) == 3
assert detail["items"][0]["status"] == "pending_review"
assert detail["evidence"][0]["sourceType"] == "thread_message"
assert detail["evidence"][0]["sourceId"] == msg["id"]
truth_payload = truth.json()["data"]
assert truth_payload["loopStage"] == "review_open"
assert truth_payload["activeChangeSetId"] == detail["changeSet"]["id"]
assert len(truth_payload["changeSets"]) == 1
assert len(truth_payload["changeItems"]) == 3
assert len(truth_payload["evidence"]) == 1
@pytest.mark.asyncio
async def test_change_item_accept_edit_defer_write_approvals_and_do_not_write_memory(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
item_ids = [item["id"] for item in detail["items"]]
accepted = await client.post(f"/pmflow-os/change-items/{item_ids[0]}/accept")
edited = await client.post(
f"/pmflow-os/change-items/{item_ids[1]}/edit",
json={"summary": "人工修订摘要", "diff": "人工修订 diff", "payload_patch": {"owner": "user"}, "reason": "收紧范围"},
)
deferred = await client.post(f"/pmflow-os/change-items/{item_ids[2]}/defer", json={"reason": "下一轮再看"})
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert accepted.status_code == 200
assert edited.status_code == 200
assert deferred.status_code == 200
assert await session.scalar(select(func.count(PMFlowApproval.id))) == 3
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
assert await session.scalar(select(func.count(SemanticFact.id))) == 0
change_set = (await session.execute(select(PMFlowChangeSet))).scalar_one()
assert change_set.status == "reviewed"
edited_row = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_ids[1])))).scalar_one()
assert edited_row.status == "edited"
assert edited_row.edited_payload["owner"] == "user"
assert truth.json()["data"]["projectMemory"]["sourceSummary"]["truth_nodes"] == 0
assert len(truth.json()["data"]["approvals"]) == 3
@pytest.mark.asyncio
async def test_change_item_review_conflict_and_closed_changeset_guard(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
item_id = detail["items"][0]["id"]
first = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
second = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
assert first.status_code == 200
assert second.status_code == 409
assert "item_already_reviewed" in second.json()["message"]
pending = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.status == "pending_review"))).scalars().first()
change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == pending.change_set_id))).scalar_one()
change_set.status = "closed"
await session.commit()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
blocked = await client.post(f"/pmflow-os/change-items/{pending.id}/accept")
assert blocked.status_code == 409
assert "changeset_not_open" in blocked.json()["message"]
@pytest.mark.asyncio
async def test_change_review_project_permission_boundary(db_env) -> None:
session = db_env
owner, project = await _seed_user_project(session)
outsider, _ = await _seed_user_project(session, tenant_id=1, member=False)
owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
outsider_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=outsider_app), base_url="http://test") as client:
denied = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
denied_action = await client.post(f"/pmflow-os/change-items/{detail['items'][0]['id']}/accept")
assert denied.status_code == 403
assert denied_action.status_code == 403
cross_tenant_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=2, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=cross_tenant_app), base_url="http://test") as client:
missing = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
assert missing.status_code == 404

View File

@ -0,0 +1,334 @@
from __future__ import annotations
import os
from datetime import timedelta
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
from app.api import pmflow_launch_api, pmflow_os_api # noqa: E402
from app.core.database import get_db # noqa: E402
from app.dependencies.auth import get_current_user # noqa: E402
from app.models import ( # noqa: E402
DevWorkspaceBinding,
FeedbackIngest,
FeishuItem,
GitHubWebhookDelivery,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
OperationLog,
PMFlowLaunchDraft,
PMFlowThread,
PMFlowThreadMessage,
ProgressEvent,
Project,
ProjectModelBinding,
PromptPackage,
ReleaseValidationRun,
RepoBinding,
RevenueEvent,
ROISnapshot,
SemanticFact,
Signal,
Task,
TaskCodeLink,
TruthNode,
User,
UserProject,
)
from app.models.base import Base # noqa: E402
from app.services import pmflow_launch_service # noqa: E402
from app.utils.timezone import now_cn_naive # noqa: E402
TABLES = [
User.__table__,
FeishuItem.__table__,
Signal.__table__,
Project.__table__,
UserProject.__table__,
PMFlowLaunchDraft.__table__,
PMFlowThread.__table__,
PMFlowThreadMessage.__table__,
PMFlowChangeSet.__table__,
PMFlowChangeItem.__table__,
PMFlowApproval.__table__,
PMFlowEvidenceLink.__table__,
TruthNode.__table__,
Task.__table__,
SemanticFact.__table__,
ModelCatalogItem.__table__,
ProjectModelBinding.__table__,
ModelRecommendationSnapshot.__table__,
ModelEvalRun.__table__,
PromptPackage.__table__,
DevWorkspaceBinding.__table__,
RepoBinding.__table__,
TaskCodeLink.__table__,
ProgressEvent.__table__,
ReleaseValidationRun.__table__,
FeedbackIngest.__table__,
RevenueEvent.__table__,
ROISnapshot.__table__,
GitHubWebhookDelivery.__table__,
OperationLog.__table__,
]
def _use_sqlite_json_types() -> None:
for table in TABLES:
for column in table.columns:
type_name = column.type.__class__.__name__.upper()
if type_name in {"JSONB", "ARRAY"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_env():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
async def _seed_user(session, *, tenant_id: int = 1, username: str = "owner"):
user = User(
tenant_id=tenant_id,
username=f"{username}-{tenant_id}",
email=f"{username}-{tenant_id}@example.com",
password_hash="hashed",
name="Owner",
role="MEMBER",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.commit()
return user
def _build_app(session, current_user) -> FastAPI:
app = FastAPI()
app.include_router(pmflow_launch_api.router)
app.include_router(pmflow_os_api.router)
async def _fake_get_db():
yield session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: current_user
return app
def _draft_payload(**overrides):
payload = {
"title": "AI 会议纪要助手",
"one_liner": "自动整理会议纪要并生成待办。",
"target_user": "个人开发者、小团队负责人",
"problem": "会议信息散落,行动项难追踪。",
"solution": "解析会议文本并转成项目任务和风险。",
"requirements": [
{"title": "导入会议文本", "summary": "支持粘贴会议纪要", "acceptance_criteria": ["能保存输入"]},
{"title": "提取行动项", "summary": "识别待办和负责人", "acceptance_criteria": ["输出任务列表"]},
{"title": "风险提醒", "summary": "识别不确定项", "acceptance_criteria": ["输出风险"]},
],
"tasks": [
{"title": "设计输入流程", "summary": "完成启动台输入", "group": "product", "priority": "medium", "requirement_index": 0},
{"title": "实现文本解析", "summary": "调用结构化服务", "group": "engineering", "priority": "high", "requirement_index": 1},
{"title": "补充失败测试", "summary": "覆盖失败态", "group": "qa", "priority": "medium", "requirement_index": 2},
],
"risks": [
{"title": "结构化质量风险", "summary": "会议内容过短会影响提取", "level": "medium", "mitigation": "提示补充上下文"},
{"title": "隐私风险", "summary": "会议内容可能包含敏感信息", "level": "high", "mitigation": "明确权限边界"},
],
"decisions": [
{"title": "先做文本输入", "reason": "首版不接录音和日历", "owner": "项目负责人"},
],
"evidence": [{"source": "user_input", "excerpt": "我想做一个 AI 会议纪要工具"}],
"confidence": 0.82,
"warnings": [],
}
payload.update(overrides)
return payload
def _patch_llm(monkeypatch, payload):
async def _fake_generate_structured_object(*args, **kwargs):
return SimpleNamespace(payload=payload, raw_text="{}", provider="test", model="test")
monkeypatch.setattr(pmflow_launch_service.llm_service, "generate_structured_object", _fake_generate_structured_object)
@pytest.mark.asyncio
async def test_create_launch_draft_success_persists_draft(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
_patch_llm(monkeypatch, _draft_payload())
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具"})
assert response.status_code == 200
payload = response.json()["data"]
assert payload["draft_id"].startswith("draft_")
rows = (await session.execute(select(PMFlowLaunchDraft))).scalars().all()
assert len(rows) == 1
assert rows[0].status == "draft"
assert rows[0].draft_payload["title"] == "AI 会议纪要助手"
@pytest.mark.asyncio
async def test_create_launch_draft_missing_core_fields_returns_422(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
_patch_llm(monkeypatch, _draft_payload(title=""))
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"})
assert response.status_code == 422
assert response.json()["code"] == 422
assert "draft_structured_failed" in response.json()["message"]
assert response.json()["data"] is None
@pytest.mark.asyncio
async def test_create_launch_draft_count_warnings_are_explicit(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
_patch_llm(monkeypatch, _draft_payload(risks=[{"title": "单一风险", "summary": "不足", "level": "medium"}]))
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/pmflow-launch/drafts", json={"input": "做一个工具"})
assert response.status_code == 200
assert "risks_count_out_of_range" in " ".join(response.json()["data"]["warnings"])
async def _create_draft_via_api(session, user, monkeypatch):
_patch_llm(monkeypatch, _draft_payload())
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=user.tenant_id, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/pmflow-launch/drafts", json={"input": "我想做一个 AI 会议纪要工具"})
assert response.status_code == 200
return app, response.json()["data"]["draft_id"]
@pytest.mark.asyncio
async def test_confirm_launch_draft_creates_project_thread_message_truth_nodes_tasks_and_truth_state(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
app, draft_id = await _create_draft_via_api(session, user, monkeypatch)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
confirm = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
truth = await client.get(f"/pmflow-os/projects/{confirm.json()['data']['project_id']}/truth-state")
assert confirm.status_code == 200
data = confirm.json()["data"]
assert data["project_id"] > 0
assert data["thread_id"] is not None
assert data["thread_message_id"] is not None
assert len(data["created_truth_node_ids"]) == 6
assert len(data["created_task_ids"]) == 3
assert (await session.scalar(select(func.count(Project.id)))) == 1
assert (await session.scalar(select(func.count(PMFlowThread.id)))) == 1
assert (await session.scalar(select(func.count(PMFlowThreadMessage.id)))) == 1
assert (await session.scalar(select(func.count(TruthNode.id)))) == 6
assert (await session.scalar(select(func.count(Task.id)))) == 3
truth_node = (await session.execute(select(TruthNode).where(TruthNode.node_type == "requirement"))).scalars().first()
assert truth_node.content["metadata"]["launch_draft_id"] == draft_id
assert truth_node.content["metadata"]["thread_message_id"] == data["thread_message_id"]
task = (await session.execute(select(Task))).scalars().first()
assert task.ai_breakdown_payload["launch_metadata"]["launch_draft_id"] == draft_id
assert task.manual_overrides["launch_metadata"]["thread_message_id"] == data["thread_message_id"]
truth_payload = truth.json()["data"]
assert truth_payload["project"]["id"] == str(data["project_id"])
assert truth_payload["thread"]["id"] == str(data["thread_id"])
assert truth_payload["messages"][0]["content"] == "我想做一个 AI 会议纪要工具"
assert truth_payload["requirements"]
assert truth_payload["tasks"]
@pytest.mark.asyncio
async def test_confirm_launch_draft_is_idempotent(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
app, draft_id = await _create_draft_via_api(session, user, monkeypatch)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
first = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
second = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["data"]["project_id"] == second.json()["data"]["project_id"]
assert (await session.scalar(select(func.count(Project.id)))) == 1
assert (await session.scalar(select(func.count(TruthNode.id)))) == 6
assert (await session.scalar(select(func.count(Task.id)))) == 3
@pytest.mark.asyncio
async def test_confirm_launch_draft_tenant_user_and_expiry_boundaries(db_env, monkeypatch) -> None:
session = db_env
owner = await _seed_user(session, tenant_id=1, username="owner")
other = await _seed_user(session, tenant_id=1, username="other")
app, draft_id = await _create_draft_via_api(session, owner, monkeypatch)
other_app = _build_app(session, SimpleNamespace(id=other.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=other_app), base_url="http://test") as client:
forbidden = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
assert forbidden.status_code == 404
row = (await session.execute(select(PMFlowLaunchDraft).where(PMFlowLaunchDraft.draft_id == draft_id))).scalar_one()
row.expires_at = now_cn_naive() - timedelta(minutes=1)
await session.commit()
owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client:
expired = await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
assert expired.status_code == 409
assert expired.json()["code"] == 409
assert "draft_expired" in expired.json()["message"]
assert expired.json()["data"] is None
@pytest.mark.asyncio
async def test_confirm_launch_draft_rolls_back_on_critical_write_failure(db_env, monkeypatch) -> None:
session = db_env
user = await _seed_user(session)
app, draft_id = await _create_draft_via_api(session, user, monkeypatch)
async def _boom(*args, **kwargs):
raise RuntimeError("task write failed")
monkeypatch.setattr(pmflow_launch_service.PMFlowLaunchService, "_create_tasks", _boom)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
with pytest.raises(RuntimeError):
await client.post(f"/pmflow-launch/drafts/{draft_id}/confirm", json={"edited_draft": {}, "confirm": True})
assert (await session.scalar(select(func.count(Project.id)))) == 0
assert (await session.scalar(select(func.count(TruthNode.id)))) == 0

View File

@ -0,0 +1,301 @@
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
from app.api import pmflow_os_api # noqa: E402
from app.core.database import get_db # noqa: E402
from app.dependencies.auth import get_current_user # noqa: E402
from app.models import ( # noqa: E402
DevWorkspaceBinding,
FeedbackIngest,
FeishuItem,
GitHubWebhookDelivery,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowLaunchDraft,
PMFlowThread,
PMFlowThreadMessage,
ProgressEvent,
Project,
ProjectModelBinding,
PromptPackage,
ReleaseValidationRun,
RepoBinding,
RevenueEvent,
ROISnapshot,
SemanticFact,
Signal,
Task,
TaskCodeLink,
TruthNode,
User,
UserProject,
)
from app.models.base import Base # noqa: E402
TABLES = [
User.__table__,
FeishuItem.__table__,
Signal.__table__,
Project.__table__,
UserProject.__table__,
PMFlowLaunchDraft.__table__,
PMFlowThread.__table__,
PMFlowThreadMessage.__table__,
PMFlowChangeSet.__table__,
PMFlowChangeItem.__table__,
PMFlowApproval.__table__,
PMFlowEvidenceLink.__table__,
TruthNode.__table__,
Task.__table__,
SemanticFact.__table__,
ModelCatalogItem.__table__,
ProjectModelBinding.__table__,
ModelRecommendationSnapshot.__table__,
ModelEvalRun.__table__,
PromptPackage.__table__,
DevWorkspaceBinding.__table__,
RepoBinding.__table__,
TaskCodeLink.__table__,
ProgressEvent.__table__,
ReleaseValidationRun.__table__,
FeedbackIngest.__table__,
RevenueEvent.__table__,
ROISnapshot.__table__,
GitHubWebhookDelivery.__table__,
]
def _use_sqlite_json_types() -> None:
for table in TABLES:
for column in table.columns:
type_name = column.type.__class__.__name__.upper()
if type_name in {"JSONB", "ARRAY"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_env():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=TABLES))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
async def _seed_user_project(session, *, tenant_id: int = 1, user_role: str = "MEMBER", member: bool = True):
user = User(
tenant_id=tenant_id,
username=f"user-{tenant_id}-{user_role}-{member}",
email=f"user-{tenant_id}-{user_role}-{member}@example.com",
password_hash="hashed",
name="Owner",
role=user_role,
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.flush()
project = Project(
tenant_id=tenant_id,
name="真实项目",
title="真实项目",
description="从真实后端读取的项目",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(project)
await session.flush()
if member:
session.add(UserProject(tenant_id=tenant_id, user_id=user.id, project_id=project.id))
await session.commit()
return user, project
def _build_app(session, current_user) -> FastAPI:
app = FastAPI()
app.include_router(pmflow_os_api.router)
async def _fake_get_db():
yield session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: current_user
return app
@pytest.mark.asyncio
async def test_truth_state_empty_project_returns_compatible_empty_projection(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["project"]["id"] == str(project.id)
assert payload["requirements"] == []
assert payload["tasks"] == []
assert payload["changeSets"] == []
assert "truth_nodes_empty" in " ".join(payload["projectionWarnings"])
@pytest.mark.asyncio
async def test_truth_state_projects_memory_from_truth_nodes_and_semantic_facts(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
requirement = TruthNode(
tenant_id=1,
node_type="requirement",
project_id=project.id,
created_by=user.id,
title="真实需求",
content={"summary": "来自 truth_nodes 的需求", "stage": "planning"},
version=1,
status="aligned",
)
risk = TruthNode(
tenant_id=1,
node_type="risk",
project_id=project.id,
created_by=user.id,
title="真实风险",
content={"summary": "来自 truth_nodes 的风险", "risk_level": "high", "mitigation": "人工确认"},
version=1,
status="confirmed",
)
session.add_all([requirement, risk])
await session.flush()
session.add(
SemanticFact(
tenant_id=1,
entity_type="truth_node",
entity_id=requirement.id,
fact_namespace="project_memory",
fact_key="target_user",
fact_value_text="个人开发者",
fact_value_norm="个人开发者",
value_type="keyword",
confidence=0.91,
evidence_source_type="truth_node",
evidence_source_id=requirement.id,
evidence_excerpt="来自 truth node",
is_active=True,
)
)
await session.commit()
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
payload = response.json()["data"]
assert payload["requirements"][0]["title"] == "真实需求"
assert payload["risks"][0]["level"] == "high"
assert payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 2
assert payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 1
assert payload["projectMemory"]["semanticFacts"][0]["source"] == "semantic_facts"
@pytest.mark.asyncio
async def test_truth_state_task_projection_comes_from_tasks_table(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
charter = TruthNode(
tenant_id=1,
node_type="charter",
project_id=project.id,
created_by=user.id,
title="真实宪章",
content={"business_goal": "真实任务来源"},
version=1,
status="aligned",
)
session.add(charter)
await session.flush()
task = Task(
tenant_id=1,
truth_node_id=charter.id,
project_id=project.id,
assignee_id=user.id,
ai_recommended_assignees=None,
ai_breakdown_payload={"acceptance_criteria": ["必须来自 tasks 表"]},
manual_overrides=None,
title="真实开发任务",
description="从 tasks 表投影出来",
required_skill_tag_ids=[],
status="in_progress",
priority="high",
)
session.add(task)
await session.commit()
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
payload = response.json()["data"]
assert payload["tasks"][0]["title"] == "真实开发任务"
assert payload["tasks"][0]["requirementId"] == str(charter.id)
assert payload["tasks"][0]["acceptanceCriteria"] == ["必须来自 tasks 表"]
assert all(item["title"] != "重构会议转写主链" for item in payload["tasks"])
@pytest.mark.asyncio
async def test_execution_summary_empty_state_is_pending_with_warnings(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(f"/pmflow-os/projects/{project.id}/execution-summary")
payload = response.json()["data"]
assert payload["execution"]["status"] == "queued"
assert payload["execution"]["prompt"] == ""
assert payload["execution"]["testSummary"] == "pending"
assert "prompt_package_empty" in " ".join(payload["warnings"])
assert payload["activeDevTasks"] == []
@pytest.mark.asyncio
async def test_project_permission_and_tenant_boundary(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session, member=True)
_, forbidden_project = await _seed_user_project(session, tenant_id=1, member=False)
_, other_tenant_project = await _seed_user_project(session, tenant_id=2, member=True)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
ok_response = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
forbidden_response = await client.get(f"/pmflow-os/projects/{forbidden_project.id}/truth-state")
not_found_response = await client.get(f"/pmflow-os/projects/{other_tenant_project.id}/truth-state")
assert ok_response.status_code == 200
assert forbidden_response.status_code == 403
assert not_found_response.status_code == 404

View File

@ -68,7 +68,7 @@ async def test_enqueue_work_item_delta_events_reads_feishu_work_items_into_kg(db
email="kg_owner@example.com", email="kg_owner@example.com",
password_hash="hash", password_hash="hash",
name="图谱负责人", name="图谱负责人",
role="ADMIN", role="REVIEWER",
status="active", status="active",
is_active=True, is_active=True,
) )

View File

@ -11,7 +11,7 @@ from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id from app.dependencies.tenant import get_tenant_id
def _build_test_app(*, role: str = "ADMIN") -> FastAPI: def _build_test_app(*, role: str = "REVIEWER") -> FastAPI:
app = FastAPI() app = FastAPI()
app.include_router(routes_route_governance.router) app.include_router(routes_route_governance.router)
@ -75,7 +75,7 @@ async def test_query_route_governance_summary_returns_manager_payload(monkeypatc
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService) monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
app = _build_test_app(role="ADMIN") app = _build_test_app(role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/rag/query-route-governance/summary", params={"project_id": 17, "window_days": 30}) response = await client.get("/rag/query-route-governance/summary", params={"project_id": 17, "window_days": 30})
@ -167,7 +167,7 @@ async def test_query_route_governance_items_and_detail_return_contract(monkeypat
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService) monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
app = _build_test_app(role="ADMIN") app = _build_test_app(role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
list_response = await client.get( list_response = await client.get(

View File

@ -137,7 +137,7 @@ def _build_app(db_session):
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001, id=9001,
tenant_id=1, tenant_id=1,
role="ADMIN", role="REVIEWER",
name="管理员", name="管理员",
username="admin", username="admin",
) )

View File

@ -89,7 +89,7 @@ def _build_app(db_session):
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001, id=9001,
tenant_id=1, tenant_id=1,
role="ADMIN", role="REVIEWER",
name="管理员", name="管理员",
username="admin", username="admin",
) )

View File

@ -76,7 +76,7 @@ def _build_app(db_session, *, role: str) -> FastAPI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_semantic_fact_admin_api_requires_manager_role(db_session) -> None: async def test_semantic_fact_admin_api_requires_manager_role(db_session) -> None:
app = _build_app(db_session, role="AI") app = _build_app(db_session, role="BUILDER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
@ -134,7 +134,7 @@ async def test_semantic_fact_admin_api_exposes_overview_objects_detail_and_actio
) )
await db_session.commit() await db_session.commit()
app = _build_app(db_session, role="ADMIN") app = _build_app(db_session, role="REVIEWER")
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:

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