Update AI steward and audit logic

This commit is contained in:
Yuuuu 2026-03-12 09:10:36 +08:00
parent 7c9d274a6c
commit a3a4ce61ff
42 changed files with 2840 additions and 207 deletions

View File

@ -1,9 +1,24 @@
{ {
"version": "v2", "version": "v2",
"updated_at": "2026-03-11T08:35:00Z", "updated_at": "2026-03-11T09:10:00Z",
"source_of_truth": "openclaw", "source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [ "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", "name": "engine_high_intent_complex_exact",
"enabled": true, "enabled": true,

View File

@ -33,6 +33,7 @@ backend/
│ ├── api/ # 路由层(含 endpoints) │ ├── api/ # 路由层(含 endpoints)
│ ├── core/ # 配置、日志、核心能力 │ ├── core/ # 配置、日志、核心能力
│ ├── dependencies/ # 鉴权/租户等依赖注入 │ ├── dependencies/ # 鉴权/租户等依赖注入
│ ├── middleware/ # 请求中间件(审计上下文注入等)
│ ├── models/ # SQLAlchemy 模型 │ ├── models/ # SQLAlchemy 模型
│ ├── schemas/ # Pydantic 模型 │ ├── schemas/ # Pydantic 模型
│ ├── services/ # 业务服务(AI/SSTC/集成) │ ├── services/ # 业务服务(AI/SSTC/集成)
@ -43,6 +44,9 @@ backend/
│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段 │ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
│ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引 │ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引
│ ├── 20260311_add_kb_chunk_ivfflat_index.py # knowledge_base_chunks 向量 IVFFlat 索引 │ ├── 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 # 飞书多轮会话上下文持久化表 │ └── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
├── scripts/ # 后端运维/数据修复脚本 ├── scripts/ # 后端运维/数据修复脚本
│ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ └── 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/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
- 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) - 新增:`backend/app/services/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别)
- 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`) - 新增:`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/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/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
@ -139,16 +148,23 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 8. 服务边界约束(禁止越界) ## 8. 服务边界约束(禁止越界)
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE` - 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 - 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `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`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交) - 项目删除接口(`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` - 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` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
- 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增数据库表:参考 `alembic/versions/` 最新 migration
@ -175,3 +191,6 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认 - `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - 后端与脚本层已清零 `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`),避免历史脚本直接查旧表名失败

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -32,7 +32,13 @@ from ..services.adaptive_rag_service import AdaptiveRAGService
from ..services.ai_orchestration_run_service import AIOrchestrationRunService from ..services.ai_orchestration_run_service import AIOrchestrationRunService
from ..services.openclaw_commander_service import OpenClawCommanderService from ..services.openclaw_commander_service import OpenClawCommanderService
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
from ..services.signal_source_repair_service import (
SignalSourceRepairMissingSignalError,
SignalSourceRepairService,
SignalSourceRepairTimeoutError,
)
from ..schemas.rag import RAGAskRequest from ..schemas.rag import RAGAskRequest
from ..utils.audited_write import audited_write
from ..utils.timezone import now_cn_aware, now_cn_naive from ..utils.timezone import now_cn_aware, now_cn_naive
@ -166,6 +172,12 @@ class SignalSourceListResponse(BaseModel):
items: List[Dict[str, Any]] 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]: def _parse_related_users(raw_value: Any) -> List[str]:
"""兼容 involved_user_ids 的 list / json-string / 单值字符串。""" """兼容 involved_user_ids 的 list / json-string / 单值字符串。"""
if raw_value is None: if raw_value is None:
@ -219,6 +231,7 @@ async def ask_ai_steward_rag(
@router.post("/command/dispatch") @router.post("/command/dispatch")
@audited_write(caller="user")
async def dispatch_openclaw_command( async def dispatch_openclaw_command(
request: OpenClawCommandDispatchRequest, request: OpenClawCommandDispatchRequest,
current_user: User = Depends(require_manager_user), current_user: User = Depends(require_manager_user),
@ -246,6 +259,7 @@ async def dispatch_openclaw_command(
@router.post("/signal-sources/onboard") @router.post("/signal-sources/onboard")
@audited_write(caller="user")
async def onboard_signal_source( async def onboard_signal_source(
request: SignalSourceOnboardRequest, request: SignalSourceOnboardRequest,
current_user: User = Depends(require_manager_user), 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()) 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) @router.post("/project-from-text", response_model=ProjectFromTextResponse)
async def create_project_from_text( async def create_project_from_text(
request: ProjectFromTextRequest, request: ProjectFromTextRequest,
@ -384,6 +426,7 @@ async def create_project_from_text(
# ==================== Signal Capture Endpoints ==================== # ==================== Signal Capture Endpoints ====================
@router.post("/signal/capture-feishu") @router.post("/signal/capture-feishu")
@audited_write(caller="ai")
async def capture_feishu_signal( async def capture_feishu_signal(
request: FeishuCaptureRequest, request: FeishuCaptureRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),

68
backend/app/api/audit.py Normal file
View File

@ -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,
}
)

View File

@ -6,7 +6,7 @@ import json
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query 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 import and_, delete, func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -33,7 +33,13 @@ from ..models import (
) )
from ..services.ai_pmo_kernel_service import AIPMOKernelService from ..services.ai_pmo_kernel_service import AIPMOKernelService
from ..services.context_cache import context_cache_service 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.sequence import ensure_project_id_sequence
from ..utils.title import compact_title from ..utils.title import compact_title
@ -60,6 +66,7 @@ class ProjectCreateRequest(BaseModel):
status: str = "active" status: str = "active"
domain_id: Optional[int] = None domain_id: Optional[int] = None
assigned_role: Optional[str] = None assigned_role: Optional[str] = None
member_user_ids: list[int] = Field(default_factory=list)
class ProjectUpdateRequest(BaseModel): class ProjectUpdateRequest(BaseModel):
@ -75,6 +82,10 @@ class LinkSignalRequest(BaseModel):
relevance_score: Optional[float] = 1.0 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: def _serialize_project_row(project: Project) -> dict:
return { return {
"id": str(project.id), "id": str(project.id),
@ -151,6 +162,23 @@ async def _build_project_summary(
return base 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( async def _get_project_or_404(
db: AsyncSession, db: AsyncSession,
*, *,
@ -364,6 +392,28 @@ async def create_project(
title = compact_title(body.title, max_len=24) title = compact_title(body.title, max_len=24)
if not title: if not title:
raise HTTPException(status_code=400, detail="title required") 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) await ensure_project_id_sequence(db)
project = Project( project = Project(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -377,21 +427,111 @@ async def create_project(
signal_count=0, signal_count=0,
) )
db.add(project) db.add(project)
await db.flush() try:
await ensure_project_default_members( await db.flush()
db, await replace_project_members(
tenant_id=tenant_id, db,
project_id=int(project.id), tenant_id=tenant_id,
owner_id=int(current_user.id), project_id=int(project.id),
assigned_role=project.assigned_role, member_user_ids=member_user_ids,
) )
await db.commit() 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 db.refresh(project)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id) 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)) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id))
return _serialize_project_row(project) 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}") @router.patch("/{project_id}")
async def update_project( async def update_project(
project_id: int, project_id: int,

View File

@ -34,6 +34,7 @@ from .recipes import router as recipes_router
from .signal_sources import router as signal_sources_router from .signal_sources import router as signal_sources_router
from .operation_catalog import router as operation_catalog_router from .operation_catalog import router as operation_catalog_router
from .ai_pmo import router as ai_pmo_router from .ai_pmo import router as ai_pmo_router
from .audit import router as audit_router
# 创建主 API 路由器 # 创建主 API 路由器
api_router = APIRouter() api_router = APIRouter()
@ -67,3 +68,4 @@ api_router.include_router(recipes_router)
api_router.include_router(signal_sources_router) api_router.include_router(signal_sources_router)
api_router.include_router(operation_catalog_router) api_router.include_router(operation_catalog_router)
api_router.include_router(ai_pmo_router) api_router.include_router(ai_pmo_router)
api_router.include_router(audit_router)

View File

@ -2,20 +2,46 @@
from __future__ import annotations from __future__ import annotations
import os
import traceback
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from fastapi import Request
from sqlalchemy import event
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncEngine,
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
create_async_engine, create_async_engine,
) )
from sqlalchemy import event from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from .core.config import settings 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 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: def _is_sqlite() -> bool:
@ -61,6 +87,126 @@ if _is_sqlite():
finally: finally:
cursor.close() 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( AsyncSessionLocal = async_sessionmaker(
bind=engine, bind=engine,
class_=AsyncSession, 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 依赖:提供数据库会话。""" """FastAPI 依赖:提供数据库会话。"""
async with AsyncSessionLocal() as session: 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 yield session

View File

@ -15,9 +15,11 @@ from ..database import get_db
from ..dependencies.tenant import get_tenant_id from ..dependencies.tenant import get_tenant_id
from ..models import User, UserStatus # noqa: E402 from ..models import User, UserStatus # noqa: E402
from ..services.auth import AuthService, has_any_role from ..services.auth import AuthService, has_any_role
from ..utils.audited_write import update_audit_context
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="auth/login", auto_error=False)
async def get_current_user( async def get_current_user(
@ -41,6 +43,45 @@ async def get_current_user(
if user is None: if user is None:
raise credentials_exception 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 return user
@ -82,7 +123,10 @@ async def get_current_tenant_id(
try: try:
tenant_id = int(getattr(current_user, "tenant_id", 0) or 0) tenant_id = int(getattr(current_user, "tenant_id", 0) or 0)
if tenant_id > 0: if tenant_id > 0:
update_audit_context(tenant_id=tenant_id)
return tenant_id return tenant_id
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
return int(header_tenant_id or 1) fallback = int(header_tenant_id or 1)
update_audit_context(tenant_id=fallback)
return fallback

View File

@ -8,6 +8,8 @@ from typing import Annotated
from fastapi import Header from fastapi import Header
from ..utils.audited_write import update_audit_context
async def get_tenant_id( async def get_tenant_id(
x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None, 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: if x_tenant_id:
try: try:
return int(x_tenant_id) resolved = int(x_tenant_id)
update_audit_context(tenant_id=resolved)
return resolved
except ValueError: except ValueError:
update_audit_context(tenant_id=1)
return 1 return 1
update_audit_context(tenant_id=1)
return 1 return 1

View File

@ -17,7 +17,8 @@ from .core.config import settings
from .core.logging import configure_logging, get_logger from .core.logging import configure_logging, get_logger
from .core.response import success_response from .core.response import success_response
from .database import AsyncSessionLocal, engine 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 WorkloadRecalcScheduler
from .services.scheduler_center import KnowledgeUrlRefreshScheduler from .services.scheduler_center import KnowledgeUrlRefreshScheduler
from .services.scheduler_center import KnowledgeGraphIncrementalScheduler from .services.scheduler_center import KnowledgeGraphIncrementalScheduler
@ -520,17 +521,20 @@ async def _warmup_world_models() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): 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", "1",
"true", "true",
"yes", "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: async with engine.begin() as conn:
await conn.run_sync(_check_database_dialect_guard) 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: async with AsyncSessionLocal() as db:
try: try:
@ -591,6 +595,8 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(AuditContextMiddleware)
origins = [item.strip() for item in settings.cors_origins.split(",") if item.strip()] origins = [item.strip() for item in settings.cors_origins.split(",") if item.strip()]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@ -0,0 +1,5 @@
"""应用中间件。"""
from .audit_context import AuditContextMiddleware
__all__ = ["AuditContextMiddleware"]

View File

@ -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

View File

@ -1,5 +1,6 @@
"""操作日志模型(全链路可追溯)""" """操作日志模型(全链路可追溯)"""
from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Integer, ForeignKey, Text, VARCHAR from sqlalchemy import BigInteger, Integer, ForeignKey, Text, VARCHAR
@ -7,6 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
from .types import JSONB from .types import JSONB
from ..utils.audited_write import format_beijing_datetime
class OperationLog(Base): class OperationLog(Base):
@ -25,3 +27,10 @@ class OperationLog(Base):
before_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) before_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
after_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) 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)

View File

@ -18,7 +18,7 @@ class UserWorkload(BaseNoCreatedAt):
DECIMAL(5, 2), nullable=False, default=40.0 DECIMAL(5, 2), nullable=False, default=40.0
) )
workload_ratio: Mapped[float] = mapped_column( 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") user = relationship("User", back_populates="workload_rel")

View File

@ -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_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
INTENT_CANDIDATES = ( INTENT_CANDIDATES = (
INTENT_SIGNAL_SOURCE_ONBOARD, 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 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]: def _safe_int(value: Any) -> Optional[int]:
try: try:
if value is None or value == "": if value is None or value == "":
@ -2155,6 +2237,7 @@ class AIPMOKernelService:
message_text: str, message_text: str,
session: Optional[ConversationSession], session: Optional[ConversationSession],
world_model: dict[str, Any], world_model: dict[str, Any],
rule_intent: Optional[CanonicalIntent] = None,
) -> Optional[CanonicalIntent]: ) -> Optional[CanonicalIntent]:
project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_names = [ project_names = [
@ -2163,6 +2246,10 @@ class AIPMOKernelService:
] ]
route_decision = select_intent_route(message_text, project_names=project_names) route_decision = select_intent_route(message_text, project_names=project_names)
correction_decision = kernel_resolve_correction(message_text) 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( prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
message_text=str(message_text or "")[:1200], message_text=str(message_text or "")[:1200],
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "", session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
@ -2170,84 +2257,190 @@ class AIPMOKernelService:
world_model_project_id=_safe_int(world_model.get("resolved_project_id")), world_model_project_id=_safe_int(world_model.get("resolved_project_id")),
candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False), candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
).strip() ).strip()
try: total_budget_seconds = _intent_llm_total_budget_seconds()
extraction = await asyncio.wait_for( stage_started_at = time.perf_counter()
llm_service.generate_structured_object(
prompt, async def _invoke_intent_stage(
db=self.db, *,
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=function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=260,
response_mode="fast",
),
timeout=llm_timeout_seconds,
)
except TimeoutError:
logger.warning(
"ai_pmo_intent_llm_timeout",
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
function_name=route_decision.function_name, stage=stage_label,
use_prompt_profile=False, function_name=function_name,
temperature=0.0, timeout_seconds=llm_timeout_seconds,
max_tokens=260, )
response_mode="fast", return None
), except Exception as exc:
timeout=INTENT_LLM_TIMEOUT_SECONDS, logger.warning(
"ai_pmo_intent_llm_failed",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
error=str(exc),
)
return None
payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None
if payload is None:
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 ""),
)
return None
intent_name = str(payload.get("intent") or "").strip()
if intent_name not in INTENT_CANDIDATES:
return None
confidence = float(payload.get("confidence") or 0.0)
project_id = _safe_int(payload.get("project_id"))
if project_id is not None and int(project_id) <= 0:
project_id = None
if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id"))
reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
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_reasons,
) )
except TimeoutError: return self._build_canonical_intent(
logger.warning( intent_name=intent_name,
"ai_pmo_intent_llm_timeout", message_text=message_text,
project_id=project_id,
confidence=confidence,
metadata={
"context_source": "llm",
"stage": stage_label,
"reasoning": reasoning[:300],
"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, tenant_id=self.tenant_id,
timeout_seconds=INTENT_LLM_TIMEOUT_SECONDS, reason="budget_insufficient",
) remaining_budget_seconds=max(0.0, remaining_budget_seconds),
return None min_remaining_seconds=stage2_min_remaining_seconds,
except Exception as exc: escalate_reasons=escalate_reasons,
logger.warning(
"ai_pmo_intent_llm_failed",
tenant_id=self.tenant_id,
error=str(exc),
) )
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 return None
payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"]
if payload is None: high_intent = await _invoke_intent_stage(
logger.warning( function_name="classify_intent_complex",
"ai_pmo_intent_llm_empty_payload", route_tier="high",
tenant_id=self.tenant_id, route_reasons=high_stage_reasons,
error_code=str(getattr(extraction, "error_code", "") or ""), stage_label="stage2_high",
provider=str(getattr(extraction, "provider", "") or ""), timeout_cap_seconds=remaining_budget_seconds,
model=str(getattr(extraction, "model", "") or ""), )
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 return None
intent_name = str(payload.get("intent") or "").strip()
if intent_name not in INTENT_CANDIDATES:
return None
confidence = float(payload.get("confidence") or 0.0)
project_id = _safe_int(payload.get("project_id"))
if project_id is not None and int(project_id) <= 0:
project_id = None
if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id"))
reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
route_tier=route_decision.tier,
route_function=route_decision.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,
)
return self._build_canonical_intent(
intent_name=intent_name,
message_text=message_text,
project_id=project_id,
confidence=confidence,
metadata={
"context_source": "llm",
"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,
"correction_action": correction_decision.action,
"correction_keywords": correction_decision.matched_keywords,
"decision_trace": decision_trace,
},
)
async def _resolve_intent( async def _resolve_intent(
self, self,
@ -2275,6 +2468,7 @@ class AIPMOKernelService:
message_text=message_text, message_text=message_text,
session=session, session=session,
world_model=world_model, world_model=world_model,
rule_intent=rule_intent,
) )
if llm_intent is not None: if llm_intent is not None:
if self._intent_arbitration_enabled(): if self._intent_arbitration_enabled():
@ -2286,6 +2480,11 @@ class AIPMOKernelService:
else: else:
selected_intent, source = llm_intent, "llm" selected_intent, source = llm_intent, "llm"
return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source 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" return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_fallback"

View File

@ -4,7 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import re import re
import time
from typing import Any, Dict, List from typing import Any, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -25,7 +27,47 @@ from .task_persistence import (
) )
logger = get_logger(__name__) 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: class AIStewardService:
@ -38,6 +80,14 @@ class AIStewardService:
@staticmethod @staticmethod
def _normalize_project_title_candidate(candidate: str) -> str: def _normalize_project_title_candidate(candidate: str) -> str:
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") 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: if not cleaned:
return "" return ""
normalized = re.sub(r"\s+", "", cleaned).lower() normalized = re.sub(r"\s+", "", cleaned).lower()
@ -67,14 +117,20 @@ class AIStewardService:
if quoted_match: if quoted_match:
return cls._normalize_project_title_candidate(quoted_match.group(1)) return cls._normalize_project_title_candidate(quoted_match.group(1))
for pattern in ( for pattern in (
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
): ):
match = re.search(pattern, cleaned, flags=re.IGNORECASE) match = re.search(pattern, cleaned, flags=re.IGNORECASE)
if not match: if not match:
continue continue
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”") 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() candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip()
normalized = cls._normalize_project_title_candidate(candidate) normalized = cls._normalize_project_title_candidate(candidate)
if normalized: if normalized:
@ -187,6 +243,24 @@ class AIStewardService:
"risks": self._build_risk_alert_drafts(analysis=analysis), "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( async def _generate_project_creation_text(
self, self,
*, *,
@ -195,24 +269,62 @@ class AIStewardService:
function_name: str, function_name: str,
timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS,
) -> str: ) -> str:
try: candidates = self._project_creation_function_candidates(function_name=function_name)
return await asyncio.wait_for( if not candidates:
self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
function_name=function_name,
),
timeout=timeout_seconds,
)
except Exception as exc:
logger.warning(
"ai_steward_project_creation_step_fallback",
tenant_id=int(tenant_id),
function_name=function_name,
error=str(exc),
)
return "" 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:
response = await asyncio.wait_for(
self.llm.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
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="all_attempts_exhausted",
)
return ""
async def _normalize_project_creation_draft( async def _normalize_project_creation_draft(
self, self,

View File

@ -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]

View File

@ -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") old_lines = old_content.split("\n")
new_lines = file.content.split("\n") new_lines = file.content.split("\n")

View File

@ -116,6 +116,7 @@ DEFAULT_LOW_TIER_HINTS = (
"openclaw_command_dispatch", "openclaw_command_dispatch",
"signal_source_ai_worker_execution", "signal_source_ai_worker_execution",
"extract_context_insights", "extract_context_insights",
"project_draft_",
"adaptive_rag_answer_normal", "adaptive_rag_answer_normal",
"rag_intent_classification", "rag_intent_classification",
"classify_intent", "classify_intent",

View File

@ -790,6 +790,14 @@ class OpenClawCommanderService:
@staticmethod @staticmethod
def _normalize_project_title_candidate(candidate: str) -> str: def _normalize_project_title_candidate(candidate: str) -> str:
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") 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: if not cleaned:
return "" return ""
normalized = re.sub(r"\s+", "", cleaned).lower() normalized = re.sub(r"\s+", "", cleaned).lower()
@ -820,15 +828,21 @@ class OpenClawCommanderService:
if quoted_match: if quoted_match:
return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1)) return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1))
for pattern in ( for pattern in (
r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)",
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)", r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)",
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
): ):
match = re.search(pattern, cleaned, re.IGNORECASE) match = re.search(pattern, cleaned, re.IGNORECASE)
if not match: if not match:
continue continue
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”") 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: if candidate:
return OpenClawCommanderService._normalize_project_title_candidate(candidate) return OpenClawCommanderService._normalize_project_title_candidate(candidate)
return "" return ""

View File

@ -5,6 +5,7 @@ from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import OperationLog from ..models import OperationLog
from ..utils.audited_write import get_audit_context, normalize_caller
async def log_operation( async def log_operation(
@ -19,9 +20,16 @@ async def log_operation(
before_data: Optional[dict] = None, before_data: Optional[dict] = None,
after_data: Optional[dict] = None, after_data: Optional[dict] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
trace_id: Optional[str] = None,
interface_path: Optional[str] = None,
caller: Optional[str] = None,
) -> None: ) -> None:
"""记录操作日志,失败时不影响主流程。""" """记录操作日志,失败时不影响主流程。"""
try: 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( db.add(
OperationLog( OperationLog(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -33,8 +41,12 @@ async def log_operation(
before_data=before_data, before_data=before_data,
after_data=after_data, after_data=after_data,
remark=remark, remark=remark,
trace_id=resolved_trace,
interface_path=resolved_interface,
caller=resolved_caller,
) )
) )
db.sync_session.info["_pmflow_audit_written"] = True
except Exception: except Exception:
# 日志失败不阻断主交易 # 日志失败不阻断主交易
return return

View File

@ -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,
)

View File

@ -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

View File

@ -197,39 +197,78 @@ class SSTCService:
} }
async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]: 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 { return {
"avg_ratio": 0.0, "avg_ratio": 0.0,
"overloaded_count": 0, "overloaded_count": 0,
"idle_count": 0, "idle_count": 0,
"member_count": 0, "member_count": 0,
"members": [],
} }
result = await self.db.execute( ratios = [float(item["workload_ratio"]) for item in member_profiles]
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)
overloaded_count = len([x for x in ratios if x > 1.0]) overloaded_count = len([x for x in ratios if x > 1.0])
idle_count = len([x for x in ratios if x < 0.3]) idle_count = len([x for x in ratios if x < 0.3])
return { return {
"avg_ratio": round(avg_ratio, 2), "avg_ratio": round(_avg(ratios), 2),
"overloaded_count": overloaded_count, "overloaded_count": overloaded_count,
"idle_count": idle_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 @staticmethod
def _task_metrics(tasks: list[Task]) -> dict[str, Any]: def _task_metrics(tasks: list[Task]) -> dict[str, Any]:
total = len(tasks) total = len(tasks)
@ -272,7 +311,7 @@ class SSTCService:
charters_ids = [int(c.id) for c in charters] charters_ids = [int(c.id) for c in charters]
alignment = await self.get_alignment_stats(charters_ids) alignment = await self.get_alignment_stats(charters_ids)
metrics = self._task_metrics(tasks) 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) workload = await self.get_workload_health(member_ids)
chain_id = str(charters[0].id) if charters else None chain_id = str(charters[0].id) if charters else None
return { return {
@ -568,7 +607,7 @@ class SSTCService:
) )
return int(result.scalar() or 0) > 0 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( member_result = await self.db.execute(
select(UserProject.user_id).where( select(UserProject.user_id).where(
and_( 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) project = await self.get_project(project_id)
if not member_ids and project: if not member_ids and project:
member_ids = set( member_ids = set(
@ -590,6 +632,11 @@ class SSTCService:
) )
) )
await self.db.commit() 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: if project and project.owner_id:
member_ids.add(int(project.owner_id)) member_ids.add(int(project.owner_id))
return sorted(member_ids) return sorted(member_ids)

View File

@ -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

View File

@ -1,41 +1,10 @@
from __future__ import annotations from __future__ import annotations
import re
from sqlalchemy import and_, select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import User, UserProject from ..models import UserProject
from ..services.project_member_service import resolve_project_member_ids
_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))
async def ensure_project_default_members( async def ensure_project_default_members(
@ -45,37 +14,19 @@ async def ensure_project_default_members(
project_id: int, project_id: int,
owner_id: int | None, owner_id: int | None,
assigned_role: str | None, assigned_role: str | None,
requested_member_user_ids: list[int] | None = None,
) -> list[int]: ) -> list[int]:
member_ids: set[int] = set() member_ids = set(
if owner_id is not None: await resolve_project_member_ids(
member_ids.add(int(owner_id)) db,
tenant_id=int(tenant_id),
expected_roles = _parse_role_tokens(assigned_role) owner_id=int(owner_id) if owner_id is not None else None,
if expected_roles: assigned_role=assigned_role,
rows = await db.execute( requested_member_user_ids=requested_member_user_ids or [],
select(User.id, User.role).where( require_requested_members=False,
and_( include_owner=True,
User.tenant_id == int(tenant_id),
User.status == "active",
User.is_active.is_(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( existing_rows = await db.execute(
select(UserProject.user_id).where( 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

BIN
backend/neural_sync.db-shm Normal file

Binary file not shown.

View File

View File

@ -2,6 +2,7 @@ import os
import unittest import unittest
from datetime import datetime from datetime import datetime
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select 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_cadence_service import AIPMOCadenceService
from app.services.ai_pmo_kernel_service import ( from app.services.ai_pmo_kernel_service import (
AIPMOKernelService, AIPMOKernelService,
INTENT_LLM_FUNCTION_NAME,
INTENT_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_QUERY,
INTENT_SIGNAL_CREATE, INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver, IntentResolver,
_intent_llm_timeout_seconds,
) )
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from app.services.llm import StructuredExtractionResult from app.services.llm import StructuredExtractionResult
@ -418,6 +422,207 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
else: else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev 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: async def test_intent_llm_failure_falls_back_to_rule_resolver(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED") prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1" os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"

View File

@ -1,4 +1,5 @@
import unittest import unittest
from unittest.mock import AsyncMock
from app.services.ai_steward import AIStewardService from app.services.ai_steward import AIStewardService
@ -8,6 +9,14 @@ class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,openclaw自动化部署。") title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,openclaw自动化部署。")
self.assertEqual(title, "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: async def test_extract_title_from_correction_sentence(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("不是创建信号,是创建项目名为openclaw自动化部署。") title = AIStewardService._extract_project_title_from_instruction("不是创建信号,是创建项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署") self.assertEqual(title, "openclaw自动化部署")
@ -29,3 +38,31 @@ class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(normalized["project"]["title"], "openclaw自动化部署") self.assertEqual(normalized["project"]["title"], "openclaw自动化部署")
self.assertEqual(normalized["project"]["description"], "项目: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",
],
)

View File

@ -89,6 +89,14 @@ class OpenClawCommanderServiceTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.executor = OperationExecutorService(db=None) # type: ignore[arg-type] 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: def test_plan_operations_accepts_tool_call_json(self) -> None:
service = OpenClawCommanderService(db=None) # type: ignore[arg-type] service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
service._commander_operation_catalog = lambda: [ # type: ignore[method-assign] service._commander_operation_catalog = lambda: [ # type: ignore[method-assign]

View File

@ -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)]),
)

View File

@ -80,7 +80,7 @@ services:
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} 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_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} 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_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL} GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}

View File

@ -80,7 +80,7 @@ services:
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} 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_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED}
LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} 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_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY}
GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL}
GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL} GLM_MODEL: ${GLM_MODEL:?missing_GLM_MODEL}

View File

@ -16,6 +16,25 @@ export interface OpenClawCommandDispatchRequest {
max_operations?: number; 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 { export interface OpenClawCommandExecutionItem {
index: number; index: number;
operation_code: string; operation_code: string;
@ -90,4 +109,11 @@ export const aiStewardApi = {
const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers"); const raw = await apiRequest<ApiEnvelope<LLMProviderPresetResponse>>("/ai-steward/llm/providers");
return unwrap(raw); 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);
},
}; };

View File

@ -5,6 +5,15 @@
import { apiRequest } from './base' 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 { export interface CodeAuditRequest {
task_name: string task_name: string
commit_message: string commit_message: string
@ -35,6 +44,49 @@ export interface CodeAuditResponse {
details: Record<string, any> 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), 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)
}

View File

@ -53,6 +53,7 @@ export interface ProjectCreateRequest {
status?: ProjectStatus status?: ProjectStatus
domain_id?: string domain_id?: string
assigned_role?: AssignedRole assigned_role?: AssignedRole
member_user_ids?: Array<string | number>
} }
/** /**
@ -66,6 +67,13 @@ export interface ProjectUpdateRequest {
assigned_role?: AssignedRole 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),
}
)
},
/** /**
* 从本地路径导入项目 * 从本地路径导入项目
*/ */

View File

@ -201,6 +201,13 @@ export interface SSTCOverview {
overloaded_count: number; overloaded_count: number;
idle_count: number; idle_count: number;
member_count: number; member_count: number;
members: Array<{
user_id: string;
name: string;
role: string;
workload_ratio: number;
status: "idle" | "normal" | "overloaded";
}>;
}; };
} }

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { compactTitle } from "@/lib/title"; import { compactTitle } from "@/lib/title";
import { formatProjectNameWithId, toZhStatus } from "@/lib/display"; import { formatProjectNameWithId, toZhRole, toZhStatus } from "@/lib/display";
import { formatBeijingDateTime } from "@/lib/datetime"; import { formatBeijingDateTime } from "@/lib/datetime";
interface SSTCGlobalHeaderProps { interface SSTCGlobalHeaderProps {
@ -36,6 +36,32 @@ function ratioColor(ratio: number): string {
return "#16a34a"; 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({ export function SSTCGlobalHeader({
overview, overview,
onOpenAi, onOpenAi,
@ -46,6 +72,7 @@ export function SSTCGlobalHeader({
const alignmentColor = scoreColor(overview.alignment_score || 0); const alignmentColor = scoreColor(overview.alignment_score || 0);
const progress = overview.task_progress.progress || 0; const progress = overview.task_progress.progress || 0;
const ratio = overview.workload_health.avg_ratio || 0; const ratio = overview.workload_health.avg_ratio || 0;
const workloadMembers = overview.workload_health.members || [];
const projectLabel = formatProjectNameWithId({ const projectLabel = formatProjectNameWithId({
id: overview.project.id, id: overview.project.id,
title: overview.project.title, title: overview.project.title,
@ -133,7 +160,7 @@ export function SSTCGlobalHeader({
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">团队负载健康度</CardTitle> <CardTitle className="text-base">团队负载健康度</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-3">
<p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}> <p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}>
{(ratio * 100).toFixed(0)}% {(ratio * 100).toFixed(0)}%
</p> </p>
@ -153,6 +180,36 @@ export function SSTCGlobalHeader({
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
覆盖成员 {overview.workload_health.member_count} 人 覆盖成员 {overview.workload_health.member_count} 人
</p> </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> </CardContent>
</Card> </Card>
</div> </div>