chore: checkpoint current PMFlow workspace
This commit is contained in:
parent
b52428f79e
commit
e04ea4d1ee
|
|
@ -11,6 +11,7 @@ DB_PORT=5435
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
FRONTEND_PORT=5174
|
FRONTEND_PORT=5174
|
||||||
|
FRONTEND_PROD_PORT=5175
|
||||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||||
|
|
||||||
# Backend Runtime
|
# Backend Runtime
|
||||||
|
|
|
||||||
|
|
@ -71,3 +71,15 @@ Pixelle-Video/
|
||||||
|
|
||||||
# Playwright browser profiles
|
# Playwright browser profiles
|
||||||
frontend/.playwright-profiles/
|
frontend/.playwright-profiles/
|
||||||
|
.playwright-cli/
|
||||||
|
frontend/.playwright-cli/
|
||||||
|
frontend/output/
|
||||||
|
output/
|
||||||
|
.tmp-feishu-*.png
|
||||||
|
task_instance_*.xlsx
|
||||||
|
yesterday_tasks_*.csv
|
||||||
|
yesterday_tasks_*.xlsx
|
||||||
|
工单数据_*.xlsx
|
||||||
|
工单全字段报表_*.xlsx
|
||||||
|
tmp_short_confirm_redesign_summary.html
|
||||||
|
ai.rtf
|
||||||
|
|
|
||||||
|
|
@ -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),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||||
return _forbidden_response()
|
return _forbidden_response()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -40,7 +40,7 @@ async def get_executive_briefing_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||||
return _forbidden_response()
|
return _forbidden_response()
|
||||||
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
|
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
|
||||||
return success_response(payload)
|
return success_response(payload)
|
||||||
|
|
@ -53,7 +53,7 @@ async def update_executive_briefing_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||||
return _forbidden_response()
|
return _forbidden_response()
|
||||||
|
|
||||||
service = ExecutiveBriefingService(db, tenant_id=tenant_id)
|
service = ExecutiveBriefingService(db, tenant_id=tenant_id)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from ...services.auth import parse_role_values
|
||||||
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
|
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
MANAGER_ROLES = {"ADMIN", "BOSS"}
|
MANAGER_ROLES = {"REVIEWER", "OWNER"}
|
||||||
|
|
||||||
|
|
||||||
def _is_manager_user(current_user: User) -> bool:
|
def _is_manager_user(current_user: User) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ async def list_audit_operations(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
header_tenant_id: int = Depends(get_tenant_id),
|
header_tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
|
if current_user is not None and not has_any_role(_user_role(current_user), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
|
raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
|
||||||
if current_user is None:
|
if current_user is None:
|
||||||
effective_tenant_id = int(header_tenant_id or 1)
|
effective_tenant_id = int(header_tenant_id or 1)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ async def list_budgets(
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
async def create_budget(
|
async def create_budget(
|
||||||
request: BudgetCreateRequest,
|
request: BudgetCreateRequest,
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|
@ -122,7 +122,7 @@ async def create_budget(
|
||||||
async def update_budget(
|
async def update_budget(
|
||||||
budget_id: int,
|
budget_id: int,
|
||||||
request: BudgetUpdateRequest,
|
request: BudgetUpdateRequest,
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|
@ -163,7 +163,7 @@ async def update_budget(
|
||||||
async def override_budget(
|
async def override_budget(
|
||||||
budget_id: int,
|
budget_id: int,
|
||||||
request: BudgetOverrideRequest,
|
request: BudgetOverrideRequest,
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None:
|
||||||
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
|
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
|
||||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
|
if current_user.id != target_user_id and not has_any_role(str(current_role), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ async def list_skill_tags(
|
||||||
async def create_skill_tag(
|
async def create_skill_tag(
|
||||||
body: SkillTagCreate,
|
body: SkillTagCreate,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -78,7 +78,7 @@ async def update_skill_tag(
|
||||||
tag_id: UUID,
|
tag_id: UUID,
|
||||||
body: SkillTagUpdate,
|
body: SkillTagUpdate,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -103,7 +103,7 @@ async def update_skill_tag(
|
||||||
async def delete_skill_tag(
|
async def delete_skill_tag(
|
||||||
tag_id: UUID,
|
tag_id: UUID,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ async def save_feishu_config(
|
||||||
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||||
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||||
"""
|
"""
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -85,7 +85,7 @@ async def get_feishu_config(
|
||||||
service = get_feishu_service(db)
|
service = get_feishu_service(db)
|
||||||
await service.load_config(tenant_id)
|
await service.load_config(tenant_id)
|
||||||
manager = get_feishu_long_connection_manager()
|
manager = get_feishu_long_connection_manager()
|
||||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"is_configured": service.is_configured(),
|
"is_configured": service.is_configured(),
|
||||||
|
|
@ -105,7 +105,7 @@ async def delete_feishu_config(
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
"""删除飞书配置。"""
|
"""删除飞书配置。"""
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ async def restart_feishu_long_connection(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""手动重启飞书长连接。"""
|
"""手动重启飞书长连接。"""
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||||
|
|
||||||
manager = get_feishu_long_connection_manager()
|
manager = get_feishu_long_connection_manager()
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class VerifyContextConsumptionRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
def _require_manager(user: User) -> None:
|
||||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class LLMEngineSettingsUpdateRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
def _require_manager(user: User) -> None:
|
||||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ async def update_model_routing(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if not has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]):
|
if not has_any_role(str(current_user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
|
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
|
||||||
|
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
def _require_manager(user: User) -> None:
|
||||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")
|
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
async def create_project(
|
||||||
body: ProjectCreateRequest,
|
body: ProjectCreateRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
title = compact_title(body.title, max_len=24)
|
title = compact_title(body.title, max_len=24)
|
||||||
|
|
@ -88,7 +88,7 @@ async def assign_project_members(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
body: ProjectMembersAssignRequest,
|
body: ProjectMembersAssignRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
|
@ -157,7 +157,7 @@ async def update_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
body: ProjectUpdateRequest,
|
body: ProjectUpdateRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
|
@ -185,7 +185,7 @@ async def update_project(
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ async def link_signal_to_project(
|
||||||
signal_id: int,
|
signal_id: int,
|
||||||
body: LinkSignalRequest,
|
body: LinkSignalRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||||
|
|
@ -74,7 +74,7 @@ async def unlink_signal_from_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
signal_id: int,
|
signal_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
):
|
):
|
||||||
tenant_id = int(current_user.tenant_id)
|
tenant_id = int(current_user.tenant_id)
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class PromptProfileUpdateRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
def _require_manager(user: User) -> None:
|
||||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")
|
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ async def export_qa_records_jsonl(
|
||||||
domain: str | None = Query(default=None),
|
domain: str | None = Query(default=None),
|
||||||
date_from: datetime | None = Query(default=None),
|
date_from: datetime | None = Query(default=None),
|
||||||
date_to: datetime | None = Query(default=None),
|
date_to: datetime | None = Query(default=None),
|
||||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ async def rag_multi_step_search(
|
||||||
@router.post("/symbols/sync")
|
@router.post("/symbols/sync")
|
||||||
async def rag_symbols_sync(
|
async def rag_symbols_sync(
|
||||||
request: SymbolSyncRequest,
|
request: SymbolSyncRequest,
|
||||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -4,76 +4,74 @@ API 路由汇总
|
||||||
统一注册所有 API 路由,供 main.py 引入。
|
统一注册所有 API 路由,供 main.py 引入。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .signals_api import router as signals_router
|
from ..core.config import settings
|
||||||
from .feedbacks import router as feedbacks_router
|
|
||||||
from .experiments import router as experiments_router
|
|
||||||
from .context import router as context_router
|
|
||||||
from .tasks import router as tasks_router
|
|
||||||
from .webhooks import router as webhooks_router
|
|
||||||
from .auth import router as auth_router
|
|
||||||
from .users_api import router as users_router
|
|
||||||
from .endpoints.skill import router as skill_router
|
|
||||||
from .workloads import router as workloads_router
|
|
||||||
from .skill_tags_api import router as skill_tags_router
|
|
||||||
from .sstc_api import router as sstc_router
|
|
||||||
from .projects_api import router as projects_router
|
|
||||||
from .scheduler import router as scheduler_router
|
|
||||||
from .ai_steward_api import router as ai_steward_router
|
|
||||||
from .feishu_api import router as feishu_router
|
|
||||||
from .wecom_api import router as wecom_router
|
|
||||||
from .notifications import router as notifications_router
|
|
||||||
from .llm_engines import router as llm_engines_router
|
|
||||||
from .llm_providers import router as llm_providers_router
|
|
||||||
from .ai_logs_api import router as ai_logs_router
|
|
||||||
from .prompt_profiles import router as prompt_profiles_router
|
|
||||||
from .rag_api import router as rag_router
|
|
||||||
from .knowledge_graph import router as knowledge_graph_router
|
|
||||||
from .recipes import router as recipes_router
|
|
||||||
from .signal_sources_api import router as signal_sources_router
|
|
||||||
from .operation_catalog import router as operation_catalog_router
|
|
||||||
from .ai_pmo_api import router as ai_pmo_router
|
|
||||||
from .audit import router as audit_router
|
|
||||||
from .model_routing import router as model_routing_router
|
|
||||||
from .semantic_facts_api import router as semantic_facts_router
|
|
||||||
from .task_briefs import router as task_briefs_router
|
|
||||||
from .mcp_api import router as mcp_router
|
|
||||||
|
|
||||||
# 创建主 API 路由器
|
CORE_ROUTE_MODULES = (
|
||||||
api_router = APIRouter()
|
"signals_api",
|
||||||
|
"feedbacks",
|
||||||
|
"experiments",
|
||||||
|
"context",
|
||||||
|
"tasks",
|
||||||
|
"webhooks",
|
||||||
|
"auth",
|
||||||
|
"users_api",
|
||||||
|
"endpoints.skill",
|
||||||
|
"workloads",
|
||||||
|
"skill_tags_api",
|
||||||
|
"sstc_api",
|
||||||
|
"projects_api",
|
||||||
|
"scheduler",
|
||||||
|
"notifications",
|
||||||
|
"recipes",
|
||||||
|
"operation_catalog",
|
||||||
|
"audit",
|
||||||
|
"task_briefs",
|
||||||
|
"pmflow_os_api",
|
||||||
|
"pmflow_launch_api",
|
||||||
|
)
|
||||||
|
|
||||||
# 注册各模块路由
|
OPTIONAL_ROUTE_GROUPS = {
|
||||||
api_router.include_router(signals_router)
|
"integrations": ("feishu_api", "wecom_api", "mcp_api", "dev_sync"),
|
||||||
api_router.include_router(feedbacks_router)
|
"knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
|
||||||
api_router.include_router(experiments_router)
|
"ai_control": (
|
||||||
api_router.include_router(context_router)
|
"ai_steward_api",
|
||||||
api_router.include_router(tasks_router)
|
"llm_engines",
|
||||||
api_router.include_router(webhooks_router)
|
"llm_providers",
|
||||||
api_router.include_router(auth_router)
|
"ai_logs_api",
|
||||||
api_router.include_router(users_router)
|
"prompt_profiles",
|
||||||
api_router.include_router(skill_router)
|
"signal_sources_api",
|
||||||
api_router.include_router(workloads_router)
|
"model_routing",
|
||||||
api_router.include_router(skill_tags_router)
|
),
|
||||||
api_router.include_router(sstc_router)
|
}
|
||||||
api_router.include_router(projects_router)
|
|
||||||
api_router.include_router(scheduler_router)
|
|
||||||
api_router.include_router(ai_steward_router)
|
def _include_module(router: APIRouter, module_path: str) -> None:
|
||||||
api_router.include_router(feishu_router)
|
module = import_module(f"{__package__}.{module_path}")
|
||||||
api_router.include_router(wecom_router)
|
router.include_router(module.router)
|
||||||
api_router.include_router(notifications_router)
|
|
||||||
api_router.include_router(llm_engines_router)
|
|
||||||
api_router.include_router(llm_providers_router)
|
def _enabled_optional_groups() -> tuple[str, ...]:
|
||||||
api_router.include_router(ai_logs_router)
|
if settings.api_route_profile == "lean":
|
||||||
api_router.include_router(prompt_profiles_router)
|
return tuple(settings.api_optional_route_groups)
|
||||||
api_router.include_router(rag_router)
|
if settings.api_optional_route_groups:
|
||||||
api_router.include_router(knowledge_graph_router)
|
return tuple(dict.fromkeys(settings.api_optional_route_groups))
|
||||||
api_router.include_router(recipes_router)
|
return tuple(OPTIONAL_ROUTE_GROUPS.keys())
|
||||||
api_router.include_router(signal_sources_router)
|
|
||||||
api_router.include_router(operation_catalog_router)
|
|
||||||
api_router.include_router(ai_pmo_router)
|
def build_api_router() -> APIRouter:
|
||||||
api_router.include_router(audit_router)
|
router = APIRouter()
|
||||||
api_router.include_router(model_routing_router)
|
for module_path in CORE_ROUTE_MODULES:
|
||||||
api_router.include_router(semantic_facts_router)
|
_include_module(router, module_path)
|
||||||
api_router.include_router(task_briefs_router)
|
|
||||||
api_router.include_router(mcp_router)
|
for group in _enabled_optional_groups():
|
||||||
|
for module_path in OPTIONAL_ROUTE_GROUPS.get(group, ()):
|
||||||
|
_include_module(router, module_path)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
api_router = build_api_router()
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ async def list_skill_tags(
|
||||||
async def create_skill_tag(
|
async def create_skill_tag(
|
||||||
body: SkillTagCreateRequest,
|
body: SkillTagCreateRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -80,7 +80,7 @@ async def update_skill_tag(
|
||||||
tag_id: int,
|
tag_id: int,
|
||||||
body: SkillTagUpdateRequest,
|
body: SkillTagUpdateRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -109,7 +109,7 @@ async def update_skill_tag(
|
||||||
async def delete_skill_tag(
|
async def delete_skill_tag(
|
||||||
tag_id: int,
|
tag_id: int,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -164,7 +164,7 @@ async def bind_user_skill(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
body: UserSkillCreateRequest,
|
body: UserSkillCreateRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
@ -208,7 +208,7 @@ async def update_user_skill(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
current_role = str(current_user.role or "")
|
current_role = str(current_user.role or "")
|
||||||
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"])
|
is_manager = has_any_role(current_role, ["OWNER", "REVIEWER"])
|
||||||
is_self = int(current_user.id) == user_id
|
is_self = int(current_user.id) == user_id
|
||||||
if not is_manager and not is_self:
|
if not is_manager and not is_self:
|
||||||
raise HTTPException(status_code=403, detail="No permission")
|
raise HTTPException(status_code=403, detail="No permission")
|
||||||
|
|
@ -251,7 +251,7 @@ async def unbind_user_skill(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
skill_id: int,
|
skill_id: int,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from . import (
|
||||||
charters_ai_alignment,
|
charters_ai_alignment,
|
||||||
charters_detail_update,
|
charters_detail_update,
|
||||||
charters_list_create,
|
charters_list_create,
|
||||||
|
model_control,
|
||||||
overview,
|
overview,
|
||||||
releases_list_create,
|
releases_list_create,
|
||||||
releases_status_generate,
|
releases_status_generate,
|
||||||
|
|
@ -34,6 +35,7 @@ for _module in [
|
||||||
charters_list_create,
|
charters_list_create,
|
||||||
charters_detail_update,
|
charters_detail_update,
|
||||||
charters_ai_alignment,
|
charters_ai_alignment,
|
||||||
|
model_control,
|
||||||
tasks_create,
|
tasks_create,
|
||||||
tasks_ai_breakdown,
|
tasks_ai_breakdown,
|
||||||
tasks_board_status,
|
tasks_board_status,
|
||||||
|
|
|
||||||
|
|
@ -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.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
||||||
from ...services.context_cache_service import context_cache_service
|
from ...services.context_cache_service import context_cache_service
|
||||||
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
|
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
|
||||||
|
from ...services.ai_project_control_service import AIProjectControlService
|
||||||
from ...services.task_breakdown import (
|
from ...services.task_breakdown import (
|
||||||
build_task_breakdown_ai_payload,
|
build_task_breakdown_ai_payload,
|
||||||
normalize_task_breakdown_payload,
|
normalize_task_breakdown_payload,
|
||||||
|
|
@ -79,6 +80,10 @@ async def ai_breakdown_tasks(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
|
||||||
|
|
||||||
|
control = AIProjectControlService(db, tenant_id=tenant_id)
|
||||||
|
active_binding = await control.get_active_binding_summary(project_id=pid)
|
||||||
|
payload = control.inject_model_tasks(payload=payload, active_binding=active_binding)
|
||||||
|
|
||||||
if not payload.tasks:
|
if not payload.tasks:
|
||||||
raise HTTPException(status_code=500, detail="AI 拆解返回空结果")
|
raise HTTPException(status_code=500, detail="AI 拆解返回空结果")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class GenerateTaskBriefRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def _require_manager(current_user: User) -> Optional[JSONResponse]:
|
def _require_manager(current_user: User) -> Optional[JSONResponse]:
|
||||||
if has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]):
|
if has_any_role(str(current_user.role or ""), ["OWNER", "REVIEWER"]):
|
||||||
return None
|
return None
|
||||||
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作"))
|
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ def _parse_role_values(raw: Any) -> list[str]:
|
||||||
if isinstance(raw, (list, tuple, set)):
|
if isinstance(raw, (list, tuple, set)):
|
||||||
values = [str(_enum_value(item)).strip().upper() for item in raw]
|
values = [str(_enum_value(item)).strip().upper() for item in raw]
|
||||||
else:
|
else:
|
||||||
values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")]
|
values = [str(token).strip().upper() for token in str(_enum_value(raw)).split(",")]
|
||||||
deduped: list[str] = []
|
deduped: list[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
if value and value not in deduped:
|
if value and value not in deduped:
|
||||||
|
|
@ -32,7 +32,7 @@ def _pack_roles(raw: Any) -> str:
|
||||||
|
|
||||||
def _has_any_role(raw: Any, *roles: str) -> bool:
|
def _has_any_role(raw: Any, *roles: str) -> bool:
|
||||||
values = set(_parse_role_values(raw))
|
values = set(_parse_role_values(raw))
|
||||||
expected = {str(role).upper() for role in roles}
|
expected = {str(role).strip().upper() for role in roles}
|
||||||
return bool(values.intersection(expected))
|
return bool(values.intersection(expected))
|
||||||
|
|
||||||
def _build_role_filter(column: Any, roles: list[str]) -> Any:
|
def _build_role_filter(column: Any, roles: list[str]) -> Any:
|
||||||
|
|
@ -61,4 +61,3 @@ def _parse_int_list(raw: Optional[str]) -> list[int]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
"password": str(lower_map.get("password", "")).strip(),
|
"password": str(lower_map.get("password", "")).strip(),
|
||||||
"name": str(lower_map.get("name", "")).strip(),
|
"name": str(lower_map.get("name", "")).strip(),
|
||||||
"email": str(lower_map.get("email", "")).strip() or None,
|
"email": str(lower_map.get("email", "")).strip() or None,
|
||||||
"role": str(lower_map.get("role", "FE")).strip().upper(),
|
"role": str(lower_map.get("role", "BUILDER")).strip().upper(),
|
||||||
"project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))),
|
"project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,4 +264,3 @@ async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]:
|
||||||
return data_rows
|
return data_rows
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")
|
raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from .schemas import BulkAssignUsersRequest, BulkDeleteUsersRequest
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""删除用户(有未完成任务时禁止)。"""
|
"""删除用户(有未完成任务时禁止)。"""
|
||||||
|
|
@ -88,7 +88,7 @@ async def delete_user(
|
||||||
async def bulk_delete_users(
|
async def bulk_delete_users(
|
||||||
body: BulkDeleteUsersRequest,
|
body: BulkDeleteUsersRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""批量删除用户(带未完成任务保护)。"""
|
"""批量删除用户(带未完成任务保护)。"""
|
||||||
|
|
@ -175,7 +175,7 @@ async def bulk_delete_users(
|
||||||
async def bulk_assign_users(
|
async def bulk_assign_users(
|
||||||
body: BulkAssignUsersRequest,
|
body: BulkAssignUsersRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""批量分配项目/角色。"""
|
"""批量分配项目/角色。"""
|
||||||
|
|
@ -206,8 +206,8 @@ async def bulk_assign_users(
|
||||||
before_data = user.to_dict()
|
before_data = user.to_dict()
|
||||||
|
|
||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
if not _has_any_role(_enum_value(current_user.role), "BOSS"):
|
if not _has_any_role(_enum_value(current_user.role), "OWNER"):
|
||||||
skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"})
|
skipped.append({"user_id": user_id, "reason": "Only OWNER can bulk assign roles"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
user.role = _pack_roles(body.role)
|
user.role = _pack_roles(body.role)
|
||||||
|
|
@ -239,4 +239,3 @@ async def bulk_assign_users(
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"updated_count": updated, "skipped": skipped}
|
return {"updated_count": updated, "skipped": skipped}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async def update_user_workload(
|
||||||
async def import_users(
|
async def import_users(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""批量导入用户(最多 50 行)。"""
|
"""批量导入用户(最多 50 行)。"""
|
||||||
|
|
@ -93,4 +93,3 @@ async def import_users(
|
||||||
"failed": len(rows) - success_count,
|
"failed": len(rows) - success_count,
|
||||||
"results": results,
|
"results": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from .helpers_user import _replace_user_projects, _serialize_user, _translate_us
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""创建用户(自动初始化 user_workloads)。"""
|
"""创建用户(自动初始化 user_workloads)。"""
|
||||||
|
|
@ -161,7 +161,7 @@ async def patch_user_status(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
body: UserStatusPatchRequest,
|
body: UserStatusPatchRequest,
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""禁用/启用用户,禁用时清空任务指派并归零负载。"""
|
"""禁用/启用用户,禁用时清空任务指派并归零负载。"""
|
||||||
|
|
@ -217,4 +217,3 @@ async def patch_user_status(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return {"message": "Status updated", "user": _serialize_user(user)}
|
return {"message": "Status updated", "user": _serialize_user(user)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,5 +45,5 @@ from ...utils.timezone import now_cn_naive
|
||||||
router = APIRouter(tags=["users"])
|
router = APIRouter(tags=["users"])
|
||||||
|
|
||||||
COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
||||||
MANAGER_ROLE_SET = {"BOSS", "ADMIN"}
|
MANAGER_ROLE_SET = {"OWNER", "REVIEWER"}
|
||||||
VALID_ROLE_VALUES = {r.value for r in UserRole}
|
VALID_ROLE_VALUES = {r.value for r in UserRole}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ async def save_wecom_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成")
|
raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -61,7 +61,7 @@ async def get_wecom_config(
|
||||||
try:
|
try:
|
||||||
service = get_wecom_service(db)
|
service = get_wecom_service(db)
|
||||||
await service.load_config(tenant_id)
|
await service.load_config(tenant_id)
|
||||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"is_configured": service.is_configured(),
|
"is_configured": service.is_configured(),
|
||||||
|
|
@ -93,7 +93,7 @@ async def delete_wecom_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class AIOptimizeApplyRequest(BaseModel):
|
||||||
async def get_team_workloads(
|
async def get_team_workloads(
|
||||||
project_id: Optional[int] = Query(None),
|
project_id: Optional[int] = Query(None),
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_ = current_user
|
_ = current_user
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ from .ai_prompts_catalog import (
|
||||||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||||
)
|
)
|
||||||
from .ai_prompt_registry import (
|
from .ai_prompt_registry import (
|
||||||
PROMPT_REGISTRY,
|
PROMPT_REGISTRY,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .part_scenes_06 import (
|
||||||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
PROMPT_INDUSTRY_TREND_QA,
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
|
PROMPT_PMFLOW_LAUNCH_DRAFT,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -73,4 +74,5 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||||
|
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,43 @@ JSON schema:
|
||||||
TEXT_OUTPUT_GUARDRAIL,
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PROMPT_PMFLOW_LAUNCH_DRAFT = _compose_prompt(
|
||||||
|
"你是 PMFlow 的新项目启动草案生成器。",
|
||||||
|
"""
|
||||||
|
用户输入了一个 AI 产品创意。你只能生成“待用户确认”的项目草案,不能声称项目已经创建。
|
||||||
|
|
||||||
|
输出必须是 JSON 对象,不要 Markdown,不要解释。字段:
|
||||||
|
{{
|
||||||
|
"title": "string",
|
||||||
|
"one_liner": "string",
|
||||||
|
"target_user": "string",
|
||||||
|
"problem": "string",
|
||||||
|
"solution": "string",
|
||||||
|
"requirements": [{{"title": "string", "summary": "string", "acceptance_criteria": ["string"]}}],
|
||||||
|
"tasks": [{{"title": "string", "summary": "string", "group": "product|engineering|qa", "priority": "low|medium|high|urgent", "acceptance_criteria": ["string"], "requirement_index": 0}}],
|
||||||
|
"risks": [{{"title": "string", "summary": "string", "level": "low|medium|high", "mitigation": "string"}}],
|
||||||
|
"decisions": [{{"title": "string", "reason": "string", "owner": "string"}}],
|
||||||
|
"evidence": [{{"source": "user_input", "excerpt": "string"}}],
|
||||||
|
"confidence": 0.0,
|
||||||
|
"warnings": ["string"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
硬约束:
|
||||||
|
1. requirements 目标 3-5 条,tasks 目标 3-7 条,risks 目标 2-4 条,decisions 目标 1-3 条。
|
||||||
|
2. 所有对象必须来自用户输入的合理结构化推断;信息不足时写入 warnings,不要编造具体事实。
|
||||||
|
3. confidence 用 0-1 数值。
|
||||||
|
4. AI 生成内容只能是待确认草案,不要使用“已确认”“已写入”等表述。
|
||||||
|
5. evidence.excerpt 必须引用或概括用户输入中的证据。
|
||||||
|
|
||||||
|
用户输入:
|
||||||
|
{input_text}
|
||||||
|
|
||||||
|
附件摘要:
|
||||||
|
{attachments_json}
|
||||||
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_EXTERNAL_TREND_QA",
|
"PROMPT_EXTERNAL_TREND_QA",
|
||||||
"PROMPT_INDUSTRY_TREND_QA",
|
"PROMPT_INDUSTRY_TREND_QA",
|
||||||
|
|
@ -208,4 +245,5 @@ __all__ = [
|
||||||
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
|
||||||
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
"PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA",
|
||||||
|
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"],
|
idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -34,7 +34,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
"properties": {"notification_id": {"type": "integer"}},
|
"properties": {"notification_id": {"type": "integer"}},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "notification_id", "is_read"],
|
idempotency_fields=["tenant_id", "notification_id", "is_read"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -48,7 +48,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
"properties": {"notification_id": {"type": "integer"}},
|
"properties": {"notification_id": {"type": "integer"}},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "notification_id", "status"],
|
idempotency_fields=["tenant_id", "notification_id", "status"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -66,7 +66,7 @@ NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "path", "content_hash"],
|
idempotency_fields=["tenant_id", "path", "content_hash"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
allowed_paths=[
|
allowed_paths=[
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "user_id", "role"],
|
idempotency_fields=["tenant_id", "user_id", "role"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -36,7 +36,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"],
|
idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -53,7 +53,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "user_id", "status", "date"],
|
idempotency_fields=["tenant_id", "user_id", "status", "date"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -71,7 +71,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "doc_id", "content_hash"],
|
idempotency_fields=["tenant_id", "doc_id", "content_hash"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -89,7 +89,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "doc_id", "chunk_hash"],
|
idempotency_fields=["tenant_id", "doc_id", "chunk_hash"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -108,7 +108,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"],
|
idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -126,7 +126,7 @@ PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"],
|
idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "title"],
|
idempotency_fields=["tenant_id", "title"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -39,7 +39,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "project_id", "updated_at"],
|
idempotency_fields=["tenant_id", "project_id", "updated_at"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -53,7 +53,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
"properties": {"project_id": {"type": "integer"}},
|
"properties": {"project_id": {"type": "integer"}},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "project_id", "status"],
|
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -67,7 +67,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
"properties": {"project_id": {"type": "integer"}},
|
"properties": {"project_id": {"type": "integer"}},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "project_id", "status"],
|
idempotency_fields=["tenant_id", "project_id", "status"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -94,7 +94,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "project_id", "title"],
|
idempotency_fields=["tenant_id", "project_id", "title"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -112,7 +112,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "task_id", "updated_at"],
|
idempotency_fields=["tenant_id", "task_id", "updated_at"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -129,7 +129,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "task_id", "assignee_id"],
|
idempotency_fields=["tenant_id", "task_id", "assignee_id"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -146,7 +146,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR"],
|
||||||
idempotency_fields=["tenant_id", "task_id", "priority"],
|
idempotency_fields=["tenant_id", "task_id", "priority"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -163,7 +163,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"],
|
rbac_policy=["OWNER", "REVIEWER", "OPERATOR", "BUILDER"],
|
||||||
idempotency_fields=["tenant_id", "task_id", "status"],
|
idempotency_fields=["tenant_id", "task_id", "status"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
@ -177,7 +177,7 @@ PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = (
|
||||||
"properties": {"task_id": {"type": "integer"}},
|
"properties": {"task_id": {"type": "integer"}},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
rbac_policy=["BOSS", "ADMIN"],
|
rbac_policy=["OWNER", "REVIEWER"],
|
||||||
idempotency_fields=["tenant_id", "task_id"],
|
idempotency_fields=["tenant_id", "task_id"],
|
||||||
audit_fields=COMMON_AUDIT_FIELDS,
|
audit_fields=COMMON_AUDIT_FIELDS,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - optional dependency in lightweight envs
|
||||||
|
def load_dotenv(*_args, **_kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
# 仅从项目根目录 .env 加载,作为唯一配置源
|
# 仅从项目根目录 .env 加载,作为唯一配置源
|
||||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
@ -24,6 +29,20 @@ DEFAULT_DATABASE_URL = (
|
||||||
f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}"
|
f"postgresql+asyncpg://{DEFAULT_DB_USER}:{DEFAULT_DB_PASSWORD}"
|
||||||
f"@{DEFAULT_DB_HOST}:{DEFAULT_DB_PORT}/{DEFAULT_DB_NAME}"
|
f"@{DEFAULT_DB_HOST}:{DEFAULT_DB_PORT}/{DEFAULT_DB_NAME}"
|
||||||
)
|
)
|
||||||
|
TRUE_VALUES = {"true", "1", "yes", "on"}
|
||||||
|
DEBUG_ENABLED = os.getenv("DEBUG", "false").lower() in TRUE_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def _env_flag(name: str, default: bool) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() in TRUE_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def _env_csv(name: str) -> tuple[str, ...]:
|
||||||
|
raw = os.getenv(name, "")
|
||||||
|
return tuple(item.strip() for item in raw.split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
|
|
@ -31,7 +50,14 @@ class Settings:
|
||||||
|
|
||||||
# 应用
|
# 应用
|
||||||
app_name: str = os.getenv("APP_NAME", "Neural Sync API")
|
app_name: str = os.getenv("APP_NAME", "Neural Sync API")
|
||||||
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
debug: bool = DEBUG_ENABLED
|
||||||
|
startup_model_probe_enabled: bool = _env_flag("STARTUP_MODEL_PROBE_ENABLED", not DEBUG_ENABLED)
|
||||||
|
feishu_long_connection_enabled: bool = _env_flag("FEISHU_LONG_CONNECTION_ENABLED", not DEBUG_ENABLED)
|
||||||
|
world_model_warmup_enabled: bool = _env_flag("STARTUP_WORLD_MODEL_WARMUP_ENABLED", not DEBUG_ENABLED)
|
||||||
|
schedulers_enabled: bool = _env_flag("STARTUP_SCHEDULERS_ENABLED", True)
|
||||||
|
background_workers_enabled: bool = _env_flag("STARTUP_BACKGROUND_WORKERS_ENABLED", True)
|
||||||
|
api_route_profile: str = os.getenv("API_ROUTE_PROFILE", "full").strip().lower() or "full"
|
||||||
|
api_optional_route_groups: tuple[str, ...] = _env_csv("API_OPTIONAL_ROUTE_GROUPS")
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||||
|
|
@ -59,6 +85,15 @@ class Settings:
|
||||||
# 多租户(预留)
|
# 多租户(预留)
|
||||||
default_tenant_id: str = os.getenv("DEFAULT_TENANT_ID", "default")
|
default_tenant_id: str = os.getenv("DEFAULT_TENANT_ID", "default")
|
||||||
|
|
||||||
|
# GitHub App
|
||||||
|
github_app_slug: Optional[str] = os.getenv("GITHUB_APP_SLUG")
|
||||||
|
github_app_install_url: Optional[str] = os.getenv("GITHUB_APP_INSTALL_URL")
|
||||||
|
github_app_callback_url: Optional[str] = os.getenv("GITHUB_APP_CALLBACK_URL")
|
||||||
|
github_app_webhook_secret: Optional[str] = os.getenv("GITHUB_APP_WEBHOOK_SECRET")
|
||||||
|
github_app_sync_endpoint: Optional[str] = os.getenv("GITHUB_APP_SYNC_ENDPOINT")
|
||||||
|
github_app_sync_token: Optional[str] = os.getenv("GITHUB_APP_SYNC_TOKEN")
|
||||||
|
github_app_installations_fixture: Optional[str] = os.getenv("GITHUB_APP_INSTALLATIONS_FIXTURE")
|
||||||
|
|
||||||
|
|
||||||
# 全局配置实例
|
# 全局配置实例
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..models import User, UserProject
|
from ..models import User, UserProject
|
||||||
from ..services.auth import parse_role_values
|
from ..services.auth import MANAGER_ROLES, parse_role_values
|
||||||
from .auth import get_current_user
|
from .auth import get_current_user
|
||||||
|
|
||||||
MANAGER_ROLES = {"BOSS", "ADMIN"}
|
|
||||||
|
|
||||||
|
|
||||||
def _role_value(role: object) -> str:
|
def _role_value(role: object) -> str:
|
||||||
return role.value if isinstance(role, Enum) else str(role or "")
|
return str(role.value if isinstance(role, Enum) else str(role or "")).strip().upper()
|
||||||
|
|
||||||
|
|
||||||
async def require_manager_user(
|
async def require_manager_user(
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,6 @@ from .core.startup.world_model_warmup import warmup_world_models
|
||||||
from .middleware import AuditContextMiddleware
|
from .middleware import AuditContextMiddleware
|
||||||
from .services.ai_orchestration_run import AIOrchestrationRunService
|
from .services.ai_orchestration_run import AIOrchestrationRunService
|
||||||
from .services.context_cache_service import register_context_cache_invalidation_hooks
|
from .services.context_cache_service import register_context_cache_invalidation_hooks
|
||||||
from .services.integrations.feishu_long_connection_core import init_feishu_long_connection_manager
|
|
||||||
from .services.scheduler_center_core import (
|
|
||||||
AIPMOCadenceScheduler,
|
|
||||||
KnowledgeGraphIncrementalScheduler,
|
|
||||||
KnowledgeUrlRefreshScheduler,
|
|
||||||
WorkloadRecalcScheduler,
|
|
||||||
)
|
|
||||||
from .services.skill import init_system_skill_tags
|
from .services.skill import init_system_skill_tags
|
||||||
from .services.semantic_fact_service import register_semantic_fact_hooks
|
from .services.semantic_fact_service import register_semantic_fact_hooks
|
||||||
|
|
||||||
|
|
@ -39,6 +32,13 @@ register_context_cache_invalidation_hooks()
|
||||||
register_semantic_fact_hooks()
|
register_semantic_fact_hooks()
|
||||||
|
|
||||||
|
|
||||||
|
def _startup_step_enabled(step: str, enabled: bool, env_name: str) -> bool:
|
||||||
|
if enabled:
|
||||||
|
return True
|
||||||
|
startup_log(f"跳过{step}", reason=f"{env_name}=false")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
"""应用生命周期:初始化依赖并启动后台任务。"""
|
"""应用生命周期:初始化依赖并启动后台任务。"""
|
||||||
|
|
@ -58,6 +58,7 @@ async def lifespan(_: FastAPI):
|
||||||
await conn.run_sync(check_database_dialect_guard)
|
await conn.run_sync(check_database_dialect_guard)
|
||||||
startup_log("数据库连接成功")
|
startup_log("数据库连接成功")
|
||||||
|
|
||||||
|
if _startup_step_enabled("模型连通性探测", settings.startup_model_probe_enabled, "STARTUP_MODEL_PROBE_ENABLED"):
|
||||||
await probe_model_connectivity_on_startup()
|
await probe_model_connectivity_on_startup()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
|
@ -76,6 +77,18 @@ async def lifespan(_: FastAPI):
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc))
|
logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc))
|
||||||
|
|
||||||
|
scheduler = None
|
||||||
|
knowledge_scheduler = None
|
||||||
|
kg_scheduler = None
|
||||||
|
ai_pmo_cadence_scheduler = None
|
||||||
|
if _startup_step_enabled("定时调度器", settings.schedulers_enabled, "STARTUP_SCHEDULERS_ENABLED"):
|
||||||
|
from .services.scheduler_center_core import (
|
||||||
|
AIPMOCadenceScheduler,
|
||||||
|
KnowledgeGraphIncrementalScheduler,
|
||||||
|
KnowledgeUrlRefreshScheduler,
|
||||||
|
WorkloadRecalcScheduler,
|
||||||
|
)
|
||||||
|
|
||||||
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
|
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
|
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
|
||||||
|
|
@ -85,45 +98,64 @@ async def lifespan(_: FastAPI):
|
||||||
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
|
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
|
||||||
ai_pmo_cadence_scheduler.start()
|
ai_pmo_cadence_scheduler.start()
|
||||||
|
|
||||||
|
feishu_lc = None
|
||||||
|
if _startup_step_enabled("飞书长连接", settings.feishu_long_connection_enabled, "FEISHU_LONG_CONNECTION_ENABLED"):
|
||||||
|
from .services.integrations.feishu_long_connection_core import init_feishu_long_connection_manager
|
||||||
|
|
||||||
feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal)
|
feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal)
|
||||||
await feishu_lc.start()
|
await feishu_lc.start()
|
||||||
|
|
||||||
|
outbox_task = None
|
||||||
|
ingress_task = None
|
||||||
|
draft_expiry_task = None
|
||||||
|
if _startup_step_enabled("后台队列消费", settings.background_workers_enabled, "STARTUP_BACKGROUND_WORKERS_ENABLED"):
|
||||||
outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop")
|
outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop")
|
||||||
ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop")
|
ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop")
|
||||||
draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop")
|
draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop")
|
||||||
|
|
||||||
|
wm_warmup_task = None
|
||||||
|
if _startup_step_enabled("World Model 预热", settings.world_model_warmup_enabled, "STARTUP_WORLD_MODEL_WARMUP_ENABLED"):
|
||||||
wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup")
|
wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup")
|
||||||
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
|
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
startup_log("服务关闭中")
|
startup_log("服务关闭中")
|
||||||
|
if outbox_task is not None:
|
||||||
outbox_task.cancel()
|
outbox_task.cancel()
|
||||||
try:
|
try:
|
||||||
await outbox_task
|
await outbox_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if ingress_task is not None:
|
||||||
ingress_task.cancel()
|
ingress_task.cancel()
|
||||||
try:
|
try:
|
||||||
await ingress_task
|
await ingress_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if draft_expiry_task is not None:
|
||||||
draft_expiry_task.cancel()
|
draft_expiry_task.cancel()
|
||||||
try:
|
try:
|
||||||
await draft_expiry_task
|
await draft_expiry_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not wm_warmup_task.done():
|
if wm_warmup_task is not None and not wm_warmup_task.done():
|
||||||
wm_warmup_task.cancel()
|
wm_warmup_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await wm_warmup_task
|
await wm_warmup_task
|
||||||
|
|
||||||
|
if feishu_lc is not None:
|
||||||
await feishu_lc.stop()
|
await feishu_lc.stop()
|
||||||
|
if ai_pmo_cadence_scheduler is not None:
|
||||||
await ai_pmo_cadence_scheduler.stop()
|
await ai_pmo_cadence_scheduler.stop()
|
||||||
|
if kg_scheduler is not None:
|
||||||
await kg_scheduler.stop()
|
await kg_scheduler.stop()
|
||||||
|
if knowledge_scheduler is not None:
|
||||||
await knowledge_scheduler.stop()
|
await knowledge_scheduler.stop()
|
||||||
|
if scheduler is not None:
|
||||||
await scheduler.stop()
|
await scheduler.stop()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
startup_log("服务已关闭")
|
startup_log("服务已关闭")
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,31 @@ from .mounted_ai_runtime import (
|
||||||
MountedToolCapabilityPolicy,
|
MountedToolCapabilityPolicy,
|
||||||
MountedTopicWorkspace,
|
MountedTopicWorkspace,
|
||||||
)
|
)
|
||||||
|
from .ai_project_control import (
|
||||||
|
ModelCatalogItem,
|
||||||
|
ProjectModelBinding,
|
||||||
|
ModelRecommendationSnapshot,
|
||||||
|
ModelUsageEvent,
|
||||||
|
ModelEvalRun,
|
||||||
|
PromptPackage,
|
||||||
|
DevWorkspaceBinding,
|
||||||
|
RepoBinding,
|
||||||
|
GitHubAppInstallation,
|
||||||
|
GitHubAppRepository,
|
||||||
|
GitHubWebhookDelivery,
|
||||||
|
TaskCodeLink,
|
||||||
|
ProgressEvent,
|
||||||
|
DevSession,
|
||||||
|
ReleaseValidationRun,
|
||||||
|
FeedbackIngest,
|
||||||
|
IterationRecommendation,
|
||||||
|
RevenueEvent,
|
||||||
|
ROISnapshot,
|
||||||
|
ComplianceCheckRun,
|
||||||
|
EvidencePack,
|
||||||
|
)
|
||||||
|
from .pmflow_launch import PMFlowLaunchDraft, PMFlowThread, PMFlowThreadMessage
|
||||||
|
from .pmflow_change_review import PMFlowChangeSet, PMFlowChangeItem, PMFlowApproval, PMFlowEvidenceLink
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -173,4 +198,32 @@ __all__ = [
|
||||||
"MountedSourceActivation",
|
"MountedSourceActivation",
|
||||||
"MountedToolCapabilityPolicy",
|
"MountedToolCapabilityPolicy",
|
||||||
"MountedTopicWorkspace",
|
"MountedTopicWorkspace",
|
||||||
|
"ModelCatalogItem",
|
||||||
|
"ProjectModelBinding",
|
||||||
|
"ModelRecommendationSnapshot",
|
||||||
|
"ModelUsageEvent",
|
||||||
|
"ModelEvalRun",
|
||||||
|
"PromptPackage",
|
||||||
|
"DevWorkspaceBinding",
|
||||||
|
"RepoBinding",
|
||||||
|
"GitHubAppInstallation",
|
||||||
|
"GitHubAppRepository",
|
||||||
|
"GitHubWebhookDelivery",
|
||||||
|
"TaskCodeLink",
|
||||||
|
"ProgressEvent",
|
||||||
|
"DevSession",
|
||||||
|
"ReleaseValidationRun",
|
||||||
|
"FeedbackIngest",
|
||||||
|
"IterationRecommendation",
|
||||||
|
"RevenueEvent",
|
||||||
|
"ROISnapshot",
|
||||||
|
"ComplianceCheckRun",
|
||||||
|
"EvidencePack",
|
||||||
|
"PMFlowLaunchDraft",
|
||||||
|
"PMFlowThread",
|
||||||
|
"PMFlowThreadMessage",
|
||||||
|
"PMFlowChangeSet",
|
||||||
|
"PMFlowChangeItem",
|
||||||
|
"PMFlowApproval",
|
||||||
|
"PMFlowEvidenceLink",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class UserRole(str, Enum):
|
||||||
"""用户角色"""
|
"""用户角色"""
|
||||||
BOSS = "BOSS"
|
OWNER = "OWNER"
|
||||||
ADMIN = "ADMIN"
|
BUILDER = "BUILDER"
|
||||||
AI = "AI"
|
OPERATOR = "OPERATOR"
|
||||||
FE = "FE"
|
REVIEWER = "REVIEWER"
|
||||||
BE = "BE"
|
|
||||||
|
|
||||||
|
|
||||||
class UserStatus(str, Enum):
|
class UserStatus(str, Enum):
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ class Budget(Base):
|
||||||
VARCHAR(20), nullable=False, default="active"
|
VARCHAR(20), nullable=False, default="active"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 超支解锁记录(BOSS/ADMIN 可以临时解锁)
|
# 超支解锁记录(OWNER/REVIEWER 可以临时解锁)
|
||||||
override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
override_history: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.tenant_id == self.tenant_id,
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
User.status == "active",
|
User.status == "active",
|
||||||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
|
||||||
)
|
)
|
||||||
).order_by(User.id.asc()).limit(1)
|
).order_by(User.id.asc()).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from .service import (
|
from .service import (
|
||||||
AuthService,
|
AuthService,
|
||||||
|
MANAGER_ROLES,
|
||||||
can_assign_tasks,
|
can_assign_tasks,
|
||||||
can_manage_users,
|
can_manage_users,
|
||||||
has_any_role,
|
has_any_role,
|
||||||
|
|
@ -11,6 +12,7 @@ from .service import (
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthService",
|
"AuthService",
|
||||||
|
"MANAGER_ROLES",
|
||||||
"has_permission",
|
"has_permission",
|
||||||
"has_any_role",
|
"has_any_role",
|
||||||
"parse_role_values",
|
"parse_role_values",
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,18 @@ class AuthService:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
MANAGER_ROLES = {"OWNER", "REVIEWER"}
|
||||||
|
VALID_ROLE_TOKENS = {"OWNER", "BUILDER", "OPERATOR", "REVIEWER"}
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_role_token(value: str | None) -> str:
|
||||||
|
token = str(value or "").strip().upper()
|
||||||
|
return token if token in VALID_ROLE_TOKENS else token
|
||||||
|
|
||||||
|
|
||||||
# Permission matrix for role-based access control
|
# Permission matrix for role-based access control
|
||||||
PERMISSION_MATRIX = {
|
PERMISSION_MATRIX = {
|
||||||
"BOSS": [
|
"OWNER": [
|
||||||
"view_all_signals",
|
"view_all_signals",
|
||||||
"assign_tasks",
|
"assign_tasks",
|
||||||
"manage_users",
|
"manage_users",
|
||||||
|
|
@ -158,8 +167,11 @@ PERMISSION_MATRIX = {
|
||||||
"view_context",
|
"view_context",
|
||||||
"view_settings",
|
"view_settings",
|
||||||
"manage_staff",
|
"manage_staff",
|
||||||
|
"manage_global_integrations",
|
||||||
|
"approve_release_gate",
|
||||||
|
"override_compliance_gate",
|
||||||
],
|
],
|
||||||
"ADMIN": [
|
"REVIEWER": [
|
||||||
"view_all_signals",
|
"view_all_signals",
|
||||||
"assign_tasks",
|
"assign_tasks",
|
||||||
"manage_domains",
|
"manage_domains",
|
||||||
|
|
@ -167,19 +179,23 @@ PERMISSION_MATRIX = {
|
||||||
"view_context",
|
"view_context",
|
||||||
"view_settings",
|
"view_settings",
|
||||||
"manage_staff",
|
"manage_staff",
|
||||||
|
"manage_global_integrations",
|
||||||
|
"approve_release_gate",
|
||||||
],
|
],
|
||||||
"AI": [
|
"BUILDER": [
|
||||||
"view_assigned_signals",
|
"view_assigned_signals",
|
||||||
"create_experiments",
|
"create_experiments",
|
||||||
"view_context",
|
"view_context",
|
||||||
|
"work_on_dev_tasks",
|
||||||
|
"use_prompt_packages",
|
||||||
|
"sync_repo_progress",
|
||||||
],
|
],
|
||||||
"FE": [
|
"OPERATOR": [
|
||||||
"view_assigned_signals",
|
|
||||||
"view_assigned_projects",
|
|
||||||
],
|
|
||||||
"BE": [
|
|
||||||
"view_assigned_signals",
|
"view_assigned_signals",
|
||||||
"view_assigned_projects",
|
"view_assigned_projects",
|
||||||
|
"ingest_feedback",
|
||||||
|
"record_revenue_events",
|
||||||
|
"view_operating_metrics",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +208,7 @@ def has_permission(user_role: str, permission: str) -> bool:
|
||||||
|
|
||||||
def has_any_role(user_role: str, allowed_roles: list) -> bool:
|
def has_any_role(user_role: str, allowed_roles: list) -> bool:
|
||||||
"""Check if user_role is in the list of allowed roles"""
|
"""Check if user_role is in the list of allowed roles"""
|
||||||
allowed = {str(role).upper() for role in allowed_roles}
|
allowed = {canonicalize_role_token(str(role)) for role in allowed_roles}
|
||||||
return any(role in allowed for role in parse_role_values(user_role))
|
return any(role in allowed for role in parse_role_values(user_role))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -203,17 +219,17 @@ def parse_role_values(user_role: str | None) -> list[str]:
|
||||||
return []
|
return []
|
||||||
values: list[str] = []
|
values: list[str] = []
|
||||||
for token in raw.split(","):
|
for token in raw.split(","):
|
||||||
role = token.strip().upper()
|
role = canonicalize_role_token(token)
|
||||||
if role and role not in values:
|
if role and role not in values:
|
||||||
values.append(role)
|
values.append(role)
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def can_manage_users(user_role: str) -> bool:
|
def can_manage_users(user_role: str) -> bool:
|
||||||
"""Check if user can manage other users (BOSS only)"""
|
"""Check if user can manage other users (OWNER only)"""
|
||||||
return has_any_role(user_role, ["BOSS"])
|
return has_any_role(user_role, ["OWNER"])
|
||||||
|
|
||||||
|
|
||||||
def can_assign_tasks(user_role: str) -> bool:
|
def can_assign_tasks(user_role: str) -> bool:
|
||||||
"""Check if user can assign tasks to others"""
|
"""Check if user can assign tasks to others"""
|
||||||
return has_any_role(user_role, ["BOSS", "ADMIN"])
|
return has_any_role(user_role, ["OWNER", "REVIEWER"])
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class BudgetEnforcementAlertsMixin:
|
||||||
note: str = "",
|
note: str = "",
|
||||||
duration_hours: int = 24,
|
duration_hours: int = 24,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""BOSS/ADMIN 超支解锁。"""
|
"""OWNER/REVIEWER 超支解锁。"""
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Budget).where(
|
select(Budget).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ class DailyBriefingHelpersSummaryMixin:
|
||||||
role_code = self._role_code(user_role)
|
role_code = self._role_code(user_role)
|
||||||
_ = signal.source.lower()
|
_ = signal.source.lower()
|
||||||
|
|
||||||
if role_code == UserRole.AI.value:
|
if role_code == UserRole.BUILDER.value:
|
||||||
ai_keywords = [
|
builder_keywords = [
|
||||||
"model",
|
"model",
|
||||||
"llm",
|
"llm",
|
||||||
"gpt",
|
"gpt",
|
||||||
|
|
@ -35,12 +35,6 @@ class DailyBriefingHelpersSummaryMixin:
|
||||||
"推理",
|
"推理",
|
||||||
"ollama",
|
"ollama",
|
||||||
"langchain",
|
"langchain",
|
||||||
]
|
|
||||||
if any(kw in content for kw in ai_keywords):
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif role_code in {UserRole.BE.value, "BACKEND"}:
|
|
||||||
backend_keywords = [
|
|
||||||
"api",
|
"api",
|
||||||
"backend",
|
"backend",
|
||||||
"server",
|
"server",
|
||||||
|
|
@ -49,12 +43,6 @@ class DailyBriefingHelpersSummaryMixin:
|
||||||
"后端",
|
"后端",
|
||||||
"接口",
|
"接口",
|
||||||
"服务",
|
"服务",
|
||||||
]
|
|
||||||
if any(kw in content for kw in backend_keywords):
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif role_code in {UserRole.FE.value, "FRONTEND"}:
|
|
||||||
frontend_keywords = [
|
|
||||||
"ui",
|
"ui",
|
||||||
"frontend",
|
"frontend",
|
||||||
"react",
|
"react",
|
||||||
|
|
@ -64,10 +52,24 @@ class DailyBriefingHelpersSummaryMixin:
|
||||||
"界面",
|
"界面",
|
||||||
"交互",
|
"交互",
|
||||||
]
|
]
|
||||||
if any(kw in content for kw in frontend_keywords):
|
if any(kw in content for kw in builder_keywords):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}:
|
elif role_code == UserRole.OPERATOR.value:
|
||||||
|
operator_keywords = [
|
||||||
|
"增长",
|
||||||
|
"留存",
|
||||||
|
"转化",
|
||||||
|
"渠道",
|
||||||
|
"运营",
|
||||||
|
"反馈",
|
||||||
|
"用户",
|
||||||
|
"推广",
|
||||||
|
]
|
||||||
|
if any(kw in content for kw in operator_keywords):
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif role_code in {UserRole.REVIEWER.value, UserRole.OWNER.value}:
|
||||||
return signal.impact_score >= 0.8
|
return signal.impact_score >= 0.8
|
||||||
|
|
||||||
return signal.impact_score >= 0.7
|
return signal.impact_score >= 0.7
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ class DailyBriefingPersonalMixin:
|
||||||
|
|
||||||
for conflict in conflicts:
|
for conflict in conflicts:
|
||||||
involved_users = parse_involved_user_ids(conflict.involved_user_ids)
|
involved_users = parse_involved_user_ids(conflict.involved_user_ids)
|
||||||
if user.id in involved_users or role_code in [UserRole.ADMIN.value, UserRole.BOSS.value]:
|
if user.id in involved_users or role_code in [UserRole.REVIEWER.value, UserRole.OWNER.value]:
|
||||||
data["conflicts"].append(
|
data["conflicts"].append(
|
||||||
{
|
{
|
||||||
"id": conflict.id,
|
"id": conflict.id,
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class FeishuPipelineCoreBaseMixin:
|
||||||
def _can_execute_high_value_action(user: Optional[User]) -> bool:
|
def _can_execute_high_value_action(user: Optional[User]) -> bool:
|
||||||
if user is None:
|
if user is None:
|
||||||
return False
|
return False
|
||||||
return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"}
|
return str(getattr(user, "role", "") or "").upper() in {"OWNER", "REVIEWER", "OPERATOR"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _stable_positive_int(*parts: Any) -> int:
|
def _stable_positive_int(*parts: Any) -> int:
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ class FeishuPipelineFollowupBMixin:
|
||||||
User.tenant_id == self.tenant_id,
|
User.tenant_id == self.tenant_id,
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
User.status == "active",
|
User.status == "active",
|
||||||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
User.role.in_(["OWNER", "REVIEWER", "OPERATOR"]),
|
||||||
)
|
)
|
||||||
).order_by(User.id.asc()).limit(1)
|
).order_by(User.id.asc()).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -258,4 +258,34 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||||
"source_context": ("dict_or_none",),
|
"source_context": ("dict_or_none",),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"pmflow_launch_draft_v1": {
|
||||||
|
"required_keys": (
|
||||||
|
"title",
|
||||||
|
"one_liner",
|
||||||
|
"target_user",
|
||||||
|
"problem",
|
||||||
|
"solution",
|
||||||
|
"requirements",
|
||||||
|
"tasks",
|
||||||
|
"risks",
|
||||||
|
"decisions",
|
||||||
|
"confidence",
|
||||||
|
"evidence",
|
||||||
|
"warnings",
|
||||||
|
),
|
||||||
|
"type_checks": {
|
||||||
|
"title": "str",
|
||||||
|
"one_liner": "str",
|
||||||
|
"target_user": "str",
|
||||||
|
"problem": "str",
|
||||||
|
"solution": "str",
|
||||||
|
"requirements": "list",
|
||||||
|
"tasks": "list",
|
||||||
|
"risks": "list",
|
||||||
|
"decisions": "list",
|
||||||
|
"confidence": "number",
|
||||||
|
"evidence": "list",
|
||||||
|
"warnings": "list",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_SPLIT_RE = re.compile(r"[,/|\s]+")
|
||||||
_ROLE_ALIAS_MAP = {
|
_ROLE_ALIAS_MAP = {
|
||||||
"AI": {"AI"},
|
"OWNER": {"OWNER"},
|
||||||
"AI_ENGINEER": {"AI"},
|
"REVIEWER": {"REVIEWER"},
|
||||||
"FRONTEND": {"FE"},
|
"OPERATOR": {"OPERATOR"},
|
||||||
"FE": {"FE"},
|
"BUILDER": {"BUILDER"},
|
||||||
"BACKEND": {"BE"},
|
|
||||||
"BE": {"BE"},
|
|
||||||
"BOSS": {"BOSS"},
|
|
||||||
"ADMIN": {"ADMIN"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class SignalCaptureNewProjectMixin:
|
||||||
and_(
|
and_(
|
||||||
User.tenant_id == tenant_id,
|
User.tenant_id == tenant_id,
|
||||||
User.status == "active",
|
User.status == "active",
|
||||||
User.role.in_(["BOSS", "ADMIN"]),
|
User.role.in_(["OWNER", "REVIEWER"]),
|
||||||
)
|
)
|
||||||
).order_by(User.id.asc()).limit(1)
|
).order_by(User.id.asc()).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
|
||||||
User.tenant_id == tenant_id,
|
User.tenant_id == tenant_id,
|
||||||
User.status == "active",
|
User.status == "active",
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
User.role.in_(["BOSS", "ADMIN"]),
|
User.role.in_(["OWNER", "REVIEWER"]),
|
||||||
)
|
)
|
||||||
).order_by(User.id.asc()).limit(1)
|
).order_by(User.id.asc()).limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -177,7 +177,7 @@ class SignalCaptureTaskOrchestrationTeamMixin:
|
||||||
User.tenant_id == tenant_id,
|
User.tenant_id == tenant_id,
|
||||||
User.status == "active",
|
User.status == "active",
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
User.role.in_(["BOSS", "ADMIN"]),
|
User.role.in_(["OWNER", "REVIEWER"]),
|
||||||
)
|
)
|
||||||
).limit(5)
|
).limit(5)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class SignalSourceLookupParseMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}:
|
if row is not None and str(row.role or "").strip().upper() in {"OWNER", "REVIEWER"}:
|
||||||
return row
|
return row
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -37,7 +37,7 @@ class SignalSourceLookupParseMixin:
|
||||||
and_(
|
and_(
|
||||||
User.tenant_id == tenant_id,
|
User.tenant_id == tenant_id,
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
User.role.in_(["BOSS", "ADMIN"]),
|
User.role.in_(["OWNER", "REVIEWER"]),
|
||||||
)
|
)
|
||||||
).order_by(User.id.asc()).limit(1)
|
).order_by(User.id.asc()).limit(1)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ def _assert_charter_aligned(charter: TruthNode) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _is_manager(user: User) -> bool:
|
def _is_manager(user: User) -> bool:
|
||||||
return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"])
|
return has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"])
|
||||||
|
|
||||||
def _require_manager(user: User) -> None:
|
def _require_manager(user: User) -> None:
|
||||||
if not _is_manager(user):
|
if not _is_manager(user):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from .shared_helpers import *
|
from .shared_helpers import *
|
||||||
from .shared_helpers import _to_json
|
from .shared_helpers import _to_json
|
||||||
|
from ..ai_project_control_service import AIProjectControlService
|
||||||
|
|
||||||
class SSTCBuildViewsMixin:
|
class SSTCBuildViewsMixin:
|
||||||
async def build_overview(self, project_id: int) -> dict[str, Any]:
|
async def build_overview(self, project_id: int) -> dict[str, Any]:
|
||||||
|
|
@ -17,6 +18,9 @@ class SSTCBuildViewsMixin:
|
||||||
member_ids = await self.get_project_workload_member_ids(project_id)
|
member_ids = await self.get_project_workload_member_ids(project_id)
|
||||||
workload = await self.get_workload_health(member_ids)
|
workload = await self.get_workload_health(member_ids)
|
||||||
chain_id = str(charters[0].id) if charters else None
|
chain_id = str(charters[0].id) if charters else None
|
||||||
|
control_summary = await AIProjectControlService(self.db, tenant_id=self.tenant_id).build_project_control_summary(
|
||||||
|
project_id=project_id
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"project": {
|
"project": {
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
|
|
@ -36,6 +40,11 @@ class SSTCBuildViewsMixin:
|
||||||
"unresolved_conflicts": alignment["unresolved_conflicts"],
|
"unresolved_conflicts": alignment["unresolved_conflicts"],
|
||||||
"task_progress": metrics,
|
"task_progress": metrics,
|
||||||
"workload_health": workload,
|
"workload_health": workload,
|
||||||
|
"model_binding": control_summary.get("model_binding"),
|
||||||
|
"prompt_package_status": control_summary.get("prompt_package_status"),
|
||||||
|
"release_validation_status": control_summary.get("release_validation_status"),
|
||||||
|
"roi_snapshot": control_summary.get("roi_snapshot"),
|
||||||
|
"compliance_status": control_summary.get("compliance_status"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def build_stages(self, project_id: int) -> list[dict[str, Any]]:
|
async def build_stages(self, project_id: int) -> list[dict[str, Any]]:
|
||||||
|
|
@ -295,4 +304,7 @@ class SSTCBuildViewsMixin:
|
||||||
for c in charters
|
for c in charters
|
||||||
],
|
],
|
||||||
"tasks_by_charter": tasks_by_charter,
|
"tasks_by_charter": tasks_by_charter,
|
||||||
|
"model_binding": await AIProjectControlService(self.db, tenant_id=self.tenant_id).get_active_binding_summary(
|
||||||
|
project_id=project_id
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from ..models import Project, User, UserProject, UserWorkload
|
||||||
from .auth.service import parse_role_values
|
from .auth.service import parse_role_values
|
||||||
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
|
from .executive_briefing_service import ExecutiveBriefingService, _deepcopy_dict
|
||||||
|
|
||||||
MANAGER_ROLES = {"BOSS", "ADMIN"}
|
MANAGER_ROLES = {"OWNER", "REVIEWER"}
|
||||||
|
|
||||||
|
|
||||||
class TenantBriefingService(ExecutiveBriefingService):
|
class TenantBriefingService(ExecutiveBriefingService):
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ async def ensure_seed_user(session: AsyncSession) -> User:
|
||||||
email="boss@pmflow.local",
|
email="boss@pmflow.local",
|
||||||
password_hash=AuthService.hash_password("boss123"),
|
password_hash=AuthService.hash_password("boss123"),
|
||||||
name="负责人",
|
name="负责人",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
session.add(seed_user)
|
session.add(seed_user)
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,5 @@ python-docx>=1.1.2
|
||||||
openpyxl>=3.1.5
|
openpyxl>=3.1.5
|
||||||
lark-oapi>=1.4.10
|
lark-oapi>=1.4.10
|
||||||
duckduckgo-search>=8.1.1
|
duckduckgo-search>=8.1.1
|
||||||
|
pytest>=8.3.3
|
||||||
|
pytest-asyncio>=0.24.0
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,7 @@ from app.services.auth import AuthService
|
||||||
async def seed_users():
|
async def seed_users():
|
||||||
"""Create default users with known passwords."""
|
"""Create default users with known passwords."""
|
||||||
# Create initial users (with simple password hashes for demo)
|
# Create initial users (with simple password hashes for demo)
|
||||||
# In production, these should be created with proper password hashing
|
# In production, these should be created with proper password hashing.
|
||||||
# admin_hash = AuthService.hash_password("admin123")
|
|
||||||
# boss_hash = AuthService.hash_password("boss123")
|
|
||||||
# ai_hash = AuthService.hash_password("ai123")
|
|
||||||
# be_hash = AuthService.hash_password("be123")
|
|
||||||
# fe_hash = AuthService.hash_password("fe123")
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# Check if users already exist
|
# Check if users already exist
|
||||||
|
|
@ -40,7 +35,7 @@ async def seed_users():
|
||||||
password_hash=AuthService.hash_password("admin123"),
|
password_hash=AuthService.hash_password("admin123"),
|
||||||
name="系统管理员",
|
name="系统管理员",
|
||||||
email="admin@pmflow.ai",
|
email="admin@pmflow.ai",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
assigned_domains=[],
|
assigned_domains=[],
|
||||||
workload=0,
|
workload=0,
|
||||||
status="active",
|
status="active",
|
||||||
|
|
@ -59,7 +54,7 @@ async def seed_users():
|
||||||
password_hash=AuthService.hash_password("boss123"),
|
password_hash=AuthService.hash_password("boss123"),
|
||||||
name="老板",
|
name="老板",
|
||||||
email="boss@pmflow.ai",
|
email="boss@pmflow.ai",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
assigned_domains=[],
|
assigned_domains=[],
|
||||||
workload=0,
|
workload=0,
|
||||||
status="active",
|
status="active",
|
||||||
|
|
@ -78,7 +73,7 @@ async def seed_users():
|
||||||
password_hash=AuthService.hash_password("ai123"),
|
password_hash=AuthService.hash_password("ai123"),
|
||||||
name="AI工程师",
|
name="AI工程师",
|
||||||
email="ai@pmflow.ai",
|
email="ai@pmflow.ai",
|
||||||
role="AI",
|
role="BUILDER",
|
||||||
assigned_domains=[],
|
assigned_domains=[],
|
||||||
workload=0,
|
workload=0,
|
||||||
status="active",
|
status="active",
|
||||||
|
|
@ -97,7 +92,7 @@ async def seed_users():
|
||||||
password_hash=AuthService.hash_password("be123"),
|
password_hash=AuthService.hash_password("be123"),
|
||||||
name="后端开发",
|
name="后端开发",
|
||||||
email="backend@pmflow.ai",
|
email="backend@pmflow.ai",
|
||||||
role="BE",
|
role="BUILDER",
|
||||||
assigned_domains=[],
|
assigned_domains=[],
|
||||||
workload=0,
|
workload=0,
|
||||||
status="active",
|
status="active",
|
||||||
|
|
@ -116,7 +111,7 @@ async def seed_users():
|
||||||
password_hash=AuthService.hash_password("fe123"),
|
password_hash=AuthService.hash_password("fe123"),
|
||||||
name="前端开发",
|
name="前端开发",
|
||||||
email="frontend@pmflow.ai",
|
email="frontend@pmflow.ai",
|
||||||
role="FE",
|
role="BUILDER",
|
||||||
assigned_domains=[],
|
assigned_domains=[],
|
||||||
workload=0,
|
workload=0,
|
||||||
status="active",
|
status="active",
|
||||||
|
|
@ -190,11 +185,11 @@ async def main():
|
||||||
print("✅ Seeding completed!")
|
print("✅ Seeding completed!")
|
||||||
print()
|
print()
|
||||||
print("📝 Default users created:")
|
print("📝 Default users created:")
|
||||||
print(" - admin / admin123 (BOSS)")
|
print(" - admin / admin123 (OWNER)")
|
||||||
print(" - boss / boss123 (BOSS)")
|
print(" - boss / boss123 (OWNER)")
|
||||||
print(" - ai_engineer / ai123 (AI)")
|
print(" - ai_engineer / ai123 (BUILDER)")
|
||||||
print(" - backend / be123 (BE)")
|
print(" - backend / be123 (BUILDER)")
|
||||||
print(" - frontend / fe123 (FE)")
|
print(" - frontend / fe123 (BUILDER)")
|
||||||
print()
|
print()
|
||||||
print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login")
|
print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
tenant_id=1,
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
)
|
)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ async def test_cognitive_conflicts_return_display_type(db_session) -> None:
|
||||||
email="tester@example.com",
|
email="tester@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="测试用户",
|
name="测试用户",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
)
|
)
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ async def test_generate_personalized_briefing_uses_naive_time_window(briefing_se
|
||||||
email="tester@example.com",
|
email="tester@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="测试用户",
|
name="测试用户",
|
||||||
role="BE",
|
role="BUILDER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ async def db_session():
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _build_app(*, db_session, role: str = "ADMIN", user_id: int = 9001) -> FastAPI:
|
def _build_app(*, db_session, role: str = "REVIEWER", user_id: int = 9001) -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(routes_brief_blockers.router)
|
app.include_router(routes_brief_blockers.router)
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ async def test_daily_briefing_v2_is_enveloped_for_any_authenticated_user(db_sess
|
||||||
|
|
||||||
monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload)
|
monkeypatch.setattr(TenantBriefingService, "build_viewer_payload", fake_build_viewer_payload)
|
||||||
|
|
||||||
app = _build_app(db_session=db_session, role="BE", user_id=7)
|
app = _build_app(db_session=db_session, role="BUILDER", user_id=7)
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"})
|
response = await client.get("/ai-steward/daily-briefing-v2", params={"mode": "live"})
|
||||||
|
|
@ -160,7 +160,7 @@ async def test_tenant_briefing_service_returns_full_scope_for_manager(db_session
|
||||||
|
|
||||||
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
||||||
|
|
||||||
payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["BOSS"], mode="snapshot")
|
payload = await service.build_viewer_payload(viewer_id=1, viewer_roles=["OWNER"], mode="snapshot")
|
||||||
|
|
||||||
assert payload["viewer_scope"] == "tenant_full"
|
assert payload["viewer_scope"] == "tenant_full"
|
||||||
assert payload["hero"]["decision_count"] == 2
|
assert payload["hero"]["decision_count"] == 2
|
||||||
|
|
@ -177,7 +177,7 @@ async def test_tenant_briefing_service_filters_personal_scope_and_load(db_sessio
|
||||||
email="be@example.com",
|
email="be@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="后端成员",
|
name="后端成员",
|
||||||
role="BE",
|
role="BUILDER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -262,7 +262,7 @@ async def test_tenant_briefing_service_filters_personal_scope_and_load(db_sessio
|
||||||
|
|
||||||
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
monkeypatch.setattr(TenantBriefingService, "get_tenant_payload", fake_get_tenant_payload)
|
||||||
|
|
||||||
payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BE"], mode="live")
|
payload = await service.build_viewer_payload(viewer_id=int(viewer.id), viewer_roles=["BUILDER"], mode="live")
|
||||||
|
|
||||||
assert payload["viewer_scope"] == "personal"
|
assert payload["viewer_scope"] == "personal"
|
||||||
assert payload["hero"]["decision_count"] == 1
|
assert payload["hero"]["decision_count"] == 1
|
||||||
|
|
@ -288,7 +288,7 @@ async def test_tenant_briefing_service_personal_workload_ratio_matches_weekly_ho
|
||||||
email="be@example.com",
|
email="be@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="后端成员",
|
name="后端成员",
|
||||||
role="BE",
|
role="BUILDER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _build_app(*, db_session, role: str = "ADMIN") -> FastAPI:
|
def _build_app(*, db_session, role: str = "REVIEWER") -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(routes_executive_briefing.router)
|
app.include_router(routes_executive_briefing.router)
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ async def test_executive_briefing_config_round_trip_is_enveloped(db_session) ->
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None:
|
async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None:
|
||||||
app = _build_app(db_session=db_session, role="BE")
|
app = _build_app(db_session=db_session, role="BUILDER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
|
|
@ -294,7 +294,7 @@ async def test_executive_briefing_pending_signal_sources_include_display_fields(
|
||||||
email="pm@example.com",
|
email="pm@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="产品经理",
|
name="产品经理",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -372,7 +372,7 @@ async def test_executive_briefing_service_builds_detailed_signal_items_and_targe
|
||||||
email="boss@example.com",
|
email="boss@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="李雷",
|
name="李雷",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -439,7 +439,7 @@ async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics
|
||||||
email="boss@example.com",
|
email="boss@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="李雷",
|
name="李雷",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -541,7 +541,7 @@ async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_
|
||||||
email="boss@example.com",
|
email="boss@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="李雷",
|
name="李雷",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -637,7 +637,7 @@ async def test_executive_briefing_signal_source_target_does_not_apply_mismatched
|
||||||
email="boss@example.com",
|
email="boss@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="李雷",
|
name="李雷",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -689,7 +689,7 @@ async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_sess
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="杨宇",
|
name="杨宇",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
@ -729,7 +729,7 @@ async def test_executive_briefing_localizes_conflict_and_signal_source_codes(db_
|
||||||
email="boss@example.com",
|
email="boss@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="李雷",
|
name="李雷",
|
||||||
role="BOSS",
|
role="OWNER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def _build_app(db_session):
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
)
|
)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="负责人",
|
name="负责人",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ def _build_app(*, db_session) -> FastAPI:
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
)
|
)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ def _build_app(db_session, *, role: str, tenant_id: int = 1) -> FastAPI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_admin_api_requires_manager_role(db_session) -> None:
|
async def test_mcp_admin_api_requires_manager_role(db_session) -> None:
|
||||||
app = _build_app(db_session, role="AI")
|
app = _build_app(db_session, role="BUILDER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -79,7 +79,7 @@ async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
|
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -140,8 +140,8 @@ async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
|
async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
|
||||||
tenant1_app = _build_app(db_session, role="ADMIN", tenant_id=1)
|
tenant1_app = _build_app(db_session, role="REVIEWER", tenant_id=1)
|
||||||
tenant2_app = _build_app(db_session, role="ADMIN", tenant_id=2)
|
tenant2_app = _build_app(db_session, role="REVIEWER", tenant_id=2)
|
||||||
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=tenant1_app), base_url="http://test") as client:
|
async with AsyncClient(transport=ASGITransport(app=tenant1_app), base_url="http://test") as client:
|
||||||
create_response = await client.post(
|
create_response = await client.post(
|
||||||
|
|
@ -166,7 +166,7 @@ async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env_var_name(db_session) -> None:
|
async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env_var_name(db_session) -> None:
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -217,7 +217,7 @@ async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
|
async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -238,7 +238,7 @@ async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirmed(db_session) -> None:
|
async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirmed(db_session) -> None:
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -283,7 +283,7 @@ async def test_duckduckgo_template_uses_backend_direct_preview_without_extra_par
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
|
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ def _build_app(db_session):
|
||||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||||
id=9001,
|
id=9001,
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
email="kg_owner@example.com",
|
||||||
password_hash="hash",
|
password_hash="hash",
|
||||||
name="图谱负责人",
|
name="图谱负责人",
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
status="active",
|
status="active",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from app.dependencies.auth import get_current_user
|
||||||
from app.dependencies.tenant import get_tenant_id
|
from app.dependencies.tenant import get_tenant_id
|
||||||
|
|
||||||
|
|
||||||
def _build_test_app(*, role: str = "ADMIN") -> FastAPI:
|
def _build_test_app(*, role: str = "REVIEWER") -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(routes_route_governance.router)
|
app.include_router(routes_route_governance.router)
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ async def test_query_route_governance_summary_returns_manager_payload(monkeypatc
|
||||||
|
|
||||||
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
|
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
|
||||||
|
|
||||||
app = _build_test_app(role="ADMIN")
|
app = _build_test_app(role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.get("/rag/query-route-governance/summary", params={"project_id": 17, "window_days": 30})
|
response = await client.get("/rag/query-route-governance/summary", params={"project_id": 17, "window_days": 30})
|
||||||
|
|
@ -167,7 +167,7 @@ async def test_query_route_governance_items_and_detail_return_contract(monkeypat
|
||||||
|
|
||||||
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
|
monkeypatch.setattr(routes_route_governance, "AdaptiveRAGService", FakeService)
|
||||||
|
|
||||||
app = _build_test_app(role="ADMIN")
|
app = _build_test_app(role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
list_response = await client.get(
|
list_response = await client.get(
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ def _build_app(db_session):
|
||||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||||
id=9001,
|
id=9001,
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ def _build_app(db_session):
|
||||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||||
id=9001,
|
id=9001,
|
||||||
tenant_id=1,
|
tenant_id=1,
|
||||||
role="ADMIN",
|
role="REVIEWER",
|
||||||
name="管理员",
|
name="管理员",
|
||||||
username="admin",
|
username="admin",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ def _build_app(db_session, *, role: str) -> FastAPI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_semantic_fact_admin_api_requires_manager_role(db_session) -> None:
|
async def test_semantic_fact_admin_api_requires_manager_role(db_session) -> None:
|
||||||
app = _build_app(db_session, role="AI")
|
app = _build_app(db_session, role="BUILDER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
@ -134,7 +134,7 @@ async def test_semantic_fact_admin_api_exposes_overview_objects_detail_and_actio
|
||||||
)
|
)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
app = _build_app(db_session, role="ADMIN")
|
app = _build_app(db_session, role="REVIEWER")
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue