chore: checkpoint current PMFlow workspace
This commit is contained in:
parent
b52428f79e
commit
e04ea4d1ee
|
|
@ -11,6 +11,7 @@ DB_PORT=5435
|
|||
REDIS_PORT=6379
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5174
|
||||
FRONTEND_PROD_PORT=5175
|
||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||
|
||||
# Backend Runtime
|
||||
|
|
|
|||
|
|
@ -71,3 +71,15 @@ Pixelle-Video/
|
|||
|
||||
# Playwright browser profiles
|
||||
frontend/.playwright-profiles/
|
||||
.playwright-cli/
|
||||
frontend/.playwright-cli/
|
||||
frontend/output/
|
||||
output/
|
||||
.tmp-feishu-*.png
|
||||
task_instance_*.xlsx
|
||||
yesterday_tasks_*.csv
|
||||
yesterday_tasks_*.xlsx
|
||||
工单数据_*.xlsx
|
||||
工单全字段报表_*.xlsx
|
||||
tmp_short_confirm_redesign_summary.html
|
||||
ai.rtf
|
||||
|
|
|
|||
|
|
@ -0,0 +1,496 @@
|
|||
"""add ai project control tables
|
||||
|
||||
Revision ID: 20260421_add_ai_project_control_tables
|
||||
Revises: 20260415_add_semantic_facts
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_add_ai_project_control_tables"
|
||||
down_revision = "20260415_add_semantic_facts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _seed_model_catalog(bind) -> None:
|
||||
table = sa.table(
|
||||
"model_catalog_items",
|
||||
sa.column("tenant_id", sa.BigInteger()),
|
||||
sa.column("provider", sa.String()),
|
||||
sa.column("model_key", sa.String()),
|
||||
sa.column("display_name", sa.String()),
|
||||
sa.column("version", sa.String()),
|
||||
sa.column("modalities", _json_type(bind)),
|
||||
sa.column("capability_tags", _json_type(bind)),
|
||||
sa.column("strengths", _json_type(bind)),
|
||||
sa.column("limitations", _json_type(bind)),
|
||||
sa.column("compliance_status", sa.String()),
|
||||
sa.column("filing_status", sa.String()),
|
||||
sa.column("input_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("output_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("official_docs_url", sa.Text()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "阿里云",
|
||||
"model_key": "qwen-plus",
|
||||
"display_name": "通义千问 Plus",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "agent", "code"],
|
||||
"strengths": ["中文通用能力强", "RAG 适配稳定", "工具调用成熟"],
|
||||
"limitations": ["不适合视频生成"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://help.aliyun.com/zh/model-studio/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "火山引擎",
|
||||
"model_key": "doubao-pro",
|
||||
"display_name": "豆包 Pro",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["长文本稳", "中文体验好", "成本适中"],
|
||||
"limitations": ["代码生成弱于专用模型"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://www.volcengine.com/product/doubao",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "百度智能云",
|
||||
"model_key": "ernie-4.0-turbo",
|
||||
"display_name": "文心一言 4.0 Turbo",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["企业落地常见", "多模态均衡"],
|
||||
"limitations": ["综合成本偏高"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.2,
|
||||
"output_cost_per_mtok": 3.2,
|
||||
"official_docs_url": "https://cloud.baidu.com/product/wenxinworkshop",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "DeepSeek",
|
||||
"model_key": "deepseek-chat",
|
||||
"display_name": "DeepSeek Chat",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "reasoning", "code", "agent"],
|
||||
"strengths": ["代码与推理性价比高", "适合技术团队"],
|
||||
"limitations": ["需要留意合规和交付边界"],
|
||||
"compliance_status": "review_required",
|
||||
"filing_status": "unknown",
|
||||
"input_cost_per_mtok": 0.27,
|
||||
"output_cost_per_mtok": 1.1,
|
||||
"official_docs_url": "https://platform.deepseek.com/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "Moonshot AI",
|
||||
"model_key": "kimi-k2",
|
||||
"display_name": "Kimi K2",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "long_context", "rag", "reasoning"],
|
||||
"strengths": ["长上下文强", "资料理解稳定"],
|
||||
"limitations": ["对多模态场景支持有限"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.0,
|
||||
"output_cost_per_mtok": 3.0,
|
||||
"official_docs_url": "https://platform.moonshot.cn/",
|
||||
"is_active": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "model_catalog_items"):
|
||||
op.create_table(
|
||||
"model_catalog_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("provider", sa.String(length=120), nullable=False),
|
||||
sa.Column("model_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("version", sa.String(length=120), nullable=False),
|
||||
sa.Column("modalities", json_type, nullable=False),
|
||||
sa.Column("capability_tags", json_type, nullable=False),
|
||||
sa.Column("strengths", json_type, nullable=False),
|
||||
sa.Column("limitations", json_type, nullable=False),
|
||||
sa.Column("compliance_status", sa.String(length=50), nullable=False, server_default="review_required"),
|
||||
sa.Column("filing_status", sa.String(length=50), nullable=False, server_default="unknown"),
|
||||
sa.Column("input_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("output_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("official_docs_url", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"model_key",
|
||||
"version",
|
||||
name="uq_model_catalog_items_tenant_provider_key_version",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_model_catalog_items_tenant_id", "model_catalog_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_provider", "model_catalog_items", ["provider"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_model_key", "model_catalog_items", ["model_key"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_is_active", "model_catalog_items", ["is_active"], unique=False)
|
||||
_seed_model_catalog(bind)
|
||||
|
||||
table_specs = {
|
||||
"project_model_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=False),
|
||||
sa.Column("selected_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("selection_reason", json_type, nullable=False),
|
||||
sa.Column("estimated_daily_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("estimated_monthly_cost_usd", sa.DECIMAL(12, 2), nullable=True),
|
||||
sa.Column("active_from", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"model_recommendation_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("recommendation_scope", sa.String(length=32), nullable=False, server_default="charter"),
|
||||
sa.Column("snapshot_input", json_type, nullable=False),
|
||||
sa.Column("recommendations", json_type, nullable=False),
|
||||
sa.Column("top_model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=True),
|
||||
sa.Column("generated_by", sa.String(length=32), nullable=False, server_default="rule_engine"),
|
||||
],
|
||||
"model_usage_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("event_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cost_usd", sa.DECIMAL(12, 4), nullable=False, server_default="0"),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
|
||||
],
|
||||
"model_eval_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("success_criteria", json_type, nullable=False),
|
||||
sa.Column("test_cases", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
],
|
||||
"prompt_packages": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="development"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("package_text", sa.Text(), nullable=False),
|
||||
sa.Column("package_payload", json_type, nullable=False),
|
||||
sa.Column("generated_from", json_type, nullable=False),
|
||||
],
|
||||
"dev_workspace_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_root", sa.Text(), nullable=True),
|
||||
sa.Column("editor_type", sa.String(length=32), nullable=False, server_default="vscode"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
|
||||
],
|
||||
"repo_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False, server_default="github"),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=False, server_default="main"),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("webhook_secret_hint", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
|
||||
],
|
||||
"task_code_links": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=False),
|
||||
sa.Column("branch_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("pr_number", sa.Integer(), nullable=True),
|
||||
sa.Column("last_commit_sha", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="linked"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"repo_binding_id",
|
||||
"branch_name",
|
||||
name="uq_task_code_links_tenant_task_repo_branch",
|
||||
),
|
||||
],
|
||||
"progress_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("derived_task_status", sa.String(length=32), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"dev_sessions": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("heartbeat_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("session_payload", json_type, nullable=False),
|
||||
],
|
||||
"release_validation_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("key_metrics", json_type, nullable=False),
|
||||
sa.Column("risk_items", json_type, nullable=False),
|
||||
sa.Column("rollback_plan", json_type, nullable=False),
|
||||
sa.Column("validation_summary", json_type, nullable=False),
|
||||
],
|
||||
"feedback_ingests": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("ingested_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("metrics", json_type, nullable=False),
|
||||
sa.Column("samples", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
],
|
||||
"iteration_recommendations": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("feedback_ingest_id", sa.BigInteger(), sa.ForeignKey("feedback_ingests.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("recommendation_summary", json_type, nullable=False),
|
||||
sa.Column("proposed_charter_patch", json_type, nullable=False),
|
||||
sa.Column("proposed_tasks", json_type, nullable=False),
|
||||
],
|
||||
"revenue_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("currency", sa.String(length=16), nullable=False, server_default="USD"),
|
||||
sa.Column("amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("external_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"roi_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("range_key", sa.String(length=32), nullable=False, server_default="monthly"),
|
||||
sa.Column("window_start", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("window_end", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("total_revenue_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("roi_ratio", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("snapshot_payload", json_type, nullable=False),
|
||||
],
|
||||
"compliance_check_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("checklist", json_type, nullable=False),
|
||||
sa.Column("issues", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
],
|
||||
"evidence_packs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("compliance_check_run_id", sa.BigInteger(), sa.ForeignKey("compliance_check_runs.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="compliance_release"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("content", json_type, nullable=False),
|
||||
sa.Column("export_payload", json_type, nullable=False),
|
||||
],
|
||||
}
|
||||
|
||||
for table_name, columns in table_specs.items():
|
||||
if _table_exists(bind, table_name):
|
||||
continue
|
||||
op.create_table(table_name, *columns)
|
||||
op.create_index(f"ix_{table_name}_tenant_id", table_name, ["tenant_id"], unique=False)
|
||||
|
||||
extra_indexes = {
|
||||
"project_model_bindings": ["project_id", "charter_id", "model_catalog_item_id", "status"],
|
||||
"model_recommendation_snapshots": ["project_id", "charter_id"],
|
||||
"model_usage_events": ["project_id", "project_model_binding_id", "source_type", "occurred_at"],
|
||||
"model_eval_runs": ["project_id", "charter_id", "project_model_binding_id", "status"],
|
||||
"prompt_packages": ["project_id", "charter_id", "task_id", "project_model_binding_id", "status"],
|
||||
"dev_workspace_bindings": ["project_id", "user_id", "status"],
|
||||
"repo_bindings": ["project_id", "provider", "status"],
|
||||
"task_code_links": ["task_id", "repo_binding_id", "pr_number", "status"],
|
||||
"progress_events": ["task_id", "project_id", "user_id", "repo_binding_id", "workspace_binding_id", "event_type", "source", "occurred_at"],
|
||||
"dev_sessions": ["project_id", "user_id", "workspace_binding_id", "status"],
|
||||
"release_validation_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"feedback_ingests": ["project_id", "charter_id", "ingested_by_user_id", "source_type", "status"],
|
||||
"iteration_recommendations": ["project_id", "charter_id", "feedback_ingest_id", "status", "priority"],
|
||||
"revenue_events": ["project_id", "source_type", "external_ref", "occurred_at"],
|
||||
"roi_snapshots": ["project_id", "range_key"],
|
||||
"compliance_check_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"evidence_packs": ["project_id", "compliance_check_run_id", "package_kind", "status"],
|
||||
}
|
||||
for table_name, columns in extra_indexes.items():
|
||||
for column_name in columns:
|
||||
op.create_index(f"ix_{table_name}_{column_name}", table_name, [column_name], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tables = [
|
||||
"evidence_packs",
|
||||
"compliance_check_runs",
|
||||
"roi_snapshots",
|
||||
"revenue_events",
|
||||
"iteration_recommendations",
|
||||
"feedback_ingests",
|
||||
"release_validation_runs",
|
||||
"dev_sessions",
|
||||
"progress_events",
|
||||
"task_code_links",
|
||||
"repo_bindings",
|
||||
"dev_workspace_bindings",
|
||||
"prompt_packages",
|
||||
"model_eval_runs",
|
||||
"model_usage_events",
|
||||
"model_recommendation_snapshots",
|
||||
"project_model_bindings",
|
||||
"model_catalog_items",
|
||||
]
|
||||
for table_name in tables:
|
||||
if not _table_exists(bind, table_name):
|
||||
continue
|
||||
for index in sa.inspect(bind).get_indexes(table_name):
|
||||
op.drop_index(index["name"], table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"""finalize v1 productization
|
||||
|
||||
Revision ID: 20260421_finalize_v1_productization
|
||||
Revises: 20260421_add_ai_project_control_tables
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_finalize_v1_productization"
|
||||
down_revision = "20260421_add_ai_project_control_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column.get("name") == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if _table_exists(bind, "users") and _column_exists(bind, "users", "role"):
|
||||
op.execute("UPDATE users SET role = 'OWNER' WHERE UPPER(role) = 'BOSS'")
|
||||
op.execute("UPDATE users SET role = 'REVIEWER' WHERE UPPER(role) = 'ADMIN'")
|
||||
op.execute("UPDATE users SET role = 'BUILDER' WHERE UPPER(role) IN ('AI', 'FE', 'BE')")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and not _column_exists(bind, "revenue_events", "category"):
|
||||
op.add_column(
|
||||
"revenue_events",
|
||||
sa.Column("category", sa.String(length=32), nullable=False, server_default="other"),
|
||||
)
|
||||
op.create_index("ix_revenue_events_category", "revenue_events", ["category"], unique=False)
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and not _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.add_column(
|
||||
"repo_bindings",
|
||||
sa.Column("installation_account_login", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.add_column("progress_events", sa.Column("delivery_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_delivery_id", "progress_events", ["delivery_id"], unique=False)
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.add_column("progress_events", sa.Column("event_source_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_event_source_id", "progress_events", ["event_source_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_installations"):
|
||||
op.create_table(
|
||||
"github_app_installations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_login", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("app_slug", sa.String(length=255), nullable=True),
|
||||
sa.Column("target_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("events", json_type, nullable=False),
|
||||
sa.Column("metadata", json_type, nullable=False),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
|
||||
)
|
||||
op.create_index("ix_github_app_installations_tenant_id", "github_app_installations", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_installation_id", "github_app_installations", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_account_login", "github_app_installations", ["account_login"], unique=False)
|
||||
op.create_index("ix_github_app_installations_status", "github_app_installations", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_repositories"):
|
||||
op.create_table(
|
||||
"github_app_repositories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=True),
|
||||
sa.Column("is_private", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"installation_id",
|
||||
"external_repo_id",
|
||||
name="uq_github_app_repositories_repo",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_github_app_repositories_tenant_id", "github_app_repositories", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_installation_id", "github_app_repositories", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_external_repo_id", "github_app_repositories", ["external_repo_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_owner", "github_app_repositories", ["owner"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_repo_name", "github_app_repositories", ["repo_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_full_name", "github_app_repositories", ["full_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_status", "github_app_repositories", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.create_table(
|
||||
"github_webhook_deliveries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("delivery_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="received"),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("received_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
|
||||
)
|
||||
op.create_index("ix_github_webhook_deliveries_tenant_id", "github_webhook_deliveries", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_delivery_id", "github_webhook_deliveries", ["delivery_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_event_type", "github_webhook_deliveries", ["event_type"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_repo_binding_id", "github_webhook_deliveries", ["repo_binding_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_project_id", "github_webhook_deliveries", ["project_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_installation_id", "github_webhook_deliveries", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_status", "github_webhook_deliveries", ["status"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_received_at", "github_webhook_deliveries", ["received_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.drop_table("github_webhook_deliveries")
|
||||
if _table_exists(bind, "github_app_repositories"):
|
||||
op.drop_table("github_app_repositories")
|
||||
if _table_exists(bind, "github_app_installations"):
|
||||
op.drop_table("github_app_installations")
|
||||
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.drop_index("ix_progress_events_event_source_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "event_source_id")
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.drop_index("ix_progress_events_delivery_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "delivery_id")
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.drop_column("repo_bindings", "installation_account_login")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and _column_exists(bind, "revenue_events", "category"):
|
||||
op.drop_index("ix_revenue_events_category", table_name="revenue_events")
|
||||
op.drop_column("revenue_events", "category")
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"""add pmflow launch tables
|
||||
|
||||
Revision ID: 20260425_add_pmflow_launch_tables
|
||||
Revises: 20260421_finalize_v1_productization
|
||||
Create Date: 2026-04-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260425_add_pmflow_launch_tables"
|
||||
down_revision = "20260421_finalize_v1_productization"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_launch_drafts"):
|
||||
op.create_table(
|
||||
"pmflow_launch_drafts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("draft_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("draft_payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("warnings", json_type, nullable=False),
|
||||
sa.Column("confirmed_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("confirmed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("confirm_request_hash", sa.String(length=128), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts", ["user_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts", ["draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts", ["status"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_launch_drafts_confirmed_project_id",
|
||||
"pmflow_launch_drafts",
|
||||
["confirmed_project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts", ["expires_at"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_threads"):
|
||||
op.create_table(
|
||||
"pmflow_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("objective", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
|
||||
)
|
||||
op.create_index("ix_pmflow_threads_tenant_id", "pmflow_threads", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_project_id", "pmflow_threads", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_launch_draft_id", "pmflow_threads", ["launch_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_status", "pmflow_threads", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_source", "pmflow_threads", ["source"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_thread_messages"):
|
||||
op.create_table(
|
||||
"pmflow_thread_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("role", sa.String(length=32), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("message_metadata", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="sent"),
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_thread_messages_launch_draft_id",
|
||||
"pmflow_thread_messages",
|
||||
["launch_draft_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_status", "pmflow_thread_messages", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_thread_messages_status", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_launch_draft_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_threads_source", "pmflow_threads"),
|
||||
("ix_pmflow_threads_status", "pmflow_threads"),
|
||||
("ix_pmflow_threads_launch_draft_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_project_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_tenant_id", "pmflow_threads"),
|
||||
("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_confirmed_project_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_thread_messages", "pmflow_threads", "pmflow_launch_drafts"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
"""add pmflow change review tables
|
||||
|
||||
Revision ID: 20260426_add_pmflow_change_review_tables
|
||||
Revises: 20260425_add_pmflow_launch_tables
|
||||
Create Date: 2026-04-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260426_add_pmflow_change_review_tables"
|
||||
down_revision = "20260425_add_pmflow_launch_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_sets"):
|
||||
op.create_table(
|
||||
"pmflow_change_sets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("source_message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("headline", sa.String(length=255), nullable=False),
|
||||
sa.Column("extraction_scope", sa.String(length=64), nullable=False, server_default="latest_message"),
|
||||
sa.Column("routing_meta", json_type, nullable=False),
|
||||
sa.Column("llm_call_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("committed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_project_id", "pmflow_change_sets", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_thread_id", "pmflow_change_sets", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets", ["source_message_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_status", "pmflow_change_sets", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_created_by", "pmflow_change_sets", ["created_by"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_items"):
|
||||
op.create_table(
|
||||
"pmflow_change_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("group", sa.String(length=32), nullable=False),
|
||||
sa.Column("object_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("diff", sa.Text(), nullable=False),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("risk_level", sa.String(length=16), nullable=False, server_default="medium"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_review"),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_items_tenant_id", "pmflow_change_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_change_set_id", "pmflow_change_items", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_project_id", "pmflow_change_items", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_status", "pmflow_change_items", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_approvals"):
|
||||
op.create_table(
|
||||
"pmflow_approvals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=False),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("action", sa.String(length=32), nullable=False),
|
||||
sa.Column("decision", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("after_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_payload", json_type, nullable=False),
|
||||
sa.Column("after_payload", json_type, nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_approvals_tenant_id", "pmflow_approvals", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_project_id", "pmflow_approvals", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_set_id", "pmflow_approvals", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_item_id", "pmflow_approvals", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_actor_user_id", "pmflow_approvals", ["actor_user_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_evidence_links"):
|
||||
op.create_table(
|
||||
"pmflow_evidence_links",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("excerpt", sa.Text(), nullable=False),
|
||||
sa.Column("display_label", sa.String(length=255), nullable=False),
|
||||
sa.Column("evidence_metadata", json_type, nullable=False),
|
||||
)
|
||||
op.create_index("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links", ["source_type"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links", ["source_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_approvals_actor_user_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_item_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_set_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_project_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_tenant_id", "pmflow_approvals"),
|
||||
("ix_pmflow_change_items_status", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_project_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_change_set_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_tenant_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_sets_created_by", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_status", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_thread_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_project_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_evidence_links", "pmflow_approvals", "pmflow_change_items", "pmflow_change_sets"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -24,7 +24,7 @@ async def get_executive_briefing(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
|
||||
try:
|
||||
|
|
@ -40,7 +40,7 @@ async def get_executive_briefing_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
|
||||
return success_response(payload)
|
||||
|
|
@ -53,7 +53,7 @@ async def update_executive_briefing_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
|
||||
service = ExecutiveBriefingService(db, tenant_id=tenant_id)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from ...services.auth import parse_role_values
|
|||
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
|
||||
from .shared import *
|
||||
|
||||
MANAGER_ROLES = {"ADMIN", "BOSS"}
|
||||
MANAGER_ROLES = {"REVIEWER", "OWNER"}
|
||||
|
||||
|
||||
def _is_manager_user(current_user: User) -> bool:
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ async def list_audit_operations(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
header_tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
|
||||
if current_user is not None and not has_any_role(_user_role(current_user), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
|
||||
if current_user is None:
|
||||
effective_tenant_id = int(header_tenant_id or 1)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async def list_budgets(
|
|||
@router.post("/")
|
||||
async def create_budget(
|
||||
request: BudgetCreateRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -122,7 +122,7 @@ async def create_budget(
|
|||
async def update_budget(
|
||||
budget_id: int,
|
||||
request: BudgetUpdateRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -163,7 +163,7 @@ async def update_budget(
|
|||
async def override_budget(
|
||||
budget_id: int,
|
||||
request: BudgetOverrideRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import success_response
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import Project, User, UserProject
|
||||
from ..services.ai_project_control_service import AIProjectControlService
|
||||
from ..services.auth import has_any_role
|
||||
|
||||
router = APIRouter(tags=["dev-sync"])
|
||||
|
||||
|
||||
class RepoBindingCreateRequest(BaseModel):
|
||||
project_id: int
|
||||
provider: str = "github"
|
||||
owner: str
|
||||
repo_name: str
|
||||
default_branch: str = "main"
|
||||
external_repo_id: str | None = None
|
||||
installation_id: str | None = None
|
||||
installation_account_login: str | None = None
|
||||
webhook_secret_hint: str | None = None
|
||||
|
||||
|
||||
class RepoBindingSelectRequest(BaseModel):
|
||||
project_id: int
|
||||
github_repository_id: int
|
||||
|
||||
|
||||
class ProgressEventCreateRequest(BaseModel):
|
||||
project_id: int
|
||||
event_type: str
|
||||
source: str = "manual"
|
||||
summary: str | None = None
|
||||
repo_binding_id: int | None = None
|
||||
workspace_binding_id: int | None = None
|
||||
payload: dict[str, object] | None = None
|
||||
occurred_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/integrations/github/app/install-url")
|
||||
async def get_github_app_install_url(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.get_github_app_install_center()
|
||||
return success_response(
|
||||
{
|
||||
"install_url": payload.get("install_url"),
|
||||
"configured": bool(payload.get("configured")),
|
||||
"callback_url": payload.get("callback_url"),
|
||||
"webhook_signature_configured": bool(payload.get("webhook_signature_configured")),
|
||||
"app_slug": payload.get("app_slug"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/integrations/github/app/installations")
|
||||
async def list_github_app_installations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.get_github_app_install_center()
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/integrations/github/app/installations/sync")
|
||||
async def sync_github_app_installations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await control.sync_github_installations()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.get("/integrations/github/repos/available")
|
||||
async def list_available_github_repos(
|
||||
project_id: int | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if project_id is not None:
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.list_available_github_repositories())
|
||||
|
||||
|
||||
async def _ensure_project_member_or_manager(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
) -> None:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
|
||||
return
|
||||
member_result = await db.execute(
|
||||
select(UserProject).where(
|
||||
and_(
|
||||
UserProject.tenant_id == int(tenant_id),
|
||||
UserProject.project_id == int(project_id),
|
||||
UserProject.user_id == int(current_user.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
if member_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=403, detail="当前用户无权操作该项目")
|
||||
|
||||
|
||||
@router.get("/me/dev-tasks")
|
||||
async def list_my_dev_tasks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.get_dev_tasks_for_user(user_id=int(current_user.id)))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/dev-sync/summary")
|
||||
async def get_project_dev_sync_summary(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(
|
||||
await control.get_project_dev_sync_summary(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/progress-events")
|
||||
async def create_progress_event(
|
||||
task_id: int,
|
||||
body: ProgressEventCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.create_or_apply_progress_event(
|
||||
task_id=int(task_id),
|
||||
project_id=int(body.project_id),
|
||||
event_type=body.event_type,
|
||||
source=body.source,
|
||||
payload=dict(body.payload or {}),
|
||||
summary=body.summary,
|
||||
user_id=int(current_user.id),
|
||||
repo_binding_id=int(body.repo_binding_id) if body.repo_binding_id is not None else None,
|
||||
workspace_binding_id=int(body.workspace_binding_id) if body.workspace_binding_id is not None else None,
|
||||
occurred_at=body.occurred_at,
|
||||
)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/internal/debug/integrations/github/repos")
|
||||
async def register_github_repo(
|
||||
body: RepoBindingCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.register_repo_binding(
|
||||
project_id=int(body.project_id),
|
||||
provider=body.provider,
|
||||
owner=body.owner,
|
||||
repo_name=body.repo_name,
|
||||
default_branch=body.default_branch,
|
||||
external_repo_id=body.external_repo_id,
|
||||
installation_id=body.installation_id,
|
||||
installation_account_login=body.installation_account_login,
|
||||
webhook_secret_hint=body.webhook_secret_hint,
|
||||
)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/integrations/github/repos/bind")
|
||||
async def bind_github_repo(
|
||||
body: RepoBindingSelectRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await control.bind_github_repository(
|
||||
project_id=int(body.project_id),
|
||||
github_repository_id=int(body.github_repository_id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.get("/integrations/github/deliveries")
|
||||
async def list_github_deliveries(
|
||||
project_id: int | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if project_id is not None:
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.list_github_deliveries(project_id=project_id, limit=limit))
|
||||
|
||||
|
||||
@router.post("/integrations/github/webhook")
|
||||
async def ingest_github_webhook(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
|
||||
signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
|
||||
delivery_id: str | None = Header(default=None, alias="X-GitHub-Delivery"),
|
||||
):
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="GitHub webhook body 不是合法 JSON") from exc
|
||||
tenant_id = int((payload.get("tenant_id") or 1))
|
||||
control = AIProjectControlService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
result = await control.ingest_github_webhook(
|
||||
event_type=str(event_type or payload.get("event_type") or "").strip(),
|
||||
delivery_id=str(delivery_id or payload.get("delivery_id") or "").strip(),
|
||||
signature_256=signature_256,
|
||||
payload=payload,
|
||||
raw_body=raw_body,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/internal/debug/integrations/github/events")
|
||||
async def ingest_github_event_debug(
|
||||
payload: dict = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
|
||||
):
|
||||
tenant_id = int((payload.get("tenant_id") or 1))
|
||||
control = AIProjectControlService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
result = await control.ingest_github_event(
|
||||
event_type=str(event_type or payload.get("event_type") or "").strip(),
|
||||
payload=payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response({"debug": True, **result})
|
||||
|
|
@ -27,5 +27,5 @@ def skill_tag_to_response(tag) -> SkillTagResponse:
|
|||
|
||||
def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None:
|
||||
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
|
||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
|
||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ async def list_skill_tags(
|
|||
async def create_skill_tag(
|
||||
body: SkillTagCreate,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -78,7 +78,7 @@ async def update_skill_tag(
|
|||
tag_id: UUID,
|
||||
body: SkillTagUpdate,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -103,7 +103,7 @@ async def update_skill_tag(
|
|||
async def delete_skill_tag(
|
||||
tag_id: UUID,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ async def save_feishu_config(
|
|||
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||
"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||
|
||||
try:
|
||||
|
|
@ -85,7 +85,7 @@ async def get_feishu_config(
|
|||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
|
||||
|
||||
return {
|
||||
"is_configured": service.is_configured(),
|
||||
|
|
@ -105,7 +105,7 @@ async def delete_feishu_config(
|
|||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""删除飞书配置。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ async def restart_feishu_long_connection(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""手动重启飞书长连接。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||
|
||||
manager = get_feishu_long_connection_manager()
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class VerifyContextConsumptionRequest(BaseModel):
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class LLMEngineSettingsUpdateRequest(BaseModel):
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ async def update_model_routing(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
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, "仅管理员可修改模型路由配置"))
|
||||
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"])
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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"))
|
||||
|
|
@ -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"))
|
||||
|
|
@ -8,7 +8,7 @@ from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectU
|
|||
async def create_project(
|
||||
body: ProjectCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
title = compact_title(body.title, max_len=24)
|
||||
|
|
@ -88,7 +88,7 @@ async def assign_project_members(
|
|||
project_id: int,
|
||||
body: ProjectMembersAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -157,7 +157,7 @@ async def update_project(
|
|||
project_id: int,
|
||||
body: ProjectUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -185,7 +185,7 @@ async def update_project(
|
|||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ async def link_signal_to_project(
|
|||
signal_id: int,
|
||||
body: LinkSignalRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -74,7 +74,7 @@ async def unlink_signal_from_project(
|
|||
project_id: int,
|
||||
signal_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class PromptProfileUpdateRequest(BaseModel):
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ async def export_qa_records_jsonl(
|
|||
domain: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ async def rag_multi_step_search(
|
|||
@router.post("/symbols/sync")
|
||||
async def rag_symbols_sync(
|
||||
request: SymbolSyncRequest,
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -4,76 +4,74 @@ API 路由汇总
|
|||
统一注册所有 API 路由,供 main.py 引入。
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .signals_api import router as signals_router
|
||||
from .feedbacks import router as feedbacks_router
|
||||
from .experiments import router as experiments_router
|
||||
from .context import router as context_router
|
||||
from .tasks import router as tasks_router
|
||||
from .webhooks import router as webhooks_router
|
||||
from .auth import router as auth_router
|
||||
from .users_api import router as users_router
|
||||
from .endpoints.skill import router as skill_router
|
||||
from .workloads import router as workloads_router
|
||||
from .skill_tags_api import router as skill_tags_router
|
||||
from .sstc_api import router as sstc_router
|
||||
from .projects_api import router as projects_router
|
||||
from .scheduler import router as scheduler_router
|
||||
from .ai_steward_api import router as ai_steward_router
|
||||
from .feishu_api import router as feishu_router
|
||||
from .wecom_api import router as wecom_router
|
||||
from .notifications import router as notifications_router
|
||||
from .llm_engines import router as llm_engines_router
|
||||
from .llm_providers import router as llm_providers_router
|
||||
from .ai_logs_api import router as ai_logs_router
|
||||
from .prompt_profiles import router as prompt_profiles_router
|
||||
from .rag_api import router as rag_router
|
||||
from .knowledge_graph import router as knowledge_graph_router
|
||||
from .recipes import router as recipes_router
|
||||
from .signal_sources_api import router as signal_sources_router
|
||||
from .operation_catalog import router as operation_catalog_router
|
||||
from .ai_pmo_api import router as ai_pmo_router
|
||||
from .audit import router as audit_router
|
||||
from .model_routing import router as model_routing_router
|
||||
from .semantic_facts_api import router as semantic_facts_router
|
||||
from .task_briefs import router as task_briefs_router
|
||||
from .mcp_api import router as mcp_router
|
||||
from ..core.config import settings
|
||||
|
||||
# 创建主 API 路由器
|
||||
api_router = APIRouter()
|
||||
CORE_ROUTE_MODULES = (
|
||||
"signals_api",
|
||||
"feedbacks",
|
||||
"experiments",
|
||||
"context",
|
||||
"tasks",
|
||||
"webhooks",
|
||||
"auth",
|
||||
"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",
|
||||
)
|
||||
|
||||
# 注册各模块路由
|
||||
api_router.include_router(signals_router)
|
||||
api_router.include_router(feedbacks_router)
|
||||
api_router.include_router(experiments_router)
|
||||
api_router.include_router(context_router)
|
||||
api_router.include_router(tasks_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(users_router)
|
||||
api_router.include_router(skill_router)
|
||||
api_router.include_router(workloads_router)
|
||||
api_router.include_router(skill_tags_router)
|
||||
api_router.include_router(sstc_router)
|
||||
api_router.include_router(projects_router)
|
||||
api_router.include_router(scheduler_router)
|
||||
api_router.include_router(ai_steward_router)
|
||||
api_router.include_router(feishu_router)
|
||||
api_router.include_router(wecom_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(llm_engines_router)
|
||||
api_router.include_router(llm_providers_router)
|
||||
api_router.include_router(ai_logs_router)
|
||||
api_router.include_router(prompt_profiles_router)
|
||||
api_router.include_router(rag_router)
|
||||
api_router.include_router(knowledge_graph_router)
|
||||
api_router.include_router(recipes_router)
|
||||
api_router.include_router(signal_sources_router)
|
||||
api_router.include_router(operation_catalog_router)
|
||||
api_router.include_router(ai_pmo_router)
|
||||
api_router.include_router(audit_router)
|
||||
api_router.include_router(model_routing_router)
|
||||
api_router.include_router(semantic_facts_router)
|
||||
api_router.include_router(task_briefs_router)
|
||||
api_router.include_router(mcp_router)
|
||||
OPTIONAL_ROUTE_GROUPS = {
|
||||
"integrations": ("feishu_api", "wecom_api", "mcp_api", "dev_sync"),
|
||||
"knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
|
||||
"ai_control": (
|
||||
"ai_steward_api",
|
||||
"llm_engines",
|
||||
"llm_providers",
|
||||
"ai_logs_api",
|
||||
"prompt_profiles",
|
||||
"signal_sources_api",
|
||||
"model_routing",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _include_module(router: APIRouter, module_path: str) -> None:
|
||||
module = import_module(f"{__package__}.{module_path}")
|
||||
router.include_router(module.router)
|
||||
|
||||
|
||||
def _enabled_optional_groups() -> tuple[str, ...]:
|
||||
if settings.api_route_profile == "lean":
|
||||
return tuple(settings.api_optional_route_groups)
|
||||
if settings.api_optional_route_groups:
|
||||
return tuple(dict.fromkeys(settings.api_optional_route_groups))
|
||||
return tuple(OPTIONAL_ROUTE_GROUPS.keys())
|
||||
|
||||
|
||||
def build_api_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
for module_path in CORE_ROUTE_MODULES:
|
||||
_include_module(router, module_path)
|
||||
|
||||
for group in _enabled_optional_groups():
|
||||
for module_path in OPTIONAL_ROUTE_GROUPS.get(group, ()):
|
||||
_include_module(router, module_path)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
api_router = build_api_router()
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ async def list_skill_tags(
|
|||
async def create_skill_tag(
|
||||
body: SkillTagCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -80,7 +80,7 @@ async def update_skill_tag(
|
|||
tag_id: int,
|
||||
body: SkillTagUpdateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -109,7 +109,7 @@ async def update_skill_tag(
|
|||
async def delete_skill_tag(
|
||||
tag_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -164,7 +164,7 @@ async def bind_user_skill(
|
|||
user_id: int,
|
||||
body: UserSkillCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -208,7 +208,7 @@ async def update_user_skill(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_role = str(current_user.role or "")
|
||||
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"])
|
||||
is_manager = has_any_role(current_role, ["OWNER", "REVIEWER"])
|
||||
is_self = int(current_user.id) == user_id
|
||||
if not is_manager and not is_self:
|
||||
raise HTTPException(status_code=403, detail="No permission")
|
||||
|
|
@ -251,7 +251,7 @@ async def unbind_user_skill(
|
|||
user_id: int,
|
||||
skill_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from . import (
|
|||
charters_ai_alignment,
|
||||
charters_detail_update,
|
||||
charters_list_create,
|
||||
model_control,
|
||||
overview,
|
||||
releases_list_create,
|
||||
releases_status_generate,
|
||||
|
|
@ -34,6 +35,7 @@ for _module in [
|
|||
charters_list_create,
|
||||
charters_detail_update,
|
||||
charters_ai_alignment,
|
||||
model_control,
|
||||
tasks_create,
|
||||
tasks_ai_breakdown,
|
||||
tasks_board_status,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.context_cache_service import context_cache_service
|
||||
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 (
|
||||
build_task_breakdown_ai_payload,
|
||||
normalize_task_breakdown_payload,
|
||||
|
|
@ -79,6 +80,10 @@ async def ai_breakdown_tasks(
|
|||
except Exception as 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:
|
||||
raise HTTPException(status_code=500, detail="AI 拆解返回空结果")
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class GenerateTaskBriefRequest(BaseModel):
|
|||
|
||||
|
||||
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 JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作"))
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ def _parse_role_values(raw: Any) -> list[str]:
|
|||
if isinstance(raw, (list, tuple, set)):
|
||||
values = [str(_enum_value(item)).strip().upper() for item in raw]
|
||||
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] = []
|
||||
for value in values:
|
||||
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:
|
||||
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))
|
||||
|
||||
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:
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|||
"password": str(lower_map.get("password", "")).strip(),
|
||||
"name": str(lower_map.get("name", "")).strip(),
|
||||
"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", ""))),
|
||||
}
|
||||
|
||||
|
|
@ -264,4 +264,3 @@ async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]:
|
|||
return data_rows
|
||||
|
||||
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from .schemas import BulkAssignUsersRequest, BulkDeleteUsersRequest
|
|||
async def delete_user(
|
||||
user_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除用户(有未完成任务时禁止)。"""
|
||||
|
|
@ -88,7 +88,7 @@ async def delete_user(
|
|||
async def bulk_delete_users(
|
||||
body: BulkDeleteUsersRequest,
|
||||
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),
|
||||
):
|
||||
"""批量删除用户(带未完成任务保护)。"""
|
||||
|
|
@ -175,7 +175,7 @@ async def bulk_delete_users(
|
|||
async def bulk_assign_users(
|
||||
body: BulkAssignUsersRequest,
|
||||
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),
|
||||
):
|
||||
"""批量分配项目/角色。"""
|
||||
|
|
@ -206,8 +206,8 @@ async def bulk_assign_users(
|
|||
before_data = user.to_dict()
|
||||
|
||||
if body.role is not None:
|
||||
if not _has_any_role(_enum_value(current_user.role), "BOSS"):
|
||||
skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"})
|
||||
if not _has_any_role(_enum_value(current_user.role), "OWNER"):
|
||||
skipped.append({"user_id": user_id, "reason": "Only OWNER can bulk assign roles"})
|
||||
continue
|
||||
try:
|
||||
user.role = _pack_roles(body.role)
|
||||
|
|
@ -239,4 +239,3 @@ async def bulk_assign_users(
|
|||
|
||||
await db.commit()
|
||||
return {"updated_count": updated, "skipped": skipped}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ async def update_user_workload(
|
|||
async def import_users(
|
||||
file: UploadFile = File(...),
|
||||
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),
|
||||
):
|
||||
"""批量导入用户(最多 50 行)。"""
|
||||
|
|
@ -93,4 +93,3 @@ async def import_users(
|
|||
"failed": len(rows) - success_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from .helpers_user import _replace_user_projects, _serialize_user, _translate_us
|
|||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
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),
|
||||
):
|
||||
"""创建用户(自动初始化 user_workloads)。"""
|
||||
|
|
@ -161,7 +161,7 @@ async def patch_user_status(
|
|||
user_id: int,
|
||||
body: UserStatusPatchRequest,
|
||||
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),
|
||||
):
|
||||
"""禁用/启用用户,禁用时清空任务指派并归零负载。"""
|
||||
|
|
@ -217,4 +217,3 @@ async def patch_user_status(
|
|||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return {"message": "Status updated", "user": _serialize_user(user)}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,5 +45,5 @@ from ...utils.timezone import now_cn_naive
|
|||
router = APIRouter(tags=["users"])
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ async def save_wecom_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成")
|
||||
|
||||
try:
|
||||
|
|
@ -61,7 +61,7 @@ async def get_wecom_config(
|
|||
try:
|
||||
service = get_wecom_service(db)
|
||||
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 {
|
||||
"is_configured": service.is_configured(),
|
||||
|
|
@ -93,7 +93,7 @@ async def delete_wecom_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class AIOptimizeApplyRequest(BaseModel):
|
|||
async def get_team_workloads(
|
||||
project_id: Optional[int] = Query(None),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from .ai_prompts_catalog import (
|
|||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||
PROMPT_EXTERNAL_TREND_QA,
|
||||
PROMPT_INDUSTRY_TREND_QA,
|
||||
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||
)
|
||||
from .ai_prompt_registry import (
|
||||
PROMPT_REGISTRY,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .part_scenes_06 import (
|
|||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||
PROMPT_EXTERNAL_TREND_QA,
|
||||
PROMPT_INDUSTRY_TREND_QA,
|
||||
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -73,4 +74,5 @@ __all__ = [
|
|||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -200,6 +200,43 @@ JSON schema:
|
|||
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__ = [
|
||||
"PROMPT_EXTERNAL_TREND_QA",
|
||||
"PROMPT_INDUSTRY_TREND_QA",
|
||||
|
|
@ -208,4 +245,5 @@ __all__ = [
|
|||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -34,7 +34,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
"properties": {"notification_id": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||
idempotency_fields=["tenant_id", "notification_id", "is_read"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -48,7 +48,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
"properties": {"notification_id": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "notification_id", "status"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -66,7 +66,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "path", "content_hash"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
allowed_paths=[
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "user_id", "role"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -36,7 +36,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -53,7 +53,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "user_id", "status", "date"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -71,7 +71,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||
idempotency_fields=["tenant_id", "doc_id", "content_hash"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -89,7 +89,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||
idempotency_fields=["tenant_id", "doc_id", "chunk_hash"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -108,7 +108,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"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"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -126,7 +126,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "BUILDER"],
|
||||
idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "title"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -39,7 +39,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "project_id", "updated_at"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -53,7 +53,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
"properties": {"project_id": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -67,7 +67,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
"properties": {"project_id": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -94,7 +94,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "project_id", "title"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -112,7 +112,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "task_id", "updated_at"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -129,7 +129,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "task_id", "assignee_id"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -146,7 +146,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||
idempotency_fields=["tenant_id", "task_id", "priority"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -163,7 +163,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
||||
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||
idempotency_fields=["tenant_id", "task_id", "status"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
@ -177,7 +177,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
|||
"properties": {"task_id": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
rbac_policy=["BOSS", "ADMIN"],
|
||||
rbac_policy=["OWNER", "REVIEWER"],
|
||||
idempotency_fields=["tenant_id", "task_id"],
|
||||
audit_fields=COMMON_AUDIT_FIELDS,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ModuleNotFoundError: # pragma: no cover - optional dependency in lightweight envs
|
||||
def load_dotenv(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
# 仅从项目根目录 .env 加载,作为唯一配置源
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
|
|
@ -24,6 +29,20 @@ DEFAULT_DATABASE_URL = (
|
|||
f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}"
|
||||
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:
|
||||
|
|
@ -31,7 +50,14 @@ class Settings:
|
|||
|
||||
# 应用
|
||||
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)
|
||||
|
|
@ -59,6 +85,15 @@ class Settings:
|
|||
# 多租户(预留)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ..core.database import get_db
|
||||
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
|
||||
|
||||
MANAGER_ROLES = {"BOSS", "ADMIN"}
|
||||
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ from .core.startup.world_model_warmup import warmup_world_models
|
|||
from .middleware import AuditContextMiddleware
|
||||
from .services.ai_orchestration_run import AIOrchestrationRunService
|
||||
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.semantic_fact_service import register_semantic_fact_hooks
|
||||
|
||||
|
|
@ -39,6 +32,13 @@ register_context_cache_invalidation_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
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用生命周期:初始化依赖并启动后台任务。"""
|
||||
|
|
@ -58,6 +58,7 @@ async def lifespan(_: FastAPI):
|
|||
await conn.run_sync(check_database_dialect_guard)
|
||||
startup_log("数据库连接成功")
|
||||
|
||||
if _startup_step_enabled("模型连通性探测", settings.startup_model_probe_enabled, "STARTUP_MODEL_PROBE_ENABLED"):
|
||||
await probe_model_connectivity_on_startup()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
|
|
@ -76,6 +77,18 @@ async def lifespan(_: FastAPI):
|
|||
except Exception as exc: # pragma: no cover
|
||||
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.start()
|
||||
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
|
||||
|
|
@ -85,45 +98,64 @@ async def lifespan(_: FastAPI):
|
|||
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
|
||||
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")
|
||||
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
|
||||
|
||||
yield
|
||||
|
||||
startup_log("服务关闭中")
|
||||
if outbox_task is not None:
|
||||
outbox_task.cancel()
|
||||
try:
|
||||
await outbox_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if ingress_task is not None:
|
||||
ingress_task.cancel()
|
||||
try:
|
||||
await ingress_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if draft_expiry_task is not None:
|
||||
draft_expiry_task.cancel()
|
||||
try:
|
||||
await draft_expiry_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if not wm_warmup_task.done():
|
||||
if wm_warmup_task is not None and not wm_warmup_task.done():
|
||||
wm_warmup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await wm_warmup_task
|
||||
|
||||
if feishu_lc is not None:
|
||||
await feishu_lc.stop()
|
||||
if ai_pmo_cadence_scheduler is not None:
|
||||
await ai_pmo_cadence_scheduler.stop()
|
||||
if kg_scheduler is not None:
|
||||
await kg_scheduler.stop()
|
||||
if knowledge_scheduler is not None:
|
||||
await knowledge_scheduler.stop()
|
||||
if scheduler is not None:
|
||||
await scheduler.stop()
|
||||
await engine.dispose()
|
||||
startup_log("服务已关闭")
|
||||
|
|
|
|||
|
|
@ -84,6 +84,31 @@ from .mounted_ai_runtime import (
|
|||
MountedToolCapabilityPolicy,
|
||||
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__ = [
|
||||
"Base",
|
||||
|
|
@ -173,4 +198,32 @@ __all__ = [
|
|||
"MountedSourceActivation",
|
||||
"MountedToolCapabilityPolicy",
|
||||
"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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -4,11 +4,10 @@ from enum import Enum
|
|||
|
||||
class UserRole(str, Enum):
|
||||
"""用户角色"""
|
||||
BOSS = "BOSS"
|
||||
ADMIN = "ADMIN"
|
||||
AI = "AI"
|
||||
FE = "FE"
|
||||
BE = "BE"
|
||||
OWNER = "OWNER"
|
||||
BUILDER = "BUILDER"
|
||||
OPERATOR = "OPERATOR"
|
||||
REVIEWER = "REVIEWER"
|
||||
|
||||
|
||||
class UserStatus(str, Enum):
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ class Budget(Base):
|
|||
VARCHAR(20), nullable=False, default="active"
|
||||
)
|
||||
|
||||
# 超支解锁记录(BOSS/ADMIN 可以临时解锁)
|
||||
# 超支解锁记录(OWNER/REVIEWER 可以临时解锁)
|
||||
override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -460,7 +460,7 @@ class KernelIngressStorageMixin:
|
|||
User.tenant_id == self.tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.status == "active",
|
||||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
||||
User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from .service import (
|
||||
AuthService,
|
||||
MANAGER_ROLES,
|
||||
can_assign_tasks,
|
||||
can_manage_users,
|
||||
has_any_role,
|
||||
|
|
@ -11,6 +12,7 @@ from .service import (
|
|||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
"MANAGER_ROLES",
|
||||
"has_permission",
|
||||
"has_any_role",
|
||||
"parse_role_values",
|
||||
|
|
|
|||
|
|
@ -146,9 +146,18 @@ class AuthService:
|
|||
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 = {
|
||||
"BOSS": [
|
||||
"OWNER": [
|
||||
"view_all_signals",
|
||||
"assign_tasks",
|
||||
"manage_users",
|
||||
|
|
@ -158,8 +167,11 @@ PERMISSION_MATRIX = {
|
|||
"view_context",
|
||||
"view_settings",
|
||||
"manage_staff",
|
||||
"manage_global_integrations",
|
||||
"approve_release_gate",
|
||||
"override_compliance_gate",
|
||||
],
|
||||
"ADMIN": [
|
||||
"REVIEWER": [
|
||||
"view_all_signals",
|
||||
"assign_tasks",
|
||||
"manage_domains",
|
||||
|
|
@ -167,19 +179,23 @@ PERMISSION_MATRIX = {
|
|||
"view_context",
|
||||
"view_settings",
|
||||
"manage_staff",
|
||||
"manage_global_integrations",
|
||||
"approve_release_gate",
|
||||
],
|
||||
"AI": [
|
||||
"BUILDER": [
|
||||
"view_assigned_signals",
|
||||
"create_experiments",
|
||||
"view_context",
|
||||
"work_on_dev_tasks",
|
||||
"use_prompt_packages",
|
||||
"sync_repo_progress",
|
||||
],
|
||||
"FE": [
|
||||
"view_assigned_signals",
|
||||
"view_assigned_projects",
|
||||
],
|
||||
"BE": [
|
||||
"OPERATOR": [
|
||||
"view_assigned_signals",
|
||||
"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:
|
||||
"""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))
|
||||
|
||||
|
||||
|
|
@ -203,17 +219,17 @@ def parse_role_values(user_role: str | None) -> list[str]:
|
|||
return []
|
||||
values: list[str] = []
|
||||
for token in raw.split(","):
|
||||
role = token.strip().upper()
|
||||
role = canonicalize_role_token(token)
|
||||
if role and role not in values:
|
||||
values.append(role)
|
||||
return values
|
||||
|
||||
|
||||
def can_manage_users(user_role: str) -> bool:
|
||||
"""Check if user can manage other users (BOSS only)"""
|
||||
return has_any_role(user_role, ["BOSS"])
|
||||
"""Check if user can manage other users (OWNER only)"""
|
||||
return has_any_role(user_role, ["OWNER"])
|
||||
|
||||
|
||||
def can_assign_tasks(user_role: str) -> bool:
|
||||
"""Check if user can assign tasks to others"""
|
||||
return has_any_role(user_role, ["BOSS", "ADMIN"])
|
||||
return has_any_role(user_role, ["OWNER", "REVIEWER"])
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class BudgetEnforcementAlertsMixin:
|
|||
note: str = "",
|
||||
duration_hours: int = 24,
|
||||
) -> dict[str, Any]:
|
||||
"""BOSS/ADMIN 超支解锁。"""
|
||||
"""OWNER/REVIEWER 超支解锁。"""
|
||||
result = await self.db.execute(
|
||||
select(Budget).where(
|
||||
and_(
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ class DailyBriefingHelpersSummaryMixin:
|
|||
role_code = self._role_code(user_role)
|
||||
_ = signal.source.lower()
|
||||
|
||||
if role_code == UserRole.AI.value:
|
||||
ai_keywords = [
|
||||
if role_code == UserRole.BUILDER.value:
|
||||
builder_keywords = [
|
||||
"model",
|
||||
"llm",
|
||||
"gpt",
|
||||
|
|
@ -35,12 +35,6 @@ class DailyBriefingHelpersSummaryMixin:
|
|||
"推理",
|
||||
"ollama",
|
||||
"langchain",
|
||||
]
|
||||
if any(kw in content for kw in ai_keywords):
|
||||
return True
|
||||
|
||||
elif role_code in {UserRole.BE.value, "BACKEND"}:
|
||||
backend_keywords = [
|
||||
"api",
|
||||
"backend",
|
||||
"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",
|
||||
"frontend",
|
||||
"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
|
||||
|
||||
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.7
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ class DailyBriefingPersonalMixin:
|
|||
|
||||
for conflict in conflicts:
|
||||
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(
|
||||
{
|
||||
"id": conflict.id,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class FeishuPipelineCoreBaseMixin:
|
|||
def _can_execute_high_value_action(user: Optional[User]) -> bool:
|
||||
if user is None:
|
||||
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
|
||||
def _stable_positive_int(*parts: Any) -> int:
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ class FeishuPipelineFollowupBMixin:
|
|||
User.tenant_id == self.tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.status == "active",
|
||||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
||||
User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -258,4 +258,34 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
|||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -13,14 +13,10 @@ from ..notification_service import NotificationService
|
|||
|
||||
_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+")
|
||||
_ROLE_ALIAS_MAP = {
|
||||
"AI": {"AI"},
|
||||
"AI_ENGINEER": {"AI"},
|
||||
"FRONTEND": {"FE"},
|
||||
"FE": {"FE"},
|
||||
"BACKEND": {"BE"},
|
||||
"BE": {"BE"},
|
||||
"BOSS": {"BOSS"},
|
||||
"ADMIN": {"ADMIN"},
|
||||
"OWNER": {"OWNER"},
|
||||
"REVIEWER": {"REVIEWER"},
|
||||
"OPERATOR": {"OPERATOR"},
|
||||
"BUILDER": {"BUILDER"},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class SignalCaptureNewProjectMixin:
|
|||
and_(
|
||||
User.tenant_id == tenant_id,
|
||||
User.status == "active",
|
||||
User.role.in_(["BOSS", "ADMIN"]),
|
||||
User.role.in_(["OWNER", "REVIEWER"]),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
|
|||
User.tenant_id == tenant_id,
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
User.role.in_(["BOSS", "ADMIN"]),
|
||||
User.role.in_(["OWNER", "REVIEWER"]),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
|
|
@ -177,7 +177,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
|
|||
User.tenant_id == tenant_id,
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
User.role.in_(["BOSS", "ADMIN"]),
|
||||
User.role.in_(["OWNER", "REVIEWER"]),
|
||||
)
|
||||
).limit(5)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class SignalSourceLookupParseMixin:
|
|||
)
|
||||
)
|
||||
).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 (
|
||||
|
|
@ -37,7 +37,7 @@ class SignalSourceLookupParseMixin:
|
|||
and_(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.role.in_(["BOSS", "ADMIN"]),
|
||||
User.role.in_(["OWNER", "REVIEWER"]),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ def _assert_charter_aligned(charter: TruthNode) -> None:
|
|||
)
|
||||
|
||||
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:
|
||||
if not _is_manager(user):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from .shared_helpers import *
|
||||
from .shared_helpers import _to_json
|
||||
from ..ai_project_control_service import AIProjectControlService
|
||||
|
||||
class SSTCBuildViewsMixin:
|
||||
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)
|
||||
workload = await self.get_workload_health(member_ids)
|
||||
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 {
|
||||
"project": {
|
||||
"id": str(project.id),
|
||||
|
|
@ -36,6 +40,11 @@ class SSTCBuildViewsMixin:
|
|||
"unresolved_conflicts": alignment["unresolved_conflicts"],
|
||||
"task_progress": metrics,
|
||||
"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]]:
|
||||
|
|
@ -295,4 +304,7 @@ class SSTCBuildViewsMixin:
|
|||
for c in charters
|
||||
],
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from ..models import Project, User, UserProject, UserWorkload
|
|||
from .auth.service import parse_role_values
|
||||
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
|
||||
|
||||
MANAGER_ROLES = {"BOSS", "ADMIN"}
|
||||
MANAGER_ROLES = {"OWNER", "REVIEWER"}
|
||||
|
||||
|
||||
class TenantBriefingService(ExecutiveBriefingService):
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ async def ensure_seed_user(session: AsyncSession) -> User:
|
|||
email="boss@pmflow.local",
|
||||
password_hash=AuthService.hash_password("boss123"),
|
||||
name="负责人",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
)
|
||||
session.add(seed_user)
|
||||
|
|
|
|||
|
|
@ -25,3 +25,5 @@ python-docx>=1.1.2
|
|||
openpyxl>=3.1.5
|
||||
lark-oapi>=1.4.10
|
||||
duckduckgo-search>=8.1.1
|
||||
pytest>=8.3.3
|
||||
pytest-asyncio>=0.24.0
|
||||
|
|
|
|||
|
|
@ -21,12 +21,7 @@ from app.services.auth import AuthService
|
|||
async def seed_users():
|
||||
"""Create default users with known passwords."""
|
||||
# Create initial users (with simple password hashes for demo)
|
||||
# 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")
|
||||
# In production, these should be created with proper password hashing.
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Check if users already exist
|
||||
|
|
@ -40,7 +35,7 @@ async def seed_users():
|
|||
password_hash=AuthService.hash_password("admin123"),
|
||||
name="系统管理员",
|
||||
email="admin@pmflow.ai",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
|
|
@ -59,7 +54,7 @@ async def seed_users():
|
|||
password_hash=AuthService.hash_password("boss123"),
|
||||
name="老板",
|
||||
email="boss@pmflow.ai",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
|
|
@ -78,7 +73,7 @@ async def seed_users():
|
|||
password_hash=AuthService.hash_password("ai123"),
|
||||
name="AI工程师",
|
||||
email="ai@pmflow.ai",
|
||||
role="AI",
|
||||
role="BUILDER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
|
|
@ -97,7 +92,7 @@ async def seed_users():
|
|||
password_hash=AuthService.hash_password("be123"),
|
||||
name="后端开发",
|
||||
email="backend@pmflow.ai",
|
||||
role="BE",
|
||||
role="BUILDER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
|
|
@ -116,7 +111,7 @@ async def seed_users():
|
|||
password_hash=AuthService.hash_password("fe123"),
|
||||
name="前端开发",
|
||||
email="frontend@pmflow.ai",
|
||||
role="FE",
|
||||
role="BUILDER",
|
||||
assigned_domains=[],
|
||||
workload=0,
|
||||
status="active",
|
||||
|
|
@ -190,11 +185,11 @@ async def main():
|
|||
print("✅ Seeding completed!")
|
||||
print()
|
||||
print("📝 Default users created:")
|
||||
print(" - admin / admin123 (BOSS)")
|
||||
print(" - boss / boss123 (BOSS)")
|
||||
print(" - ai_engineer / ai123 (AI)")
|
||||
print(" - backend / be123 (BE)")
|
||||
print(" - frontend / fe123 (FE)")
|
||||
print(" - admin / admin123 (OWNER)")
|
||||
print(" - boss / boss123 (OWNER)")
|
||||
print(" - ai_engineer / ai123 (BUILDER)")
|
||||
print(" - backend / be123 (BUILDER)")
|
||||
print(" - frontend / fe123 (BUILDER)")
|
||||
print()
|
||||
print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -60,7 +60,7 @@ def _build_app(*, db_session) -> FastAPI:
|
|||
tenant_id=1,
|
||||
name="管理员",
|
||||
username="admin",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
)
|
||||
return app
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ async def test_cognitive_conflicts_return_display_type(db_session) -> None:
|
|||
email="tester@example.com",
|
||||
password_hash="hash",
|
||||
name="测试用户",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
|
|||
tenant_id=1,
|
||||
name="管理员",
|
||||
username="admin",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ async def test_generate_personalized_briefing_uses_naive_time_window(briefing_se
|
|||
email="tester@example.com",
|
||||
password_hash="hash",
|
||||
name="测试用户",
|
||||
role="BE",
|
||||
role="BUILDER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async def db_session():
|
|||
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.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)
|
||||
|
||||
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)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
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)
|
||||
|
||||
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["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",
|
||||
password_hash="hash",
|
||||
name="后端成员",
|
||||
role="BE",
|
||||
role="BUILDER",
|
||||
status="active",
|
||||
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)
|
||||
|
||||
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["hero"]["decision_count"] == 1
|
||||
|
|
@ -288,7 +288,7 @@ async def test_tenant_briefing_service_personal_workload_ratio_matches_weekly_ho
|
|||
email="be@example.com",
|
||||
password_hash="hash",
|
||||
name="后端成员",
|
||||
role="BE",
|
||||
role="BUILDER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -82,7 +82,7 @@ async def db_session():
|
|||
await engine.dispose()
|
||||
|
||||
|
||||
def _build_app(*, db_session, role: str = "ADMIN") -> FastAPI:
|
||||
def _build_app(*, db_session, role: str = "REVIEWER") -> FastAPI:
|
||||
app = FastAPI()
|
||||
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
|
||||
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)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
|
|
@ -294,7 +294,7 @@ async def test_executive_briefing_pending_signal_sources_include_display_fields(
|
|||
email="pm@example.com",
|
||||
password_hash="hash",
|
||||
name="产品经理",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -372,7 +372,7 @@ async def test_executive_briefing_service_builds_detailed_signal_items_and_targe
|
|||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="李雷",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -439,7 +439,7 @@ async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics
|
|||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="李雷",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -541,7 +541,7 @@ async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_
|
|||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="李雷",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -637,7 +637,7 @@ async def test_executive_briefing_signal_source_target_does_not_apply_mismatched
|
|||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="李雷",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -689,7 +689,7 @@ async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_sess
|
|||
email="owner@example.com",
|
||||
password_hash="hash",
|
||||
name="杨宇",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
@ -729,7 +729,7 @@ async def test_executive_briefing_localizes_conflict_and_signal_source_codes(db_
|
|||
email="boss@example.com",
|
||||
password_hash="hash",
|
||||
name="李雷",
|
||||
role="BOSS",
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def _build_app(db_session):
|
|||
tenant_id=1,
|
||||
name="管理员",
|
||||
username="admin",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
)
|
||||
return app
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se
|
|||
email="owner@example.com",
|
||||
password_hash="hash",
|
||||
name="负责人",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def _build_app(*, db_session) -> FastAPI:
|
|||
tenant_id=1,
|
||||
name="管理员",
|
||||
username="admin",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
)
|
||||
return app
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def _build_app(db_session, *, role: str, tenant_id: int = 1) -> FastAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
|
||||
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)
|
||||
app = _build_app(db_session, role="ADMIN")
|
||||
app = _build_app(db_session, role="REVIEWER")
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
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
|
||||
async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
|
||||
tenant1_app = _build_app(db_session, role="ADMIN", tenant_id=1)
|
||||
tenant2_app = _build_app(db_session, role="ADMIN", tenant_id=2)
|
||||
tenant1_app = _build_app(db_session, role="REVIEWER", tenant_id=1)
|
||||
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:
|
||||
create_response = await client.post(
|
||||
|
|
@ -166,7 +166,7 @@ async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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)
|
||||
app = _build_app(db_session, role="ADMIN")
|
||||
app = _build_app(db_session, role="REVIEWER")
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ def _build_app(db_session):
|
|||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9001,
|
||||
tenant_id=1,
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
name="管理员",
|
||||
username="admin",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -68,7 +68,7 @@ async def test_enqueue_work_item_delta_events_reads_feishu_work_items_into_kg(db
|
|||
email="kg_owner@example.com",
|
||||
password_hash="hash",
|
||||
name="图谱负责人",
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.dependencies.auth import get_current_user
|
|||
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.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)
|
||||
|
||||
app = _build_test_app(role="ADMIN")
|
||||
app = _build_test_app(role="REVIEWER")
|
||||
transport = ASGITransport(app=app)
|
||||
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})
|
||||
|
|
@ -167,7 +167,7 @@ async def test_query_route_governance_items_and_detail_return_contract(monkeypat
|
|||
|
||||
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
|
||||
|
||||
app = _build_test_app(role="ADMIN")
|
||||
app = _build_test_app(role="REVIEWER")
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
list_response = await client.get(
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ def _build_app(db_session):
|
|||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9001,
|
||||
tenant_id=1,
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
name="管理员",
|
||||
username="admin",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ def _build_app(db_session):
|
|||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9001,
|
||||
tenant_id=1,
|
||||
role="ADMIN",
|
||||
role="REVIEWER",
|
||||
name="管理员",
|
||||
username="admin",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ def _build_app(db_session, *, role: str) -> FastAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
app = _build_app(db_session, role="ADMIN")
|
||||
app = _build_app(db_session, role="REVIEWER")
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
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
Loading…
Reference in New Issue