使用北京时间更新数据库写入」}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
LLM_PROVIDER=glm
LLM_FALLBACK_PROVIDER=glm
OPENCLAW_ROUTING_ENABLED=false
OPENCLAW_ROUTING_ENABLED=true
OPENCLAW_ROUTING_RULES_PATH=../.openclaw/pmflow-routing-rules.json
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
LLM_DIRECT_EMERGENCY_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=true
LLM_MIXED_ROUTING_ENABLED=false
LLM_ROUTING_DEFAULT_TIER=active
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
LLM_REQUEST_TIMEOUT=90

View File

@ -4,6 +4,16 @@
"source_of_truth": "openclaw",
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
"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",
"enabled": true,
@ -31,6 +41,7 @@
"api_env": "OPENAI_API_KEY",
"match_any": [
"adaptive_rag_answer",
"classify_intent_complex",
"feishu_project_prefill",
"feishu_task_orchestration",
"generate_task_breakdown",

View File

@ -51,6 +51,10 @@ backend/
└── tests/
```
- 新增:`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/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`)
@ -144,6 +148,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
- 配置读取类接口:参考 `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`
- 新增数据库表:参考 `alembic/versions/` 最新 migration
- 新增飞书事件处理:参考 `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` 已移除且不再读取
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取
- 模型命名统一使用裸模型名(如 `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 datetime import datetime
from typing import Any, Optional
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
@ -38,6 +38,13 @@ class PMOResumeWorkItemRequest(BaseModel):
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")
async def process_text(
request: PMOProcessTextRequest,
@ -218,7 +225,7 @@ async def resume_work_item(
if target is None:
raise HTTPException(status_code=404, detail="work_item_not_found")
service = AIPMOKernelService(db, tenant_id=tenant_id)
result = await service.process_ingress(
raw_result = await service.process_ingress(
source_system="ai_pmo_ui",
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)}",
@ -229,8 +236,54 @@ async def resume_work_item(
chat_type="manual",
project_hint_id=request.project_id or _safe_int(target.project_id),
)
if not result.get("handled"):
raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed"))
result = _normalize_resume_result(raw=raw_result, target_work_item=target)
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)
@ -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,
"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.signal_source_orchestration_service import SignalSourceOrchestrationService
from ..schemas.rag import RAGAskRequest
from ..utils.timezone import now_cn_naive
router = APIRouter(prefix="/ai-steward", tags=["AI Steward"])
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
return now_cn_naive()
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 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 ..database import get_db
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.context_cache import context_cache_service
from ..utils.project_members import ensure_project_default_members
@ -154,6 +172,148 @@ async def _get_project_or_404(
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("/")
async def list_projects(
summary: Optional[int] = Query(default=0),
@ -270,22 +430,12 @@ async def delete_project(
):
tenant_id = int(current_user.tenant_id)
_ = current_user
try:
await _get_project_or_404(db, tenant_id=tenant_id, 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 _cleanup_project_dependencies(
db,
tenant_id=tenant_id,
project_id=project_id,
)
await db.execute(
delete(Project).where(
@ -296,7 +446,14 @@ async def delete_project(
)
)
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 AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
return {"message": "Project deleted"}

View File

@ -40,6 +40,8 @@ if not _is_sqlite():
"max_overflow": max(0, int(settings.db_max_overflow)),
"pool_timeout": max(5, int(settings.db_pool_timeout)),
"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 ..models import AIOrchestrationRun
from ..utils.timezone import now_cn_naive
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
return now_cn_naive()
class AIOrchestrationRunService:

View File

@ -9,10 +9,11 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
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:
return datetime.now(timezone.utc).replace(tzinfo=None)
return now_cn_naive()
class AIPMOCadenceService:

View File

@ -57,6 +57,12 @@ from ..schemas.world_model import (
WorldModel,
)
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_steward import AIStewardService
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 .llm import llm_service
from .signal_source_orchestration_service import SignalSourceOrchestrationService
from ..utils.timezone import now_cn_naive
logger = get_logger(__name__)
@ -195,13 +202,21 @@ INTENT_CANDIDATES = (
INTENT_FOLLOWUP_CONTINUE,
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_KEY_PREFIX = "wm:static"
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:
@ -1152,6 +1167,32 @@ class AIPMOKernelService:
world_model=world_model,
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.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio")
@ -1174,12 +1215,22 @@ class AIPMOKernelService:
)
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")):
work_item.state = STATE_WAITING_MANUAL
work_item.action_bundle = decision_payload["execution_plan"]
work_item.execution_result = {
"status": "waiting_manual",
"reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(),
"decision_trace": decision_trace,
}
manual_issues = ["waiting_manual"]
policy_gate_code = str(policy.get("policy_gate_code") or "").strip()
@ -1276,6 +1327,8 @@ class AIPMOKernelService:
source_message_id=synthetic_message_id,
)
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
if _safe_int(execution_result.get("project_id")) is not None:
work_item.project_id = _safe_int(execution_result.get("project_id"))
@ -1841,6 +1894,121 @@ class AIPMOKernelService:
def _intent_llm_enabled() -> bool:
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
def _is_short_confirmation_message(
cls,
@ -1945,6 +2113,13 @@ class AIPMOKernelService:
session: Optional[ConversationSession],
world_model: dict[str, Any],
) -> 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(
message_text=str(message_text or "")[:1200],
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
@ -1958,7 +2133,7 @@ class AIPMOKernelService:
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=INTENT_LLM_FUNCTION_NAME,
function_name=route_decision.function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=260,
@ -2002,6 +2177,17 @@ class AIPMOKernelService:
if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id"))
reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
route_tier=route_decision.tier,
route_function=route_decision.function_name,
gate_action="unresolved",
gate_code="intent_only",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=route_decision.reasons,
)
return self._build_canonical_intent(
intent_name=intent_name,
message_text=message_text,
@ -2010,7 +2196,13 @@ class AIPMOKernelService:
metadata={
"context_source": "llm",
"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],
) -> tuple[CanonicalIntent, str]:
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 session is None:
return self.intent_resolver.resolve(text=message_text, project_id=project_id), "rule_fallback"
return rule_intent, "rule_fallback"
return (
self._build_short_confirm_intent(
message_text=message_text,
@ -2041,9 +2234,17 @@ class AIPMOKernelService:
world_model=world_model,
)
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(
self,
@ -2983,6 +3184,11 @@ class AIPMOKernelService:
"work_item_id": int(work_item.id),
"decision_level": work_item.decision_level,
"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):
result["rag_result"] = execution_result["rag_result"]
@ -3009,6 +3215,14 @@ class AIPMOKernelService:
return "work_item.resume"
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
def _extract_followup_work_item_id(message: str) -> Optional[int]:
match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or ""))

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import re
from typing import Any, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession
@ -13,6 +14,7 @@ from ..database import get_db
from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode
from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
from ..utils.title import compact_title
from .adaptive_rag_service import AdaptiveRAGService
from .llm import LLMService
from .task_persistence import (
@ -33,6 +35,71 @@ class AIStewardService:
self.db = db
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(
self,
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 = {
"name": str(project_draft.get("name") or "未命名项目").strip()[:50] or "未命名项目",
"title": str(project_draft.get("title") or input_text[:50] or "未命名项目").strip()[:50] or "未命名项目",
"description": str(project_draft.get("description") or input_text).strip()[:4000] or input_text[:4000],
"name": normalized_name[:50] or "未命名项目",
"title": normalized_title[:50] or "未命名项目",
"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",
"domain_id": analysis.get("domain_id"),
}

View File

@ -33,10 +33,16 @@ from ..models import (
)
from .llm import LLMService
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 .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService
from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import CN_TZ, now_cn_aware
logger = get_logger(__name__)
@ -344,7 +350,7 @@ def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
return now_cn_aware()
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:
return 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)
@ -570,6 +576,28 @@ class FeishuSignalPipelineService:
correction_match = recall_rows[0]
except Exception as 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):
return {
@ -1578,7 +1606,7 @@ class FeishuSignalPipelineService:
) -> Optional[int]:
occurred_at = captured_at or _utc_now()
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(
tenant_id=self.tenant_id,
feishu_message_id=feishu_message_id,
@ -1792,7 +1820,7 @@ class FeishuSignalPipelineService:
)
occurred_at = captured_at or _utc_now()
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)
if not fields.get("ready"):
@ -1998,7 +2026,7 @@ class FeishuSignalPipelineService:
)
occurred_at = captured_at or _utc_now()
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)
if not fields.get("ready"):
@ -2188,7 +2216,7 @@ class FeishuSignalPipelineService:
) -> dict[str, Any]:
occurred_at = captured_at or _utc_now()
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 {}
blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。"
queued = await self._queue_command_intent_item(
@ -2726,7 +2754,7 @@ class FeishuSignalPipelineService:
routing_policy = await self._load_routing_policy()
occurred_at = captured_at or _utc_now()
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]] = []
parent_item_id: Optional[int] = None
@ -3785,7 +3813,7 @@ class FeishuSignalPipelineService:
return None
occurred_at = captured_at or _utc_now()
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(
tenant_id=self.tenant_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)]
# 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,
minute=0,
second=0,
@ -4732,7 +4760,23 @@ class FeishuSignalPipelineService:
signal_auto_enter_threshold: float,
) -> Optional[int]:
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.route_status = ROUTE_STATUS_QUEUED
item.queue_visible = True
@ -4747,10 +4791,20 @@ class FeishuSignalPipelineService:
item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。"
item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。"
item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。"
elif gate.action == "reject":
item.blocking_reason = gate.reason
item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。"
item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。"
else:
item.blocking_reason = "当前置信度不足以自动入库,需要你拍板后再继续。"
item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。"
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
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
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)}"],
"target_url": f"/projects/{int(matched_project_id)}/board",
"project_id": int(matched_project_id),
"decision_trace": decision_trace,
}
await self._notify_signal_auto_entered(
project_id=int(matched_project_id),

View File

@ -90,6 +90,7 @@ DEFAULT_TIER_MODEL_OVERRIDES = {
"safety": OPENCLAW_DEFAULT_SAFETY_MODEL,
}
DEFAULT_HIGH_TIER_HINTS = (
"classify_intent_complex",
"project_from_text",
"feishu_project_prefill",
"feishu_task_orchestration",
@ -225,6 +226,17 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"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 ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import now_cn_naive
from ..utils.title import compact_title
SUPPORTED_COMMANDER_OPERATION_CODES = (
@ -66,7 +67,7 @@ SOURCE_ONBOARDING_FALLBACK_RE = re.compile(
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
return now_cn_naive()
class OpenClawCommanderService:

View File

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

View File

@ -28,6 +28,7 @@ from ..models import (
User,
)
from ..database import AsyncSessionLocal
from ..utils.timezone import now_cn_naive
from .ai_orchestration_run_service import AIOrchestrationRunService
from .prompt_profile_service import prompt_profile_service
from .llm import llm_service
@ -52,7 +53,7 @@ COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0
def _utc_now_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
return now_cn_naive()
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 (
AIPMOKernelService,
INTENT_PROJECT_CREATE,
INTENT_SIGNAL_CREATE,
INTENT_SIGNAL_SOURCE_ONBOARD,
IntentResolver,
)
@ -464,6 +465,81 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
else:
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 with self.session_factory() as db:
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_USER: ${POSTGRES_USER:-pmflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
ports:
- "${DB_PORT:-5435}:5432"
volumes:
@ -55,6 +57,8 @@ services:
redis:
condition: service_healthy
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}
REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
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_USER: ${POSTGRES_USER:-pmflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
ports:
- "${DB_PORT:-5435}:5432"
volumes:
@ -55,6 +57,8 @@ services:
redis:
condition: service_healthy
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}
REDIS_URL: redis://:${REDIS_PASSWORD:?missing_REDIS_PASSWORD}@redis:6379/0
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>>;
}
export interface AIPMOWorkItemDecisionRequest {
action: "approve" | "reject";
project_id?: number | null;
source_message_id?: string;
reason?: string;
}
export interface AIPMOCadenceSnapshot {
id: number;
scope_type: string;
@ -151,6 +158,11 @@ export const aiPmoApi = {
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> {
const { data } = await api.get<ApiEnvelope<AIPMOCadenceSnapshot | null>>("/ai-pmo/cadence/latest", {
params: {

View File

@ -440,7 +440,8 @@ export default function InboxPage() {
return;
}
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,
});
await refreshAll();
@ -465,6 +466,19 @@ export default function InboxPage() {
});
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) {
await notificationsApi.markRead(item.source_notification.id);
await refreshAll();

View File

@ -6,6 +6,7 @@ export type InboxDisplayKind = "action_required" | "auto_running" | "auto_done"
export type InboxSectionKey = "all" | InboxDisplayKind;
export type InboxActionKind =
| "confirm"
| "reject"
| "open_detail"
| "open_target"
| "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()