使用北京时间更新数据库写入」}Sorry seems odd maybe

This commit is contained in:
Yuuuu 2026-03-11 14:39:42 +08:00
parent 2fd932e395
commit 2e358bc2a4
30 changed files with 2008 additions and 59 deletions

View File

@ -17,11 +17,11 @@ PMFLOW_EXPECTED_DB_DIALECT=postgresql
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
LLM_PROVIDER=glm LLM_PROVIDER=glm
LLM_FALLBACK_PROVIDER=glm LLM_FALLBACK_PROVIDER=glm
OPENCLAW_ROUTING_ENABLED=false OPENCLAW_ROUTING_ENABLED=true
OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_DIRECT_EMERGENCY_ENABLED=true LLM_DIRECT_EMERGENCY_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=true LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=active LLM_ROUTING_DEFAULT_TIER=active
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
LLM_REQUEST_TIMEOUT=90 LLM_REQUEST_TIMEOUT=90

View File

@ -4,6 +4,16 @@
"source_of_truth": "openclaw", "source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"rules": [ "rules": [
{
"name": "engine_high_intent_complex_exact",
"enabled": true,
"priority": 320,
"tier": "high",
"provider": "openai",
"model": "MiniMax-M2.5",
"api_env": "OPENAI_API_KEY",
"function_exact": "classify_intent_complex"
},
{ {
"name": "engine_low_execute_glm_flash", "name": "engine_low_execute_glm_flash",
"enabled": true, "enabled": true,
@ -31,6 +41,7 @@
"api_env": "OPENAI_API_KEY", "api_env": "OPENAI_API_KEY",
"match_any": [ "match_any": [
"adaptive_rag_answer", "adaptive_rag_answer",
"classify_intent_complex",
"feishu_project_prefill", "feishu_project_prefill",
"feishu_task_orchestration", "feishu_task_orchestration",
"generate_task_breakdown", "generate_task_breakdown",

View File

@ -51,6 +51,10 @@ backend/
└── tests/ └── tests/
``` ```
- 新增:`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/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`)
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
- 新增:`backend/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 移除:`backend/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`) - 移除:`backend/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`)
@ -144,6 +148,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 配置读取类接口:参考 `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`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交)
- 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
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` - 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
@ -163,3 +169,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取 - 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取 - OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
- 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier
- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间

View File

@ -0,0 +1,70 @@
"""shift all naive timestamps to beijing and set db timezone
Revision ID: shift_all_timestamps_to_beijing
Revises: add_conversation_sessions_table
Create Date: 2026-03-11
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "shift_all_timestamps_to_beijing"
down_revision = "add_conversation_sessions_table"
branch_labels = None
depends_on = None
def _is_postgresql() -> bool:
bind = op.get_bind()
return str(getattr(getattr(bind, "dialect", None), "name", "")).lower().startswith("postgres")
def _list_timestamp_columns() -> list[tuple[str, str]]:
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
ORDER BY table_name, column_name
"""
)
).fetchall()
return [(str(row[0]), str(row[1])) for row in rows]
def _shift_all_timestamp_columns(*, hours: int) -> None:
delta_expr = f"INTERVAL '{int(hours)} hours'" if int(hours) >= 0 else f"- INTERVAL '{abs(int(hours))} hours'"
for table_name, column_name in _list_timestamp_columns():
if int(hours) >= 0:
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" + {delta_expr} WHERE "{column_name}" IS NOT NULL'
else:
sql = f'UPDATE "{table_name}" SET "{column_name}" = "{column_name}" {delta_expr} WHERE "{column_name}" IS NOT NULL'
op.execute(sa.text(sql))
def _set_database_timezone(timezone_name: str) -> None:
bind = op.get_bind()
database_name = str(bind.execute(sa.text("SELECT current_database()")).scalar_one())
role_name = str(bind.execute(sa.text("SELECT current_user")).scalar_one())
op.execute(sa.text(f'ALTER DATABASE "{database_name}" SET timezone TO \'{timezone_name}\''))
op.execute(sa.text(f'ALTER ROLE "{role_name}" SET timezone TO \'{timezone_name}\''))
def upgrade() -> None:
if not _is_postgresql():
return
_set_database_timezone("Asia/Shanghai")
_shift_all_timestamp_columns(hours=8)
def downgrade() -> None:
if not _is_postgresql():
return
_shift_all_timestamp_columns(hours=-8)
_set_database_timezone("UTC")

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -38,6 +38,13 @@ class PMOResumeWorkItemRequest(BaseModel):
source_message_id: Optional[str] = Field(default=None, max_length=128) source_message_id: Optional[str] = Field(default=None, max_length=128)
class PMOWorkItemDecisionRequest(BaseModel):
action: Literal["approve", "reject"] = Field(..., description="审批动作:approve=继续执行,reject=标记失效")
project_id: Optional[int] = Field(default=None)
source_message_id: Optional[str] = Field(default=None, max_length=128)
reason: Optional[str] = Field(default=None, max_length=500)
@router.post("/process-text") @router.post("/process-text")
async def process_text( async def process_text(
request: PMOProcessTextRequest, request: PMOProcessTextRequest,
@ -218,7 +225,7 @@ async def resume_work_item(
if target is None: if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found") raise HTTPException(status_code=404, detail="work_item_not_found")
service = AIPMOKernelService(db, tenant_id=tenant_id) service = AIPMOKernelService(db, tenant_id=tenant_id)
result = await service.process_ingress( raw_result = await service.process_ingress(
source_system="ai_pmo_ui", source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"继续处理 work_item #{int(work_item_id)}", message_text=f"继续处理 work_item #{int(work_item_id)}",
@ -229,8 +236,54 @@ async def resume_work_item(
chat_type="manual", chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id), project_hint_id=request.project_id or _safe_int(target.project_id),
) )
if not result.get("handled"): result = _normalize_resume_result(raw=raw_result, target_work_item=target)
raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed")) return success_response(result)
@router.post("/work-items/{work_item_id}/decision")
async def decide_work_item(
work_item_id: int,
request: PMOWorkItemDecisionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
target = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.id == int(work_item_id),
)
).limit(1)
)
).scalar_one_or_none()
if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
if request.action == "reject":
result = await _reject_work_item(
db=db,
tenant_id=tenant_id,
target=target,
current_user=current_user,
reason=request.reason,
)
return success_response(result)
service = AIPMOKernelService(db, tenant_id=tenant_id)
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
source_message_id=request.source_message_id or f"decision-approve-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
message_text=f"继续处理 work_item #{int(work_item_id)}",
sender=str(current_user.name or current_user.username or "PMFlow用户"),
channel_ref="ai_pmo_ui",
source_sender_open_id=None,
captured_at=None,
chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id),
)
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
return success_response(result) return success_response(result)
@ -415,3 +468,133 @@ def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project:
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
} }
def _normalize_resume_result(*, raw: dict[str, Any], target_work_item: WorkItem) -> dict[str, Any]:
if not bool(raw.get("handled")):
reason = str(raw.get("error") or raw.get("reason") or "继续执行失败").strip() or "继续执行失败"
return {
"handled": False,
"status": "failed_safe",
"followup_reply": reason,
"target_work_item_id": int(target_work_item.id),
"target_state": str(target_work_item.state or "").strip() or None,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": _safe_int(target_work_item.project_id),
"signal_id": _safe_int(target_work_item.signal_id),
"auto_tasks": [],
}
state = None
rows = raw.get("results") if isinstance(raw.get("results"), list) else []
if rows and isinstance(rows[0], dict):
state = str(rows[0].get("state") or "").strip() or None
if not state:
state = str(target_work_item.state or "").strip() or None
if state == "succeeded":
status = "succeeded"
elif state == "waiting_manual":
status = "pending_manual"
elif state == "ignored":
status = "invalidated"
else:
status = "failed_safe"
followup_reply = str(raw.get("direct_reply_text") or "").strip()
if not followup_reply:
if status == "succeeded":
followup_reply = "已继续执行并完成。"
elif status == "pending_manual":
followup_reply = "继续执行后仍需补充信息或人工确认。"
elif status == "invalidated":
followup_reply = "该工作项已失效,不再继续执行。"
else:
followup_reply = "继续执行失败,请查看详情。"
linked_project_ids = raw.get("linked_project_ids") if isinstance(raw.get("linked_project_ids"), list) else []
project_id = _safe_int(linked_project_ids[0]) if linked_project_ids else _safe_int(target_work_item.project_id)
return {
"handled": True,
"status": status,
"followup_reply": followup_reply,
"target_work_item_id": int(target_work_item.id),
"target_state": state,
"target_intent": str(target_work_item.intent or "").strip() or None,
"project_id": project_id,
"signal_id": _safe_int(raw.get("signal_id")) or _safe_int(target_work_item.signal_id),
"auto_tasks": raw.get("auto_tasks") if isinstance(raw.get("auto_tasks"), list) else [],
}
async def _reject_work_item(
*,
db: AsyncSession,
tenant_id: int,
target: WorkItem,
current_user: User,
reason: Optional[str],
) -> dict[str, Any]:
current_state = str(target.state or "").strip()
if current_state == "ignored":
return {
"handled": True,
"status": "invalidated",
"followup_reply": "该工作项已处于失效状态。",
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}
reject_reason = str(reason or "").strip() or "人工审批拒绝,工作项已失效。"
existing_execution = target.execution_result if isinstance(target.execution_result, dict) else {}
target.state = "ignored"
target.execution_result = {
**existing_execution,
"status": "invalidated",
"manual_decision": "reject",
"manual_reason": reject_reason,
"operator_user_id": _safe_int(current_user.id),
"processed_at": datetime.now().isoformat(),
}
target.review_summary = {
"verified": False,
"issues": ["rejected_by_user"],
"next_step": "该工作项已进入失效状态",
"operator": str(current_user.name or current_user.username or "").strip() or None,
"reason": reject_reason,
}
target.reply_template_key = "action_rejected"
decision_row = (
await db.execute(
select(DecisionRecord)
.where(
and_(
DecisionRecord.tenant_id == tenant_id,
DecisionRecord.work_item_id == int(target.id),
)
)
.order_by(DecisionRecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
if decision_row is not None:
decision_row.execution_result = target.execution_result
decision_row.final_outcome = "ignored"
await db.commit()
return {
"handled": True,
"status": "invalidated",
"followup_reply": reject_reason,
"target_work_item_id": int(target.id),
"target_state": "ignored",
"target_intent": str(target.intent or "").strip() or None,
"project_id": _safe_int(target.project_id),
"signal_id": _safe_int(target.signal_id),
"auto_tasks": [],
}

View File

@ -33,13 +33,14 @@ 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 ..schemas.rag import RAGAskRequest from ..schemas.rag import RAGAskRequest
from ..utils.timezone import now_cn_naive
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
async def _ensure_project_signal_link( async def _ensure_project_signal_link(

View File

@ -7,12 +7,30 @@ 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
from sqlalchemy import and_, delete, func, select from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user, get_tenant_id, require_roles from ..dependencies import get_current_user, get_tenant_id, require_roles
from ..models import Project, ProjectSignalLink, Signal, Task, User from ..models import (
AIOrchestrationRun,
AIWorkerExecution,
Budget,
ConversationSession,
CostTracking,
Experiment,
OperationExecutionLedger,
OperationLog,
Project,
ProjectSignalLink,
Signal,
SignalSourceRegistry,
Task,
User,
UserProject,
WorkItem,
)
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 ..utils.project_members import ensure_project_default_members
@ -154,6 +172,148 @@ async def _get_project_or_404(
return project return project
async def _cleanup_project_dependencies(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> None:
task_ids_subquery = (
select(Task.id)
.where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
.scalar_subquery()
)
# 先断开任务子依赖,避免删除 tasks 时触发约束错误。
await db.execute(
update(CostTracking)
.where(
and_(
CostTracking.tenant_id == tenant_id,
CostTracking.task_id.in_(task_ids_subquery),
)
)
.values(task_id=None)
)
# 非空外键:必须先删依赖记录。
await db.execute(
delete(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == tenant_id,
AIWorkerExecution.project_id == project_id,
)
)
)
await db.execute(
delete(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == tenant_id,
AIOrchestrationRun.project_id == project_id,
)
)
)
await db.execute(
delete(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == tenant_id,
SignalSourceRegistry.project_id == project_id,
)
)
)
await db.execute(
delete(ProjectSignalLink).where(
and_(
ProjectSignalLink.tenant_id == tenant_id,
ProjectSignalLink.project_id == project_id,
)
)
)
await db.execute(
delete(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
)
await db.execute(
delete(UserProject).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == project_id,
)
)
)
# 可空外键:保留记录,将 project 解绑为 null。
await db.execute(
update(WorkItem)
.where(
and_(
WorkItem.tenant_id == tenant_id,
WorkItem.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationLog)
.where(
and_(
OperationLog.tenant_id == tenant_id,
OperationLog.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(OperationExecutionLedger)
.where(
and_(
OperationExecutionLedger.tenant_id == tenant_id,
OperationExecutionLedger.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Experiment)
.where(
and_(
Experiment.tenant_id == tenant_id,
Experiment.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(Budget)
.where(
and_(
Budget.tenant_id == tenant_id,
Budget.project_id == project_id,
)
)
.values(project_id=None)
)
await db.execute(
update(ConversationSession)
.where(
and_(
ConversationSession.tenant_id == tenant_id,
ConversationSession.current_project_id == project_id,
)
)
.values(current_project_id=None)
)
@router.get("/") @router.get("/")
async def list_projects( async def list_projects(
summary: Optional[int] = Query(default=0), summary: Optional[int] = Query(default=0),
@ -270,22 +430,12 @@ async def delete_project(
): ):
tenant_id = int(current_user.tenant_id) tenant_id = int(current_user.tenant_id)
_ = current_user _ = current_user
try:
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
await db.execute( await _cleanup_project_dependencies(
delete(ProjectSignalLink).where( db,
and_( tenant_id=tenant_id,
ProjectSignalLink.tenant_id == tenant_id, project_id=project_id,
ProjectSignalLink.project_id == project_id,
)
)
)
await db.execute(
delete(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
)
)
) )
await db.execute( await db.execute(
delete(Project).where( delete(Project).where(
@ -296,7 +446,14 @@ async def delete_project(
) )
) )
await db.commit() await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc
except Exception:
await db.rollback()
raise
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=project_id)
return {"message": "Project deleted"} return {"message": "Project deleted"}

View File

@ -40,6 +40,8 @@ if not _is_sqlite():
"max_overflow": max(0, int(settings.db_max_overflow)), "max_overflow": max(0, int(settings.db_max_overflow)),
"pool_timeout": max(5, int(settings.db_pool_timeout)), "pool_timeout": max(5, int(settings.db_pool_timeout)),
"pool_recycle": max(60, int(settings.db_pool_recycle)), "pool_recycle": max(60, int(settings.db_pool_recycle)),
# 强制数据库会话时区为北京时间,确保 now()/CURRENT_TIMESTAMP 为 Asia/Shanghai。
"connect_args": {"server_settings": {"timezone": "Asia/Shanghai"}},
} }
) )

View File

@ -0,0 +1,263 @@
"""Shared decision kernel for dual-agent routing and guardrails."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Iterable, Sequence
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
BACKGROUND_MARKERS = ("因为", "所以", "用来", "为了", "方便", "希望", "背景", "场景")
MULTI_SEGMENT_MARKERS = (",", ",", "、", ";", ";", "并且", "同时", "然后")
INTENT_GROUP_KEYWORDS: dict[str, tuple[str, ...]] = {
"project": ("项目", "立项", "创建项目", "新增项目", "新建项目"),
"signal": ("信号", "信号源", "接入", "onboard", "source"),
"task": ("任务", "分配", "指派", "执行", "部署"),
"change": ("修改", "更新", "改为", "改成", "撤销", "回滚", "归档"),
}
CORRECTION_ACTION_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
("undo_last", ("撤销", "撤回", "undo", "作废上一步", "取消上一步")),
("rollback", ("回滚", "恢复到", "还原", "回退")),
("replace_target", ("改为", "改成", "替换为", "不是", "不对", "纠正")),
("reroute", ("重新识别", "重新判断", "重新路由", "再判断一次")),
)
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return float(default)
try:
value = float(str(raw).strip())
except Exception:
return float(default)
return float(default) if value < 0 else value
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).strip().lower() in BOOL_TRUE_VALUES
def _normalize_text(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).lower()
def _normalize_name(name: str) -> str:
return re.sub(r"[\s_\-]+", "", str(name or "")).lower()
@dataclass(slots=True)
class IntentRouteDecision:
function_name: str
tier: str
is_complex: bool
reasons: list[str]
@dataclass(slots=True)
class GateDecision:
action: str
gate_code: str
reason: str
auto_execute: bool
@dataclass(slots=True)
class CorrectionDecision:
action: str
confidence: float
matched_keywords: list[str]
def _count_intent_groups(text: str) -> int:
normalized = _normalize_text(text)
hits = 0
for tokens in INTENT_GROUP_KEYWORDS.values():
if any(_normalize_name(token) in normalized for token in tokens):
hits += 1
return hits
def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]:
normalized = _normalize_text(text)
if not normalized:
return False, []
reasons: list[str] = []
length_threshold = int(_env_float("AGENT_COMPLEX_LENGTH_THRESHOLD", 20))
if len(normalized) > length_threshold:
reasons.append("length_gt_threshold")
has_multi_segment = any(marker in text for marker in MULTI_SEGMENT_MARKERS)
if has_multi_segment:
reasons.append("multi_segment")
has_background = any(marker in text for marker in BACKGROUND_MARKERS)
if has_background:
reasons.append("has_background")
intent_group_hits = _count_intent_groups(text)
if intent_group_hits >= 3 or (
intent_group_hits >= 2
and (
len(normalized) > max(14, length_threshold)
or has_multi_segment
or has_background
)
):
reasons.append("multi_intent_groups")
for name in project_names or []:
candidate = _normalize_name(name)
if len(candidate) >= 2 and candidate in normalized:
reasons.append("contains_project_name")
break
return bool(reasons), reasons
def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision:
is_complex, reasons = is_complex_instruction(text, project_names=project_names)
if is_complex:
return IntentRouteDecision(
function_name=INTENT_FUNCTION_COMPLEX,
tier="high",
is_complex=True,
reasons=reasons,
)
return IntentRouteDecision(
function_name=INTENT_FUNCTION_SIMPLE,
tier="low",
is_complex=False,
reasons=["default_simple"],
)
def resolve_correction(text: str) -> CorrectionDecision:
normalized = _normalize_text(text)
if not normalized:
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
for action, tokens in CORRECTION_ACTION_PATTERNS:
matched = [token for token in tokens if _normalize_name(token) in normalized]
if matched:
confidence = 0.9 if len(matched) >= 2 else 0.78
return CorrectionDecision(
action=action,
confidence=float(min(1.0, confidence)),
matched_keywords=matched,
)
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
def gate_execution(
*,
confidence: float,
risk_level: str = "medium",
correction_action: str = "none",
force_manual: bool = False,
force_manual_code: str = "manual_review_required",
force_manual_reason: str = "",
) -> GateDecision:
auto_threshold = _env_float("AGENT_GATE_AUTO_THRESHOLD", 0.82)
review_threshold = _env_float("AGENT_GATE_REVIEW_THRESHOLD", 0.60)
strict_high_risk = _env_bool("AGENT_GATE_STRICT_HIGH_RISK", True)
require_review_below_auto = _env_bool("AGENT_GATE_REVIEW_BELOW_AUTO", False)
normalized_risk = str(risk_level or "medium").strip().lower()
safe_confidence = max(0.0, min(1.0, float(confidence or 0.0)))
if force_manual:
return GateDecision(
action="pending_manual",
gate_code=str(force_manual_code or "manual_review_required").strip() or "manual_review_required",
reason=str(force_manual_reason or "命中人工复核策略,需人工确认后执行。").strip(),
auto_execute=False,
)
if correction_action in {"undo_last", "rollback", "replace_target", "reroute"}:
return GateDecision(
action="pending_manual",
gate_code="correction_review",
reason="检测到修正/回滚语义,需人工确认后执行。",
auto_execute=False,
)
if safe_confidence < review_threshold:
return GateDecision(
action="reject",
gate_code="confidence_too_low",
reason="置信度低于安全阈值,已阻断自动执行。",
auto_execute=False,
)
if normalized_risk in {"high", "critical"} and strict_high_risk and safe_confidence < max(auto_threshold, 0.9):
return GateDecision(
action="pending_manual",
gate_code="high_risk_manual_review",
reason="高风险写操作需要更高置信度,当前进入待人工确认。",
auto_execute=False,
)
if safe_confidence < auto_threshold and require_review_below_auto:
return GateDecision(
action="pending_manual",
gate_code="confidence_review_required",
reason="置信度未达自动执行阈值,进入待人工确认。",
auto_execute=False,
)
return GateDecision(
action="auto_execute",
gate_code="auto_execute_allowed",
reason="置信度达标,允许自动执行。",
auto_execute=True,
)
def build_decision_trace(
*,
intent: str,
confidence: float,
route_tier: str,
route_function: str,
gate_action: str,
gate_code: str,
correction_action: str = "none",
correction_keywords: Iterable[str] | None = None,
complexity_reasons: Iterable[str] | None = None,
) -> dict[str, object]:
return {
"intent": str(intent or "").strip(),
"confidence": round(max(0.0, min(1.0, float(confidence or 0.0))), 4),
"route_tier": str(route_tier or "").strip(),
"route_function": str(route_function or "").strip(),
"gate_action": str(gate_action or "").strip(),
"gate_code": str(gate_code or "").strip(),
"correction_action": str(correction_action or "none").strip(),
"correction_keywords": [str(token) for token in (correction_keywords or []) if str(token).strip()],
"complexity_reasons": [str(token) for token in (complexity_reasons or []) if str(token).strip()],
}
def compute_capability_score(
*,
intent_accuracy: float,
gate_precision: float,
exec_success: float,
correction_success: float,
) -> float:
score = (
0.30 * max(0.0, min(1.0, float(intent_accuracy or 0.0)))
+ 0.30 * max(0.0, min(1.0, float(gate_precision or 0.0)))
+ 0.25 * max(0.0, min(1.0, float(exec_success or 0.0)))
+ 0.15 * max(0.0, min(1.0, float(correction_success or 0.0)))
)
return round(score, 4)

View File

@ -9,10 +9,11 @@ from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AIOrchestrationRun from ..models import AIOrchestrationRun
from ..utils.timezone import now_cn_naive
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
class AIOrchestrationRunService: class AIOrchestrationRunService:

View File

@ -9,10 +9,11 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem
from ..utils.timezone import now_cn_naive
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
class AIPMOCadenceService: class AIPMOCadenceService:

View File

@ -57,6 +57,12 @@ from ..schemas.world_model import (
WorldModel, WorldModel,
) )
from .adaptive_rag_service import AdaptiveRAGService from .adaptive_rag_service import AdaptiveRAGService
from .ai_agent_kernel import (
build_decision_trace,
gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction,
select_intent_route,
)
from .ai_pmo_cadence_service import AIPMOCadenceService from .ai_pmo_cadence_service import AIPMOCadenceService
from .ai_steward import AIStewardService from .ai_steward import AIStewardService
from .ai_pmo_review_service import AIPMOReviewService from .ai_pmo_review_service import AIPMOReviewService
@ -66,6 +72,7 @@ from .openclaw_commander_service import OpenClawCommanderService
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from .llm import llm_service from .llm import llm_service
from .signal_source_orchestration_service import SignalSourceOrchestrationService from .signal_source_orchestration_service import SignalSourceOrchestrationService
from ..utils.timezone import now_cn_naive
logger = get_logger(__name__) logger = get_logger(__name__)
@ -195,13 +202,21 @@ INTENT_CANDIDATES = (
INTENT_FOLLOWUP_CONTINUE, INTENT_FOLLOWUP_CONTINUE,
INTENT_IGNORE, INTENT_IGNORE,
) )
WRITE_INTENTS = {
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
INTENT_FOLLOWUP_CONFIRM,
INTENT_FOLLOWUP_CONTINUE,
}
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
def _env_bool(name: str, default: bool = False) -> bool: def _env_bool(name: str, default: bool = False) -> bool:
@ -1152,6 +1167,32 @@ class AIPMOKernelService:
world_model=world_model, world_model=world_model,
message_text=clean_text, message_text=clean_text,
) )
correction_action = str((intent.metadata or {}).get("correction_action") or "none").strip() or "none"
requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review"))
manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip()
manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip()
llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip()
route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip()
route_reasons = (intent.metadata or {}).get("route_reasons")
gate = kernel_gate_execution(
confidence=float(intent.confidence or 0.0),
risk_level=self._risk_level_for_intent(intent.name),
correction_action=correction_action,
force_manual=requires_manual_review,
force_manual_code=manual_review_code,
force_manual_reason=manual_review_reason,
)
decision_trace = build_decision_trace(
intent=intent.name,
confidence=float(intent.confidence or 0.0),
route_tier=route_tier,
route_function=llm_function,
gate_action=gate.action,
gate_code=gate.gate_code,
correction_action=correction_action,
correction_keywords=(intent.metadata or {}).get("correction_keywords") if isinstance(intent.metadata, dict) else [],
complexity_reasons=route_reasons if isinstance(route_reasons, list) else [],
)
work_item.state = STATE_DECIDED work_item.state = STATE_DECIDED
work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio") work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio")
@ -1174,12 +1215,22 @@ class AIPMOKernelService:
) )
policy = decision_payload["policy"] policy = decision_payload["policy"]
if not gate.auto_execute:
policy["auto_execute"] = False
policy["policy_gate_hit"] = True
policy["policy_gate_code"] = gate.gate_code
policy["approval_requirement"] = {
"required": True,
"reason": gate.gate_code,
"message": gate.reason,
}
if not bool(policy.get("auto_execute")): if not bool(policy.get("auto_execute")):
work_item.state = STATE_WAITING_MANUAL work_item.state = STATE_WAITING_MANUAL
work_item.action_bundle = decision_payload["execution_plan"] work_item.action_bundle = decision_payload["execution_plan"]
work_item.execution_result = { work_item.execution_result = {
"status": "waiting_manual", "status": "waiting_manual",
"reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(), "reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(),
"decision_trace": decision_trace,
} }
manual_issues = ["waiting_manual"] manual_issues = ["waiting_manual"]
policy_gate_code = str(policy.get("policy_gate_code") or "").strip() policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
@ -1276,6 +1327,8 @@ class AIPMOKernelService:
source_message_id=synthetic_message_id, source_message_id=synthetic_message_id,
) )
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
if isinstance(execution_result, dict):
execution_result.setdefault("decision_trace", decision_trace)
work_item.execution_result = execution_result work_item.execution_result = execution_result
if _safe_int(execution_result.get("project_id")) is not None: if _safe_int(execution_result.get("project_id")) is not None:
work_item.project_id = _safe_int(execution_result.get("project_id")) work_item.project_id = _safe_int(execution_result.get("project_id"))
@ -1841,6 +1894,121 @@ class AIPMOKernelService:
def _intent_llm_enabled() -> bool: def _intent_llm_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True) return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
@staticmethod
def _intent_arbitration_enabled() -> bool:
return _env_bool("AI_PMO_INTENT_ARBITRATION_ENABLED", True)
@staticmethod
def _intent_conflict_policy() -> str:
raw = str(os.getenv("AI_PMO_INTENT_CONFLICT_POLICY", "pending_manual") or "").strip().lower()
return raw if raw in INTENT_CONFLICT_POLICIES else "pending_manual"
@staticmethod
def _copy_intent_with_metadata(intent: CanonicalIntent, **metadata_updates: Any) -> CanonicalIntent:
metadata = dict(intent.metadata or {})
metadata.update(metadata_updates)
return CanonicalIntent(
intent.name,
float(intent.confidence or 0.0),
intent.project_id,
intent.signal_title,
intent.signal_summary,
metadata,
)
@staticmethod
def _is_default_rule_query(intent: CanonicalIntent) -> bool:
if intent.name != INTENT_QUERY:
return False
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback"
@staticmethod
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
if intent.name != INTENT_PROJECT_CREATE:
return True, {}
extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
has_title = bool(str(extracted_title or "").strip())
return has_title, {
"slot_check": "project_title",
"slot_ok": has_title,
"extracted_project_title": extracted_title[:50] if extracted_title else "",
}
def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent:
slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text)
if slot_ok:
if slot_meta:
return self._copy_intent_with_metadata(intent, **slot_meta)
return intent
return self._copy_intent_with_metadata(
intent,
**slot_meta,
requires_manual_review=True,
manual_review_code="slot_incomplete",
manual_review_reason="关键信息不完整,需人工确认后执行。",
)
def _arbitrate_intent(
self,
*,
rule_intent: CanonicalIntent,
llm_intent: CanonicalIntent,
message_text: str,
) -> tuple[CanonicalIntent, str]:
if rule_intent.name == llm_intent.name:
return self._copy_intent_with_metadata(
llm_intent,
arbitration="consensus",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
), "llm"
policy = self._intent_conflict_policy()
rule_is_write = rule_intent.name in WRITE_INTENTS
llm_is_write = llm_intent.name in WRITE_INTENTS
has_write_conflict = rule_is_write or llm_is_write
prefer_rule_by_default = (
not self._is_default_rule_query(rule_intent)
and (
rule_intent.confidence >= llm_intent.confidence
or self._is_default_rule_query(llm_intent)
)
)
if policy == "prefer_rule":
chosen = rule_intent
source = "rule_fallback"
elif policy == "prefer_llm":
chosen = llm_intent
source = "llm"
else:
chosen = rule_intent if prefer_rule_by_default else llm_intent
source = "rule_fallback" if chosen is rule_intent else "llm"
if has_write_conflict and policy == "pending_manual":
chosen = self._copy_intent_with_metadata(
chosen,
arbitration="conflict_manual_review",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
requires_manual_review=True,
manual_review_code="intent_conflict_write",
manual_review_reason="规则与LLM意图冲突,且涉及写操作,需人工确认后执行。",
)
return chosen, source
return self._copy_intent_with_metadata(
chosen,
arbitration="prefer_rule" if source == "rule_fallback" else "prefer_llm",
arbitration_rule_intent=rule_intent.name,
arbitration_llm_intent=llm_intent.name,
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
), source
@classmethod @classmethod
def _is_short_confirmation_message( def _is_short_confirmation_message(
cls, cls,
@ -1945,6 +2113,13 @@ class AIPMOKernelService:
session: Optional[ConversationSession], session: Optional[ConversationSession],
world_model: dict[str, Any], world_model: dict[str, Any],
) -> Optional[CanonicalIntent]: ) -> Optional[CanonicalIntent]:
project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_names = [
str(project_snapshot.get("title") or "").strip(),
str(project_snapshot.get("name") or "").strip(),
]
route_decision = select_intent_route(message_text, project_names=project_names)
correction_decision = kernel_resolve_correction(message_text)
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 "",
@ -1958,7 +2133,7 @@ class AIPMOKernelService:
prompt, prompt,
db=self.db, db=self.db,
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
function_name=INTENT_LLM_FUNCTION_NAME, function_name=route_decision.function_name,
use_prompt_profile=False, use_prompt_profile=False,
temperature=0.0, temperature=0.0,
max_tokens=260, max_tokens=260,
@ -2002,6 +2177,17 @@ class AIPMOKernelService:
if project_id is None: if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id")) project_id = _safe_int(world_model.get("resolved_project_id"))
reasoning = str(payload.get("reasoning") or "").strip() reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
route_tier=route_decision.tier,
route_function=route_decision.function_name,
gate_action="unresolved",
gate_code="intent_only",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=route_decision.reasons,
)
return self._build_canonical_intent( return self._build_canonical_intent(
intent_name=intent_name, intent_name=intent_name,
message_text=message_text, message_text=message_text,
@ -2010,7 +2196,13 @@ class AIPMOKernelService:
metadata={ metadata={
"context_source": "llm", "context_source": "llm",
"reasoning": reasoning[:300], "reasoning": reasoning[:300],
"llm_function": INTENT_LLM_FUNCTION_NAME, "llm_function": route_decision.function_name,
"route_tier": route_decision.tier,
"route_complex": route_decision.is_complex,
"route_reasons": route_decision.reasons,
"correction_action": correction_decision.action,
"correction_keywords": correction_decision.matched_keywords,
"decision_trace": decision_trace,
}, },
) )
@ -2022,9 +2214,10 @@ class AIPMOKernelService:
world_model: dict[str, Any], world_model: dict[str, Any],
) -> tuple[CanonicalIntent, str]: ) -> tuple[CanonicalIntent, str]:
project_id = _safe_int(world_model.get("resolved_project_id")) project_id = _safe_int(world_model.get("resolved_project_id"))
rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id)
if self._is_short_confirmation_message(message_text=message_text, session=session): if self._is_short_confirmation_message(message_text=message_text, session=session):
if session is None: if session is None:
return self.intent_resolver.resolve(text=message_text, project_id=project_id), "rule_fallback" return rule_intent, "rule_fallback"
return ( return (
self._build_short_confirm_intent( self._build_short_confirm_intent(
message_text=message_text, message_text=message_text,
@ -2041,9 +2234,17 @@ class AIPMOKernelService:
world_model=world_model, world_model=world_model,
) )
if llm_intent is not None: if llm_intent is not None:
return llm_intent, "llm" if self._intent_arbitration_enabled():
selected_intent, source = self._arbitrate_intent(
rule_intent=rule_intent,
llm_intent=llm_intent,
message_text=message_text,
)
else:
selected_intent, source = llm_intent, "llm"
return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source
return self.intent_resolver.resolve(text=message_text, project_id=project_id), "rule_fallback" return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_fallback"
async def _build_portfolio_summary( async def _build_portfolio_summary(
self, self,
@ -2983,6 +3184,11 @@ class AIPMOKernelService:
"work_item_id": int(work_item.id), "work_item_id": int(work_item.id),
"decision_level": work_item.decision_level, "decision_level": work_item.decision_level,
"review_summary": work_item.review_summary, "review_summary": work_item.review_summary,
"decision_trace": (
execution_result.get("decision_trace")
if isinstance(execution_result, dict)
else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None)
),
} }
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = execution_result["rag_result"] result["rag_result"] = execution_result["rag_result"]
@ -3009,6 +3215,14 @@ class AIPMOKernelService:
return "work_item.resume" return "work_item.resume"
return None return None
@staticmethod
def _risk_level_for_intent(intent_name: str) -> str:
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
return "high"
if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
return "medium"
return "low"
@staticmethod @staticmethod
def _extract_followup_work_item_id(message: str) -> Optional[int]: def _extract_followup_work_item_id(message: str) -> Optional[int]:
match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or "")) match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or ""))

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
from typing import Any, Dict, List from typing import Any, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -13,6 +14,7 @@ from ..database import get_db
from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode
from ..utils.project_members import ensure_project_default_members from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence from ..utils.sequence import ensure_project_id_sequence
from ..utils.title import compact_title
from .adaptive_rag_service import AdaptiveRAGService from .adaptive_rag_service import AdaptiveRAGService
from .llm import LLMService from .llm import LLMService
from .task_persistence import ( from .task_persistence import (
@ -33,6 +35,71 @@ class AIStewardService:
self.db = db self.db = db
self.llm = LLMService() self.llm = LLMService()
@staticmethod
def _normalize_project_title_candidate(candidate: str) -> str:
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
if not cleaned:
return ""
normalized = re.sub(r"\s+", "", cleaned).lower()
generic_tokens = {
"项目",
"一个项目",
"新项目",
"项目列表",
"项目库",
"pmflow",
"pmflow项目列表",
}
if normalized in generic_tokens:
return ""
if any(token in normalized for token in ("创建项目", "新建项目", "新增项目", "不是创建信号")):
return ""
return compact_title(cleaned, max_len=24)
@classmethod
def _extract_project_title_from_instruction(cls, text: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
if not cleaned:
return ""
# 优先去掉“不是X,是Y”中的前置纠正片段,避免把纠正话术落到标题里。
cleaned = re.sub(r"^.*?不是[^,。,;;]*信号[^,。,;;]*[,,;; ]*是", "", cleaned)
quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned)
if quoted_match:
return cls._normalize_project_title_candidate(quoted_match.group(1))
for pattern in (
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
):
match = re.search(pattern, cleaned, flags=re.IGNORECASE)
if not match:
continue
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
candidate = re.sub(r"^(?:一个|个|新项目|项目)\s*", "", candidate, flags=re.IGNORECASE)
candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip()
normalized = cls._normalize_project_title_candidate(candidate)
if normalized:
return normalized
return ""
@staticmethod
def _looks_like_instruction_sentence(text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
return "项目" in normalized and any(
token in normalized
for token in ("创建", "新建", "新增", "增加", "添加", "帮我", "给我", "不是", "信号")
)
@classmethod
def _compact_project_description(cls, *, input_text: str, title: str, draft_description: str) -> str:
raw = str(draft_description or "").strip()
if raw and not cls._looks_like_instruction_sentence(raw):
return raw[:4000]
if title:
return f"项目:{title}"[:4000]
return str(input_text or "").strip()[:4000]
async def create_project_from_natural_language( async def create_project_from_natural_language(
self, self,
input_text: str, input_text: str,
@ -207,10 +274,26 @@ class AIStewardService:
} }
) )
extracted_title = self._extract_project_title_from_instruction(input_text)
draft_title = str(project_draft.get("title") or "").strip()
if self._looks_like_instruction_sentence(draft_title):
draft_title = ""
normalized_title = (
self._normalize_project_title_candidate(draft_title)
or extracted_title
or compact_title(str(input_text or "").strip(), max_len=24)
or "未命名项目"
)
draft_name = str(project_draft.get("name") or "").strip()
normalized_name = self._normalize_project_title_candidate(draft_name) or normalized_title
normalized_project = { normalized_project = {
"name": str(project_draft.get("name") or "未命名项目").strip()[:50] or "未命名项目", "name": normalized_name[:50] or "未命名项目",
"title": str(project_draft.get("title") or input_text[:50] or "未命名项目").strip()[:50] or "未命名项目", "title": normalized_title[:50] or "未命名项目",
"description": str(project_draft.get("description") or input_text).strip()[:4000] or input_text[:4000], "description": self._compact_project_description(
input_text=input_text,
title=normalized_title,
draft_description=str(project_draft.get("description") or ""),
),
"assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend", "assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend",
"domain_id": analysis.get("domain_id"), "domain_id": analysis.get("domain_id"),
} }

View File

@ -33,10 +33,16 @@ from ..models import (
) )
from .llm import LLMService from .llm import LLMService
from .adaptive_rag_service import AdaptiveRAGService from .adaptive_rag_service import AdaptiveRAGService
from .ai_agent_kernel import (
build_decision_trace,
gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction,
)
from .notification_service import NotificationService from .notification_service import NotificationService
from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService
from ..utils.project_members import ensure_project_default_members from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import CN_TZ, now_cn_aware
logger = get_logger(__name__) logger = get_logger(__name__)
@ -344,7 +350,7 @@ def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
def _utc_now() -> datetime: def _utc_now() -> datetime:
return datetime.now(timezone.utc) return now_cn_aware()
def _compact_text(value: Any, max_len: int = 160) -> str: def _compact_text(value: Any, max_len: int = 160) -> str:
@ -366,7 +372,7 @@ def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Opt
if value is None: if value is None:
return None return None
if value.tzinfo is not None: if value.tzinfo is not None:
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = value.astimezone(CN_TZ).replace(tzinfo=None)
return value + timedelta(minutes=minutes) return value + timedelta(minutes=minutes)
@ -570,6 +576,28 @@ class FeishuSignalPipelineService:
correction_match = recall_rows[0] correction_match = recall_rows[0]
except Exception as exc: except Exception as exc:
logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}")
correction_decision = kernel_resolve_correction(text)
if correction_decision.action != "none":
decision_trace = build_decision_trace(
intent=INTENT_CLARIFICATION_REQUIRED,
confidence=correction_decision.confidence,
route_tier="rule",
route_function="_resolve_intent_gate",
gate_action="pending_manual",
gate_code="correction_review",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=[],
)
return {
"intent": INTENT_CLARIFICATION_REQUIRED,
"canonical_action": "clarify",
"clarification_template": "检测到修正/回滚语义,已进入待确认队列。请明确目标对象后继续执行。",
"risk_level": "high",
"fields": {},
"correction_match": correction_match,
"decision_trace": decision_trace,
}
if self._is_healthcheck_message(text): if self._is_healthcheck_message(text):
return { return {
@ -1578,7 +1606,7 @@ class FeishuSignalPipelineService:
) -> Optional[int]: ) -> Optional[int]:
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is not None: if occurred_at.tzinfo is not None:
occurred_at = occurred_at.astimezone(timezone.utc).replace(tzinfo=None) occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None)
item = FeishuItem( item = FeishuItem(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
@ -1792,7 +1820,7 @@ class FeishuSignalPipelineService:
) )
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is None: if occurred_at.tzinfo is None:
occurred_at = occurred_at.replace(tzinfo=timezone.utc) occurred_at = occurred_at.replace(tzinfo=CN_TZ)
operator_user = await self._resolve_command_operator_user(sender=sender) operator_user = await self._resolve_command_operator_user(sender=sender)
if not fields.get("ready"): if not fields.get("ready"):
@ -1998,7 +2026,7 @@ class FeishuSignalPipelineService:
) )
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is None: if occurred_at.tzinfo is None:
occurred_at = occurred_at.replace(tzinfo=timezone.utc) occurred_at = occurred_at.replace(tzinfo=CN_TZ)
operator_user = await self._resolve_command_operator_user(sender=sender) operator_user = await self._resolve_command_operator_user(sender=sender)
if not fields.get("ready"): if not fields.get("ready"):
@ -2188,7 +2216,7 @@ class FeishuSignalPipelineService:
) -> dict[str, Any]: ) -> dict[str, Any]:
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is None: if occurred_at.tzinfo is None:
occurred_at = occurred_at.replace(tzinfo=timezone.utc) occurred_at = occurred_at.replace(tzinfo=CN_TZ)
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。" blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。"
queued = await self._queue_command_intent_item( queued = await self._queue_command_intent_item(
@ -2726,7 +2754,7 @@ class FeishuSignalPipelineService:
routing_policy = await self._load_routing_policy() routing_policy = await self._load_routing_policy()
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is None: if occurred_at.tzinfo is None:
occurred_at = occurred_at.replace(tzinfo=timezone.utc) occurred_at = occurred_at.replace(tzinfo=CN_TZ)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
parent_item_id: Optional[int] = None parent_item_id: Optional[int] = None
@ -3785,7 +3813,7 @@ class FeishuSignalPipelineService:
return None return None
occurred_at = captured_at or _utc_now() occurred_at = captured_at or _utc_now()
if occurred_at.tzinfo is not None: if occurred_at.tzinfo is not None:
occurred_at = occurred_at.astimezone(timezone.utc).replace(tzinfo=None) occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None)
item = FeishuItem( item = FeishuItem(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
feishu_message_id=feishu_message_id, feishu_message_id=feishu_message_id,
@ -4021,7 +4049,7 @@ class FeishuSignalPipelineService:
pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)] pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)]
# DB 列是 timestamp without time zone,比较值必须使用 naive UTC,避免 asyncpg aware/naive 报错。 # DB 列是 timestamp without time zone,比较值必须使用 naive UTC,避免 asyncpg aware/naive 报错。
today_start = _utc_now().astimezone(timezone.utc).replace( today_start = _utc_now().astimezone(CN_TZ).replace(
hour=0, hour=0,
minute=0, minute=0,
second=0, second=0,
@ -4732,7 +4760,23 @@ class FeishuSignalPipelineService:
signal_auto_enter_threshold: float, signal_auto_enter_threshold: float,
) -> Optional[int]: ) -> Optional[int]:
item.matched_project_id = int(matched_project_id) if matched_project_id else None item.matched_project_id = int(matched_project_id) if matched_project_id else None
if cold_start_enabled or confidence < signal_auto_enter_threshold or not matched_project_id: gate = kernel_gate_execution(
confidence=float(confidence or 0.0),
risk_level=("medium" if matched_project_id else "high"),
correction_action="none",
)
decision_trace = build_decision_trace(
intent=INTENT_SIGNAL_INTAKE,
confidence=float(confidence or 0.0),
route_tier="rule",
route_function="_route_signal_item",
gate_action=gate.action,
gate_code=gate.gate_code,
correction_action="none",
correction_keywords=[],
complexity_reasons=[],
)
if cold_start_enabled or gate.action != "auto_execute" or confidence < signal_auto_enter_threshold or not matched_project_id:
item.card_type = "signal" item.card_type = "signal"
item.route_status = ROUTE_STATUS_QUEUED item.route_status = ROUTE_STATUS_QUEUED
item.queue_visible = True item.queue_visible = True
@ -4747,10 +4791,20 @@ class FeishuSignalPipelineService:
item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。" item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。"
item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。" item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。"
item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。" item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。"
elif gate.action == "reject":
item.blocking_reason = gate.reason
item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。"
item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。"
else: else:
item.blocking_reason = "当前置信度不足以自动入库,需要你拍板后再继续。" item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。"
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。"
item.result_payload = {
"result_summary": "信号已进入待确认队列。",
"result_count": 0,
"impact_objects": [signal.title or f"信号#{signal.id}"],
"decision_trace": decision_trace,
}
return matched_project_id return matched_project_id
await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id))
@ -4771,6 +4825,7 @@ class FeishuSignalPipelineService:
"impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"], "impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"],
"target_url": f"/projects/{int(matched_project_id)}/board", "target_url": f"/projects/{int(matched_project_id)}/board",
"project_id": int(matched_project_id), "project_id": int(matched_project_id),
"decision_trace": decision_trace,
} }
await self._notify_signal_auto_entered( await self._notify_signal_auto_entered(
project_id=int(matched_project_id), project_id=int(matched_project_id),

View File

@ -90,6 +90,7 @@ DEFAULT_TIER_MODEL_OVERRIDES = {
"safety": OPENCLAW_DEFAULT_SAFETY_MODEL, "safety": OPENCLAW_DEFAULT_SAFETY_MODEL,
} }
DEFAULT_HIGH_TIER_HINTS = ( DEFAULT_HIGH_TIER_HINTS = (
"classify_intent_complex",
"project_from_text", "project_from_text",
"feishu_project_prefill", "feishu_project_prefill",
"feishu_task_orchestration", "feishu_task_orchestration",
@ -225,6 +226,17 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"project_id": ("number_or_none",), "project_id": ("number_or_none",),
}, },
}, },
"classify_intent_complex": {
"required_keys": ("intent", "confidence", "reasoning"),
"type_checks": {
"intent": "str",
"confidence": "number",
"reasoning": "str",
},
"optional_type_checks": {
"project_id": ("number_or_none",),
},
},
} }

View File

@ -41,6 +41,7 @@ from .sstc_service import ALIGNED_STATUSES
from .task_persistence import build_task_create_draft, persist_task from .task_persistence import build_task_create_draft, persist_task
from ..utils.project_members import ensure_project_default_members from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import now_cn_naive
from ..utils.title import compact_title from ..utils.title import compact_title
SUPPORTED_COMMANDER_OPERATION_CODES = ( SUPPORTED_COMMANDER_OPERATION_CODES = (
@ -66,7 +67,7 @@ SOURCE_ONBOARDING_FALLBACK_RE = re.compile(
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
class OpenClawCommanderService: class OpenClawCommanderService:

View File

@ -32,12 +32,13 @@ from ..models import (
Task, Task,
User, User,
) )
from ..utils.timezone import now_cn_naive
from .context_cache import context_cache_service from .context_cache import context_cache_service
from .symbol_index_service import SymbolIndexService from .symbol_index_service import SymbolIndexService
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
class ProjectKnowledgeGraphService: class ProjectKnowledgeGraphService:

View File

@ -28,6 +28,7 @@ from ..models import (
User, User,
) )
from ..database import AsyncSessionLocal from ..database import AsyncSessionLocal
from ..utils.timezone import now_cn_naive
from .ai_orchestration_run_service import AIOrchestrationRunService from .ai_orchestration_run_service import AIOrchestrationRunService
from .prompt_profile_service import prompt_profile_service from .prompt_profile_service import prompt_profile_service
from .llm import llm_service from .llm import llm_service
@ -52,7 +53,7 @@ COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0
def _utc_now_naive() -> datetime: def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return now_cn_naive()
class SignalSourceOrchestrationService: class SignalSourceOrchestrationService:

View File

@ -0,0 +1,18 @@
"""PMFlow 时间工具:统一使用北京时间(Asia/Shanghai)。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
CN_TZ = timezone(timedelta(hours=8), name="Asia/Shanghai")
def now_cn_aware() -> datetime:
"""返回带时区的北京时间。"""
return datetime.now(CN_TZ)
def now_cn_naive() -> datetime:
"""返回不带时区的北京时间(用于 TIMESTAMP WITHOUT TIME ZONE)。"""
return now_cn_aware().replace(tzinfo=None)

View File

@ -0,0 +1,86 @@
import os
import unittest
from app.services.ai_agent_kernel import (
compute_capability_score,
gate_execution,
resolve_correction,
select_intent_route,
)
class AIAgentKernelTest(unittest.TestCase):
def test_select_intent_route_prefers_complex_for_multi_clause_instruction(self) -> None:
decision = select_intent_route(
"给我新增个项目,openclaw自动化部署,为了后续方便统一发布。",
project_names=["OpenClaw 自动化部署"],
)
self.assertEqual(decision.function_name, "classify_intent_complex")
self.assertEqual(decision.tier, "high")
self.assertTrue(decision.is_complex)
def test_select_intent_route_simple_instruction(self) -> None:
decision = select_intent_route("归档项目#51")
self.assertEqual(decision.function_name, "classify_intent_ai_pmo_kernel")
self.assertEqual(decision.tier, "low")
self.assertFalse(decision.is_complex)
def test_resolve_correction_detects_rollback(self) -> None:
result = resolve_correction("这条不是新增项目,回滚刚才那次操作。")
self.assertIn(result.action, {"rollback", "replace_target"})
self.assertGreaterEqual(result.confidence, 0.78)
self.assertTrue(result.matched_keywords)
def test_gate_execution_respects_threshold_and_risk(self) -> None:
old_auto = os.environ.get("AGENT_GATE_AUTO_THRESHOLD")
old_review = os.environ.get("AGENT_GATE_REVIEW_THRESHOLD")
old_review_below_auto = os.environ.get("AGENT_GATE_REVIEW_BELOW_AUTO")
os.environ["AGENT_GATE_AUTO_THRESHOLD"] = "0.82"
os.environ["AGENT_GATE_REVIEW_THRESHOLD"] = "0.60"
try:
low = gate_execution(confidence=0.42, risk_level="low")
self.assertEqual(low.action, "reject")
pending = gate_execution(confidence=0.70, risk_level="medium")
self.assertEqual(pending.action, "auto_execute")
high_risk = gate_execution(confidence=0.86, risk_level="high")
self.assertEqual(high_risk.action, "pending_manual")
allowed = gate_execution(confidence=0.93, risk_level="high")
self.assertEqual(allowed.action, "auto_execute")
os.environ["AGENT_GATE_REVIEW_BELOW_AUTO"] = "1"
pending_with_switch = gate_execution(confidence=0.70, risk_level="medium")
self.assertEqual(pending_with_switch.action, "pending_manual")
forced = gate_execution(
confidence=0.99,
risk_level="low",
force_manual=True,
force_manual_code="intent_conflict_write",
force_manual_reason="规则与LLM冲突",
)
self.assertEqual(forced.action, "pending_manual")
self.assertEqual(forced.gate_code, "intent_conflict_write")
finally:
if old_auto is None:
os.environ.pop("AGENT_GATE_AUTO_THRESHOLD", None)
else:
os.environ["AGENT_GATE_AUTO_THRESHOLD"] = old_auto
if old_review is None:
os.environ.pop("AGENT_GATE_REVIEW_THRESHOLD", None)
else:
os.environ["AGENT_GATE_REVIEW_THRESHOLD"] = old_review
if old_review_below_auto is None:
os.environ.pop("AGENT_GATE_REVIEW_BELOW_AUTO", None)
else:
os.environ["AGENT_GATE_REVIEW_BELOW_AUTO"] = old_review_below_auto
def test_compute_capability_score(self) -> None:
score = compute_capability_score(
intent_accuracy=0.82,
gate_precision=0.80,
exec_success=0.78,
correction_success=0.76,
)
self.assertAlmostEqual(score, 0.795, places=3)
if __name__ == "__main__":
unittest.main()

View File

@ -26,6 +26,7 @@ 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_PROJECT_CREATE, INTENT_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver, IntentResolver,
) )
@ -464,6 +465,81 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
else: else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
async def test_intent_arbitration_conflict_write_requires_manual_review(self) -> None:
old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
old_arb = os.environ.get("AI_PMO_INTENT_ARBITRATION_ENABLED")
old_policy = os.environ.get("AI_PMO_INTENT_CONFLICT_POLICY")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = "1"
os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = "pending_manual"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"intent": INTENT_SIGNAL_CREATE,
"confidence": 0.95,
"project_id": None,
"reasoning": "用户像是在提新增记录",
},
source_field="content",
provider="openai",
model="MiniMax-M2.5",
raw_text='{"intent":"signal.create"}',
validation="ok",
)
),
):
intent, source = await service._resolve_intent(
message_text="给我新增个项目,openclaw自动化部署。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
)
self.assertIn(source, {"llm", "rule_fallback"})
self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review")))
self.assertEqual((intent.metadata or {}).get("manual_review_code"), "intent_conflict_write")
self.assertEqual((intent.metadata or {}).get("arbitration"), "conflict_manual_review")
finally:
if old_llm is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm
if old_arb is None:
os.environ.pop("AI_PMO_INTENT_ARBITRATION_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_ARBITRATION_ENABLED"] = old_arb
if old_policy is None:
os.environ.pop("AI_PMO_INTENT_CONFLICT_POLICY", None)
else:
os.environ["AI_PMO_INTENT_CONFLICT_POLICY"] = old_policy
async def test_project_create_slot_guard_requires_title(self) -> None:
old_llm = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
intent, source = await service._resolve_intent(
message_text="请创建项目。",
session=None,
world_model={"resolved_project_id": None, "project": {}},
)
self.assertEqual(source, "rule_fallback")
self.assertEqual(intent.name, INTENT_PROJECT_CREATE)
self.assertFalse(bool((intent.metadata or {}).get("slot_ok")))
self.assertTrue(bool((intent.metadata or {}).get("requires_manual_review")))
self.assertEqual((intent.metadata or {}).get("manual_review_code"), "slot_incomplete")
finally:
if old_llm is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = old_llm
async def test_policy_gate_blocks_archive_like_request(self) -> None: async def test_policy_gate_blocks_archive_like_request(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)

View File

@ -0,0 +1,143 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.ai_pmo import PMOWorkItemDecisionRequest, _normalize_resume_result, decide_work_item
from app.models import Base, DecisionRecord, IngestEvent, User, WorkItem
class AIPMOWorkItemDecisionApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-decision-api-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
def test_normalize_resume_result_maps_answered_to_succeeded(self) -> None:
target = SimpleNamespace(
id=99,
state="waiting_manual",
intent="signal.create",
project_id=7,
signal_id=None,
)
raw = {
"handled": True,
"status": "answered",
"direct_reply_text": "已继续处理:信号已入库。",
"results": [{"state": "succeeded"}],
"linked_project_ids": [7],
"signal_id": 13,
"auto_tasks": [],
}
normalized = _normalize_resume_result(raw=raw, target_work_item=target)
self.assertTrue(normalized["handled"])
self.assertEqual(normalized["status"], "succeeded")
self.assertEqual(normalized["target_work_item_id"], 99)
self.assertEqual(normalized["target_state"], "succeeded")
self.assertEqual(normalized["project_id"], 7)
self.assertEqual(normalized["signal_id"], 13)
async def test_decision_reject_marks_waiting_manual_item_ignored(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="decision_boss",
email="decision_boss@example.com",
password_hash="hash",
name="决策人",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
ingest = IngestEvent(
tenant_id=1,
channel="ai_pmo_ui_message",
event_key="decision-reject-1",
status="done",
payload={"message_text": "删除项目id 52"},
)
db.add(ingest)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest.id),
source_system="ai_pmo_ui",
source_message_id="decision-reject-item-1",
state="waiting_manual",
intent="query.read_only",
project_id=52,
signal_id=None,
execution_result={"status": "waiting_manual"},
)
db.add(work_item)
await db.flush()
decision = DecisionRecord(
tenant_id=1,
work_item_id=int(work_item.id),
decision_object_type="project",
decision_object_id=52,
decision_level="project",
policy_gate_hit=True,
policy_gate_code="delete_or_archive",
approval_requirement={"required": True},
)
db.add(decision)
await db.commit()
response = await decide_work_item(
work_item_id=int(work_item.id),
request=PMOWorkItemDecisionRequest(action="reject", reason="不同意执行,标记失效"),
current_user=user,
db=db,
tenant_id=1,
)
refreshed = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.id == int(work_item.id),
)
)
)
).scalar_one()
refreshed_decision = (
await db.execute(
select(DecisionRecord).where(
and_(
DecisionRecord.tenant_id == 1,
DecisionRecord.work_item_id == int(work_item.id),
)
)
)
).scalar_one()
self.assertEqual(response["code"], 0)
self.assertEqual(response["data"]["status"], "invalidated")
self.assertEqual(refreshed.state, "ignored")
self.assertEqual(refreshed.reply_template_key, "action_rejected")
self.assertEqual(refreshed_decision.final_outcome, "ignored")

View File

@ -0,0 +1,31 @@
import unittest
from app.services.ai_steward import AIStewardService
class AIStewardTitleExtractTest(unittest.IsolatedAsyncioTestCase):
async def test_extract_title_from_plain_project_create_instruction(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("给我新增个项目,openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_extract_title_from_correction_sentence(self) -> None:
title = AIStewardService._extract_project_title_from_instruction("不是创建信号,是创建项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
async def test_normalize_project_draft_should_not_use_instruction_sentence_as_title(self) -> None:
service = AIStewardService(db=None) # type: ignore[arg-type]
normalized = await service._normalize_project_creation_draft(
draft={
"analysis": {},
"project": {
"title": "不是创建信号,是创建项目名为openclaw自动化部署。",
"description": "不是创建信号,是创建项目名为openclaw自动化部署。",
},
"tasks": [],
},
input_text="不是创建信号,是创建项目名为openclaw自动化部署。",
creator_id=1,
tenant_id=1,
)
self.assertEqual(normalized["project"]["title"], "openclaw自动化部署")
self.assertEqual(normalized["project"]["description"], "项目:openclaw自动化部署")

View File

@ -0,0 +1,338 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.projects import delete_project
from app.models import (
AIOrchestrationRun,
AIWorkerExecution,
Base,
Budget,
ConversationSession,
CostTracking,
Experiment,
IngestEvent,
OperationExecutionLedger,
OperationLog,
Project,
SignalSourceRegistry,
Task,
TruthNode,
User,
UserProject,
WorkItem,
)
class DeleteProjectApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = NamedTemporaryFile(prefix="pmflow-delete-project-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 test_delete_project_cleans_fk_dependencies(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="boss-delete-project",
email="boss-delete-project@example.com",
password_hash="x",
name="Boss",
role="BOSS",
status="active",
is_active=True,
)
db.add(user)
await db.flush()
project = Project(
tenant_id=1,
name="P-Delete",
title="P-Delete",
description="for delete test",
status="active",
owner_id=int(user.id),
signal_count=0,
)
db.add(project)
await db.flush()
truth_node = TruthNode(
tenant_id=1,
node_type="task",
project_id=int(project.id),
created_by=int(user.id),
title="TN",
content={"k": "v"},
version=1,
status="active",
)
db.add(truth_node)
await db.flush()
task = Task(
tenant_id=1,
truth_node_id=int(truth_node.id),
project_id=int(project.id),
assignee_id=int(user.id),
title="task-a",
description="d",
required_skill_tag_ids=[1],
status="todo",
priority="p1",
)
db.add(task)
await db.flush()
cost_tracking = CostTracking(
tenant_id=1,
truth_node_id=int(truth_node.id),
task_id=int(task.id),
period="project",
estimated_tokens=1,
actual_tokens=1,
estimated_cost_usd=0.1,
actual_cost_usd=0.1,
roi_analysis={},
optimization_suggestions={},
)
db.add(cost_tracking)
registry = SignalSourceRegistry(
tenant_id=1,
project_id=int(project.id),
source_kind="github",
source_name="repo",
source_identifier="github:org/repo",
)
db.add(registry)
await db.flush()
worker_execution = AIWorkerExecution(
tenant_id=1,
project_id=int(project.id),
signal_source_registry_id=int(registry.id),
trace_id="trace-worker-delete",
worker_role_key="pm_dispatcher",
worker_role_name="PM Dispatcher",
)
db.add(worker_execution)
orchestration_run = AIOrchestrationRun(
tenant_id=1,
trace_id="trace-run-delete",
run_type="signal_source_onboarding",
project_id=int(project.id),
response_mode="fast",
status="queued",
worker_total=0,
worker_completed=0,
worker_failed=0,
)
db.add(orchestration_run)
ingest_event = IngestEvent(
tenant_id=1,
channel="feishu",
event_key="delete-project-event",
status="done",
)
db.add(ingest_event)
await db.flush()
work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ingest_event.id),
source_system="feishu",
source_message_id="msg-delete-project",
state="received",
project_id=int(project.id),
)
db.add(work_item)
operation_log = OperationLog(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="delete",
)
db.add(operation_log)
operation_ledger = OperationExecutionLedger(
tenant_id=1,
operation_code="project.delete",
project_id=int(project.id),
idempotency_key="project-delete-key",
status="processing",
)
db.add(operation_ledger)
experiment = Experiment(
tenant_id=1,
title="exp",
status="draft",
project_id=int(project.id),
)
db.add(experiment)
budget = Budget(
tenant_id=1,
budget_type="project",
project_id=int(project.id),
period="monthly",
budget_amount_usd=100.0,
alert_threshold_percent=80.0,
hard_limit_enabled=True,
current_usage_usd=10.0,
status="active",
override_history={},
)
db.add(budget)
conversation = ConversationSession(
tenant_id=1,
sender="ou_delete_project",
current_project_id=int(project.id),
last_intent="delete_or_archive",
)
db.add(conversation)
user_project = UserProject(
tenant_id=1,
user_id=int(user.id),
project_id=int(project.id),
)
db.add(user_project)
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()),
):
result = await delete_project(
project_id=int(project.id),
db=db,
current_user=user,
)
self.assertEqual(result, {"message": "Project deleted"})
project_row = (
await db.execute(
select(Project).where(
and_(Project.tenant_id == 1, Project.id == int(project.id))
)
)
).scalar_one_or_none()
self.assertIsNone(project_row)
worker_rows = (
await db.execute(
select(AIWorkerExecution).where(
and_(
AIWorkerExecution.tenant_id == 1,
AIWorkerExecution.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(worker_rows), 0)
run_rows = (
await db.execute(
select(AIOrchestrationRun).where(
and_(
AIOrchestrationRun.tenant_id == 1,
AIOrchestrationRun.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(run_rows), 0)
registry_rows = (
await db.execute(
select(SignalSourceRegistry).where(
and_(
SignalSourceRegistry.tenant_id == 1,
SignalSourceRegistry.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(registry_rows), 0)
user_project_rows = (
await db.execute(
select(UserProject).where(
and_(
UserProject.tenant_id == 1,
UserProject.project_id == int(project.id),
)
)
)
).scalars().all()
self.assertEqual(len(user_project_rows), 0)
task_rows = (
await db.execute(
select(Task).where(and_(Task.tenant_id == 1, Task.project_id == int(project.id)))
)
).scalars().all()
self.assertEqual(len(task_rows), 0)
refreshed_cost_tracking = (
await db.execute(select(CostTracking).where(CostTracking.id == int(cost_tracking.id)))
).scalar_one()
self.assertIsNone(refreshed_cost_tracking.task_id)
refreshed_work_item = (
await db.execute(select(WorkItem).where(WorkItem.id == int(work_item.id)))
).scalar_one()
self.assertIsNone(refreshed_work_item.project_id)
refreshed_operation_log = (
await db.execute(select(OperationLog).where(OperationLog.id == int(operation_log.id)))
).scalar_one()
self.assertIsNone(refreshed_operation_log.project_id)
refreshed_ledger = (
await db.execute(
select(OperationExecutionLedger).where(OperationExecutionLedger.id == int(operation_ledger.id))
)
).scalar_one()
self.assertIsNone(refreshed_ledger.project_id)
refreshed_experiment = (
await db.execute(select(Experiment).where(Experiment.id == int(experiment.id)))
).scalar_one()
self.assertIsNone(refreshed_experiment.project_id)
refreshed_budget = (
await db.execute(select(Budget).where(Budget.id == int(budget.id)))
).scalar_one()
self.assertIsNone(refreshed_budget.project_id)
refreshed_conversation = (
await db.execute(select(ConversationSession).where(ConversationSession.id == int(conversation.id)))
).scalar_one()
self.assertIsNone(refreshed_conversation.current_project_id)

View File

@ -7,6 +7,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-pmflow} POSTGRES_DB: ${POSTGRES_DB:-pmflow}
POSTGRES_USER: ${POSTGRES_USER:-pmflow} POSTGRES_USER: ${POSTGRES_USER:-pmflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
ports: ports:
- "${DB_PORT:-5435}:5432" - "${DB_PORT:-5435}:5432"
volumes: volumes:
@ -55,6 +57,8 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT} PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT}

View File

@ -7,6 +7,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-pmflow} POSTGRES_DB: ${POSTGRES_DB:-pmflow}
POSTGRES_USER: ${POSTGRES_USER:-pmflow} POSTGRES_USER: ${POSTGRES_USER:-pmflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
ports: ports:
- "${DB_PORT:-5435}:5432" - "${DB_PORT:-5435}:5432"
volumes: volumes:
@ -55,6 +57,8 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?missing_POSTGRES_USER}:${POSTGRES_PASSWORD:?missing_POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:?missing_POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT} PMFLOW_EXPECTED_DB_DIALECT: ${PMFLOW_EXPECTED_DB_DIALECT:?missing_PMFLOW_EXPECTED_DB_DIALECT}

View File

@ -102,6 +102,13 @@ export interface AIPMOResumeResult {
auto_tasks?: Array<Record<string, unknown>>; auto_tasks?: Array<Record<string, unknown>>;
} }
export interface AIPMOWorkItemDecisionRequest {
action: "approve" | "reject";
project_id?: number | null;
source_message_id?: string;
reason?: string;
}
export interface AIPMOCadenceSnapshot { export interface AIPMOCadenceSnapshot {
id: number; id: number;
scope_type: string; scope_type: string;
@ -151,6 +158,11 @@ export const aiPmoApi = {
return unwrap(data); return unwrap(data);
}, },
async decideWorkItem(workItemId: number, body: AIPMOWorkItemDecisionRequest): Promise<AIPMOResumeResult> {
const { data } = await api.post<ApiEnvelope<AIPMOResumeResult>>(`/ai-pmo/work-items/${workItemId}/decision`, body);
return unwrap(data);
},
async getLatestCadence(params: { cadenceType: string; projectId?: number }): Promise<AIPMOCadenceSnapshot | null> { async getLatestCadence(params: { cadenceType: string; projectId?: number }): Promise<AIPMOCadenceSnapshot | null> {
const { data } = await api.get<ApiEnvelope<AIPMOCadenceSnapshot | null>>("/ai-pmo/cadence/latest", { const { data } = await api.get<ApiEnvelope<AIPMOCadenceSnapshot | null>>("/ai-pmo/cadence/latest", {
params: { params: {

View File

@ -440,7 +440,8 @@ export default function InboxPage() {
return; return;
} }
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
const result = await aiPmoApi.resumeWorkItem(item.source_work_item.id, { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "approve",
project_id: item.project_id ?? undefined, project_id: item.project_id ?? undefined,
}); });
await refreshAll(); await refreshAll();
@ -465,6 +466,19 @@ export default function InboxPage() {
}); });
return; return;
} }
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "reject",
reason: "人工审批拒绝,已标记失效",
});
await refreshAll();
const followupReply = String(result.followup_reply || "").trim();
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
if (workItemDetailItem?.id === item.id) {
setWorkItemDetailItem(null);
}
return;
}
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) { if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
await notificationsApi.markRead(item.source_notification.id); await notificationsApi.markRead(item.source_notification.id);
await refreshAll(); await refreshAll();

View File

@ -6,6 +6,7 @@ export type InboxDisplayKind = "action_required" | "auto_running" | "auto_done"
export type InboxSectionKey = "all" | InboxDisplayKind; export type InboxSectionKey = "all" | InboxDisplayKind;
export type InboxActionKind = export type InboxActionKind =
| "confirm" | "confirm"
| "reject"
| "open_detail" | "open_detail"
| "open_target" | "open_target"
| "mark_read" | "mark_read"

View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Offline replay scorer for dual-agent capability metrics."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(slots=True)
class MetricAccumulator:
count: int = 0
intent_sum: float = 0.0
gate_sum: float = 0.0
exec_sum: float = 0.0
correction_sum: float = 0.0
def append(self, *, intent: float, gate: float, exec_success: float, correction: float) -> None:
self.count += 1
self.intent_sum += intent
self.gate_sum += gate
self.exec_sum += exec_success
self.correction_sum += correction
def summary(self) -> dict[str, Any]:
if self.count <= 0:
return {
"count": 0,
"intent_accuracy": 0.0,
"gate_precision": 0.0,
"exec_success": 0.0,
"correction_success": 0.0,
"score": 0.0,
}
intent_accuracy = self.intent_sum / self.count
gate_precision = self.gate_sum / self.count
exec_success = self.exec_sum / self.count
correction_success = self.correction_sum / self.count
return {
"count": self.count,
"intent_accuracy": round(intent_accuracy, 4),
"gate_precision": round(gate_precision, 4),
"exec_success": round(exec_success, 4),
"correction_success": round(correction_success, 4),
"score": _compute_capability_score(
intent_accuracy=intent_accuracy,
gate_precision=gate_precision,
exec_success=exec_success,
correction_success=correction_success,
),
}
def _compute_capability_score(
*,
intent_accuracy: float,
gate_precision: float,
exec_success: float,
correction_success: float,
) -> float:
score = (
0.30 * max(0.0, min(1.0, float(intent_accuracy or 0.0)))
+ 0.30 * max(0.0, min(1.0, float(gate_precision or 0.0)))
+ 0.25 * max(0.0, min(1.0, float(exec_success or 0.0)))
+ 0.15 * max(0.0, min(1.0, float(correction_success or 0.0)))
)
return round(score, 4)
def _to_binary(value: Any) -> float:
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return 1.0 if float(value) >= 1.0 else (1.0 if float(value) >= 0.5 else 0.0)
text = str(value or "").strip().lower()
return 1.0 if text in {"1", "true", "yes", "y", "ok", "pass", "passed", "success", "succeeded"} else 0.0
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line:
continue
parsed = json.loads(line)
if isinstance(parsed, dict):
rows.append(parsed)
return rows
def _bucket_of(row: dict[str, Any]) -> str:
bucket = str(row.get("bucket") or "").strip().lower()
if bucket in {"simple", "complex"}:
return bucket
trace = row.get("decision_trace") if isinstance(row.get("decision_trace"), dict) else {}
route_tier = str(trace.get("route_tier") or row.get("route_tier") or "").strip().lower()
return "complex" if route_tier == "high" else "simple"
def _metric(row: dict[str, Any], key: str, fallback_keys: list[str]) -> float:
if key in row:
return _to_binary(row.get(key))
for name in fallback_keys:
if name in row:
return _to_binary(row.get(name))
return 0.0
def main() -> None:
parser = argparse.ArgumentParser(description="Replay dual-agent metrics from JSONL samples.")
parser.add_argument("--input", required=True, help="Path to JSONL file.")
parser.add_argument("--output", default="", help="Optional output path; default prints to stdout.")
args = parser.parse_args()
rows = _load_jsonl(Path(args.input))
overall = MetricAccumulator()
simple = MetricAccumulator()
complex_bucket = MetricAccumulator()
for row in rows:
intent = _metric(row, "intent_correct", ["intent_accuracy", "intent_success"])
gate = _metric(row, "gate_correct", ["gate_precision", "gate_success"])
exec_success = _metric(row, "exec_success", ["execution_success", "succeeded"])
correction = _metric(row, "correction_success", ["correction_correct"])
overall.append(intent=intent, gate=gate, exec_success=exec_success, correction=correction)
bucket_name = _bucket_of(row)
if bucket_name == "complex":
complex_bucket.append(intent=intent, gate=gate, exec_success=exec_success, correction=correction)
else:
simple.append(intent=intent, gate=gate, exec_success=exec_success, correction=correction)
payload = {
"overall": overall.summary(),
"simple": simple.summary(),
"complex": complex_bucket.summary(),
"thresholds": {
"overall_score_target": 0.80,
"bucket_score_target": 0.78,
"intent_target": 0.80,
"gate_target": 0.78,
"correction_target": 0.75,
},
}
text = json.dumps(payload, ensure_ascii=False, indent=2)
if args.output:
Path(args.output).write_text(text + "\n", encoding="utf-8")
return
print(text)
if __name__ == "__main__":
main()