Update AI steward and audit logic
This commit is contained in:
parent
7c9d274a6c
commit
a3a4ce61ff
|
|
@ -1,9 +1,24 @@
|
|||
{
|
||||
"version": "v2",
|
||||
"updated_at": "2026-03-11T08:35:00Z",
|
||||
"updated_at": "2026-03-11T09:10:00Z",
|
||||
"source_of_truth": "openclaw",
|
||||
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||
"rules": [
|
||||
{
|
||||
"name": "engine_low_project_draft_glm47_fallback",
|
||||
"enabled": true,
|
||||
"priority": 340,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"model": "GLM-4.7",
|
||||
"api_env": "GLM_API_KEY",
|
||||
"match_any": [
|
||||
"project_draft_analysis_glm47_fallback",
|
||||
"project_draft_signal_glm47_fallback",
|
||||
"project_draft_charter_glm47_fallback",
|
||||
"project_draft_task_chain_glm47_fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_intent_complex_exact",
|
||||
"enabled": true,
|
||||
|
|
|
|||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -33,6 +33,7 @@ backend/
|
|||
│ ├── api/ # 路由层(含 endpoints)
|
||||
│ ├── core/ # 配置、日志、核心能力
|
||||
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||
│ ├── middleware/ # 请求中间件(审计上下文注入等)
|
||||
│ ├── models/ # SQLAlchemy 模型
|
||||
│ ├── schemas/ # Pydantic 模型
|
||||
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
||||
|
|
@ -43,6 +44,9 @@ backend/
|
|||
│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
|
||||
│ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引
|
||||
│ ├── 20260311_add_kb_chunk_ivfflat_index.py # knowledge_base_chunks 向量 IVFFlat 索引
|
||||
│ ├── 20260311_add_operation_log_audit_fields.py # operation_logs 审计字段扩展(trace/path/caller)
|
||||
│ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items)
|
||||
│ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2)
|
||||
│ └── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
||||
├── scripts/ # 后端运维/数据修复脚本
|
||||
│ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
||||
|
|
@ -53,6 +57,11 @@ backend/
|
|||
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
|
||||
- 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别)
|
||||
- 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`)
|
||||
- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化)
|
||||
- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
|
||||
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
|
||||
- 新增:`backend/app/services/audit_query_service.py`(operation_logs 过滤与序列化)
|
||||
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
||||
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
|
||||
- 新增:`backend/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
|
||||
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
|
||||
|
|
@ -139,16 +148,23 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
|
||||
## 8. 服务边界约束(禁止越界)
|
||||
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||
- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行
|
||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
|
||||
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
|
||||
|
||||
## 9. 新增代码的参考模式
|
||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||
- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`)
|
||||
- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`)
|
||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
||||
- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交)
|
||||
- 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects`
|
||||
- SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示
|
||||
- AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome`
|
||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||
|
|
@ -175,3 +191,6 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认
|
||||
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
|
||||
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义
|
||||
- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐
|
||||
- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录
|
||||
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
"""add ai_pmo_work_items compatibility view
|
||||
|
||||
Revision ID: add_ai_pmo_work_items_view
|
||||
Revises: add_operation_log_audit_fields
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_ai_pmo_work_items_view"
|
||||
down_revision = "add_operation_log_audit_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "work_items"):
|
||||
return
|
||||
|
||||
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE VIEW ai_pmo_work_items AS
|
||||
SELECT
|
||||
id,
|
||||
ingest_event_id,
|
||||
source_system,
|
||||
source_message_id,
|
||||
state,
|
||||
intent,
|
||||
decision_level,
|
||||
project_id,
|
||||
signal_id,
|
||||
owner_user_id,
|
||||
dedupe_key,
|
||||
context_snapshot,
|
||||
action_bundle,
|
||||
execution_result,
|
||||
review_summary,
|
||||
reply_template_key,
|
||||
tenant_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM work_items
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""add operation log audit fields
|
||||
|
||||
Revision ID: add_operation_log_audit_fields
|
||||
Revises: shift_all_timestamps_to_beijing
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_operation_log_audit_fields"
|
||||
down_revision = "shift_all_timestamps_to_beijing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "operation_logs"):
|
||||
return
|
||||
|
||||
with op.batch_alter_table("operation_logs") as batch_op:
|
||||
if not _column_exists(bind, "operation_logs", "trace_id"):
|
||||
batch_op.add_column(sa.Column("trace_id", sa.String(length=64), nullable=True))
|
||||
if not _column_exists(bind, "operation_logs", "interface_path"):
|
||||
batch_op.add_column(sa.Column("interface_path", sa.String(length=255), nullable=True))
|
||||
if not _column_exists(bind, "operation_logs", "caller"):
|
||||
batch_op.add_column(
|
||||
sa.Column("caller", sa.String(length=16), nullable=False, server_default=sa.text("'system'"))
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||
op.create_index("ix_operation_logs_trace_id", "operation_logs", ["trace_id"], unique=False)
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||
op.create_index("ix_operation_logs_interface_path", "operation_logs", ["interface_path"], unique=False)
|
||||
if not _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||
op.create_index("ix_operation_logs_caller", "operation_logs", ["caller"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "operation_logs"):
|
||||
return
|
||||
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||
op.drop_index("ix_operation_logs_caller", table_name="operation_logs")
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||
op.drop_index("ix_operation_logs_interface_path", table_name="operation_logs")
|
||||
if _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||
op.drop_index("ix_operation_logs_trace_id", table_name="operation_logs")
|
||||
|
||||
with op.batch_alter_table("operation_logs") as batch_op:
|
||||
if _column_exists(bind, "operation_logs", "caller"):
|
||||
batch_op.drop_column("caller")
|
||||
if _column_exists(bind, "operation_logs", "interface_path"):
|
||||
batch_op.drop_column("interface_path")
|
||||
if _column_exists(bind, "operation_logs", "trace_id"):
|
||||
batch_op.drop_column("trace_id")
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""expand user_workloads workload_ratio precision
|
||||
|
||||
Revision ID: expand_user_workload_ratio_precision
|
||||
Revises: add_ai_pmo_work_items_view
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "expand_user_workload_ratio_precision"
|
||||
down_revision = "add_ai_pmo_work_items_view"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_numeric_meta(bind, table_name: str, column_name: str) -> tuple[int | None, int | None] | None:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return None
|
||||
for column in inspector.get_columns(table_name):
|
||||
if str(column.get("name")) == column_name:
|
||||
col_type = column.get("type")
|
||||
return (
|
||||
int(getattr(col_type, "precision", 0) or 0) or None,
|
||||
int(getattr(col_type, "scale", 0) or 0) or None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _alter_workload_ratio(bind, *, precision: int, scale: int) -> None:
|
||||
current = _column_numeric_meta(bind, "user_workloads", "workload_ratio")
|
||||
if current == (precision, scale):
|
||||
return
|
||||
current_precision = int((current or (4, 2))[0] or 4)
|
||||
current_scale = int((current or (4, 2))[1] or 2)
|
||||
with op.batch_alter_table("user_workloads") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"workload_ratio",
|
||||
existing_type=sa.Numeric(current_precision, current_scale),
|
||||
type_=sa.Numeric(precision, scale),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "user_workloads"):
|
||||
return
|
||||
_alter_workload_ratio(bind, precision=10, scale=2)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "user_workloads"):
|
||||
return
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE user_workloads
|
||||
SET workload_ratio = CASE
|
||||
WHEN workload_ratio > 99.99 THEN 99.99
|
||||
WHEN workload_ratio < -99.99 THEN -99.99
|
||||
ELSE workload_ratio
|
||||
END
|
||||
"""
|
||||
)
|
||||
_alter_workload_ratio(bind, precision=4, scale=2)
|
||||
|
|
@ -32,7 +32,13 @@ from ..services.adaptive_rag_service import AdaptiveRAGService
|
|||
from ..services.ai_orchestration_run_service import AIOrchestrationRunService
|
||||
from ..services.openclaw_commander_service import OpenClawCommanderService
|
||||
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||
from ..services.signal_source_repair_service import (
|
||||
SignalSourceRepairMissingSignalError,
|
||||
SignalSourceRepairService,
|
||||
SignalSourceRepairTimeoutError,
|
||||
)
|
||||
from ..schemas.rag import RAGAskRequest
|
||||
from ..utils.audited_write import audited_write
|
||||
|
||||
|
||||
from ..utils.timezone import now_cn_aware, now_cn_naive
|
||||
|
|
@ -166,6 +172,12 @@ class SignalSourceListResponse(BaseModel):
|
|||
items: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class SignalSourceRepairRequest(BaseModel):
|
||||
project_id: int = Field(..., ge=1)
|
||||
registry_ids: List[int] = Field(default_factory=list, min_length=1, max_length=50)
|
||||
sender: str = Field(default="PMFlow修复器", min_length=1, max_length=64)
|
||||
|
||||
|
||||
def _parse_related_users(raw_value: Any) -> List[str]:
|
||||
"""兼容 involved_user_ids 的 list / json-string / 单值字符串。"""
|
||||
if raw_value is None:
|
||||
|
|
@ -219,6 +231,7 @@ async def ask_ai_steward_rag(
|
|||
|
||||
|
||||
@router.post("/command/dispatch")
|
||||
@audited_write(caller="user")
|
||||
async def dispatch_openclaw_command(
|
||||
request: OpenClawCommandDispatchRequest,
|
||||
current_user: User = Depends(require_manager_user),
|
||||
|
|
@ -246,6 +259,7 @@ async def dispatch_openclaw_command(
|
|||
|
||||
|
||||
@router.post("/signal-sources/onboard")
|
||||
@audited_write(caller="user")
|
||||
async def onboard_signal_source(
|
||||
request: SignalSourceOnboardRequest,
|
||||
current_user: User = Depends(require_manager_user),
|
||||
|
|
@ -341,6 +355,34 @@ async def list_project_signal_sources(
|
|||
)
|
||||
return success_response(SignalSourceListResponse(items=items).model_dump())
|
||||
|
||||
|
||||
@router.post("/signal-sources/repair-dirty")
|
||||
@audited_write(caller="system")
|
||||
async def repair_dirty_signal_sources(
|
||||
request: SignalSourceRepairRequest,
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
rows = await SignalSourceRepairService(db).repair_registries(
|
||||
tenant_id=effective_tenant_id,
|
||||
project_id=int(request.project_id),
|
||||
registry_ids=[int(item) for item in request.registry_ids],
|
||||
sender=str(request.sender or "PMFlow修复器").strip()[:64] or "PMFlow修复器",
|
||||
)
|
||||
except SignalSourceRepairTimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except SignalSourceRepairMissingSignalError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return success_response(
|
||||
{
|
||||
"project_id": int(request.project_id),
|
||||
"items": rows,
|
||||
}
|
||||
)
|
||||
|
||||
@router.post("/project-from-text", response_model=ProjectFromTextResponse)
|
||||
async def create_project_from_text(
|
||||
request: ProjectFromTextRequest,
|
||||
|
|
@ -384,6 +426,7 @@ async def create_project_from_text(
|
|||
# ==================== Signal Capture Endpoints ====================
|
||||
|
||||
@router.post("/signal/capture-feishu")
|
||||
@audited_write(caller="ai")
|
||||
async def capture_feishu_signal(
|
||||
request: FeishuCaptureRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
"""审计日志查询 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_optional_current_user
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..services.audit_query_service import AuditQueryService
|
||||
from ..services.auth import has_any_role
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["Audit"])
|
||||
|
||||
|
||||
def _effective_tenant_id(current_user: User, header_tenant_id: int) -> int:
|
||||
try:
|
||||
return int(getattr(current_user, "tenant_id") or header_tenant_id or 1)
|
||||
except Exception:
|
||||
return int(header_tenant_id or 1)
|
||||
|
||||
|
||||
def _user_role(user: User) -> str:
|
||||
role = getattr(user, "role", "")
|
||||
return str(getattr(role, "value", role) or "").strip()
|
||||
|
||||
|
||||
@router.get("/operations")
|
||||
async def list_audit_operations(
|
||||
tenant_id: Optional[int] = Query(default=None, ge=1),
|
||||
entity_type: Optional[str] = Query(default=None),
|
||||
start_time: Optional[datetime] = Query(default=None),
|
||||
end_time: Optional[datetime] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
current_user: Optional[User] = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
header_tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
|
||||
if current_user is None:
|
||||
effective_tenant_id = int(header_tenant_id or 1)
|
||||
else:
|
||||
effective_tenant_id = _effective_tenant_id(current_user, header_tenant_id)
|
||||
if tenant_id is not None and int(tenant_id) != effective_tenant_id:
|
||||
raise HTTPException(status_code=403, detail="cross-tenant query is forbidden")
|
||||
scoped_tenant_id = int(tenant_id or effective_tenant_id)
|
||||
|
||||
items = await AuditQueryService(db).list_operations(
|
||||
tenant_id=scoped_tenant_id,
|
||||
entity_type=entity_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
)
|
||||
return success_response(
|
||||
{
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
"tenant_id": scoped_tenant_id,
|
||||
}
|
||||
)
|
||||
|
|
@ -6,7 +6,7 @@ import json
|
|||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -33,7 +33,13 @@ from ..models import (
|
|||
)
|
||||
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||
from ..services.context_cache import context_cache_service
|
||||
from ..utils.project_members import ensure_project_default_members
|
||||
from ..services.operation_log_service import log_operation
|
||||
from ..services.project_member_service import (
|
||||
ProjectMemberAssignmentError,
|
||||
notify_member_assignment_failure,
|
||||
replace_project_members,
|
||||
resolve_project_member_ids,
|
||||
)
|
||||
from ..utils.sequence import ensure_project_id_sequence
|
||||
from ..utils.title import compact_title
|
||||
|
||||
|
|
@ -60,6 +66,7 @@ class ProjectCreateRequest(BaseModel):
|
|||
status: str = "active"
|
||||
domain_id: Optional[int] = None
|
||||
assigned_role: Optional[str] = None
|
||||
member_user_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectUpdateRequest(BaseModel):
|
||||
|
|
@ -75,6 +82,10 @@ class LinkSignalRequest(BaseModel):
|
|||
relevance_score: Optional[float] = 1.0
|
||||
|
||||
|
||||
class ProjectMembersAssignRequest(BaseModel):
|
||||
member_user_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _serialize_project_row(project: Project) -> dict:
|
||||
return {
|
||||
"id": str(project.id),
|
||||
|
|
@ -151,6 +162,23 @@ async def _build_project_summary(
|
|||
return base
|
||||
|
||||
|
||||
async def _fetch_project_member_user_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
) -> list[int]:
|
||||
rows = await db.execute(
|
||||
select(UserProject.user_id).where(
|
||||
and_(
|
||||
UserProject.tenant_id == int(tenant_id),
|
||||
UserProject.project_id == int(project_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None)
|
||||
|
||||
|
||||
async def _get_project_or_404(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
|
@ -364,6 +392,28 @@ async def create_project(
|
|||
title = compact_title(body.title, max_len=24)
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title required")
|
||||
try:
|
||||
member_user_ids = await resolve_project_member_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
owner_id=int(current_user.id),
|
||||
assigned_role=body.assigned_role,
|
||||
requested_member_user_ids=body.member_user_ids,
|
||||
require_requested_members=False,
|
||||
include_owner=True,
|
||||
)
|
||||
except ProjectMemberAssignmentError as exc:
|
||||
await notify_member_assignment_failure(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
actor_user_id=int(current_user.id),
|
||||
project_title=title,
|
||||
reason=str(exc),
|
||||
interface_path="/projects/",
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
await ensure_project_id_sequence(db)
|
||||
project = Project(
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -377,21 +427,111 @@ async def create_project(
|
|||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
try:
|
||||
await db.flush()
|
||||
await ensure_project_default_members(
|
||||
await replace_project_members(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project.id),
|
||||
owner_id=int(current_user.id),
|
||||
assigned_role=project.assigned_role,
|
||||
member_user_ids=member_user_ids,
|
||||
)
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=int(current_user.id),
|
||||
project_id=int(project.id),
|
||||
entity_type="project",
|
||||
entity_id=str(project.id),
|
||||
action="project.create",
|
||||
before_data=None,
|
||||
after_data={
|
||||
"title": project.title,
|
||||
"status": project.status,
|
||||
"member_user_ids": member_user_ids,
|
||||
},
|
||||
interface_path="/projects/",
|
||||
caller="user",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(project)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
@router.put("/{project_id}/members")
|
||||
async def assign_project_members(
|
||||
project_id: int,
|
||||
body: ProjectMembersAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
before_member_user_ids = await _fetch_project_member_user_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project.id),
|
||||
)
|
||||
|
||||
try:
|
||||
member_user_ids = await resolve_project_member_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
owner_id=None,
|
||||
assigned_role=project.assigned_role,
|
||||
requested_member_user_ids=body.member_user_ids,
|
||||
require_requested_members=True,
|
||||
include_owner=False,
|
||||
)
|
||||
await replace_project_members(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project.id),
|
||||
member_user_ids=member_user_ids,
|
||||
)
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=int(current_user.id),
|
||||
project_id=int(project.id),
|
||||
entity_type="project",
|
||||
entity_id=str(project.id),
|
||||
action="project.members.assign",
|
||||
before_data={"member_user_ids": before_member_user_ids},
|
||||
after_data={"member_user_ids": member_user_ids},
|
||||
interface_path=f"/projects/{project_id}/members",
|
||||
caller="user",
|
||||
)
|
||||
await db.commit()
|
||||
except ProjectMemberAssignmentError as exc:
|
||||
await db.rollback()
|
||||
await notify_member_assignment_failure(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
actor_user_id=int(current_user.id),
|
||||
project_id=int(project.id),
|
||||
project_title=project.title or project.name,
|
||||
reason=str(exc),
|
||||
interface_path=f"/projects/{project_id}/members",
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
|
||||
return {
|
||||
"project_id": str(project.id),
|
||||
"member_user_ids": [str(user_id) for user_id in member_user_ids],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from .recipes import router as recipes_router
|
|||
from .signal_sources import router as signal_sources_router
|
||||
from .operation_catalog import router as operation_catalog_router
|
||||
from .ai_pmo import router as ai_pmo_router
|
||||
from .audit import router as audit_router
|
||||
|
||||
# 创建主 API 路由器
|
||||
api_router = APIRouter()
|
||||
|
|
@ -67,3 +68,4 @@ api_router.include_router(recipes_router)
|
|||
api_router.include_router(signal_sources_router)
|
||||
api_router.include_router(operation_catalog_router)
|
||||
api_router.include_router(ai_pmo_router)
|
||||
api_router.include_router(audit_router)
|
||||
|
|
|
|||
|
|
@ -2,20 +2,46 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from .core.config import settings
|
||||
from .core.logging import get_logger
|
||||
from .models import OperationLog
|
||||
from .utils.audited_write import get_audit_context
|
||||
|
||||
DATABASE_URL = settings.database_url
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AUDIT_ENFORCE_KEY = "_pmflow_audit_enforce"
|
||||
AUDIT_WRITTEN_KEY = "_pmflow_audit_written"
|
||||
AUDIT_BUSINESS_WRITE_KEY = "_pmflow_business_write"
|
||||
AUDIT_METHOD_KEY = "_pmflow_request_method"
|
||||
AUDIT_PATH_KEY = "_pmflow_interface_path"
|
||||
AUDIT_TENANT_KEY = "_pmflow_tenant_id"
|
||||
|
||||
DDL_PREFIXES = (
|
||||
"create ",
|
||||
"alter ",
|
||||
"drop ",
|
||||
"truncate ",
|
||||
"rename ",
|
||||
"comment ",
|
||||
"grant ",
|
||||
"revoke ",
|
||||
)
|
||||
|
||||
|
||||
def _is_sqlite() -> bool:
|
||||
|
|
@ -61,6 +87,126 @@ if _is_sqlite():
|
|||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _strip_sql_leading_comments(statement: str) -> str:
|
||||
text = str(statement or "")
|
||||
while True:
|
||||
stripped = text.lstrip()
|
||||
if stripped.startswith("/*") and "*/" in stripped:
|
||||
text = stripped.split("*/", 1)[1]
|
||||
continue
|
||||
if stripped.startswith("--") and "\n" in stripped:
|
||||
text = stripped.split("\n", 1)[1]
|
||||
continue
|
||||
return stripped
|
||||
|
||||
|
||||
def _is_ddl_statement(statement: str) -> bool:
|
||||
normalized = _strip_sql_leading_comments(statement).lower()
|
||||
return any(normalized.startswith(prefix) for prefix in DDL_PREFIXES)
|
||||
|
||||
|
||||
def _is_migration_stack() -> bool:
|
||||
for frame in traceback.extract_stack(limit=50):
|
||||
filename = str(frame.filename or "").replace("\\", "/").lower()
|
||||
if "/alembic/" in filename:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@event.listens_for(engine.sync_engine, "before_cursor_execute", retval=True)
|
||||
def _block_runtime_ddl(_conn, _cursor, statement, parameters, _context, _executemany): # pragma: no cover
|
||||
if os.getenv("PMFLOW_ALLOW_RUNTIME_DDL", "0").lower() in {"1", "true", "yes"}:
|
||||
return statement, parameters
|
||||
if not _is_ddl_statement(str(statement or "")):
|
||||
return statement, parameters
|
||||
if _is_migration_stack():
|
||||
return statement, parameters
|
||||
context = get_audit_context()
|
||||
logger.error(
|
||||
"runtime_ddl_blocked",
|
||||
trace_id=context.trace_id,
|
||||
interface_path=context.interface_path,
|
||||
statement_preview=str(statement or "")[:200],
|
||||
)
|
||||
raise RuntimeError("运行期禁止 DDL,请使用 Alembic migration。")
|
||||
|
||||
|
||||
def _is_mutation_method(method: str) -> bool:
|
||||
return str(method or "").upper() in {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _is_business_change(session: Session, instance: object) -> bool:
|
||||
if isinstance(instance, OperationLog):
|
||||
session.info[AUDIT_WRITTEN_KEY] = True
|
||||
return False
|
||||
if instance in session.dirty and not session.is_modified(instance, include_collections=False):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _collect_business_write_flag(session: Session) -> None:
|
||||
if not session.info.get(AUDIT_ENFORCE_KEY):
|
||||
return
|
||||
if session.info.get(AUDIT_BUSINESS_WRITE_KEY):
|
||||
return
|
||||
changed = set(session.new).union(session.dirty).union(session.deleted)
|
||||
if any(_is_business_change(session, row) for row in changed):
|
||||
session.info[AUDIT_BUSINESS_WRITE_KEY] = True
|
||||
|
||||
|
||||
def _build_auto_audit_log(session: Session) -> OperationLog:
|
||||
context = get_audit_context()
|
||||
tenant_id = int(context.tenant_id or session.info.get(AUDIT_TENANT_KEY) or 1)
|
||||
user_id = int(context.user_id or os.getenv("PMFLOW_AUDIT_SYSTEM_USER_ID", "1"))
|
||||
path = str(context.interface_path or session.info.get(AUDIT_PATH_KEY) or "unknown")[:255]
|
||||
method = str(context.method or session.info.get(AUDIT_METHOD_KEY) or "UNKNOWN").upper()[:16]
|
||||
trace_id = str(context.trace_id or "").strip()[:64] or None
|
||||
caller = str(context.caller or "system").strip().lower()[:16] or "system"
|
||||
entity_id = trace_id or f"{path}:{method.lower()}"
|
||||
return OperationLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
project_id=None,
|
||||
entity_type="interface_write",
|
||||
entity_id=entity_id[:128],
|
||||
action=f"auto:{method.lower()}",
|
||||
before_data=None,
|
||||
after_data={
|
||||
"method": method,
|
||||
"path": path,
|
||||
"trace_id": trace_id,
|
||||
},
|
||||
remark="auto generated audit log for write transaction",
|
||||
trace_id=trace_id,
|
||||
interface_path=path,
|
||||
caller=caller,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _mark_session_business_write(session: Session, _flush_context, _instances) -> None: # pragma: no cover
|
||||
_collect_business_write_flag(session)
|
||||
|
||||
|
||||
@event.listens_for(Session, "before_commit")
|
||||
def _ensure_request_audit_log(session: Session) -> None: # pragma: no cover
|
||||
if not session.info.get(AUDIT_ENFORCE_KEY):
|
||||
return
|
||||
if not session.info.get(AUDIT_BUSINESS_WRITE_KEY):
|
||||
return
|
||||
if session.info.get(AUDIT_WRITTEN_KEY):
|
||||
return
|
||||
session.add(_build_auto_audit_log(session))
|
||||
session.info[AUDIT_WRITTEN_KEY] = True
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_commit")
|
||||
@event.listens_for(Session, "after_rollback")
|
||||
def _clear_audit_flags(session: Session) -> None: # pragma: no cover
|
||||
session.info.pop(AUDIT_BUSINESS_WRITE_KEY, None)
|
||||
session.info.pop(AUDIT_WRITTEN_KEY, None)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
|
|
@ -69,7 +215,22 @@ AsyncSessionLocal = async_sessionmaker(
|
|||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
def _resolve_tenant_from_request(request: Request) -> int:
|
||||
raw = str(request.headers.get("X-Tenant-ID") or "").strip()
|
||||
if not raw:
|
||||
return 1
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI 依赖:提供数据库会话。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
method = str(request.method or "").upper()
|
||||
session.info[AUDIT_ENFORCE_KEY] = _is_mutation_method(method)
|
||||
session.info[AUDIT_METHOD_KEY] = method[:16]
|
||||
session.info[AUDIT_PATH_KEY] = str(request.url.path or "")[:255] or "/"
|
||||
session.info[AUDIT_TENANT_KEY] = _resolve_tenant_from_request(request)
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ from ..database import get_db
|
|||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User, UserStatus # noqa: E402
|
||||
from ..services.auth import AuthService, has_any_role
|
||||
from ..utils.audited_write import update_audit_context
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="auth/login", auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
|
|
@ -41,6 +43,45 @@ async def get_current_user(
|
|||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
update_audit_context(
|
||||
caller="user",
|
||||
user_id=int(user.id),
|
||||
tenant_id=int(getattr(user, "tenant_id", 1) or 1),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
token: str | None = Depends(oauth2_scheme_optional),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
"""可选认证用户:无 token 时返回 None,有 token 时按 get_current_user 逻辑校验。"""
|
||||
if not token:
|
||||
return None
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
username = AuthService.verify_token(token)
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
update_audit_context(
|
||||
caller="user",
|
||||
user_id=int(user.id),
|
||||
tenant_id=int(getattr(user, "tenant_id", 1) or 1),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -82,7 +123,10 @@ async def get_current_tenant_id(
|
|||
try:
|
||||
tenant_id = int(getattr(current_user, "tenant_id", 0) or 0)
|
||||
if tenant_id > 0:
|
||||
update_audit_context(tenant_id=tenant_id)
|
||||
return tenant_id
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return int(header_tenant_id or 1)
|
||||
fallback = int(header_tenant_id or 1)
|
||||
update_audit_context(tenant_id=fallback)
|
||||
return fallback
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from typing import Annotated
|
|||
|
||||
from fastapi import Header
|
||||
|
||||
from ..utils.audited_write import update_audit_context
|
||||
|
||||
|
||||
async def get_tenant_id(
|
||||
x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None,
|
||||
|
|
@ -18,7 +20,11 @@ async def get_tenant_id(
|
|||
"""
|
||||
if x_tenant_id:
|
||||
try:
|
||||
return int(x_tenant_id)
|
||||
resolved = int(x_tenant_id)
|
||||
update_audit_context(tenant_id=resolved)
|
||||
return resolved
|
||||
except ValueError:
|
||||
update_audit_context(tenant_id=1)
|
||||
return 1
|
||||
update_audit_context(tenant_id=1)
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ from .core.config import settings
|
|||
from .core.logging import configure_logging, get_logger
|
||||
from .core.response import success_response
|
||||
from .database import AsyncSessionLocal, engine
|
||||
from .models import Base, Project, WorkItem
|
||||
from .middleware import AuditContextMiddleware
|
||||
from .models import Project, WorkItem
|
||||
from .services.scheduler_center import WorkloadRecalcScheduler
|
||||
from .services.scheduler_center import KnowledgeUrlRefreshScheduler
|
||||
from .services.scheduler_center import KnowledgeGraphIncrementalScheduler
|
||||
|
|
@ -520,17 +521,20 @@ async def _warmup_world_models() -> None:
|
|||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用生命周期:初始化表与系统技能标签。"""
|
||||
auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "true").lower() in {
|
||||
auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
if auto_create_tables:
|
||||
logger.error(
|
||||
"runtime_ddl_blocked",
|
||||
auto_create_tables=auto_create_tables,
|
||||
reason="runtime ddl is forbidden, run alembic migration instead",
|
||||
)
|
||||
raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_check_database_dialect_guard)
|
||||
if auto_create_tables:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(_sqlite_backfill_columns)
|
||||
await conn.run_sync(_ensure_feishu_integrity_guards)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
|
|
@ -591,6 +595,8 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(AuditContextMiddleware)
|
||||
|
||||
origins = [item.strip() for item in settings.cors_origins.split(",") if item.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
"""应用中间件。"""
|
||||
|
||||
from .audit_context import AuditContextMiddleware
|
||||
|
||||
__all__ = ["AuditContextMiddleware"]
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"""请求审计上下文中间件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from ..utils.audited_write import AuditRequestContext, normalize_caller, reset_audit_context, set_audit_context
|
||||
|
||||
|
||||
class AuditContextMiddleware(BaseHTTPMiddleware):
|
||||
"""为每个请求注入 trace/caller/path 审计上下文。"""
|
||||
|
||||
@staticmethod
|
||||
def _infer_caller(request: Request) -> str:
|
||||
caller_header = request.headers.get("X-Caller")
|
||||
if caller_header:
|
||||
return normalize_caller(caller_header)
|
||||
path = str(request.url.path or "")
|
||||
if path.startswith(("/webhooks", "/feishu", "/wecom")):
|
||||
return "ai"
|
||||
if request.headers.get("Authorization"):
|
||||
return "user"
|
||||
return "system"
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
trace_id = str(request.headers.get("X-Trace-Id") or f"req-{uuid4().hex[:24]}")[:64]
|
||||
context = AuditRequestContext(
|
||||
trace_id=trace_id,
|
||||
interface_path=str(request.url.path or "")[:255] or "/",
|
||||
caller=self._infer_caller(request),
|
||||
method=str(request.method or "").upper()[:16],
|
||||
)
|
||||
token = set_audit_context(context)
|
||||
request.state.trace_id = trace_id
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
reset_audit_context(token)
|
||||
response.headers["X-Trace-Id"] = trace_id
|
||||
return response
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""操作日志模型(全链路可追溯)"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Integer, ForeignKey, Text, VARCHAR
|
||||
|
|
@ -7,6 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||
|
||||
from .base import Base
|
||||
from .types import JSONB
|
||||
from ..utils.audited_write import format_beijing_datetime
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
|
|
@ -25,3 +27,10 @@ class OperationLog(Base):
|
|||
before_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
after_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||
interface_path: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||
caller: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="system", index=True)
|
||||
|
||||
@property
|
||||
def created_at_beijing(self) -> Optional[str]:
|
||||
return format_beijing_datetime(self.created_at if isinstance(self.created_at, datetime) else None)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class UserWorkload(BaseNoCreatedAt):
|
|||
DECIMAL(5, 2), nullable=False, default=40.0
|
||||
)
|
||||
workload_ratio: Mapped[float] = mapped_column(
|
||||
DECIMAL(4, 2), nullable=False, default=0.0
|
||||
DECIMAL(10, 2), nullable=False, default=0.0
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="workload_rel")
|
||||
|
|
|
|||
|
|
@ -203,7 +203,12 @@ SHORT_CONFIRM_HINTS = (
|
|||
"可以",
|
||||
"没问题",
|
||||
)
|
||||
INTENT_LLM_TIMEOUT_SECONDS = 8.0
|
||||
INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0
|
||||
INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0
|
||||
INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0
|
||||
INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0
|
||||
INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72
|
||||
INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0
|
||||
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
|
||||
INTENT_CANDIDATES = (
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
|
|
@ -238,6 +243,83 @@ def _env_bool(name: str, default: bool = False) -> bool:
|
|||
return str(raw).strip().lower() in BOOL_TRUE_VALUES
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return float(default)
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def _intent_llm_timeout_seconds(*, function_name: str) -> float:
|
||||
normalized = str(function_name or "").strip().lower()
|
||||
if normalized == "classify_intent_complex":
|
||||
default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS
|
||||
configured = _env_float(
|
||||
"AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS",
|
||||
_env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout),
|
||||
)
|
||||
elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower():
|
||||
default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS
|
||||
configured = _env_float(
|
||||
"AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS",
|
||||
_env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout),
|
||||
)
|
||||
else:
|
||||
default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS
|
||||
configured = _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout)
|
||||
return min(max(float(configured), 12.0), 120.0)
|
||||
|
||||
|
||||
def _intent_llm_total_budget_seconds() -> float:
|
||||
configured = _env_float("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS)
|
||||
return min(max(float(configured), 20.0), 120.0)
|
||||
|
||||
|
||||
def _intent_llm_stage2_confidence_threshold() -> float:
|
||||
configured = _env_float("AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD", INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT)
|
||||
return min(max(float(configured), 0.40), 0.95)
|
||||
|
||||
|
||||
def _intent_llm_stage2_min_remaining_seconds() -> float:
|
||||
configured = _env_float(
|
||||
"AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS",
|
||||
INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS,
|
||||
)
|
||||
return min(max(float(configured), 8.0), 30.0)
|
||||
|
||||
|
||||
def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool:
|
||||
if str(correction_action or "").strip().lower() != "none":
|
||||
return True
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS)
|
||||
|
||||
|
||||
def _should_escalate_to_high_intent_route(
|
||||
*,
|
||||
route_is_complex: bool,
|
||||
low_intent: Optional["CanonicalIntent"],
|
||||
rule_intent: Optional["CanonicalIntent"],
|
||||
) -> tuple[bool, list[str]]:
|
||||
reasons: list[str] = []
|
||||
if route_is_complex:
|
||||
reasons.append("route_complex")
|
||||
if low_intent is None:
|
||||
reasons.append("stage1_empty")
|
||||
else:
|
||||
low_confidence = float(low_intent.confidence or 0.0)
|
||||
if low_confidence < _intent_llm_stage2_confidence_threshold():
|
||||
reasons.append("stage1_low_confidence")
|
||||
if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""):
|
||||
reasons.append("stage1_rule_conflict")
|
||||
return bool(reasons), reasons
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
|
|
@ -2155,6 +2237,7 @@ class AIPMOKernelService:
|
|||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
world_model: dict[str, Any],
|
||||
rule_intent: Optional[CanonicalIntent] = None,
|
||||
) -> Optional[CanonicalIntent]:
|
||||
project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
|
||||
project_names = [
|
||||
|
|
@ -2163,6 +2246,10 @@ class AIPMOKernelService:
|
|||
]
|
||||
route_decision = select_intent_route(message_text, project_names=project_names)
|
||||
correction_decision = kernel_resolve_correction(message_text)
|
||||
forced_low_tier = _should_force_low_tier_intent_route(
|
||||
message_text=message_text,
|
||||
correction_action=correction_decision.action,
|
||||
)
|
||||
prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
|
||||
message_text=str(message_text or "")[:1200],
|
||||
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
|
||||
|
|
@ -2170,31 +2257,64 @@ class AIPMOKernelService:
|
|||
world_model_project_id=_safe_int(world_model.get("resolved_project_id")),
|
||||
candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
|
||||
).strip()
|
||||
total_budget_seconds = _intent_llm_total_budget_seconds()
|
||||
stage_started_at = time.perf_counter()
|
||||
|
||||
async def _invoke_intent_stage(
|
||||
*,
|
||||
function_name: str,
|
||||
route_tier: str,
|
||||
route_reasons: list[str],
|
||||
stage_label: str,
|
||||
timeout_cap_seconds: Optional[float] = None,
|
||||
) -> Optional[CanonicalIntent]:
|
||||
llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name)
|
||||
if timeout_cap_seconds is not None:
|
||||
llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0))
|
||||
if llm_timeout_seconds <= 0.0:
|
||||
return None
|
||||
logger.info(
|
||||
"ai_pmo_intent_llm_route",
|
||||
tenant_id=self.tenant_id,
|
||||
stage=stage_label,
|
||||
function_name=function_name,
|
||||
route_tier=route_tier,
|
||||
timeout_seconds=llm_timeout_seconds,
|
||||
forced_low_tier=forced_low_tier,
|
||||
correction_action=correction_decision.action,
|
||||
correction_keywords=correction_decision.matched_keywords,
|
||||
route_reasons=route_reasons,
|
||||
total_budget_seconds=total_budget_seconds,
|
||||
)
|
||||
try:
|
||||
extraction = await asyncio.wait_for(
|
||||
llm_service.generate_structured_object(
|
||||
prompt,
|
||||
db=self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
function_name=route_decision.function_name,
|
||||
function_name=function_name,
|
||||
use_prompt_profile=False,
|
||||
temperature=0.0,
|
||||
max_tokens=260,
|
||||
response_mode="fast",
|
||||
),
|
||||
timeout=INTENT_LLM_TIMEOUT_SECONDS,
|
||||
timeout=llm_timeout_seconds,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"ai_pmo_intent_llm_timeout",
|
||||
tenant_id=self.tenant_id,
|
||||
timeout_seconds=INTENT_LLM_TIMEOUT_SECONDS,
|
||||
stage=stage_label,
|
||||
function_name=function_name,
|
||||
timeout_seconds=llm_timeout_seconds,
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ai_pmo_intent_llm_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
stage=stage_label,
|
||||
function_name=function_name,
|
||||
error=str(exc),
|
||||
)
|
||||
return None
|
||||
|
|
@ -2204,6 +2324,8 @@ class AIPMOKernelService:
|
|||
logger.warning(
|
||||
"ai_pmo_intent_llm_empty_payload",
|
||||
tenant_id=self.tenant_id,
|
||||
stage=stage_label,
|
||||
function_name=function_name,
|
||||
error_code=str(getattr(extraction, "error_code", "") or ""),
|
||||
provider=str(getattr(extraction, "provider", "") or ""),
|
||||
model=str(getattr(extraction, "model", "") or ""),
|
||||
|
|
@ -2223,13 +2345,13 @@ class AIPMOKernelService:
|
|||
decision_trace = build_decision_trace(
|
||||
intent=intent_name,
|
||||
confidence=confidence,
|
||||
route_tier=route_decision.tier,
|
||||
route_function=route_decision.function_name,
|
||||
route_tier=route_tier,
|
||||
route_function=function_name,
|
||||
gate_action="unresolved",
|
||||
gate_code="intent_only",
|
||||
correction_action=correction_decision.action,
|
||||
correction_keywords=correction_decision.matched_keywords,
|
||||
complexity_reasons=route_decision.reasons,
|
||||
complexity_reasons=route_reasons,
|
||||
)
|
||||
return self._build_canonical_intent(
|
||||
intent_name=intent_name,
|
||||
|
|
@ -2238,17 +2360,88 @@ class AIPMOKernelService:
|
|||
confidence=confidence,
|
||||
metadata={
|
||||
"context_source": "llm",
|
||||
"stage": stage_label,
|
||||
"reasoning": reasoning[:300],
|
||||
"llm_function": route_decision.function_name,
|
||||
"route_tier": route_decision.tier,
|
||||
"route_complex": route_decision.is_complex,
|
||||
"route_reasons": route_decision.reasons,
|
||||
"llm_function": function_name,
|
||||
"route_tier": route_tier,
|
||||
"route_complex": route_tier == "high",
|
||||
"route_reasons": route_reasons,
|
||||
"forced_low_tier": forced_low_tier,
|
||||
"timeout_seconds": llm_timeout_seconds,
|
||||
"total_budget_seconds": total_budget_seconds,
|
||||
"correction_action": correction_decision.action,
|
||||
"correction_keywords": correction_decision.matched_keywords,
|
||||
"decision_trace": decision_trace,
|
||||
},
|
||||
)
|
||||
|
||||
low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])]
|
||||
if forced_low_tier:
|
||||
low_stage_reasons.append("correction_forced_low_tier")
|
||||
low_intent = await _invoke_intent_stage(
|
||||
function_name=INTENT_LLM_FUNCTION_NAME,
|
||||
route_tier="low",
|
||||
route_reasons=low_stage_reasons,
|
||||
stage_label="stage1_low",
|
||||
)
|
||||
if forced_low_tier:
|
||||
return low_intent
|
||||
|
||||
should_escalate, escalate_reasons = _should_escalate_to_high_intent_route(
|
||||
route_is_complex=bool(route_decision.is_complex),
|
||||
low_intent=low_intent,
|
||||
rule_intent=rule_intent,
|
||||
)
|
||||
if not should_escalate:
|
||||
return low_intent
|
||||
|
||||
elapsed_seconds = time.perf_counter() - stage_started_at
|
||||
remaining_budget_seconds = total_budget_seconds - elapsed_seconds
|
||||
stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds()
|
||||
if remaining_budget_seconds < stage2_min_remaining_seconds:
|
||||
logger.info(
|
||||
"ai_pmo_intent_stage2_skipped",
|
||||
tenant_id=self.tenant_id,
|
||||
reason="budget_insufficient",
|
||||
remaining_budget_seconds=max(0.0, remaining_budget_seconds),
|
||||
min_remaining_seconds=stage2_min_remaining_seconds,
|
||||
escalate_reasons=escalate_reasons,
|
||||
)
|
||||
if low_intent is not None:
|
||||
return self._copy_intent_with_metadata(
|
||||
low_intent,
|
||||
stage2_skipped=True,
|
||||
stage2_skip_reason="budget_insufficient",
|
||||
stage2_reasons=escalate_reasons,
|
||||
)
|
||||
return None
|
||||
|
||||
high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"]
|
||||
high_intent = await _invoke_intent_stage(
|
||||
function_name="classify_intent_complex",
|
||||
route_tier="high",
|
||||
route_reasons=high_stage_reasons,
|
||||
stage_label="stage2_high",
|
||||
timeout_cap_seconds=remaining_budget_seconds,
|
||||
)
|
||||
if high_intent is not None:
|
||||
if low_intent is not None:
|
||||
return self._copy_intent_with_metadata(
|
||||
high_intent,
|
||||
stage2_promoted=True,
|
||||
stage1_intent=low_intent.name,
|
||||
stage1_confidence=float(low_intent.confidence or 0.0),
|
||||
)
|
||||
return high_intent
|
||||
if low_intent is not None:
|
||||
return self._copy_intent_with_metadata(
|
||||
low_intent,
|
||||
stage2_attempted=True,
|
||||
stage2_failed=True,
|
||||
stage2_reasons=escalate_reasons,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _resolve_intent(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -2275,6 +2468,7 @@ class AIPMOKernelService:
|
|||
message_text=message_text,
|
||||
session=session,
|
||||
world_model=world_model,
|
||||
rule_intent=rule_intent,
|
||||
)
|
||||
if llm_intent is not None:
|
||||
if self._intent_arbitration_enabled():
|
||||
|
|
@ -2286,6 +2480,11 @@ class AIPMOKernelService:
|
|||
else:
|
||||
selected_intent, source = llm_intent, "llm"
|
||||
return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source
|
||||
logger.info(
|
||||
"ai_pmo_intent_rule_fallback",
|
||||
tenant_id=self.tenant_id,
|
||||
reason="intent_llm_empty_or_failed_or_timeout",
|
||||
)
|
||||
|
||||
return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_fallback"
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -25,7 +27,47 @@ from .task_persistence import (
|
|||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return float(default)
|
||||
try:
|
||||
return float(str(raw).strip())
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return int(default)
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
|
||||
PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = max(
|
||||
8.0,
|
||||
_env_float("AI_STEWARD_PROJECT_CREATION_STEP_TIMEOUT_SECONDS", 18.0),
|
||||
)
|
||||
PROJECT_CREATION_STEP_RETRY_COUNT = max(
|
||||
0,
|
||||
_env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1),
|
||||
)
|
||||
PROJECT_CREATION_STEP_FALLBACK_FUNCTIONS = {
|
||||
"project_from_text_analysis": "project_draft_analysis_glm47_fallback",
|
||||
"project_from_text_signal": "project_draft_signal_glm47_fallback",
|
||||
"project_from_text_charter": "project_draft_charter_glm47_fallback",
|
||||
"project_from_text_task_chain": "project_draft_task_chain_glm47_fallback",
|
||||
}
|
||||
PROJECT_CREATION_STUB_MARKERS = (
|
||||
"我暂时无法生成可靠回答",
|
||||
"模型服务异常",
|
||||
"请稍后重试",
|
||||
)
|
||||
|
||||
|
||||
class AIStewardService:
|
||||
|
|
@ -38,6 +80,14 @@ class AIStewardService:
|
|||
@staticmethod
|
||||
def _normalize_project_title_candidate(candidate: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
|
||||
if not cleaned:
|
||||
return ""
|
||||
cleaned = re.sub(
|
||||
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
|
||||
"",
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
).strip(" []【】()()::\"'“”")
|
||||
if not cleaned:
|
||||
return ""
|
||||
normalized = re.sub(r"\s+", "", cleaned).lower()
|
||||
|
|
@ -67,14 +117,20 @@ class AIStewardService:
|
|||
if quoted_match:
|
||||
return cls._normalize_project_title_candidate(quoted_match.group(1))
|
||||
for pattern in (
|
||||
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
|
||||
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
|
||||
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
|
||||
):
|
||||
match = re.search(pattern, cleaned, flags=re.IGNORECASE)
|
||||
if not match:
|
||||
continue
|
||||
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
|
||||
candidate = re.sub(r"^(?:一个|个|新项目|项目)\s*", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(r"^(?:一个|个|新项目|项目|需求)\s*", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(
|
||||
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
|
||||
"",
|
||||
candidate,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip()
|
||||
normalized = cls._normalize_project_title_candidate(candidate)
|
||||
if normalized:
|
||||
|
|
@ -187,6 +243,24 @@ class AIStewardService:
|
|||
"risks": self._build_risk_alert_drafts(analysis=analysis),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_project_creation_stub_response(text: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||||
if not normalized:
|
||||
return True
|
||||
return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS)
|
||||
|
||||
@staticmethod
|
||||
def _project_creation_function_candidates(function_name: str) -> list[str]:
|
||||
base = str(function_name or "").strip()
|
||||
if not base:
|
||||
return []
|
||||
candidates = [base]
|
||||
fallback = str(PROJECT_CREATION_STEP_FALLBACK_FUNCTIONS.get(base) or "").strip()
|
||||
if fallback and fallback != base:
|
||||
candidates.append(fallback)
|
||||
return candidates
|
||||
|
||||
async def _generate_project_creation_text(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -195,22 +269,60 @@ class AIStewardService:
|
|||
function_name: str,
|
||||
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
|
||||
) -> str:
|
||||
candidates = self._project_creation_function_candidates(function_name=function_name)
|
||||
if not candidates:
|
||||
return ""
|
||||
attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1
|
||||
for candidate_function in candidates:
|
||||
for attempt_index in range(attempts_per_function):
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
response = await asyncio.wait_for(
|
||||
self.llm.generate(
|
||||
prompt,
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
function_name=function_name,
|
||||
function_name=candidate_function,
|
||||
response_mode="fast",
|
||||
),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ai_steward_project_creation_step_retry",
|
||||
tenant_id=int(tenant_id),
|
||||
function_name=candidate_function,
|
||||
attempt=attempt_index + 1,
|
||||
elapsed_seconds=round(time.perf_counter() - started_at, 2),
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
|
||||
cleaned_response = str(response or "").strip()
|
||||
elapsed_seconds = round(time.perf_counter() - started_at, 2)
|
||||
if self._looks_like_project_creation_stub_response(cleaned_response):
|
||||
logger.warning(
|
||||
"ai_steward_project_creation_step_stub_retry",
|
||||
tenant_id=int(tenant_id),
|
||||
function_name=candidate_function,
|
||||
attempt=attempt_index + 1,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
"ai_steward_project_creation_step_ok",
|
||||
tenant_id=int(tenant_id),
|
||||
function_name=candidate_function,
|
||||
attempt=attempt_index + 1,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
return cleaned_response
|
||||
|
||||
logger.warning(
|
||||
"ai_steward_project_creation_step_fallback",
|
||||
tenant_id=int(tenant_id),
|
||||
function_name=function_name,
|
||||
error=str(exc),
|
||||
error="all_attempts_exhausted",
|
||||
)
|
||||
return ""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
"""审计日志查询服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import OperationLog
|
||||
from ..utils.audited_write import format_beijing_datetime
|
||||
from ..utils.timezone import CN_TZ
|
||||
|
||||
|
||||
def _to_beijing_naive(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _serialize_log(row: OperationLog) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"tenant_id": int(row.tenant_id),
|
||||
"user_id": int(row.user_id),
|
||||
"project_id": int(row.project_id) if row.project_id is not None else None,
|
||||
"entity_type": str(row.entity_type or ""),
|
||||
"entity_id": str(row.entity_id or ""),
|
||||
"action": str(row.action or ""),
|
||||
"trace_id": str(row.trace_id or ""),
|
||||
"interface_path": str(row.interface_path or ""),
|
||||
"caller": str(row.caller or "system"),
|
||||
"remark": str(row.remark or ""),
|
||||
"before_data": row.before_data if isinstance(row.before_data, dict) else None,
|
||||
"after_data": row.after_data if isinstance(row.after_data, dict) else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"created_at_beijing": row.created_at_beijing or format_beijing_datetime(row.created_at),
|
||||
}
|
||||
|
||||
|
||||
class AuditQueryService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_operations(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
entity_type: Optional[str],
|
||||
start_time: Optional[datetime],
|
||||
end_time: Optional[datetime],
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
filters = [OperationLog.tenant_id == int(tenant_id)]
|
||||
if entity_type:
|
||||
filters.append(OperationLog.entity_type == str(entity_type).strip()[:64])
|
||||
normalized_start = _to_beijing_naive(start_time)
|
||||
normalized_end = _to_beijing_naive(end_time)
|
||||
if normalized_start is not None:
|
||||
filters.append(OperationLog.created_at >= normalized_start)
|
||||
if normalized_end is not None:
|
||||
filters.append(OperationLog.created_at <= normalized_end)
|
||||
|
||||
query = (
|
||||
select(OperationLog)
|
||||
.where(and_(*filters))
|
||||
.order_by(OperationLog.created_at.desc(), OperationLog.id.desc())
|
||||
.limit(max(1, min(int(limit), 100)))
|
||||
)
|
||||
rows = (await self.db.execute(query)).scalars().all()
|
||||
return [_serialize_log(row) for row in rows]
|
||||
|
|
@ -407,7 +407,8 @@ IMPORTANT:
|
|||
)
|
||||
|
||||
# 对于修改的文件
|
||||
old_content = file_contexts.get(file.path)?.content or ""
|
||||
old_file_info = file_contexts.get(file.path)
|
||||
old_content = old_file_info.content if old_file_info is not None else ""
|
||||
old_lines = old_content.split("\n")
|
||||
new_lines = file.content.split("\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ DEFAULT_LOW_TIER_HINTS = (
|
|||
"openclaw_command_dispatch",
|
||||
"signal_source_ai_worker_execution",
|
||||
"extract_context_insights",
|
||||
"project_draft_",
|
||||
"adaptive_rag_answer_normal",
|
||||
"rag_intent_classification",
|
||||
"classify_intent",
|
||||
|
|
|
|||
|
|
@ -790,6 +790,14 @@ class OpenClawCommanderService:
|
|||
@staticmethod
|
||||
def _normalize_project_title_candidate(candidate: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
|
||||
if not cleaned:
|
||||
return ""
|
||||
cleaned = re.sub(
|
||||
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
|
||||
"",
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
).strip(" []【】()()::\"'“”")
|
||||
if not cleaned:
|
||||
return ""
|
||||
normalized = re.sub(r"\s+", "", cleaned).lower()
|
||||
|
|
@ -820,15 +828,21 @@ class OpenClawCommanderService:
|
|||
if quoted_match:
|
||||
return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1))
|
||||
for pattern in (
|
||||
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
|
||||
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
|
||||
r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)",
|
||||
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
|
||||
):
|
||||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||||
if not match:
|
||||
continue
|
||||
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
|
||||
candidate = re.sub(r"^(?:一个|个|新项目)\s*", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(r"^(?:一个|个|新项目|项目|需求)\s*", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(
|
||||
r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*",
|
||||
"",
|
||||
candidate,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if candidate:
|
||||
return OpenClawCommanderService._normalize_project_title_candidate(candidate)
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Optional
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import OperationLog
|
||||
from ..utils.audited_write import get_audit_context, normalize_caller
|
||||
|
||||
|
||||
async def log_operation(
|
||||
|
|
@ -19,9 +20,16 @@ async def log_operation(
|
|||
before_data: Optional[dict] = None,
|
||||
after_data: Optional[dict] = None,
|
||||
remark: Optional[str] = None,
|
||||
trace_id: Optional[str] = None,
|
||||
interface_path: Optional[str] = None,
|
||||
caller: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录操作日志,失败时不影响主流程。"""
|
||||
try:
|
||||
context = get_audit_context()
|
||||
resolved_trace = str(trace_id or context.trace_id or "").strip()[:64] or None
|
||||
resolved_interface = str(interface_path or context.interface_path or "").strip()[:255] or None
|
||||
resolved_caller = normalize_caller(caller or context.caller or "system")
|
||||
db.add(
|
||||
OperationLog(
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -33,8 +41,12 @@ async def log_operation(
|
|||
before_data=before_data,
|
||||
after_data=after_data,
|
||||
remark=remark,
|
||||
trace_id=resolved_trace,
|
||||
interface_path=resolved_interface,
|
||||
caller=resolved_caller,
|
||||
)
|
||||
)
|
||||
db.sync_session.info["_pmflow_audit_written"] = True
|
||||
except Exception:
|
||||
# 日志失败不阻断主交易
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
"""项目成员分配服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import User, UserProject
|
||||
from .notification_service import NotificationService
|
||||
|
||||
_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+")
|
||||
_ROLE_ALIAS_MAP = {
|
||||
"AI": {"AI"},
|
||||
"AI_ENGINEER": {"AI"},
|
||||
"FRONTEND": {"FE"},
|
||||
"FE": {"FE"},
|
||||
"BACKEND": {"BE"},
|
||||
"BE": {"BE"},
|
||||
"BOSS": {"BOSS"},
|
||||
"ADMIN": {"ADMIN"},
|
||||
}
|
||||
|
||||
|
||||
class ProjectMemberAssignmentError(ValueError):
|
||||
"""项目成员分配失败。"""
|
||||
|
||||
def __init__(self, message: str, *, invalid_user_ids: Sequence[int] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.invalid_user_ids = [int(uid) for uid in (invalid_user_ids or [])]
|
||||
|
||||
|
||||
def _normalize_user_ids(user_ids: Iterable[int] | None) -> list[int]:
|
||||
normalized: set[int] = set()
|
||||
for raw in user_ids or []:
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
normalized.add(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(normalized)
|
||||
|
||||
|
||||
def _parse_role_tokens(raw: str | None) -> set[str]:
|
||||
tokens = {
|
||||
token.strip().upper()
|
||||
for token in _ROLE_SPLIT_RE.split(str(raw or "").strip())
|
||||
if token.strip()
|
||||
}
|
||||
normalized: set[str] = set()
|
||||
for token in tokens:
|
||||
normalized.update(_ROLE_ALIAS_MAP.get(token, {token}))
|
||||
return normalized
|
||||
|
||||
|
||||
def _user_matches_project_role(user_role: str | None, expected_roles: set[str]) -> bool:
|
||||
if not expected_roles:
|
||||
return False
|
||||
return bool(_parse_role_tokens(user_role).intersection(expected_roles))
|
||||
|
||||
|
||||
async def _load_active_user_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
candidate_user_ids: Sequence[int] | None = None,
|
||||
) -> set[int]:
|
||||
query = select(User.id).where(
|
||||
and_(
|
||||
User.tenant_id == int(tenant_id),
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
normalized_ids = _normalize_user_ids(candidate_user_ids)
|
||||
if normalized_ids:
|
||||
query = query.where(User.id.in_(normalized_ids))
|
||||
rows = await db.execute(query)
|
||||
return {int(uid) for uid in rows.scalars().all() if uid is not None}
|
||||
|
||||
|
||||
async def _load_role_member_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
assigned_role: str | None,
|
||||
) -> set[int]:
|
||||
expected_roles = _parse_role_tokens(assigned_role)
|
||||
if not expected_roles:
|
||||
return set()
|
||||
|
||||
rows = await db.execute(
|
||||
select(User.id, User.role).where(
|
||||
and_(
|
||||
User.tenant_id == int(tenant_id),
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
role_member_ids: set[int] = set()
|
||||
for user_id, user_role in rows.all():
|
||||
if _user_matches_project_role(user_role, expected_roles):
|
||||
role_member_ids.add(int(user_id))
|
||||
return role_member_ids
|
||||
|
||||
|
||||
async def resolve_project_member_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
owner_id: int | None,
|
||||
assigned_role: str | None,
|
||||
requested_member_user_ids: Sequence[int] | None = None,
|
||||
require_requested_members: bool = False,
|
||||
include_owner: bool = True,
|
||||
) -> list[int]:
|
||||
"""解析项目成员列表,支持显式成员、角色兜底与 owner 兜底。"""
|
||||
member_ids: set[int] = set()
|
||||
requested_ids = _normalize_user_ids(requested_member_user_ids)
|
||||
|
||||
if requested_ids:
|
||||
active_requested = await _load_active_user_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
candidate_user_ids=requested_ids,
|
||||
)
|
||||
invalid_user_ids = sorted(set(requested_ids) - active_requested)
|
||||
if invalid_user_ids:
|
||||
raise ProjectMemberAssignmentError(
|
||||
"member_user_ids 包含不存在或非激活用户",
|
||||
invalid_user_ids=invalid_user_ids,
|
||||
)
|
||||
member_ids.update(active_requested)
|
||||
elif require_requested_members:
|
||||
raise ProjectMemberAssignmentError("至少分配一名项目成员")
|
||||
|
||||
if include_owner and owner_id is not None:
|
||||
owner_ids = await _load_active_user_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
candidate_user_ids=[int(owner_id)],
|
||||
)
|
||||
member_ids.update(owner_ids)
|
||||
|
||||
if not member_ids and not require_requested_members:
|
||||
member_ids.update(
|
||||
await _load_role_member_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
assigned_role=assigned_role,
|
||||
)
|
||||
)
|
||||
|
||||
if not member_ids and not require_requested_members:
|
||||
active_user_ids = await _load_active_user_ids(db, tenant_id=tenant_id)
|
||||
if active_user_ids:
|
||||
member_ids.add(min(active_user_ids))
|
||||
|
||||
if not member_ids:
|
||||
raise ProjectMemberAssignmentError("当前租户没有可分配成员,已拒绝写入")
|
||||
return sorted(member_ids)
|
||||
|
||||
|
||||
async def replace_project_members(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
member_user_ids: Sequence[int],
|
||||
) -> list[int]:
|
||||
"""全量替换项目成员。"""
|
||||
normalized_ids = _normalize_user_ids(member_user_ids)
|
||||
if not normalized_ids:
|
||||
raise ProjectMemberAssignmentError("至少分配一名项目成员")
|
||||
|
||||
await db.execute(
|
||||
delete(UserProject).where(
|
||||
and_(
|
||||
UserProject.tenant_id == int(tenant_id),
|
||||
UserProject.project_id == int(project_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
for user_id in normalized_ids:
|
||||
db.add(
|
||||
UserProject(
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(user_id),
|
||||
project_id=int(project_id),
|
||||
)
|
||||
)
|
||||
return normalized_ids
|
||||
|
||||
|
||||
async def notify_member_assignment_failure(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
actor_user_id: int | None,
|
||||
reason: str,
|
||||
interface_path: str,
|
||||
project_id: int | None = None,
|
||||
project_title: str | None = None,
|
||||
commit: bool = False,
|
||||
) -> None:
|
||||
"""将成员分配失败事件写入消息台。"""
|
||||
if actor_user_id is None:
|
||||
return
|
||||
|
||||
project_label = f"项目#{int(project_id)}" if project_id is not None else (project_title or "新项目")
|
||||
content = (
|
||||
f"接口:{interface_path}\n"
|
||||
f"项目:{project_label}\n"
|
||||
f"原因:{reason}"
|
||||
)
|
||||
await NotificationService(db).create_notification(
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(actor_user_id),
|
||||
category="system",
|
||||
title="项目成员分配失败",
|
||||
content=content,
|
||||
source="project_member_service",
|
||||
entity_type="project",
|
||||
entity_id=int(project_id) if project_id is not None else None,
|
||||
target_url=f"/projects/{int(project_id)}/board" if project_id is not None else "/projects",
|
||||
kind="error",
|
||||
priority="high",
|
||||
status="failed",
|
||||
blocking_reason=reason[:500],
|
||||
recommended_action="retry",
|
||||
dedupe_key=(
|
||||
f"project-member-failed:{int(tenant_id)}:"
|
||||
f"{int(actor_user_id)}:{interface_path}:{int(project_id or 0)}"
|
||||
)[:255],
|
||||
commit=commit,
|
||||
)
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
"""信号源脏数据修复服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import SignalSourceRegistry, User
|
||||
from ..utils.audited_write import get_audit_context
|
||||
from ..utils.timezone import now_cn_naive
|
||||
from .notification_service import NotificationService
|
||||
from .operation_log_service import log_operation
|
||||
from .signal_capture import ProactiveSignalCaptureService
|
||||
|
||||
|
||||
class SignalSourceRepairError(RuntimeError):
|
||||
"""信号源修复失败。"""
|
||||
|
||||
|
||||
class SignalSourceRepairTimeoutError(SignalSourceRepairError):
|
||||
"""信号源修复超时。"""
|
||||
|
||||
def __init__(self, *, registry_id: int, stage: str, timeout_seconds: int) -> None:
|
||||
super().__init__(
|
||||
f"signal source repair timeout: registry_id={int(registry_id)}, "
|
||||
f"stage={stage}, timeout_seconds={int(timeout_seconds)}"
|
||||
)
|
||||
self.registry_id = int(registry_id)
|
||||
self.stage = str(stage)
|
||||
self.timeout_seconds = int(timeout_seconds)
|
||||
|
||||
|
||||
class SignalSourceRepairMissingSignalError(SignalSourceRepairError):
|
||||
"""修复路径未拿到 signal_id。"""
|
||||
|
||||
def __init__(self, *, registry_id: int, project_id: int, status: str) -> None:
|
||||
super().__init__(
|
||||
f"signal source repair failed: registry_id={int(registry_id)}, "
|
||||
f"project_id={int(project_id)}, capture_status={status or 'unknown'}"
|
||||
)
|
||||
self.registry_id = int(registry_id)
|
||||
self.project_id = int(project_id)
|
||||
self.status = str(status or "")
|
||||
|
||||
|
||||
class SignalSourceRepairService:
|
||||
"""通过 capture-feishu 接口补录信号并回填历史 registry.signal_id。"""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_timeout_seconds() -> int:
|
||||
raw = (
|
||||
os.getenv("SIGNAL_SOURCE_REPAIR_TIMEOUT_SECONDS")
|
||||
or os.getenv("LLM_REQUEST_TIMEOUT")
|
||||
or "60"
|
||||
)
|
||||
try:
|
||||
timeout = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
timeout = 60
|
||||
return max(10, min(timeout, 300))
|
||||
|
||||
@staticmethod
|
||||
def _build_repair_message(*, project_id: int, registry_id: int, source_name: str) -> str:
|
||||
return (
|
||||
f"给项目ID: {project_id} 补录历史接入记录 registry#{registry_id}({source_name}),"
|
||||
"请仅执行入库,不需要二次处理。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_explicit_repair_message(*, project_id: int, row: SignalSourceRegistry) -> str:
|
||||
source_kind = str(row.source_kind or "").strip()[:40]
|
||||
source_name = str(row.source_name or "历史信号源").strip()[:80]
|
||||
source_identifier = str(row.source_identifier or "").strip()[:180]
|
||||
return (
|
||||
f"项目 {int(project_id)} 新增信号源:{source_name}。"
|
||||
f"source_kind={source_kind}; source_identifier={source_identifier}。"
|
||||
"这是历史脏数据补录,请执行 signal_source.onboard 入库并生成消息台记录。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_capture_message_id(*, project_id: int, registry_id: int, stage: str) -> str:
|
||||
phase = "explicit" if str(stage or "").strip().lower() == "explicit" else "base"
|
||||
return f"om_registry_repair_{int(project_id)}_{int(registry_id)}_{phase}"[:120]
|
||||
|
||||
async def _load_registry(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
registry_id: int,
|
||||
) -> Optional[SignalSourceRegistry]:
|
||||
return (
|
||||
await self.db.execute(
|
||||
select(SignalSourceRegistry).where(
|
||||
and_(
|
||||
SignalSourceRegistry.tenant_id == int(tenant_id),
|
||||
SignalSourceRegistry.project_id == int(project_id),
|
||||
SignalSourceRegistry.id == int(registry_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _build_group_chat_id(row: SignalSourceRegistry) -> str:
|
||||
raw = (row.config or {}).get("chat_id") or row.source_identifier or f"repair_registry_{int(row.id)}"
|
||||
return str(raw)[:120]
|
||||
|
||||
@staticmethod
|
||||
def _extract_route_status(capture_result: dict[str, Any]) -> str:
|
||||
rows = capture_result.get("results")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return ""
|
||||
first = rows[0] if isinstance(rows[0], dict) else {}
|
||||
return str(first.get("route_status") or "")
|
||||
|
||||
@classmethod
|
||||
def _extract_signal_id(cls, capture_result: dict[str, Any]) -> Optional[int]:
|
||||
candidates: list[Any] = [capture_result.get("signal_id")]
|
||||
for key in ("signal_source_workflow", "signal_source_orchestration", "ai_execution"):
|
||||
payload = capture_result.get(key)
|
||||
if isinstance(payload, dict):
|
||||
candidates.append(payload.get("signal_id"))
|
||||
rows = capture_result.get("results")
|
||||
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||
candidates.append(rows[0].get("signal_id"))
|
||||
for item in candidates:
|
||||
value = cls._safe_int(item)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_identifier_key(value: Any) -> str:
|
||||
key = re.sub(r"\s+", "", str(value or "").strip().lower())
|
||||
if not key:
|
||||
return ""
|
||||
for prefix in (
|
||||
"feishu:chat:",
|
||||
"wecom:chat:",
|
||||
"feishu_chat_",
|
||||
"wecom_chat_",
|
||||
"feishu:",
|
||||
"wecom:",
|
||||
):
|
||||
if key.startswith(prefix):
|
||||
key = key[len(prefix):]
|
||||
break
|
||||
return key[:180]
|
||||
|
||||
async def _find_reusable_signal_id(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
row: SignalSourceRegistry,
|
||||
) -> Optional[int]:
|
||||
target_key = self._normalize_identifier_key(row.source_identifier)
|
||||
if not target_key:
|
||||
return None
|
||||
candidates = (
|
||||
await self.db.execute(
|
||||
select(SignalSourceRegistry).where(
|
||||
and_(
|
||||
SignalSourceRegistry.tenant_id == int(tenant_id),
|
||||
SignalSourceRegistry.project_id == int(project_id),
|
||||
SignalSourceRegistry.id != int(row.id),
|
||||
SignalSourceRegistry.signal_id.is_not(None),
|
||||
)
|
||||
)
|
||||
.order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc())
|
||||
.limit(50)
|
||||
)
|
||||
).scalars().all()
|
||||
for candidate in candidates:
|
||||
current_key = self._normalize_identifier_key(candidate.source_identifier)
|
||||
if current_key and current_key == target_key:
|
||||
return self._safe_int(candidate.signal_id)
|
||||
for candidate in candidates:
|
||||
current_key = self._normalize_identifier_key(candidate.source_identifier)
|
||||
if current_key and (current_key.endswith(target_key) or target_key.endswith(current_key)):
|
||||
return self._safe_int(candidate.signal_id)
|
||||
return None
|
||||
|
||||
async def _capture_registry(
|
||||
self,
|
||||
*,
|
||||
capture_service: ProactiveSignalCaptureService,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
sender: str,
|
||||
row: SignalSourceRegistry,
|
||||
stage: str,
|
||||
timeout_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
message = (
|
||||
self._build_explicit_repair_message(project_id=project_id, row=row)
|
||||
if stage == "explicit"
|
||||
else self._build_repair_message(
|
||||
project_id=project_id,
|
||||
registry_id=int(row.id),
|
||||
source_name=str(row.source_name or "信号源"),
|
||||
)
|
||||
)
|
||||
coroutine = capture_service.capture_from_feishu(
|
||||
group_chat_id=self._build_group_chat_id(row),
|
||||
message_content=message,
|
||||
sender=sender,
|
||||
tenant_id=int(tenant_id),
|
||||
feishu_message_id=self._build_capture_message_id(
|
||||
project_id=project_id,
|
||||
registry_id=int(row.id),
|
||||
stage=stage,
|
||||
),
|
||||
)
|
||||
try:
|
||||
return await asyncio.wait_for(coroutine, timeout=timeout_seconds)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise SignalSourceRepairTimeoutError(
|
||||
registry_id=int(row.id),
|
||||
stage=stage,
|
||||
timeout_seconds=timeout_seconds,
|
||||
) from exc
|
||||
|
||||
async def _active_user_ids(self, *, tenant_id: int) -> list[int]:
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
select(User.id).where(
|
||||
and_(
|
||||
User.tenant_id == int(tenant_id),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return [int(item) for item in rows if item is not None]
|
||||
|
||||
async def _emit_visibility_records(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
row: SignalSourceRegistry,
|
||||
signal_id: int,
|
||||
capture_status: str,
|
||||
) -> None:
|
||||
dedupe_key = f"signal-source-repair:{int(tenant_id)}:{int(row.id)}:{int(signal_id)}"
|
||||
await NotificationService(self.db).create_notifications(
|
||||
tenant_id=int(tenant_id),
|
||||
user_ids=await self._active_user_ids(tenant_id=tenant_id),
|
||||
category="system",
|
||||
title="AI 已修复历史信号源关联",
|
||||
content=(
|
||||
f"项目ID: {int(project_id)}\n"
|
||||
f"信号源ID: {int(row.id)}\n"
|
||||
f"signal_id: {int(signal_id)}\n"
|
||||
f"status: {capture_status or 'relinked'}"
|
||||
),
|
||||
source="ai_steward_repair",
|
||||
entity_type="signal_source_registry",
|
||||
entity_id=int(row.id),
|
||||
kind="info",
|
||||
priority="medium",
|
||||
status="notified",
|
||||
recommended_action="view_result",
|
||||
dedupe_key=dedupe_key,
|
||||
payload={
|
||||
"project_id": int(project_id),
|
||||
"registry_id": int(row.id),
|
||||
"signal_id": int(signal_id),
|
||||
"capture_status": capture_status or "relinked",
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
context = get_audit_context()
|
||||
await log_operation(
|
||||
self.db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(context.user_id or 1),
|
||||
project_id=int(project_id),
|
||||
entity_type="signal_source_registry",
|
||||
entity_id=str(int(row.id)),
|
||||
action="signal_source_registry.repair_link",
|
||||
after_data={
|
||||
"signal_id": int(signal_id),
|
||||
"capture_status": capture_status or "relinked",
|
||||
},
|
||||
remark="repair dirty signal source registry link",
|
||||
)
|
||||
|
||||
async def _persist_registry_link(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
row: SignalSourceRegistry,
|
||||
signal_id: int,
|
||||
capture_status: str,
|
||||
) -> None:
|
||||
row.signal_id = int(signal_id)
|
||||
row.last_signal_at = now_cn_naive()
|
||||
await self._emit_visibility_records(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
row=row,
|
||||
signal_id=signal_id,
|
||||
capture_status=capture_status,
|
||||
)
|
||||
await self.db.flush()
|
||||
|
||||
def _build_item_result(
|
||||
self,
|
||||
*,
|
||||
row: SignalSourceRegistry,
|
||||
signal_id: Optional[int],
|
||||
capture_status: str,
|
||||
work_item_id: Optional[int],
|
||||
route_status: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"registry_id": int(row.id),
|
||||
"signal_id": signal_id,
|
||||
"capture_status": capture_status,
|
||||
"work_item_id": work_item_id,
|
||||
"route_status": route_status,
|
||||
}
|
||||
|
||||
async def _repair_single_registry(
|
||||
self,
|
||||
*,
|
||||
capture_service: ProactiveSignalCaptureService,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
registry_id: int,
|
||||
sender: str,
|
||||
timeout_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
row = await self._load_registry(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
registry_id=registry_id,
|
||||
)
|
||||
if row is None:
|
||||
return {"registry_id": int(registry_id), "status": "not_found"}
|
||||
if row.signal_id is not None:
|
||||
return self._build_item_result(
|
||||
row=row,
|
||||
signal_id=self._safe_int(row.signal_id),
|
||||
capture_status="already_linked",
|
||||
work_item_id=None,
|
||||
route_status="",
|
||||
)
|
||||
reusable_signal_id = await self._find_reusable_signal_id(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
row=row,
|
||||
)
|
||||
if reusable_signal_id is not None:
|
||||
await self._persist_registry_link(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
row=row,
|
||||
signal_id=int(reusable_signal_id),
|
||||
capture_status="relinked",
|
||||
)
|
||||
return self._build_item_result(
|
||||
row=row,
|
||||
signal_id=int(reusable_signal_id),
|
||||
capture_status="relinked",
|
||||
work_item_id=None,
|
||||
route_status="",
|
||||
)
|
||||
capture_result = await self._capture_registry(
|
||||
capture_service=capture_service,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
sender=sender,
|
||||
row=row,
|
||||
stage="base",
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
signal_id = self._extract_signal_id(capture_result)
|
||||
if signal_id is None:
|
||||
capture_result = await self._capture_registry(
|
||||
capture_service=capture_service,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
sender=sender,
|
||||
row=row,
|
||||
stage="explicit",
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
signal_id = self._extract_signal_id(capture_result)
|
||||
if signal_id is None:
|
||||
signal_id = await self._find_reusable_signal_id(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
row=row,
|
||||
)
|
||||
if signal_id is None:
|
||||
raise SignalSourceRepairMissingSignalError(
|
||||
registry_id=int(row.id),
|
||||
project_id=int(project_id),
|
||||
status=str(capture_result.get("status") or ""),
|
||||
)
|
||||
capture_status = str(capture_result.get("status") or "captured")
|
||||
await self._persist_registry_link(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
row=row,
|
||||
signal_id=int(signal_id),
|
||||
capture_status=capture_status,
|
||||
)
|
||||
return self._build_item_result(
|
||||
row=row,
|
||||
signal_id=int(signal_id),
|
||||
capture_status=capture_status,
|
||||
work_item_id=self._safe_int(capture_result.get("work_item_id")),
|
||||
route_status=self._extract_route_status(capture_result),
|
||||
)
|
||||
|
||||
async def repair_registries(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
registry_ids: list[int],
|
||||
sender: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
capture_service = ProactiveSignalCaptureService(self.db)
|
||||
timeout_seconds = self._resolve_timeout_seconds()
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
for registry_id in registry_ids:
|
||||
item = await self._repair_single_registry(
|
||||
capture_service=capture_service,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
registry_id=int(registry_id),
|
||||
sender=sender,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
results.append(item)
|
||||
await self.db.commit()
|
||||
except Exception:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
return results
|
||||
|
|
@ -197,39 +197,78 @@ class SSTCService:
|
|||
}
|
||||
|
||||
async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]:
|
||||
if not assignee_ids:
|
||||
member_profiles = await self._load_member_workload_profiles(assignee_ids)
|
||||
if not member_profiles:
|
||||
return {
|
||||
"avg_ratio": 0.0,
|
||||
"overloaded_count": 0,
|
||||
"idle_count": 0,
|
||||
"member_count": 0,
|
||||
"members": [],
|
||||
}
|
||||
|
||||
result = await self.db.execute(
|
||||
select(UserWorkload).where(
|
||||
and_(
|
||||
UserWorkload.tenant_id == self.tenant_id,
|
||||
UserWorkload.user_id.in_(assignee_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
workloads = list(result.scalars().all())
|
||||
ratios = []
|
||||
for row in workloads:
|
||||
ratio = float(row.workload_ratio or 0)
|
||||
if ratio > 1.5:
|
||||
ratio = ratio / 100.0
|
||||
ratios.append(ratio)
|
||||
avg_ratio = _avg(ratios)
|
||||
ratios = [float(item["workload_ratio"]) for item in member_profiles]
|
||||
overloaded_count = len([x for x in ratios if x > 1.0])
|
||||
idle_count = len([x for x in ratios if x < 0.3])
|
||||
return {
|
||||
"avg_ratio": round(avg_ratio, 2),
|
||||
"avg_ratio": round(_avg(ratios), 2),
|
||||
"overloaded_count": overloaded_count,
|
||||
"idle_count": idle_count,
|
||||
"member_count": len(assignee_ids),
|
||||
"member_count": len(member_profiles),
|
||||
"members": member_profiles,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_workload_ratio(raw_ratio: float | int | None) -> float:
|
||||
ratio = float(raw_ratio or 0)
|
||||
if ratio > 1.5:
|
||||
ratio = ratio / 100.0
|
||||
return max(0.0, ratio)
|
||||
|
||||
async def _load_member_workload_profiles(self, assignee_ids: list[int]) -> list[dict[str, Any]]:
|
||||
if not assignee_ids:
|
||||
return []
|
||||
|
||||
rows = await self.db.execute(
|
||||
select(User.id, User.name, User.username, User.role, UserWorkload.workload_ratio)
|
||||
.select_from(User)
|
||||
.outerjoin(
|
||||
UserWorkload,
|
||||
and_(
|
||||
UserWorkload.tenant_id == self.tenant_id,
|
||||
UserWorkload.user_id == User.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.tenant_id == self.tenant_id,
|
||||
User.id.in_(assignee_ids),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
.order_by(User.id.asc())
|
||||
)
|
||||
|
||||
members: list[dict[str, Any]] = []
|
||||
for user_id, name, username, role, workload_ratio in rows.all():
|
||||
ratio = self._normalize_workload_ratio(workload_ratio)
|
||||
if ratio > 1.0:
|
||||
status = "overloaded"
|
||||
elif ratio < 0.3:
|
||||
status = "idle"
|
||||
else:
|
||||
status = "normal"
|
||||
members.append(
|
||||
{
|
||||
"user_id": str(int(user_id)),
|
||||
"name": str(name or username or f"用户{int(user_id)}"),
|
||||
"role": str(role or ""),
|
||||
"workload_ratio": round(ratio, 2),
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
return members
|
||||
|
||||
@staticmethod
|
||||
def _task_metrics(tasks: list[Task]) -> dict[str, Any]:
|
||||
total = len(tasks)
|
||||
|
|
@ -272,7 +311,7 @@ class SSTCService:
|
|||
charters_ids = [int(c.id) for c in charters]
|
||||
alignment = await self.get_alignment_stats(charters_ids)
|
||||
metrics = self._task_metrics(tasks)
|
||||
member_ids = await self.get_project_member_ids(project_id)
|
||||
member_ids = await self.get_project_workload_member_ids(project_id)
|
||||
workload = await self.get_workload_health(member_ids)
|
||||
chain_id = str(charters[0].id) if charters else None
|
||||
return {
|
||||
|
|
@ -568,7 +607,7 @@ class SSTCService:
|
|||
)
|
||||
return int(result.scalar() or 0) > 0
|
||||
|
||||
async def get_project_member_ids(self, project_id: int) -> list[int]:
|
||||
async def _load_project_member_id_set(self, project_id: int) -> set[int]:
|
||||
member_result = await self.db.execute(
|
||||
select(UserProject.user_id).where(
|
||||
and_(
|
||||
|
|
@ -577,7 +616,10 @@ class SSTCService:
|
|||
)
|
||||
)
|
||||
)
|
||||
member_ids = {int(x) for x in member_result.scalars().all() if x is not None}
|
||||
return {int(x) for x in member_result.scalars().all() if x is not None}
|
||||
|
||||
async def get_project_workload_member_ids(self, project_id: int) -> list[int]:
|
||||
member_ids = await self._load_project_member_id_set(project_id)
|
||||
project = await self.get_project(project_id)
|
||||
if not member_ids and project:
|
||||
member_ids = set(
|
||||
|
|
@ -590,6 +632,11 @@ class SSTCService:
|
|||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
return sorted(member_ids)
|
||||
|
||||
async def get_project_member_ids(self, project_id: int) -> list[int]:
|
||||
member_ids = set(await self.get_project_workload_member_ids(project_id))
|
||||
project = await self.get_project(project_id)
|
||||
if project and project.owner_id:
|
||||
member_ids.add(int(project.owner_id))
|
||||
return sorted(member_ids)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"""写接口审计上下文工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from inspect import iscoroutinefunction
|
||||
from typing import Any, Awaitable, Callable, Optional, TypeVar, cast
|
||||
|
||||
from .timezone import CN_TZ
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
VALID_CALLERS = {"system", "user", "ai"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditRequestContext:
|
||||
trace_id: Optional[str] = None
|
||||
interface_path: Optional[str] = None
|
||||
caller: str = "system"
|
||||
method: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
_audit_context: ContextVar[AuditRequestContext] = ContextVar(
|
||||
"pmflow_audit_request_context",
|
||||
default=AuditRequestContext(),
|
||||
)
|
||||
|
||||
|
||||
def normalize_caller(value: Optional[str]) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
return normalized if normalized in VALID_CALLERS else "system"
|
||||
|
||||
|
||||
def get_audit_context() -> AuditRequestContext:
|
||||
return _audit_context.get()
|
||||
|
||||
|
||||
def set_audit_context(context: AuditRequestContext) -> Token[AuditRequestContext]:
|
||||
return _audit_context.set(
|
||||
AuditRequestContext(
|
||||
trace_id=(str(context.trace_id)[:64] if context.trace_id else None),
|
||||
interface_path=(str(context.interface_path)[:255] if context.interface_path else None),
|
||||
caller=normalize_caller(context.caller),
|
||||
method=(str(context.method)[:16] if context.method else None),
|
||||
tenant_id=int(context.tenant_id) if context.tenant_id is not None else None,
|
||||
user_id=int(context.user_id) if context.user_id is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def reset_audit_context(token: Token[AuditRequestContext]) -> None:
|
||||
_audit_context.reset(token)
|
||||
|
||||
|
||||
def update_audit_context(
|
||||
*,
|
||||
trace_id: Optional[str] = None,
|
||||
interface_path: Optional[str] = None,
|
||||
caller: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
tenant_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> AuditRequestContext:
|
||||
current = get_audit_context()
|
||||
next_ctx = replace(
|
||||
current,
|
||||
trace_id=current.trace_id if trace_id is None else trace_id,
|
||||
interface_path=current.interface_path if interface_path is None else interface_path,
|
||||
caller=current.caller if caller is None else normalize_caller(caller),
|
||||
method=current.method if method is None else method,
|
||||
tenant_id=current.tenant_id if tenant_id is None else int(tenant_id),
|
||||
user_id=current.user_id if user_id is None else int(user_id),
|
||||
)
|
||||
_audit_context.set(next_ctx)
|
||||
return next_ctx
|
||||
|
||||
|
||||
def format_beijing_datetime(dt: Optional[datetime]) -> Optional[str]:
|
||||
if dt is None:
|
||||
return None
|
||||
localized = dt.replace(tzinfo=CN_TZ) if dt.tzinfo is None else dt.astimezone(CN_TZ)
|
||||
return localized.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def audited_write(*, caller: Optional[str] = None) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""写接口装饰器:覆写当前请求 caller 审计上下文。"""
|
||||
|
||||
normalized = normalize_caller(caller)
|
||||
|
||||
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
if not iscoroutinefunction(func):
|
||||
return func
|
||||
|
||||
@wraps(func)
|
||||
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
current = get_audit_context()
|
||||
token = set_audit_context(replace(current, caller=normalized))
|
||||
try:
|
||||
return await cast(Callable[..., Awaitable[Any]], func)(*args, **kwargs)
|
||||
finally:
|
||||
reset_audit_context(token)
|
||||
|
||||
return _async_wrapper
|
||||
|
||||
return _decorator
|
||||
|
|
@ -1,41 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import User, UserProject
|
||||
|
||||
_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+")
|
||||
_ROLE_ALIAS_MAP = {
|
||||
"AI": {"AI"},
|
||||
"AI_ENGINEER": {"AI"},
|
||||
"FRONTEND": {"FE"},
|
||||
"FE": {"FE"},
|
||||
"BACKEND": {"BE"},
|
||||
"BE": {"BE"},
|
||||
"BOSS": {"BOSS"},
|
||||
"ADMIN": {"ADMIN"},
|
||||
}
|
||||
|
||||
|
||||
def _parse_role_tokens(raw: str | None) -> set[str]:
|
||||
tokens = {
|
||||
token.strip().upper()
|
||||
for token in _ROLE_SPLIT_RE.split(str(raw or "").strip())
|
||||
if token.strip()
|
||||
}
|
||||
normalized: set[str] = set()
|
||||
for token in tokens:
|
||||
normalized.update(_ROLE_ALIAS_MAP.get(token, {token}))
|
||||
return normalized
|
||||
|
||||
|
||||
def _user_matches_project_role(user_role: str | None, expected_roles: set[str]) -> bool:
|
||||
if not expected_roles:
|
||||
return False
|
||||
return bool(_parse_role_tokens(user_role).intersection(expected_roles))
|
||||
from ..models import UserProject
|
||||
from ..services.project_member_service import resolve_project_member_ids
|
||||
|
||||
|
||||
async def ensure_project_default_members(
|
||||
|
|
@ -45,37 +14,19 @@ async def ensure_project_default_members(
|
|||
project_id: int,
|
||||
owner_id: int | None,
|
||||
assigned_role: str | None,
|
||||
requested_member_user_ids: list[int] | None = None,
|
||||
) -> list[int]:
|
||||
member_ids: set[int] = set()
|
||||
if owner_id is not None:
|
||||
member_ids.add(int(owner_id))
|
||||
|
||||
expected_roles = _parse_role_tokens(assigned_role)
|
||||
if expected_roles:
|
||||
rows = await db.execute(
|
||||
select(User.id, User.role).where(
|
||||
and_(
|
||||
User.tenant_id == int(tenant_id),
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
member_ids = set(
|
||||
await resolve_project_member_ids(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
owner_id=int(owner_id) if owner_id is not None else None,
|
||||
assigned_role=assigned_role,
|
||||
requested_member_user_ids=requested_member_user_ids or [],
|
||||
require_requested_members=False,
|
||||
include_owner=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
for user_id, user_role in rows.all():
|
||||
if _user_matches_project_role(user_role, expected_roles):
|
||||
member_ids.add(int(user_id))
|
||||
|
||||
if not member_ids:
|
||||
fallback_rows = await db.execute(
|
||||
select(User.id).where(
|
||||
and_(
|
||||
User.tenant_id == int(tenant_id),
|
||||
User.status == "active",
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
member_ids.update(int(user_id) for user_id in fallback_rows.scalars().all())
|
||||
|
||||
existing_rows = await db.execute(
|
||||
select(UserProject.user_id).where(
|
||||
|
|
@ -96,4 +47,7 @@ async def ensure_project_default_members(
|
|||
)
|
||||
)
|
||||
|
||||
return sorted(member_ids.union(existing_member_ids))
|
||||
all_member_ids = sorted(member_ids.union(existing_member_ids))
|
||||
if not all_member_ids:
|
||||
raise ValueError("项目成员为空,已拒绝写入")
|
||||
return all_member_ids
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,6 +2,7 @@ import os
|
|||
import unittest
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
|
@ -25,10 +26,13 @@ from app.models import (
|
|||
from app.services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||
from app.services.ai_pmo_kernel_service import (
|
||||
AIPMOKernelService,
|
||||
INTENT_LLM_FUNCTION_NAME,
|
||||
INTENT_PROJECT_CREATE,
|
||||
INTENT_QUERY,
|
||||
INTENT_SIGNAL_CREATE,
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
IntentResolver,
|
||||
_intent_llm_timeout_seconds,
|
||||
)
|
||||
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||
from app.services.llm import StructuredExtractionResult
|
||||
|
|
@ -418,6 +422,207 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
|
|||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
|
||||
|
||||
def test_intent_llm_timeout_default_is_no_longer_eight_seconds(self) -> None:
|
||||
self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name="classify_intent_complex"), 30.0)
|
||||
self.assertGreaterEqual(_intent_llm_timeout_seconds(function_name=INTENT_LLM_FUNCTION_NAME), 30.0)
|
||||
|
||||
async def test_correction_message_forces_low_tier_glm47_intent_function(self) -> None:
|
||||
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
captured_function_name: dict[str, str] = {}
|
||||
try:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
|
||||
async def _mock_generate(_: str, **kwargs):
|
||||
captured_function_name["value"] = str(kwargs.get("function_name") or "")
|
||||
return StructuredExtractionResult(
|
||||
payload={
|
||||
"intent": INTENT_PROJECT_CREATE,
|
||||
"confidence": 0.93,
|
||||
"project_id": None,
|
||||
"reasoning": "否定纠错后回退项目创建",
|
||||
},
|
||||
source_field="content",
|
||||
provider="glm",
|
||||
model="GLM-4.7",
|
||||
validation="ok",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.select_intent_route",
|
||||
return_value=SimpleNamespace(
|
||||
function_name="classify_intent_complex",
|
||||
tier="high",
|
||||
reasons=["complex_multi_clause"],
|
||||
is_complex=True,
|
||||
),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
|
||||
return_value=SimpleNamespace(action="rewrite", matched_keywords=["不是"], confidence=0.92),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
|
||||
AsyncMock(side_effect=_mock_generate),
|
||||
):
|
||||
llm_intent = await service._resolve_intent_with_llm(
|
||||
message_text="不是创建信号,是创建项目名为openclaw自动化部署。",
|
||||
session=None,
|
||||
world_model={"resolved_project_id": None, "project": {}},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(llm_intent)
|
||||
self.assertEqual(captured_function_name.get("value"), INTENT_LLM_FUNCTION_NAME)
|
||||
self.assertTrue(bool((llm_intent.metadata or {}).get("forced_low_tier")))
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
|
||||
|
||||
async def test_two_stage_intent_escalates_to_high_on_conflict(self) -> None:
|
||||
prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
prev_budget = os.environ.get("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = "80"
|
||||
called_functions: list[str] = []
|
||||
try:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
|
||||
async def _mock_generate(_: str, **kwargs):
|
||||
fn = str(kwargs.get("function_name") or "")
|
||||
called_functions.append(fn)
|
||||
if fn == INTENT_LLM_FUNCTION_NAME:
|
||||
return StructuredExtractionResult(
|
||||
payload={
|
||||
"intent": INTENT_SIGNAL_CREATE,
|
||||
"confidence": 0.58,
|
||||
"project_id": None,
|
||||
"reasoning": "低阶先判成信号创建",
|
||||
},
|
||||
source_field="content",
|
||||
provider="glm",
|
||||
model="GLM-4.7",
|
||||
validation="ok",
|
||||
)
|
||||
return StructuredExtractionResult(
|
||||
payload={
|
||||
"intent": INTENT_PROJECT_CREATE,
|
||||
"confidence": 0.93,
|
||||
"project_id": None,
|
||||
"reasoning": "高阶复判为项目创建",
|
||||
},
|
||||
source_field="content",
|
||||
provider="openai",
|
||||
model="GLM-5",
|
||||
validation="ok",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.select_intent_route",
|
||||
return_value=SimpleNamespace(
|
||||
function_name="classify_intent_complex",
|
||||
tier="high",
|
||||
reasons=["complex_multi_clause"],
|
||||
is_complex=True,
|
||||
),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
|
||||
return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
|
||||
AsyncMock(side_effect=_mock_generate),
|
||||
):
|
||||
llm_intent = await service._resolve_intent_with_llm(
|
||||
message_text="给我新增个项目,需求名字为openclaw自动化部署。",
|
||||
session=None,
|
||||
world_model={"resolved_project_id": None, "project": {}},
|
||||
rule_intent=service._build_canonical_intent(
|
||||
intent_name=INTENT_PROJECT_CREATE,
|
||||
message_text="给我新增个项目,需求名字为openclaw自动化部署。",
|
||||
project_id=None,
|
||||
confidence=0.88,
|
||||
metadata={"context_source": "rule"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIsNotNone(llm_intent)
|
||||
self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME, "classify_intent_complex"])
|
||||
self.assertEqual(llm_intent.name, INTENT_PROJECT_CREATE)
|
||||
self.assertEqual((llm_intent.metadata or {}).get("llm_function"), "classify_intent_complex")
|
||||
self.assertTrue(bool((llm_intent.metadata or {}).get("stage2_promoted")))
|
||||
finally:
|
||||
if prev_enabled is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled
|
||||
if prev_budget is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS"] = prev_budget
|
||||
|
||||
async def test_two_stage_intent_keeps_low_when_confident_and_no_conflict(self) -> None:
|
||||
prev_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
called_functions: list[str] = []
|
||||
try:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
|
||||
async def _mock_generate(_: str, **kwargs):
|
||||
fn = str(kwargs.get("function_name") or "")
|
||||
called_functions.append(fn)
|
||||
return StructuredExtractionResult(
|
||||
payload={
|
||||
"intent": INTENT_QUERY,
|
||||
"confidence": 0.95,
|
||||
"project_id": None,
|
||||
"reasoning": "低阶高置信查询",
|
||||
},
|
||||
source_field="content",
|
||||
provider="glm",
|
||||
model="GLM-4.7",
|
||||
validation="ok",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.select_intent_route",
|
||||
return_value=SimpleNamespace(
|
||||
function_name=INTENT_LLM_FUNCTION_NAME,
|
||||
tier="low",
|
||||
reasons=["default_simple"],
|
||||
is_complex=False,
|
||||
),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.kernel_resolve_correction",
|
||||
return_value=SimpleNamespace(action="none", matched_keywords=[], confidence=0.0),
|
||||
), patch(
|
||||
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
|
||||
AsyncMock(side_effect=_mock_generate),
|
||||
):
|
||||
llm_intent = await service._resolve_intent_with_llm(
|
||||
message_text="项目进展如何?",
|
||||
session=None,
|
||||
world_model={"resolved_project_id": None, "project": {}},
|
||||
rule_intent=service._build_canonical_intent(
|
||||
intent_name=INTENT_QUERY,
|
||||
message_text="项目进展如何?",
|
||||
project_id=None,
|
||||
confidence=0.90,
|
||||
metadata={"context_source": "rule"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIsNotNone(llm_intent)
|
||||
self.assertEqual(called_functions, [INTENT_LLM_FUNCTION_NAME])
|
||||
self.assertEqual(llm_intent.name, INTENT_QUERY)
|
||||
self.assertEqual((llm_intent.metadata or {}).get("stage"), "stage1_low")
|
||||
finally:
|
||||
if prev_enabled is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev_enabled
|
||||
|
||||
async def test_intent_llm_failure_falls_back_to_rule_resolver(self) -> None:
|
||||
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.services.ai_steward import AIStewardService
|
||||
|
||||
|
|
@ -8,6 +9,14 @@ class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
|
|||
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
||||
async def test_extract_title_from_requirement_name_instruction(self) -> None:
|
||||
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,需求名字为openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
||||
async def test_extract_title_from_project_name_instruction(self) -> None:
|
||||
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,项目名为openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
||||
async def test_extract_title_from_correction_sentence(self) -> None:
|
||||
title = AIStewardService._extract_project_title_from_instruction("不是创建信号,是创建项目名为openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
|
@ -29,3 +38,31 @@ class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertEqual(normalized["project"]["title"], "openclaw自动化部署")
|
||||
self.assertEqual(normalized["project"]["description"], "项目:openclaw自动化部署")
|
||||
|
||||
async def test_project_creation_text_retries_with_glm47_fallback_function(self) -> None:
|
||||
service = AIStewardService(db=None) # type: ignore[arg-type]
|
||||
called_functions: list[str] = []
|
||||
|
||||
async def _mock_generate(_: str, **kwargs):
|
||||
fn = str(kwargs.get("function_name") or "")
|
||||
called_functions.append(fn)
|
||||
if len(called_functions) <= 2:
|
||||
return "我暂时无法生成可靠回答(模型服务异常)。请稍后重试。"
|
||||
return '{"project_name":"openclaw自动化部署"}'
|
||||
|
||||
service.llm.generate = AsyncMock(side_effect=_mock_generate) # type: ignore[method-assign]
|
||||
text = await service._generate_project_creation_text(
|
||||
prompt="生成分析结果",
|
||||
tenant_id=1,
|
||||
function_name="project_from_text_analysis",
|
||||
timeout_seconds=5.0,
|
||||
)
|
||||
self.assertEqual(text, '{"project_name":"openclaw自动化部署"}')
|
||||
self.assertEqual(
|
||||
called_functions,
|
||||
[
|
||||
"project_from_text_analysis",
|
||||
"project_from_text_analysis",
|
||||
"project_draft_analysis_glm47_fallback",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ class OpenClawCommanderServiceTest(unittest.TestCase):
|
|||
def setUp(self) -> None:
|
||||
self.executor = OperationExecutorService(db=None) # type: ignore[arg-type]
|
||||
|
||||
def test_extract_project_create_title_handles_requirement_prefix(self) -> None:
|
||||
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,需求名字为openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
||||
def test_extract_project_create_title_handles_project_name_prefix(self) -> None:
|
||||
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,项目名为openclaw自动化部署。")
|
||||
self.assertEqual(title, "openclaw自动化部署")
|
||||
|
||||
def test_plan_operations_accepts_tool_call_json(self) -> None:
|
||||
service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
|
||||
service._commander_operation_catalog = lambda: [ # type: ignore[method-assign]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
import os
|
||||
import unittest
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api.projects import (
|
||||
ProjectCreateRequest,
|
||||
ProjectMembersAssignRequest,
|
||||
assign_project_members,
|
||||
create_project,
|
||||
)
|
||||
from app.models import Base, Notification, Project, User, UserProject
|
||||
|
||||
|
||||
class ProjectsMemberAssignmentApiTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
tmp = NamedTemporaryFile(prefix="pmflow-project-members-test-", suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
self.db_path = tmp.name
|
||||
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
|
||||
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.engine.dispose()
|
||||
if getattr(self, "db_path", None):
|
||||
try:
|
||||
os.remove(self.db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
async def _seed_users(self, db: AsyncSession) -> tuple[User, User, User]:
|
||||
boss = User(
|
||||
tenant_id=1,
|
||||
username="boss-member-test",
|
||||
email="boss-member-test@example.com",
|
||||
password_hash="x",
|
||||
name="Boss",
|
||||
role="BOSS",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
member_a = User(
|
||||
tenant_id=1,
|
||||
username="member-a-test",
|
||||
email="member-a-test@example.com",
|
||||
password_hash="x",
|
||||
name="Member A",
|
||||
role="BE",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
member_b = User(
|
||||
tenant_id=1,
|
||||
username="member-b-test",
|
||||
email="member-b-test@example.com",
|
||||
password_hash="x",
|
||||
name="Member B",
|
||||
role="FE",
|
||||
status="active",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([boss, member_a, member_b])
|
||||
await db.flush()
|
||||
return boss, member_a, member_b
|
||||
|
||||
async def test_create_project_assigns_requested_members_and_owner(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
boss, member_a, member_b = await self._seed_users(db)
|
||||
|
||||
with (
|
||||
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
|
||||
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
|
||||
):
|
||||
payload = await create_project(
|
||||
body=ProjectCreateRequest(
|
||||
title="成员分配测试项目",
|
||||
member_user_ids=[int(member_a.id), int(member_b.id)],
|
||||
),
|
||||
db=db,
|
||||
current_user=boss,
|
||||
)
|
||||
|
||||
project_id = int(payload["id"])
|
||||
assigned_rows = (
|
||||
await db.execute(
|
||||
select(UserProject.user_id).where(
|
||||
and_(
|
||||
UserProject.tenant_id == 1,
|
||||
UserProject.project_id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(
|
||||
sorted(int(uid) for uid in assigned_rows),
|
||||
sorted([int(boss.id), int(member_a.id), int(member_b.id)]),
|
||||
)
|
||||
|
||||
async def test_assign_project_members_empty_should_notify_and_raise(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
boss, member_a, _ = await self._seed_users(db)
|
||||
project = Project(
|
||||
tenant_id=1,
|
||||
name="P-members-empty",
|
||||
title="P-members-empty",
|
||||
description="members test",
|
||||
owner_id=int(boss.id),
|
||||
status="active",
|
||||
assigned_role="backend",
|
||||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
db.add(
|
||||
UserProject(
|
||||
tenant_id=1,
|
||||
user_id=int(member_a.id),
|
||||
project_id=int(project.id),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with self.assertRaises(HTTPException) as exc_ctx:
|
||||
await assign_project_members(
|
||||
project_id=int(project.id),
|
||||
body=ProjectMembersAssignRequest(member_user_ids=[]),
|
||||
db=db,
|
||||
current_user=boss,
|
||||
)
|
||||
self.assertEqual(exc_ctx.exception.status_code, 400)
|
||||
|
||||
notifications = (
|
||||
await db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.tenant_id == 1,
|
||||
Notification.user_id == int(boss.id),
|
||||
Notification.title == "项目成员分配失败",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertGreaterEqual(len(notifications), 1)
|
||||
|
||||
async def test_assign_project_members_replaces_member_list(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
boss, member_a, member_b = await self._seed_users(db)
|
||||
project = Project(
|
||||
tenant_id=1,
|
||||
name="P-members-replace",
|
||||
title="P-members-replace",
|
||||
description="members replace test",
|
||||
owner_id=int(boss.id),
|
||||
status="active",
|
||||
assigned_role="backend",
|
||||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
db.add(
|
||||
UserProject(
|
||||
tenant_id=1,
|
||||
user_id=int(boss.id),
|
||||
project_id=int(project.id),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with (
|
||||
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
|
||||
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
|
||||
):
|
||||
payload = await assign_project_members(
|
||||
project_id=int(project.id),
|
||||
body=ProjectMembersAssignRequest(member_user_ids=[int(member_a.id), int(member_b.id)]),
|
||||
db=db,
|
||||
current_user=boss,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(payload["member_user_ids"]),
|
||||
sorted([str(int(member_a.id)), str(int(member_b.id))]),
|
||||
)
|
||||
assigned_rows = (
|
||||
await db.execute(
|
||||
select(UserProject.user_id).where(
|
||||
and_(
|
||||
UserProject.tenant_id == 1,
|
||||
UserProject.project_id == int(project.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertEqual(
|
||||
sorted(int(uid) for uid in assigned_rows),
|
||||
sorted([int(member_a.id), int(member_b.id)]),
|
||||
)
|
||||
|
|
@ -80,7 +80,7 @@ services:
|
|||
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
|
||||
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
|
||||
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
||||
AUTO_CREATE_TABLES: "true"
|
||||
AUTO_CREATE_TABLES: "false"
|
||||
GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
|
||||
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
|
||||
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ services:
|
|||
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
|
||||
LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
|
||||
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT}
|
||||
AUTO_CREATE_TABLES: "true"
|
||||
AUTO_CREATE_TABLES: "false"
|
||||
GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
|
||||
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
|
||||
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,25 @@ export interface OpenClawCommandDispatchRequest {
|
|||
max_operations?: number;
|
||||
}
|
||||
|
||||
export interface SignalSourceRepairRequest {
|
||||
project_id: number;
|
||||
registry_ids: number[];
|
||||
sender?: string;
|
||||
}
|
||||
|
||||
export interface SignalSourceRepairItem {
|
||||
registry_id: number;
|
||||
signal_id: number | null;
|
||||
capture_status: string;
|
||||
work_item_id: number | null;
|
||||
route_status: string;
|
||||
}
|
||||
|
||||
export interface SignalSourceRepairResponse {
|
||||
project_id: number;
|
||||
items: SignalSourceRepairItem[];
|
||||
}
|
||||
|
||||
export interface OpenClawCommandExecutionItem {
|
||||
index: number;
|
||||
operation_code: string;
|
||||
|
|
@ -90,4 +109,11 @@ export const aiStewardApi = {
|
|||
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
|
||||
return unwrap(raw);
|
||||
},
|
||||
async repairDirtySignalSources(body: SignalSourceRepairRequest): Promise<SignalSourceRepairResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<SignalSourceRepairResponse>>("/ai-steward/signal-sources/repair-dirty", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(raw);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@
|
|||
|
||||
import { apiRequest } from './base'
|
||||
|
||||
type ApiEnvelope<T> = { code: number; message: string; data: T }
|
||||
|
||||
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||
if (raw && typeof raw === 'object' && 'code' in (raw as ApiEnvelope<T>) && 'data' in (raw as ApiEnvelope<T>)) {
|
||||
return (raw as ApiEnvelope<T>).data
|
||||
}
|
||||
return raw as T
|
||||
}
|
||||
|
||||
export interface CodeAuditRequest {
|
||||
task_name: string
|
||||
commit_message: string
|
||||
|
|
@ -35,6 +44,49 @@ export interface CodeAuditResponse {
|
|||
details: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AuditOperation {
|
||||
id: number
|
||||
tenant_id: number
|
||||
user_id: number
|
||||
project_id: number | null
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
action: string
|
||||
trace_id: string
|
||||
interface_path: string
|
||||
caller: 'system' | 'user' | 'ai' | string
|
||||
remark: string
|
||||
before_data: Record<string, unknown> | null
|
||||
after_data: Record<string, unknown> | null
|
||||
created_at: string | null
|
||||
created_at_beijing: string | null
|
||||
}
|
||||
|
||||
export interface AuditOperationsResponse {
|
||||
items: AuditOperation[]
|
||||
count: number
|
||||
tenant_id: number
|
||||
}
|
||||
|
||||
export interface ListAuditOperationsParams {
|
||||
tenant_id?: number
|
||||
entity_type?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function toQueryString(params: ListAuditOperationsParams): string {
|
||||
const query = new URLSearchParams()
|
||||
if (typeof params.tenant_id === 'number') query.set('tenant_id', String(params.tenant_id))
|
||||
if (params.entity_type) query.set('entity_type', params.entity_type)
|
||||
if (params.start_time) query.set('start_time', params.start_time)
|
||||
if (params.end_time) query.set('end_time', params.end_time)
|
||||
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
|
||||
const rendered = query.toString()
|
||||
return rendered ? `?${rendered}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计代码变更
|
||||
*/
|
||||
|
|
@ -44,3 +96,12 @@ export async function auditCodeChange(request: CodeAuditRequest): Promise<CodeAu
|
|||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listAuditOperations(
|
||||
params: ListAuditOperationsParams = {},
|
||||
): Promise<AuditOperationsResponse> {
|
||||
const raw = await apiRequest<ApiEnvelope<AuditOperationsResponse>>(`/audit/operations${toQueryString(params)}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
return unwrap(raw)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface ProjectCreateRequest {
|
|||
status?: ProjectStatus
|
||||
domain_id?: string
|
||||
assigned_role?: AssignedRole
|
||||
member_user_ids?: Array<string | number>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,6 +67,13 @@ export interface ProjectUpdateRequest {
|
|||
assigned_role?: AssignedRole
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配项目成员请求(全量替换)
|
||||
*/
|
||||
export interface ProjectMembersAssignRequest {
|
||||
member_user_ids: Array<string | number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联信号请求
|
||||
*/
|
||||
|
|
@ -254,6 +262,22 @@ export const projectsApi = {
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 分配项目成员(全量替换)
|
||||
*/
|
||||
async assignMembers(
|
||||
projectId: string,
|
||||
data: ProjectMembersAssignRequest
|
||||
): Promise<{ project_id: string; member_user_ids: string[] }> {
|
||||
return apiRequest<{ project_id: string; member_user_ids: string[] }>(
|
||||
`/projects/${projectId}/members`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 从本地路径导入项目
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -201,6 +201,13 @@ export interface SSTCOverview {
|
|||
overloaded_count: number;
|
||||
idle_count: number;
|
||||
member_count: number;
|
||||
members: Array<{
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
workload_ratio: number;
|
||||
status: "idle" | "normal" | "overloaded";
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { compactTitle } from "@/lib/title";
|
||||
import { formatProjectNameWithId, toZhStatus } from "@/lib/display";
|
||||
import { formatProjectNameWithId, toZhRole, toZhStatus } from "@/lib/display";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface SSTCGlobalHeaderProps {
|
||||
|
|
@ -36,6 +36,32 @@ function ratioColor(ratio: number): string {
|
|||
return "#16a34a";
|
||||
}
|
||||
|
||||
function ratioClass(ratio: number): string {
|
||||
if (ratio > 1.0) return "text-rose-600";
|
||||
if (ratio < 0.3) return "text-sky-600";
|
||||
return "text-emerald-600";
|
||||
}
|
||||
|
||||
function memberStatusLabel(status: "idle" | "normal" | "overloaded"): string {
|
||||
if (status === "overloaded") return "过载";
|
||||
if (status === "idle") return "空闲";
|
||||
return "正常";
|
||||
}
|
||||
|
||||
function memberStatusClass(status: "idle" | "normal" | "overloaded"): string {
|
||||
if (status === "overloaded") return "bg-rose-100 text-rose-700";
|
||||
if (status === "idle") return "bg-sky-100 text-sky-700";
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
|
||||
function normalizeRoleLabel(rawRole: string): string {
|
||||
const primaryRole = String(rawRole || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.find(Boolean);
|
||||
return toZhRole(primaryRole || rawRole);
|
||||
}
|
||||
|
||||
export function SSTCGlobalHeader({
|
||||
overview,
|
||||
onOpenAi,
|
||||
|
|
@ -46,6 +72,7 @@ export function SSTCGlobalHeader({
|
|||
const alignmentColor = scoreColor(overview.alignment_score || 0);
|
||||
const progress = overview.task_progress.progress || 0;
|
||||
const ratio = overview.workload_health.avg_ratio || 0;
|
||||
const workloadMembers = overview.workload_health.members || [];
|
||||
const projectLabel = formatProjectNameWithId({
|
||||
id: overview.project.id,
|
||||
title: overview.project.title,
|
||||
|
|
@ -133,7 +160,7 @@ export function SSTCGlobalHeader({
|
|||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">团队负载健康度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}>
|
||||
{(ratio * 100).toFixed(0)}%
|
||||
</p>
|
||||
|
|
@ -153,6 +180,36 @@ export function SSTCGlobalHeader({
|
|||
<Users className="h-3 w-3" />
|
||||
覆盖成员 {overview.workload_health.member_count} 人
|
||||
</p>
|
||||
<div className="space-y-1.5 rounded-lg border border-slate-200/70 bg-slate-50/70 p-2">
|
||||
{workloadMembers.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">暂无成员负载明细,请先分配项目成员</p>
|
||||
) : (
|
||||
workloadMembers.slice(0, 4).map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-slate-800">{member.name}</p>
|
||||
<p className="truncate text-[11px] text-slate-500">{normalizeRoleLabel(member.role)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-semibold ${ratioClass(Number(member.workload_ratio || 0))}`}>
|
||||
{(Number(member.workload_ratio || 0) * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${memberStatusClass(member.status)}`}
|
||||
>
|
||||
{memberStatusLabel(member.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{workloadMembers.length > 4 ? (
|
||||
<p className="text-[11px] text-slate-500">其余 {workloadMembers.length - 4} 人请点击查看完整负载</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue