使用北京时间更新数据库写入」}Sorry seems odd maybe
This commit is contained in:
parent
2fd932e395
commit
2e358bc2a4
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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*` 写入入口均已切换到北京时间
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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": [],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 ""))
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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自动化部署")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue