Update AI steward and audit logic
This commit is contained in:
parent
7c9d274a6c
commit
a3a4ce61ff
|
|
@ -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,
|
||||||
|
|
|
||||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -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`),避免历史脚本直接查旧表名失败
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""add ai_pmo_work_items compatibility view
|
||||||
|
|
||||||
|
Revision ID: add_ai_pmo_work_items_view
|
||||||
|
Revises: add_operation_log_audit_fields
|
||||||
|
Create Date: 2026-03-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "add_ai_pmo_work_items_view"
|
||||||
|
down_revision = "add_operation_log_audit_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "work_items"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE VIEW ai_pmo_work_items AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ingest_event_id,
|
||||||
|
source_system,
|
||||||
|
source_message_id,
|
||||||
|
state,
|
||||||
|
intent,
|
||||||
|
decision_level,
|
||||||
|
project_id,
|
||||||
|
signal_id,
|
||||||
|
owner_user_id,
|
||||||
|
dedupe_key,
|
||||||
|
context_snapshot,
|
||||||
|
action_bundle,
|
||||||
|
execution_result,
|
||||||
|
review_summary,
|
||||||
|
reply_template_key,
|
||||||
|
tenant_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM work_items
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP VIEW IF EXISTS ai_pmo_work_items")
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""add operation log audit fields
|
||||||
|
|
||||||
|
Revision ID: add_operation_log_audit_fields
|
||||||
|
Revises: shift_all_timestamps_to_beijing
|
||||||
|
Create Date: 2026-03-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "add_operation_log_audit_fields"
|
||||||
|
down_revision = "shift_all_timestamps_to_beijing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "operation_logs"):
|
||||||
|
return
|
||||||
|
|
||||||
|
with op.batch_alter_table("operation_logs") as batch_op:
|
||||||
|
if not _column_exists(bind, "operation_logs", "trace_id"):
|
||||||
|
batch_op.add_column(sa.Column("trace_id", sa.String(length=64), nullable=True))
|
||||||
|
if not _column_exists(bind, "operation_logs", "interface_path"):
|
||||||
|
batch_op.add_column(sa.Column("interface_path", sa.String(length=255), nullable=True))
|
||||||
|
if not _column_exists(bind, "operation_logs", "caller"):
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("caller", sa.String(length=16), nullable=False, server_default=sa.text("'system'"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||||
|
op.create_index("ix_operation_logs_trace_id", "operation_logs", ["trace_id"], unique=False)
|
||||||
|
if not _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||||
|
op.create_index("ix_operation_logs_interface_path", "operation_logs", ["interface_path"], unique=False)
|
||||||
|
if not _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||||
|
op.create_index("ix_operation_logs_caller", "operation_logs", ["caller"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "operation_logs"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _index_exists(bind, "operation_logs", "ix_operation_logs_caller"):
|
||||||
|
op.drop_index("ix_operation_logs_caller", table_name="operation_logs")
|
||||||
|
if _index_exists(bind, "operation_logs", "ix_operation_logs_interface_path"):
|
||||||
|
op.drop_index("ix_operation_logs_interface_path", table_name="operation_logs")
|
||||||
|
if _index_exists(bind, "operation_logs", "ix_operation_logs_trace_id"):
|
||||||
|
op.drop_index("ix_operation_logs_trace_id", table_name="operation_logs")
|
||||||
|
|
||||||
|
with op.batch_alter_table("operation_logs") as batch_op:
|
||||||
|
if _column_exists(bind, "operation_logs", "caller"):
|
||||||
|
batch_op.drop_column("caller")
|
||||||
|
if _column_exists(bind, "operation_logs", "interface_path"):
|
||||||
|
batch_op.drop_column("interface_path")
|
||||||
|
if _column_exists(bind, "operation_logs", "trace_id"):
|
||||||
|
batch_op.drop_column("trace_id")
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""expand user_workloads workload_ratio precision
|
||||||
|
|
||||||
|
Revision ID: expand_user_workload_ratio_precision
|
||||||
|
Revises: add_ai_pmo_work_items_view
|
||||||
|
Create Date: 2026-03-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "expand_user_workload_ratio_precision"
|
||||||
|
down_revision = "add_ai_pmo_work_items_view"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_numeric_meta(bind, table_name: str, column_name: str) -> tuple[int | None, int | None] | None:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return None
|
||||||
|
for column in inspector.get_columns(table_name):
|
||||||
|
if str(column.get("name")) == column_name:
|
||||||
|
col_type = column.get("type")
|
||||||
|
return (
|
||||||
|
int(getattr(col_type, "precision", 0) or 0) or None,
|
||||||
|
int(getattr(col_type, "scale", 0) or 0) or None,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _alter_workload_ratio(bind, *, precision: int, scale: int) -> None:
|
||||||
|
current = _column_numeric_meta(bind, "user_workloads", "workload_ratio")
|
||||||
|
if current == (precision, scale):
|
||||||
|
return
|
||||||
|
current_precision = int((current or (4, 2))[0] or 4)
|
||||||
|
current_scale = int((current or (4, 2))[1] or 2)
|
||||||
|
with op.batch_alter_table("user_workloads") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"workload_ratio",
|
||||||
|
existing_type=sa.Numeric(current_precision, current_scale),
|
||||||
|
type_=sa.Numeric(precision, scale),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "user_workloads"):
|
||||||
|
return
|
||||||
|
_alter_workload_ratio(bind, precision=10, scale=2)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "user_workloads"):
|
||||||
|
return
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_workloads
|
||||||
|
SET workload_ratio = CASE
|
||||||
|
WHEN workload_ratio > 99.99 THEN 99.99
|
||||||
|
WHEN workload_ratio < -99.99 THEN -99.99
|
||||||
|
ELSE workload_ratio
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_alter_workload_ratio(bind, precision=4, scale=2)
|
||||||
|
|
@ -32,7 +32,13 @@ from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||||
from ..services.ai_orchestration_run_service import AIOrchestrationRunService
|
from ..services.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),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""审计日志查询 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies.auth import get_optional_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..services.audit_query_service import AuditQueryService
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/audit", tags=["Audit"])
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_tenant_id(current_user: User, header_tenant_id: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(getattr(current_user, "tenant_id") or header_tenant_id or 1)
|
||||||
|
except Exception:
|
||||||
|
return int(header_tenant_id or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_role(user: User) -> str:
|
||||||
|
role = getattr(user, "role", "")
|
||||||
|
return str(getattr(role, "value", role) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operations")
|
||||||
|
async def list_audit_operations(
|
||||||
|
tenant_id: Optional[int] = Query(default=None, ge=1),
|
||||||
|
entity_type: Optional[str] = Query(default=None),
|
||||||
|
start_time: Optional[datetime] = Query(default=None),
|
||||||
|
end_time: Optional[datetime] = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=100),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
header_tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
|
||||||
|
if current_user is None:
|
||||||
|
effective_tenant_id = int(header_tenant_id or 1)
|
||||||
|
else:
|
||||||
|
effective_tenant_id = _effective_tenant_id(current_user, header_tenant_id)
|
||||||
|
if tenant_id is not None and int(tenant_id) != effective_tenant_id:
|
||||||
|
raise HTTPException(status_code=403, detail="cross-tenant query is forbidden")
|
||||||
|
scoped_tenant_id = int(tenant_id or effective_tenant_id)
|
||||||
|
|
||||||
|
items = await AuditQueryService(db).list_operations(
|
||||||
|
tenant_id=scoped_tenant_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"items": items,
|
||||||
|
"count": len(items),
|
||||||
|
"tenant_id": scoped_tenant_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -6,7 +6,7 @@ import json
|
||||||
from typing import Any, Optional
|
from 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)
|
||||||
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await ensure_project_default_members(
|
await replace_project_members(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
project_id=int(project.id),
|
project_id=int(project.id),
|
||||||
owner_id=int(current_user.id),
|
member_user_ids=member_user_ids,
|
||||||
assigned_role=project.assigned_role,
|
)
|
||||||
|
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()
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""应用中间件。"""
|
||||||
|
|
||||||
|
from .audit_context import AuditContextMiddleware
|
||||||
|
|
||||||
|
__all__ = ["AuditContextMiddleware"]
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""请求审计上下文中间件。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from ..utils.audited_write import AuditRequestContext, normalize_caller, reset_audit_context, set_audit_context
|
||||||
|
|
||||||
|
|
||||||
|
class AuditContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""为每个请求注入 trace/caller/path 审计上下文。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_caller(request: Request) -> str:
|
||||||
|
caller_header = request.headers.get("X-Caller")
|
||||||
|
if caller_header:
|
||||||
|
return normalize_caller(caller_header)
|
||||||
|
path = str(request.url.path or "")
|
||||||
|
if path.startswith(("/webhooks", "/feishu", "/wecom")):
|
||||||
|
return "ai"
|
||||||
|
if request.headers.get("Authorization"):
|
||||||
|
return "user"
|
||||||
|
return "system"
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
trace_id = str(request.headers.get("X-Trace-Id") or f"req-{uuid4().hex[:24]}")[:64]
|
||||||
|
context = AuditRequestContext(
|
||||||
|
trace_id=trace_id,
|
||||||
|
interface_path=str(request.url.path or "")[:255] or "/",
|
||||||
|
caller=self._infer_caller(request),
|
||||||
|
method=str(request.method or "").upper()[:16],
|
||||||
|
)
|
||||||
|
token = set_audit_context(context)
|
||||||
|
request.state.trace_id = trace_id
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
finally:
|
||||||
|
reset_audit_context(token)
|
||||||
|
response.headers["X-Trace-Id"] = trace_id
|
||||||
|
return response
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""操作日志模型(全链路可追溯)"""
|
"""操作日志模型(全链路可追溯)"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from 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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,31 +2257,64 @@ 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()
|
||||||
|
total_budget_seconds = _intent_llm_total_budget_seconds()
|
||||||
|
stage_started_at = time.perf_counter()
|
||||||
|
|
||||||
|
async def _invoke_intent_stage(
|
||||||
|
*,
|
||||||
|
function_name: str,
|
||||||
|
route_tier: str,
|
||||||
|
route_reasons: list[str],
|
||||||
|
stage_label: str,
|
||||||
|
timeout_cap_seconds: Optional[float] = None,
|
||||||
|
) -> Optional[CanonicalIntent]:
|
||||||
|
llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name)
|
||||||
|
if timeout_cap_seconds is not None:
|
||||||
|
llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0))
|
||||||
|
if llm_timeout_seconds <= 0.0:
|
||||||
|
return None
|
||||||
|
logger.info(
|
||||||
|
"ai_pmo_intent_llm_route",
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
stage=stage_label,
|
||||||
|
function_name=function_name,
|
||||||
|
route_tier=route_tier,
|
||||||
|
timeout_seconds=llm_timeout_seconds,
|
||||||
|
forced_low_tier=forced_low_tier,
|
||||||
|
correction_action=correction_decision.action,
|
||||||
|
correction_keywords=correction_decision.matched_keywords,
|
||||||
|
route_reasons=route_reasons,
|
||||||
|
total_budget_seconds=total_budget_seconds,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
extraction = await asyncio.wait_for(
|
extraction = await asyncio.wait_for(
|
||||||
llm_service.generate_structured_object(
|
llm_service.generate_structured_object(
|
||||||
prompt,
|
prompt,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
function_name=route_decision.function_name,
|
function_name=function_name,
|
||||||
use_prompt_profile=False,
|
use_prompt_profile=False,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
max_tokens=260,
|
max_tokens=260,
|
||||||
response_mode="fast",
|
response_mode="fast",
|
||||||
),
|
),
|
||||||
timeout=INTENT_LLM_TIMEOUT_SECONDS,
|
timeout=llm_timeout_seconds,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"ai_pmo_intent_llm_timeout",
|
"ai_pmo_intent_llm_timeout",
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
timeout_seconds=INTENT_LLM_TIMEOUT_SECONDS,
|
stage=stage_label,
|
||||||
|
function_name=function_name,
|
||||||
|
timeout_seconds=llm_timeout_seconds,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"ai_pmo_intent_llm_failed",
|
"ai_pmo_intent_llm_failed",
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
|
stage=stage_label,
|
||||||
|
function_name=function_name,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
@ -2204,6 +2324,8 @@ class AIPMOKernelService:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"ai_pmo_intent_llm_empty_payload",
|
"ai_pmo_intent_llm_empty_payload",
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
|
stage=stage_label,
|
||||||
|
function_name=function_name,
|
||||||
error_code=str(getattr(extraction, "error_code", "") or ""),
|
error_code=str(getattr(extraction, "error_code", "") or ""),
|
||||||
provider=str(getattr(extraction, "provider", "") or ""),
|
provider=str(getattr(extraction, "provider", "") or ""),
|
||||||
model=str(getattr(extraction, "model", "") or ""),
|
model=str(getattr(extraction, "model", "") or ""),
|
||||||
|
|
@ -2223,13 +2345,13 @@ class AIPMOKernelService:
|
||||||
decision_trace = build_decision_trace(
|
decision_trace = build_decision_trace(
|
||||||
intent=intent_name,
|
intent=intent_name,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
route_tier=route_decision.tier,
|
route_tier=route_tier,
|
||||||
route_function=route_decision.function_name,
|
route_function=function_name,
|
||||||
gate_action="unresolved",
|
gate_action="unresolved",
|
||||||
gate_code="intent_only",
|
gate_code="intent_only",
|
||||||
correction_action=correction_decision.action,
|
correction_action=correction_decision.action,
|
||||||
correction_keywords=correction_decision.matched_keywords,
|
correction_keywords=correction_decision.matched_keywords,
|
||||||
complexity_reasons=route_decision.reasons,
|
complexity_reasons=route_reasons,
|
||||||
)
|
)
|
||||||
return self._build_canonical_intent(
|
return self._build_canonical_intent(
|
||||||
intent_name=intent_name,
|
intent_name=intent_name,
|
||||||
|
|
@ -2238,17 +2360,88 @@ class AIPMOKernelService:
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
metadata={
|
metadata={
|
||||||
"context_source": "llm",
|
"context_source": "llm",
|
||||||
|
"stage": stage_label,
|
||||||
"reasoning": reasoning[:300],
|
"reasoning": reasoning[:300],
|
||||||
"llm_function": route_decision.function_name,
|
"llm_function": function_name,
|
||||||
"route_tier": route_decision.tier,
|
"route_tier": route_tier,
|
||||||
"route_complex": route_decision.is_complex,
|
"route_complex": route_tier == "high",
|
||||||
"route_reasons": route_decision.reasons,
|
"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_action": correction_decision.action,
|
||||||
"correction_keywords": correction_decision.matched_keywords,
|
"correction_keywords": correction_decision.matched_keywords,
|
||||||
"decision_trace": decision_trace,
|
"decision_trace": decision_trace,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])]
|
||||||
|
if forced_low_tier:
|
||||||
|
low_stage_reasons.append("correction_forced_low_tier")
|
||||||
|
low_intent = await _invoke_intent_stage(
|
||||||
|
function_name=INTENT_LLM_FUNCTION_NAME,
|
||||||
|
route_tier="low",
|
||||||
|
route_reasons=low_stage_reasons,
|
||||||
|
stage_label="stage1_low",
|
||||||
|
)
|
||||||
|
if forced_low_tier:
|
||||||
|
return low_intent
|
||||||
|
|
||||||
|
should_escalate, escalate_reasons = _should_escalate_to_high_intent_route(
|
||||||
|
route_is_complex=bool(route_decision.is_complex),
|
||||||
|
low_intent=low_intent,
|
||||||
|
rule_intent=rule_intent,
|
||||||
|
)
|
||||||
|
if not should_escalate:
|
||||||
|
return low_intent
|
||||||
|
|
||||||
|
elapsed_seconds = time.perf_counter() - stage_started_at
|
||||||
|
remaining_budget_seconds = total_budget_seconds - elapsed_seconds
|
||||||
|
stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds()
|
||||||
|
if remaining_budget_seconds < stage2_min_remaining_seconds:
|
||||||
|
logger.info(
|
||||||
|
"ai_pmo_intent_stage2_skipped",
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
reason="budget_insufficient",
|
||||||
|
remaining_budget_seconds=max(0.0, remaining_budget_seconds),
|
||||||
|
min_remaining_seconds=stage2_min_remaining_seconds,
|
||||||
|
escalate_reasons=escalate_reasons,
|
||||||
|
)
|
||||||
|
if low_intent is not None:
|
||||||
|
return self._copy_intent_with_metadata(
|
||||||
|
low_intent,
|
||||||
|
stage2_skipped=True,
|
||||||
|
stage2_skip_reason="budget_insufficient",
|
||||||
|
stage2_reasons=escalate_reasons,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"]
|
||||||
|
high_intent = await _invoke_intent_stage(
|
||||||
|
function_name="classify_intent_complex",
|
||||||
|
route_tier="high",
|
||||||
|
route_reasons=high_stage_reasons,
|
||||||
|
stage_label="stage2_high",
|
||||||
|
timeout_cap_seconds=remaining_budget_seconds,
|
||||||
|
)
|
||||||
|
if high_intent is not None:
|
||||||
|
if low_intent is not None:
|
||||||
|
return self._copy_intent_with_metadata(
|
||||||
|
high_intent,
|
||||||
|
stage2_promoted=True,
|
||||||
|
stage1_intent=low_intent.name,
|
||||||
|
stage1_confidence=float(low_intent.confidence or 0.0),
|
||||||
|
)
|
||||||
|
return high_intent
|
||||||
|
if low_intent is not None:
|
||||||
|
return self._copy_intent_with_metadata(
|
||||||
|
low_intent,
|
||||||
|
stage2_attempted=True,
|
||||||
|
stage2_failed=True,
|
||||||
|
stage2_reasons=escalate_reasons,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _resolve_intent(
|
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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,22 +269,60 @@ 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:
|
||||||
|
candidates = self._project_creation_function_candidates(function_name=function_name)
|
||||||
|
if not candidates:
|
||||||
|
return ""
|
||||||
|
attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1
|
||||||
|
for candidate_function in candidates:
|
||||||
|
for attempt_index in range(attempts_per_function):
|
||||||
|
started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
self.llm.generate(
|
self.llm.generate(
|
||||||
prompt,
|
prompt,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name=function_name,
|
function_name=candidate_function,
|
||||||
|
response_mode="fast",
|
||||||
),
|
),
|
||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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(
|
logger.warning(
|
||||||
"ai_steward_project_creation_step_fallback",
|
"ai_steward_project_creation_step_fallback",
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
error=str(exc),
|
error="all_attempts_exhausted",
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""审计日志查询服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import OperationLog
|
||||||
|
from ..utils.audited_write import format_beijing_datetime
|
||||||
|
from ..utils.timezone import CN_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _to_beijing_naive(value: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value
|
||||||
|
return value.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_log(row: OperationLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": int(row.id),
|
||||||
|
"tenant_id": int(row.tenant_id),
|
||||||
|
"user_id": int(row.user_id),
|
||||||
|
"project_id": int(row.project_id) if row.project_id is not None else None,
|
||||||
|
"entity_type": str(row.entity_type or ""),
|
||||||
|
"entity_id": str(row.entity_id or ""),
|
||||||
|
"action": str(row.action or ""),
|
||||||
|
"trace_id": str(row.trace_id or ""),
|
||||||
|
"interface_path": str(row.interface_path or ""),
|
||||||
|
"caller": str(row.caller or "system"),
|
||||||
|
"remark": str(row.remark or ""),
|
||||||
|
"before_data": row.before_data if isinstance(row.before_data, dict) else None,
|
||||||
|
"after_data": row.after_data if isinstance(row.after_data, dict) else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"created_at_beijing": row.created_at_beijing or format_beijing_datetime(row.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditQueryService:
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def list_operations(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
entity_type: Optional[str],
|
||||||
|
start_time: Optional[datetime],
|
||||||
|
end_time: Optional[datetime],
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
filters = [OperationLog.tenant_id == int(tenant_id)]
|
||||||
|
if entity_type:
|
||||||
|
filters.append(OperationLog.entity_type == str(entity_type).strip()[:64])
|
||||||
|
normalized_start = _to_beijing_naive(start_time)
|
||||||
|
normalized_end = _to_beijing_naive(end_time)
|
||||||
|
if normalized_start is not None:
|
||||||
|
filters.append(OperationLog.created_at >= normalized_start)
|
||||||
|
if normalized_end is not None:
|
||||||
|
filters.append(OperationLog.created_at <= normalized_end)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(OperationLog)
|
||||||
|
.where(and_(*filters))
|
||||||
|
.order_by(OperationLog.created_at.desc(), OperationLog.id.desc())
|
||||||
|
.limit(max(1, min(int(limit), 100)))
|
||||||
|
)
|
||||||
|
rows = (await self.db.execute(query)).scalars().all()
|
||||||
|
return [_serialize_log(row) for row in rows]
|
||||||
|
|
@ -407,7 +407,8 @@ IMPORTANT:
|
||||||
)
|
)
|
||||||
|
|
||||||
# 对于修改的文件
|
# 对于修改的文件
|
||||||
old_content = file_contexts.get(file.path)?.content or ""
|
old_file_info = file_contexts.get(file.path)
|
||||||
|
old_content = old_file_info.content if old_file_info is not None else ""
|
||||||
old_lines = old_content.split("\n")
|
old_lines = old_content.split("\n")
|
||||||
new_lines = file.content.split("\n")
|
new_lines = file.content.split("\n")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""项目成员分配服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import User, UserProject
|
||||||
|
from .notification_service import NotificationService
|
||||||
|
|
||||||
|
_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+")
|
||||||
|
_ROLE_ALIAS_MAP = {
|
||||||
|
"AI": {"AI"},
|
||||||
|
"AI_ENGINEER": {"AI"},
|
||||||
|
"FRONTEND": {"FE"},
|
||||||
|
"FE": {"FE"},
|
||||||
|
"BACKEND": {"BE"},
|
||||||
|
"BE": {"BE"},
|
||||||
|
"BOSS": {"BOSS"},
|
||||||
|
"ADMIN": {"ADMIN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMemberAssignmentError(ValueError):
|
||||||
|
"""项目成员分配失败。"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, invalid_user_ids: Sequence[int] | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.invalid_user_ids = [int(uid) for uid in (invalid_user_ids or [])]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_user_ids(user_ids: Iterable[int] | None) -> list[int]:
|
||||||
|
normalized: set[int] = set()
|
||||||
|
for raw in user_ids or []:
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
normalized.add(int(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return sorted(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_role_tokens(raw: str | None) -> set[str]:
|
||||||
|
tokens = {
|
||||||
|
token.strip().upper()
|
||||||
|
for token in _ROLE_SPLIT_RE.split(str(raw or "").strip())
|
||||||
|
if token.strip()
|
||||||
|
}
|
||||||
|
normalized: set[str] = set()
|
||||||
|
for token in tokens:
|
||||||
|
normalized.update(_ROLE_ALIAS_MAP.get(token, {token}))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _user_matches_project_role(user_role: str | None, expected_roles: set[str]) -> bool:
|
||||||
|
if not expected_roles:
|
||||||
|
return False
|
||||||
|
return bool(_parse_role_tokens(user_role).intersection(expected_roles))
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_active_user_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
candidate_user_ids: Sequence[int] | None = None,
|
||||||
|
) -> set[int]:
|
||||||
|
query = select(User.id).where(
|
||||||
|
and_(
|
||||||
|
User.tenant_id == int(tenant_id),
|
||||||
|
User.status == "active",
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
normalized_ids = _normalize_user_ids(candidate_user_ids)
|
||||||
|
if normalized_ids:
|
||||||
|
query = query.where(User.id.in_(normalized_ids))
|
||||||
|
rows = await db.execute(query)
|
||||||
|
return {int(uid) for uid in rows.scalars().all() if uid is not None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_role_member_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
assigned_role: str | None,
|
||||||
|
) -> set[int]:
|
||||||
|
expected_roles = _parse_role_tokens(assigned_role)
|
||||||
|
if not expected_roles:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
rows = await db.execute(
|
||||||
|
select(User.id, User.role).where(
|
||||||
|
and_(
|
||||||
|
User.tenant_id == int(tenant_id),
|
||||||
|
User.status == "active",
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
role_member_ids: set[int] = set()
|
||||||
|
for user_id, user_role in rows.all():
|
||||||
|
if _user_matches_project_role(user_role, expected_roles):
|
||||||
|
role_member_ids.add(int(user_id))
|
||||||
|
return role_member_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_project_member_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
owner_id: int | None,
|
||||||
|
assigned_role: str | None,
|
||||||
|
requested_member_user_ids: Sequence[int] | None = None,
|
||||||
|
require_requested_members: bool = False,
|
||||||
|
include_owner: bool = True,
|
||||||
|
) -> list[int]:
|
||||||
|
"""解析项目成员列表,支持显式成员、角色兜底与 owner 兜底。"""
|
||||||
|
member_ids: set[int] = set()
|
||||||
|
requested_ids = _normalize_user_ids(requested_member_user_ids)
|
||||||
|
|
||||||
|
if requested_ids:
|
||||||
|
active_requested = await _load_active_user_ids(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
candidate_user_ids=requested_ids,
|
||||||
|
)
|
||||||
|
invalid_user_ids = sorted(set(requested_ids) - active_requested)
|
||||||
|
if invalid_user_ids:
|
||||||
|
raise ProjectMemberAssignmentError(
|
||||||
|
"member_user_ids 包含不存在或非激活用户",
|
||||||
|
invalid_user_ids=invalid_user_ids,
|
||||||
|
)
|
||||||
|
member_ids.update(active_requested)
|
||||||
|
elif require_requested_members:
|
||||||
|
raise ProjectMemberAssignmentError("至少分配一名项目成员")
|
||||||
|
|
||||||
|
if include_owner and owner_id is not None:
|
||||||
|
owner_ids = await _load_active_user_ids(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
candidate_user_ids=[int(owner_id)],
|
||||||
|
)
|
||||||
|
member_ids.update(owner_ids)
|
||||||
|
|
||||||
|
if not member_ids and not require_requested_members:
|
||||||
|
member_ids.update(
|
||||||
|
await _load_role_member_ids(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
assigned_role=assigned_role,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not member_ids and not require_requested_members:
|
||||||
|
active_user_ids = await _load_active_user_ids(db, tenant_id=tenant_id)
|
||||||
|
if active_user_ids:
|
||||||
|
member_ids.add(min(active_user_ids))
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
raise ProjectMemberAssignmentError("当前租户没有可分配成员,已拒绝写入")
|
||||||
|
return sorted(member_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_project_members(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
member_user_ids: Sequence[int],
|
||||||
|
) -> list[int]:
|
||||||
|
"""全量替换项目成员。"""
|
||||||
|
normalized_ids = _normalize_user_ids(member_user_ids)
|
||||||
|
if not normalized_ids:
|
||||||
|
raise ProjectMemberAssignmentError("至少分配一名项目成员")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
delete(UserProject).where(
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == int(tenant_id),
|
||||||
|
UserProject.project_id == int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for user_id in normalized_ids:
|
||||||
|
db.add(
|
||||||
|
UserProject(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
user_id=int(user_id),
|
||||||
|
project_id=int(project_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return normalized_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_member_assignment_failure(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
actor_user_id: int | None,
|
||||||
|
reason: str,
|
||||||
|
interface_path: str,
|
||||||
|
project_id: int | None = None,
|
||||||
|
project_title: str | None = None,
|
||||||
|
commit: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""将成员分配失败事件写入消息台。"""
|
||||||
|
if actor_user_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
project_label = f"项目#{int(project_id)}" if project_id is not None else (project_title or "新项目")
|
||||||
|
content = (
|
||||||
|
f"接口:{interface_path}\n"
|
||||||
|
f"项目:{project_label}\n"
|
||||||
|
f"原因:{reason}"
|
||||||
|
)
|
||||||
|
await NotificationService(db).create_notification(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
user_id=int(actor_user_id),
|
||||||
|
category="system",
|
||||||
|
title="项目成员分配失败",
|
||||||
|
content=content,
|
||||||
|
source="project_member_service",
|
||||||
|
entity_type="project",
|
||||||
|
entity_id=int(project_id) if project_id is not None else None,
|
||||||
|
target_url=f"/projects/{int(project_id)}/board" if project_id is not None else "/projects",
|
||||||
|
kind="error",
|
||||||
|
priority="high",
|
||||||
|
status="failed",
|
||||||
|
blocking_reason=reason[:500],
|
||||||
|
recommended_action="retry",
|
||||||
|
dedupe_key=(
|
||||||
|
f"project-member-failed:{int(tenant_id)}:"
|
||||||
|
f"{int(actor_user_id)}:{interface_path}:{int(project_id or 0)}"
|
||||||
|
)[:255],
|
||||||
|
commit=commit,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,465 @@
|
||||||
|
"""信号源脏数据修复服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import SignalSourceRegistry, User
|
||||||
|
from ..utils.audited_write import get_audit_context
|
||||||
|
from ..utils.timezone import now_cn_naive
|
||||||
|
from .notification_service import NotificationService
|
||||||
|
from .operation_log_service import log_operation
|
||||||
|
from .signal_capture import ProactiveSignalCaptureService
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceRepairError(RuntimeError):
|
||||||
|
"""信号源修复失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceRepairTimeoutError(SignalSourceRepairError):
|
||||||
|
"""信号源修复超时。"""
|
||||||
|
|
||||||
|
def __init__(self, *, registry_id: int, stage: str, timeout_seconds: int) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"signal source repair timeout: registry_id={int(registry_id)}, "
|
||||||
|
f"stage={stage}, timeout_seconds={int(timeout_seconds)}"
|
||||||
|
)
|
||||||
|
self.registry_id = int(registry_id)
|
||||||
|
self.stage = str(stage)
|
||||||
|
self.timeout_seconds = int(timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceRepairMissingSignalError(SignalSourceRepairError):
|
||||||
|
"""修复路径未拿到 signal_id。"""
|
||||||
|
|
||||||
|
def __init__(self, *, registry_id: int, project_id: int, status: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"signal source repair failed: registry_id={int(registry_id)}, "
|
||||||
|
f"project_id={int(project_id)}, capture_status={status or 'unknown'}"
|
||||||
|
)
|
||||||
|
self.registry_id = int(registry_id)
|
||||||
|
self.project_id = int(project_id)
|
||||||
|
self.status = str(status or "")
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSourceRepairService:
|
||||||
|
"""通过 capture-feishu 接口补录信号并回填历史 registry.signal_id。"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_int(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_timeout_seconds() -> int:
|
||||||
|
raw = (
|
||||||
|
os.getenv("SIGNAL_SOURCE_REPAIR_TIMEOUT_SECONDS")
|
||||||
|
or os.getenv("LLM_REQUEST_TIMEOUT")
|
||||||
|
or "60"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timeout = int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 60
|
||||||
|
return max(10, min(timeout, 300))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_repair_message(*, project_id: int, registry_id: int, source_name: str) -> str:
|
||||||
|
return (
|
||||||
|
f"给项目ID: {project_id} 补录历史接入记录 registry#{registry_id}({source_name}),"
|
||||||
|
"请仅执行入库,不需要二次处理。"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_explicit_repair_message(*, project_id: int, row: SignalSourceRegistry) -> str:
|
||||||
|
source_kind = str(row.source_kind or "").strip()[:40]
|
||||||
|
source_name = str(row.source_name or "历史信号源").strip()[:80]
|
||||||
|
source_identifier = str(row.source_identifier or "").strip()[:180]
|
||||||
|
return (
|
||||||
|
f"项目 {int(project_id)} 新增信号源:{source_name}。"
|
||||||
|
f"source_kind={source_kind}; source_identifier={source_identifier}。"
|
||||||
|
"这是历史脏数据补录,请执行 signal_source.onboard 入库并生成消息台记录。"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_capture_message_id(*, project_id: int, registry_id: int, stage: str) -> str:
|
||||||
|
phase = "explicit" if str(stage or "").strip().lower() == "explicit" else "base"
|
||||||
|
return f"om_registry_repair_{int(project_id)}_{int(registry_id)}_{phase}"[:120]
|
||||||
|
|
||||||
|
async def _load_registry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
registry_id: int,
|
||||||
|
) -> Optional[SignalSourceRegistry]:
|
||||||
|
return (
|
||||||
|
await self.db.execute(
|
||||||
|
select(SignalSourceRegistry).where(
|
||||||
|
and_(
|
||||||
|
SignalSourceRegistry.tenant_id == int(tenant_id),
|
||||||
|
SignalSourceRegistry.project_id == int(project_id),
|
||||||
|
SignalSourceRegistry.id == int(registry_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_group_chat_id(row: SignalSourceRegistry) -> str:
|
||||||
|
raw = (row.config or {}).get("chat_id") or row.source_identifier or f"repair_registry_{int(row.id)}"
|
||||||
|
return str(raw)[:120]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_route_status(capture_result: dict[str, Any]) -> str:
|
||||||
|
rows = capture_result.get("results")
|
||||||
|
if not isinstance(rows, list) or not rows:
|
||||||
|
return ""
|
||||||
|
first = rows[0] if isinstance(rows[0], dict) else {}
|
||||||
|
return str(first.get("route_status") or "")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_signal_id(cls, capture_result: dict[str, Any]) -> Optional[int]:
|
||||||
|
candidates: list[Any] = [capture_result.get("signal_id")]
|
||||||
|
for key in ("signal_source_workflow", "signal_source_orchestration", "ai_execution"):
|
||||||
|
payload = capture_result.get(key)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
candidates.append(payload.get("signal_id"))
|
||||||
|
rows = capture_result.get("results")
|
||||||
|
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||||
|
candidates.append(rows[0].get("signal_id"))
|
||||||
|
for item in candidates:
|
||||||
|
value = cls._safe_int(item)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_identifier_key(value: Any) -> str:
|
||||||
|
key = re.sub(r"\s+", "", str(value or "").strip().lower())
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
for prefix in (
|
||||||
|
"feishu:chat:",
|
||||||
|
"wecom:chat:",
|
||||||
|
"feishu_chat_",
|
||||||
|
"wecom_chat_",
|
||||||
|
"feishu:",
|
||||||
|
"wecom:",
|
||||||
|
):
|
||||||
|
if key.startswith(prefix):
|
||||||
|
key = key[len(prefix):]
|
||||||
|
break
|
||||||
|
return key[:180]
|
||||||
|
|
||||||
|
async def _find_reusable_signal_id(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
row: SignalSourceRegistry,
|
||||||
|
) -> Optional[int]:
|
||||||
|
target_key = self._normalize_identifier_key(row.source_identifier)
|
||||||
|
if not target_key:
|
||||||
|
return None
|
||||||
|
candidates = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(SignalSourceRegistry).where(
|
||||||
|
and_(
|
||||||
|
SignalSourceRegistry.tenant_id == int(tenant_id),
|
||||||
|
SignalSourceRegistry.project_id == int(project_id),
|
||||||
|
SignalSourceRegistry.id != int(row.id),
|
||||||
|
SignalSourceRegistry.signal_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for candidate in candidates:
|
||||||
|
current_key = self._normalize_identifier_key(candidate.source_identifier)
|
||||||
|
if current_key and current_key == target_key:
|
||||||
|
return self._safe_int(candidate.signal_id)
|
||||||
|
for candidate in candidates:
|
||||||
|
current_key = self._normalize_identifier_key(candidate.source_identifier)
|
||||||
|
if current_key and (current_key.endswith(target_key) or target_key.endswith(current_key)):
|
||||||
|
return self._safe_int(candidate.signal_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _capture_registry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
capture_service: ProactiveSignalCaptureService,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
sender: str,
|
||||||
|
row: SignalSourceRegistry,
|
||||||
|
stage: str,
|
||||||
|
timeout_seconds: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
message = (
|
||||||
|
self._build_explicit_repair_message(project_id=project_id, row=row)
|
||||||
|
if stage == "explicit"
|
||||||
|
else self._build_repair_message(
|
||||||
|
project_id=project_id,
|
||||||
|
registry_id=int(row.id),
|
||||||
|
source_name=str(row.source_name or "信号源"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coroutine = capture_service.capture_from_feishu(
|
||||||
|
group_chat_id=self._build_group_chat_id(row),
|
||||||
|
message_content=message,
|
||||||
|
sender=sender,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
feishu_message_id=self._build_capture_message_id(
|
||||||
|
project_id=project_id,
|
||||||
|
registry_id=int(row.id),
|
||||||
|
stage=stage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(coroutine, timeout=timeout_seconds)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise SignalSourceRepairTimeoutError(
|
||||||
|
registry_id=int(row.id),
|
||||||
|
stage=stage,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _active_user_ids(self, *, tenant_id: int) -> list[int]:
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(User.id).where(
|
||||||
|
and_(
|
||||||
|
User.tenant_id == int(tenant_id),
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [int(item) for item in rows if item is not None]
|
||||||
|
|
||||||
|
async def _emit_visibility_records(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
row: SignalSourceRegistry,
|
||||||
|
signal_id: int,
|
||||||
|
capture_status: str,
|
||||||
|
) -> None:
|
||||||
|
dedupe_key = f"signal-source-repair:{int(tenant_id)}:{int(row.id)}:{int(signal_id)}"
|
||||||
|
await NotificationService(self.db).create_notifications(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
user_ids=await self._active_user_ids(tenant_id=tenant_id),
|
||||||
|
category="system",
|
||||||
|
title="AI 已修复历史信号源关联",
|
||||||
|
content=(
|
||||||
|
f"项目ID: {int(project_id)}\n"
|
||||||
|
f"信号源ID: {int(row.id)}\n"
|
||||||
|
f"signal_id: {int(signal_id)}\n"
|
||||||
|
f"status: {capture_status or 'relinked'}"
|
||||||
|
),
|
||||||
|
source="ai_steward_repair",
|
||||||
|
entity_type="signal_source_registry",
|
||||||
|
entity_id=int(row.id),
|
||||||
|
kind="info",
|
||||||
|
priority="medium",
|
||||||
|
status="notified",
|
||||||
|
recommended_action="view_result",
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
payload={
|
||||||
|
"project_id": int(project_id),
|
||||||
|
"registry_id": int(row.id),
|
||||||
|
"signal_id": int(signal_id),
|
||||||
|
"capture_status": capture_status or "relinked",
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
context = get_audit_context()
|
||||||
|
await log_operation(
|
||||||
|
self.db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
user_id=int(context.user_id or 1),
|
||||||
|
project_id=int(project_id),
|
||||||
|
entity_type="signal_source_registry",
|
||||||
|
entity_id=str(int(row.id)),
|
||||||
|
action="signal_source_registry.repair_link",
|
||||||
|
after_data={
|
||||||
|
"signal_id": int(signal_id),
|
||||||
|
"capture_status": capture_status or "relinked",
|
||||||
|
},
|
||||||
|
remark="repair dirty signal source registry link",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _persist_registry_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
row: SignalSourceRegistry,
|
||||||
|
signal_id: int,
|
||||||
|
capture_status: str,
|
||||||
|
) -> None:
|
||||||
|
row.signal_id = int(signal_id)
|
||||||
|
row.last_signal_at = now_cn_naive()
|
||||||
|
await self._emit_visibility_records(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
row=row,
|
||||||
|
signal_id=signal_id,
|
||||||
|
capture_status=capture_status,
|
||||||
|
)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
def _build_item_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
row: SignalSourceRegistry,
|
||||||
|
signal_id: Optional[int],
|
||||||
|
capture_status: str,
|
||||||
|
work_item_id: Optional[int],
|
||||||
|
route_status: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"registry_id": int(row.id),
|
||||||
|
"signal_id": signal_id,
|
||||||
|
"capture_status": capture_status,
|
||||||
|
"work_item_id": work_item_id,
|
||||||
|
"route_status": route_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _repair_single_registry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
capture_service: ProactiveSignalCaptureService,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
registry_id: int,
|
||||||
|
sender: str,
|
||||||
|
timeout_seconds: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = await self._load_registry(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
registry_id=registry_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return {"registry_id": int(registry_id), "status": "not_found"}
|
||||||
|
if row.signal_id is not None:
|
||||||
|
return self._build_item_result(
|
||||||
|
row=row,
|
||||||
|
signal_id=self._safe_int(row.signal_id),
|
||||||
|
capture_status="already_linked",
|
||||||
|
work_item_id=None,
|
||||||
|
route_status="",
|
||||||
|
)
|
||||||
|
reusable_signal_id = await self._find_reusable_signal_id(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
row=row,
|
||||||
|
)
|
||||||
|
if reusable_signal_id is not None:
|
||||||
|
await self._persist_registry_link(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
row=row,
|
||||||
|
signal_id=int(reusable_signal_id),
|
||||||
|
capture_status="relinked",
|
||||||
|
)
|
||||||
|
return self._build_item_result(
|
||||||
|
row=row,
|
||||||
|
signal_id=int(reusable_signal_id),
|
||||||
|
capture_status="relinked",
|
||||||
|
work_item_id=None,
|
||||||
|
route_status="",
|
||||||
|
)
|
||||||
|
capture_result = await self._capture_registry(
|
||||||
|
capture_service=capture_service,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
sender=sender,
|
||||||
|
row=row,
|
||||||
|
stage="base",
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
signal_id = self._extract_signal_id(capture_result)
|
||||||
|
if signal_id is None:
|
||||||
|
capture_result = await self._capture_registry(
|
||||||
|
capture_service=capture_service,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
sender=sender,
|
||||||
|
row=row,
|
||||||
|
stage="explicit",
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
signal_id = self._extract_signal_id(capture_result)
|
||||||
|
if signal_id is None:
|
||||||
|
signal_id = await self._find_reusable_signal_id(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
row=row,
|
||||||
|
)
|
||||||
|
if signal_id is None:
|
||||||
|
raise SignalSourceRepairMissingSignalError(
|
||||||
|
registry_id=int(row.id),
|
||||||
|
project_id=int(project_id),
|
||||||
|
status=str(capture_result.get("status") or ""),
|
||||||
|
)
|
||||||
|
capture_status = str(capture_result.get("status") or "captured")
|
||||||
|
await self._persist_registry_link(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
row=row,
|
||||||
|
signal_id=int(signal_id),
|
||||||
|
capture_status=capture_status,
|
||||||
|
)
|
||||||
|
return self._build_item_result(
|
||||||
|
row=row,
|
||||||
|
signal_id=int(signal_id),
|
||||||
|
capture_status=capture_status,
|
||||||
|
work_item_id=self._safe_int(capture_result.get("work_item_id")),
|
||||||
|
route_status=self._extract_route_status(capture_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def repair_registries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: int,
|
||||||
|
registry_ids: list[int],
|
||||||
|
sender: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
capture_service = ProactiveSignalCaptureService(self.db)
|
||||||
|
timeout_seconds = self._resolve_timeout_seconds()
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
for registry_id in registry_ids:
|
||||||
|
item = await self._repair_single_registry(
|
||||||
|
capture_service=capture_service,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
registry_id=int(registry_id),
|
||||||
|
sender=sender,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
results.append(item)
|
||||||
|
await self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
await self.db.rollback()
|
||||||
|
raise
|
||||||
|
return results
|
||||||
|
|
@ -197,39 +197,78 @@ class SSTCService:
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]:
|
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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""写接口审计上下文工具。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import iscoroutinefunction
|
||||||
|
from typing import Any, Awaitable, Callable, Optional, TypeVar, cast
|
||||||
|
|
||||||
|
from .timezone import CN_TZ
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
VALID_CALLERS = {"system", "user", "ai"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditRequestContext:
|
||||||
|
trace_id: Optional[str] = None
|
||||||
|
interface_path: Optional[str] = None
|
||||||
|
caller: str = "system"
|
||||||
|
method: Optional[str] = None
|
||||||
|
tenant_id: Optional[int] = None
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
_audit_context: ContextVar[AuditRequestContext] = ContextVar(
|
||||||
|
"pmflow_audit_request_context",
|
||||||
|
default=AuditRequestContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_caller(value: Optional[str]) -> str:
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
return normalized if normalized in VALID_CALLERS else "system"
|
||||||
|
|
||||||
|
|
||||||
|
def get_audit_context() -> AuditRequestContext:
|
||||||
|
return _audit_context.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_audit_context(context: AuditRequestContext) -> Token[AuditRequestContext]:
|
||||||
|
return _audit_context.set(
|
||||||
|
AuditRequestContext(
|
||||||
|
trace_id=(str(context.trace_id)[:64] if context.trace_id else None),
|
||||||
|
interface_path=(str(context.interface_path)[:255] if context.interface_path else None),
|
||||||
|
caller=normalize_caller(context.caller),
|
||||||
|
method=(str(context.method)[:16] if context.method else None),
|
||||||
|
tenant_id=int(context.tenant_id) if context.tenant_id is not None else None,
|
||||||
|
user_id=int(context.user_id) if context.user_id is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_audit_context(token: Token[AuditRequestContext]) -> None:
|
||||||
|
_audit_context.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def update_audit_context(
|
||||||
|
*,
|
||||||
|
trace_id: Optional[str] = None,
|
||||||
|
interface_path: Optional[str] = None,
|
||||||
|
caller: Optional[str] = None,
|
||||||
|
method: Optional[str] = None,
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
) -> AuditRequestContext:
|
||||||
|
current = get_audit_context()
|
||||||
|
next_ctx = replace(
|
||||||
|
current,
|
||||||
|
trace_id=current.trace_id if trace_id is None else trace_id,
|
||||||
|
interface_path=current.interface_path if interface_path is None else interface_path,
|
||||||
|
caller=current.caller if caller is None else normalize_caller(caller),
|
||||||
|
method=current.method if method is None else method,
|
||||||
|
tenant_id=current.tenant_id if tenant_id is None else int(tenant_id),
|
||||||
|
user_id=current.user_id if user_id is None else int(user_id),
|
||||||
|
)
|
||||||
|
_audit_context.set(next_ctx)
|
||||||
|
return next_ctx
|
||||||
|
|
||||||
|
|
||||||
|
def format_beijing_datetime(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
localized = dt.replace(tzinfo=CN_TZ) if dt.tzinfo is None else dt.astimezone(CN_TZ)
|
||||||
|
return localized.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def audited_write(*, caller: Optional[str] = None) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""写接口装饰器:覆写当前请求 caller 审计上下文。"""
|
||||||
|
|
||||||
|
normalized = normalize_caller(caller)
|
||||||
|
|
||||||
|
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
if not iscoroutinefunction(func):
|
||||||
|
return func
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
current = get_audit_context()
|
||||||
|
token = set_audit_context(replace(current, caller=normalized))
|
||||||
|
try:
|
||||||
|
return await cast(Callable[..., Awaitable[Any]], func)(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
reset_audit_context(token)
|
||||||
|
|
||||||
|
return _async_wrapper
|
||||||
|
|
||||||
|
return _decorator
|
||||||
|
|
@ -1,41 +1,10 @@
|
||||||
from __future__ import annotations
|
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
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.api.projects import (
|
||||||
|
ProjectCreateRequest,
|
||||||
|
ProjectMembersAssignRequest,
|
||||||
|
assign_project_members,
|
||||||
|
create_project,
|
||||||
|
)
|
||||||
|
from app.models import Base, Notification, Project, User, UserProject
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsMemberAssignmentApiTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
tmp = NamedTemporaryFile(prefix="pmflow-project-members-test-", suffix=".db", delete=False)
|
||||||
|
tmp.close()
|
||||||
|
self.db_path = tmp.name
|
||||||
|
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
|
||||||
|
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async def asyncTearDown(self) -> None:
|
||||||
|
await self.engine.dispose()
|
||||||
|
if getattr(self, "db_path", None):
|
||||||
|
try:
|
||||||
|
os.remove(self.db_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _seed_users(self, db: AsyncSession) -> tuple[User, User, User]:
|
||||||
|
boss = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="boss-member-test",
|
||||||
|
email="boss-member-test@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Boss",
|
||||||
|
role="BOSS",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
member_a = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="member-a-test",
|
||||||
|
email="member-a-test@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Member A",
|
||||||
|
role="BE",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
member_b = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="member-b-test",
|
||||||
|
email="member-b-test@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Member B",
|
||||||
|
role="FE",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([boss, member_a, member_b])
|
||||||
|
await db.flush()
|
||||||
|
return boss, member_a, member_b
|
||||||
|
|
||||||
|
async def test_create_project_assigns_requested_members_and_owner(self) -> None:
|
||||||
|
async with self.session_factory() as db:
|
||||||
|
boss, member_a, member_b = await self._seed_users(db)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
|
||||||
|
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
|
||||||
|
):
|
||||||
|
payload = await create_project(
|
||||||
|
body=ProjectCreateRequest(
|
||||||
|
title="成员分配测试项目",
|
||||||
|
member_user_ids=[int(member_a.id), int(member_b.id)],
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
current_user=boss,
|
||||||
|
)
|
||||||
|
|
||||||
|
project_id = int(payload["id"])
|
||||||
|
assigned_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProject.user_id).where(
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == 1,
|
||||||
|
UserProject.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(int(uid) for uid in assigned_rows),
|
||||||
|
sorted([int(boss.id), int(member_a.id), int(member_b.id)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_assign_project_members_empty_should_notify_and_raise(self) -> None:
|
||||||
|
async with self.session_factory() as db:
|
||||||
|
boss, member_a, _ = await self._seed_users(db)
|
||||||
|
project = Project(
|
||||||
|
tenant_id=1,
|
||||||
|
name="P-members-empty",
|
||||||
|
title="P-members-empty",
|
||||||
|
description="members test",
|
||||||
|
owner_id=int(boss.id),
|
||||||
|
status="active",
|
||||||
|
assigned_role="backend",
|
||||||
|
signal_count=0,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
db.add(
|
||||||
|
UserProject(
|
||||||
|
tenant_id=1,
|
||||||
|
user_id=int(member_a.id),
|
||||||
|
project_id=int(project.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as exc_ctx:
|
||||||
|
await assign_project_members(
|
||||||
|
project_id=int(project.id),
|
||||||
|
body=ProjectMembersAssignRequest(member_user_ids=[]),
|
||||||
|
db=db,
|
||||||
|
current_user=boss,
|
||||||
|
)
|
||||||
|
self.assertEqual(exc_ctx.exception.status_code, 400)
|
||||||
|
|
||||||
|
notifications = (
|
||||||
|
await db.execute(
|
||||||
|
select(Notification).where(
|
||||||
|
and_(
|
||||||
|
Notification.tenant_id == 1,
|
||||||
|
Notification.user_id == int(boss.id),
|
||||||
|
Notification.title == "项目成员分配失败",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
self.assertGreaterEqual(len(notifications), 1)
|
||||||
|
|
||||||
|
async def test_assign_project_members_replaces_member_list(self) -> None:
|
||||||
|
async with self.session_factory() as db:
|
||||||
|
boss, member_a, member_b = await self._seed_users(db)
|
||||||
|
project = Project(
|
||||||
|
tenant_id=1,
|
||||||
|
name="P-members-replace",
|
||||||
|
title="P-members-replace",
|
||||||
|
description="members replace test",
|
||||||
|
owner_id=int(boss.id),
|
||||||
|
status="active",
|
||||||
|
assigned_role="backend",
|
||||||
|
signal_count=0,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
db.add(
|
||||||
|
UserProject(
|
||||||
|
tenant_id=1,
|
||||||
|
user_id=int(boss.id),
|
||||||
|
project_id=int(project.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.projects.context_cache_service.invalidate_system_context", AsyncMock()),
|
||||||
|
patch("app.api.projects.AIPMOKernelService.invalidate_world_model_cache", AsyncMock()),
|
||||||
|
):
|
||||||
|
payload = await assign_project_members(
|
||||||
|
project_id=int(project.id),
|
||||||
|
body=ProjectMembersAssignRequest(member_user_ids=[int(member_a.id), int(member_b.id)]),
|
||||||
|
db=db,
|
||||||
|
current_user=boss,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(payload["member_user_ids"]),
|
||||||
|
sorted([str(int(member_a.id)), str(int(member_b.id))]),
|
||||||
|
)
|
||||||
|
assigned_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProject.user_id).where(
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == 1,
|
||||||
|
UserProject.project_id == int(project.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(int(uid) for uid in assigned_rows),
|
||||||
|
sorted([int(member_a.id), int(member_b.id)]),
|
||||||
|
)
|
||||||
|
|
@ -80,7 +80,7 @@ services:
|
||||||
LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL}
|
LLM_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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从本地路径导入项目
|
* 从本地路径导入项目
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue