Implement mounted AI runtime and retire legacy Feishu brains
This commit is contained in:
parent
0037c6954a
commit
ecfaa7336c
|
|
@ -76,6 +76,8 @@ frontend/ # React 前端
|
|||
|
||||
根据用户的具体需求,生成高质量、可直接运行的代码。
|
||||
|
||||
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
|
||||
|
||||
---
|
||||
|
||||
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -5,6 +5,7 @@
|
|||
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
||||
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
|
||||
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
|
||||
|
||||
## 2. 项目定位
|
||||
- 项目名:`PMFlow / Neural Sync`
|
||||
|
|
@ -117,7 +118,7 @@ backend/
|
|||
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
|
||||
- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
|
||||
- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断)
|
||||
- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:保留现有业务 Prompt 拆分,统一生成 `domain/mode/risk_level/missing_fields/allow_execute/next_action`)
|
||||
- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:当前仅保留给 `/rag/ask` 等顾问式问答合同使用;已退出 Feishu mention 主回复与 AI PMO ingress capture 主链)
|
||||
- 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约)
|
||||
- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单)
|
||||
- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段)
|
||||
|
|
@ -195,13 +196,18 @@ backend/
|
|||
- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
|
||||
- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`)
|
||||
- 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`)
|
||||
- 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedToolCapabilityPolicy`)
|
||||
- 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务)
|
||||
- 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务)
|
||||
- 新增:`backend/app/services/prompt_release/service.py`(只读 QA prompt release 版本管理与 serving 渲染优先级)
|
||||
- 新增:`backend/app/services/mounted_ai_runtime_service.py`、`backend/app/services/mounted_ai_runtime_write_service.py`(Mounted Runtime 读模型与写路径:来源/主题/run 查询 + ingress topic/run/trace 落库)
|
||||
- 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials)
|
||||
- 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments)
|
||||
- 新增:`backend/app/api/ai_steward_api/routes_mounted_ai.py`、`backend/app/schemas/mounted_ai_runtime.py`(Mounted Runtime 控制台接口:sources/topics/runs/run-detail/cutover-status/eval-suites)
|
||||
- 新增:`backend/alembic/versions/20260327_add_reply_iteration_training_tables.py`(回复质量迭代/训练管线主迁移:学习资产表、prompt release、训练数据集、训练作业与 experiment 扩展字段)
|
||||
- 新增:`backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py`(Mounted Runtime 建表迁移:topic workspace、draft workspace、conversation run、agent trace、tool capability policy)
|
||||
- 新增:`backend/tests/test_reply_iteration_service.py`、`test_training_pipeline_service.py`、`test_reply_iteration_api.py`、`test_reply_iteration_training_migration.py`(回复质量闭环与训练管线基础回归)
|
||||
- 新增:`backend/tests/test_mounted_ai_runtime_models.py`、`test_mounted_ai_runtime_migration.py`、`test_mounted_ai_runtime_api.py`、`test_mounted_ai_runtime_write_service.py`(Mounted Runtime 模型/迁移/API/写路径回归)
|
||||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架)
|
||||
- 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`)
|
||||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`)
|
||||
|
|
@ -217,7 +223,7 @@ backend/
|
|||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入)
|
||||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助)
|
||||
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`)
|
||||
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理)
|
||||
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理,并把 ingress 主链同步写入 Mounted Runtime 的 topic/run/trace)
|
||||
- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`)
|
||||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口)
|
||||
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存)
|
||||
|
|
@ -371,6 +377,7 @@ frontend/
|
|||
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
|
||||
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
||||
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API)
|
||||
- 新增:`frontend/src/api/mountedAi.ts`(Mounted Runtime 控制台 API:sources/topics/runs/run-detail/cutover-status)
|
||||
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
|
||||
- 更新:`frontend/src/api/aiSteward.ts`(新增 AI 管家 QA 封装:`/rag/ask`、QA sessions、feedback、会话改名;浮窗不再直接 `fetch`)
|
||||
- 更新:`frontend/src/api/aiPmo.ts`(新增 `processText` 封装,供 AI 管家浮窗把“允许执行”显式交接到 AI PMO Kernel)
|
||||
|
|
@ -383,6 +390,8 @@ frontend/
|
|||
- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录)
|
||||
- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛)
|
||||
- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛)
|
||||
- 新增:`frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx`、`MountedAITopicsPanel.tsx`、`MountedAIRunsPanel.tsx`、`MountedAIRunReplayPanel.tsx`、`MountedAIAcceptancePanel.tsx`(Mounted AI Console 来源状态、topic workspace、run 列表、replay 详情与验收门槛面板)
|
||||
- 更新:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`(Scheduler Center 升级为 Mounted AI Console:接入 mounted runtime 查询、run replay 与 PLAN3 验收门槛展示)
|
||||
- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛)
|
||||
- 更新:`frontend/src/pages/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`)
|
||||
- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
|
||||
|
|
@ -578,6 +587,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high`
|
||||
- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`)
|
||||
- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`)
|
||||
- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`(`GET /ai-steward/mounted-ai/sources|topics|runs|runs/{id}|cutover-status|eval-suites|eval-suites/{id}`,统一 `{code,message,data}` 包络);其中 `cutover-status` 必须返回模块级旧脑退役视图:`legacy_cutover_state / retired_item_count / active_item_count / legacy_cutover_items[]`
|
||||
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`)
|
||||
- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`、`POST /ai-steward/knowledge-graph/sync/work-items`;旧 `POST /ai-steward/knowledge-graph/sync/feishu` 仅保留兼容别名)
|
||||
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重、`raw_context` 持久化,并为每条 registry 同步绑定/物化正式 `Signal`)
|
||||
|
|
@ -603,7 +613,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part/consultant_mode_preference`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
|
||||
- RAG 空回答保护:`POST /rag/ask` 若 `answer` 与 `reply_text` 同时为空,必须返回失败包络,禁止再用“当前暂无可用回答。”之类兜底文案冒充成功结果
|
||||
- RAG Grounding Runtime 合同补充:`POST /rag/ask` 的成功返回统一透传 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;`AdaptiveRAG` 内部允许通过兼容层把异构 `sources` 映射成稳定 citation 结构,但业务语义判断仍只允许放在 `workflow_semantic_planner`
|
||||
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断
|
||||
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,当前仅供 RAG / AI Steward 顾问式问答透传,已不再驱动 Feishu mention 回复或 AI PMO ingress capture 主链
|
||||
- RAG 顾问模式偏好约束:`consultant_mode_preference` 只允许取 `clarify/advise/challenge/brainstorm/execute`,且仅影响回答风格与 prompt wrapper;它不能替代 `workflow_semantic_planner` 去决定 `query vs execute`
|
||||
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
|
||||
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
"""add mounted ai runtime tables
|
||||
|
||||
Revision ID: 20260405_add_mounted_ai_runtime_tables
|
||||
Revises: 20260331_add_qa_record_runtime_fields
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260405_add_mounted_ai_runtime_tables"
|
||||
down_revision = "20260331_add_qa_record_runtime_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _base_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
]
|
||||
|
||||
|
||||
def _create_mounted_topic_workspaces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_topic_workspaces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_topic_workspaces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("topic_key", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("channel_ref", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("thread_key", sa.VARCHAR(length=255), nullable=True),
|
||||
sa.Column("root_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("mode", sa.VARCHAR(length=24), nullable=False, server_default="chat"),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
|
||||
sa.Column("facts_json", _json_type(bind), nullable=True),
|
||||
sa.Column("goals_json", _json_type(bind), nullable=True),
|
||||
sa.Column("constraints_json", _json_type(bind), nullable=True),
|
||||
sa.Column("options_json", _json_type(bind), nullable=True),
|
||||
sa.Column("open_questions", _json_type(bind), nullable=True),
|
||||
sa.Column("pending_actions", _json_type(bind), nullable=True),
|
||||
sa.Column("bound_entities", _json_type(bind), nullable=True),
|
||||
sa.Column("readiness_score", sa.Float(), nullable=True, server_default="0"),
|
||||
sa.Column("last_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("last_triggered_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("metadata_json", _json_type(bind), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"topic_key",
|
||||
name="uq_mounted_topic_workspace_source_topic",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_topic_workspaces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_topic_workspaces_topic_key", ["topic_key"]),
|
||||
("ix_mounted_topic_workspaces_source_system", ["source_system"]),
|
||||
("ix_mounted_topic_workspaces_channel_ref", ["channel_ref"]),
|
||||
("ix_mounted_topic_workspaces_thread_key", ["thread_key"]),
|
||||
("ix_mounted_topic_workspaces_root_message_id", ["root_message_id"]),
|
||||
("ix_mounted_topic_workspaces_mode", ["mode"]),
|
||||
("ix_mounted_topic_workspaces_status", ["status"]),
|
||||
("ix_mounted_topic_workspaces_last_message_id", ["last_message_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_topic_workspaces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_draft_workspaces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_draft_workspaces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"topic_workspace_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("state", sa.VARCHAR(length=24), nullable=False, server_default="collecting"),
|
||||
sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("requirement_card", _json_type(bind), nullable=True),
|
||||
sa.Column("draft_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("required_confirmations", _json_type(bind), nullable=True),
|
||||
sa.Column("last_ready_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("confirm_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"topic_workspace_id",
|
||||
name="uq_mounted_draft_workspace_topic",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_draft_workspaces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_draft_workspaces_topic_workspace_id", ["topic_workspace_id"]),
|
||||
("ix_mounted_draft_workspaces_state", ["state"]),
|
||||
("ix_mounted_draft_workspaces_confirm_message_id", ["confirm_message_id"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_draft_workspaces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_conversation_runs(bind) -> None:
|
||||
if _table_exists(bind, "mounted_conversation_runs"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_conversation_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("trace_id", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"topic_workspace_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
|
||||
sa.Column("source_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("mode_before", sa.VARCHAR(length=24), nullable=True),
|
||||
sa.Column("mode_after", sa.VARCHAR(length=24), nullable=True),
|
||||
sa.Column("active_agent", sa.VARCHAR(length=64), nullable=True),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="queued"),
|
||||
sa.Column("trigger_reason", sa.VARCHAR(length=128), nullable=True),
|
||||
sa.Column("context_layers_hit", _json_type(bind), nullable=True),
|
||||
sa.Column("tool_calls", _json_type(bind), nullable=True),
|
||||
sa.Column("final_reply", sa.Text(), nullable=True),
|
||||
sa.Column("execution_outcome", _json_type(bind), nullable=True),
|
||||
sa.Column("error_text", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_conversation_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_conversation_runs_trace_id", ["trace_id"]),
|
||||
("ix_mounted_conversation_runs_topic_workspace_id", ["topic_workspace_id"]),
|
||||
("ix_mounted_conversation_runs_source_system", ["source_system"]),
|
||||
("ix_mounted_conversation_runs_source_message_id", ["source_message_id"]),
|
||||
("ix_mounted_conversation_runs_mode_before", ["mode_before"]),
|
||||
("ix_mounted_conversation_runs_mode_after", ["mode_after"]),
|
||||
("ix_mounted_conversation_runs_active_agent", ["active_agent"]),
|
||||
("ix_mounted_conversation_runs_status", ["status"]),
|
||||
("ix_mounted_conversation_runs_trigger_reason", ["trigger_reason"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_conversation_runs", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_agent_traces(bind) -> None:
|
||||
if _table_exists(bind, "mounted_agent_traces"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_agent_traces",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"conversation_run_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("agent_name", sa.VARCHAR(length=64), nullable=False),
|
||||
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="completed"),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("input_summary", _json_type(bind), nullable=True),
|
||||
sa.Column("output_summary", _json_type(bind), nullable=True),
|
||||
sa.Column("tool_trace", _json_type(bind), nullable=True),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_agent_traces_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_agent_traces_conversation_run_id", ["conversation_run_id"]),
|
||||
("ix_mounted_agent_traces_agent_name", ["agent_name"]),
|
||||
("ix_mounted_agent_traces_status", ["status"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_agent_traces", index_name, columns)
|
||||
|
||||
|
||||
def _create_mounted_tool_capability_policies(bind) -> None:
|
||||
if _table_exists(bind, "mounted_tool_capability_policies"):
|
||||
return
|
||||
op.create_table(
|
||||
"mounted_tool_capability_policies",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tool_name", sa.VARCHAR(length=128), nullable=False),
|
||||
sa.Column("risk_level", sa.VARCHAR(length=24), nullable=False, server_default="medium"),
|
||||
sa.Column("confirm_required", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("auto_execute_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("source_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("tenant_scope", _json_type(bind), nullable=True),
|
||||
sa.Column("policy_metadata", _json_type(bind), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"tool_name",
|
||||
name="uq_mounted_tool_capability_policy_tool",
|
||||
),
|
||||
)
|
||||
for index_name, columns in (
|
||||
("ix_mounted_tool_capability_policies_tenant_id", ["tenant_id"]),
|
||||
("ix_mounted_tool_capability_policies_tool_name", ["tool_name"]),
|
||||
("ix_mounted_tool_capability_policies_risk_level", ["risk_level"]),
|
||||
("ix_mounted_tool_capability_policies_is_active", ["is_active"]),
|
||||
):
|
||||
_create_index_if_missing(bind, "mounted_tool_capability_policies", index_name, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_create_mounted_topic_workspaces(bind)
|
||||
_create_mounted_draft_workspaces(bind)
|
||||
_create_mounted_conversation_runs(bind)
|
||||
_create_mounted_agent_traces(bind)
|
||||
_create_mounted_tool_capability_policies(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for table_name in (
|
||||
"mounted_tool_capability_policies",
|
||||
"mounted_agent_traces",
|
||||
"mounted_conversation_runs",
|
||||
"mounted_draft_workspaces",
|
||||
"mounted_topic_workspaces",
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -8,6 +8,7 @@ from . import (
|
|||
routes_conflicts,
|
||||
routes_executive_briefing,
|
||||
routes_feedback_center,
|
||||
routes_mounted_ai,
|
||||
routes_reply_iteration,
|
||||
routes_rag_sources,
|
||||
routes_stats_config,
|
||||
|
|
@ -22,6 +23,7 @@ for _module in [
|
|||
routes_conflicts,
|
||||
routes_executive_briefing,
|
||||
routes_feedback_center,
|
||||
routes_mounted_ai,
|
||||
routes_reply_iteration,
|
||||
routes_brief_blockers,
|
||||
routes_stats_config,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.response import ApiResponse, success_response
|
||||
from ...dependencies.permissions import require_manager_user
|
||||
from ...dependencies.tenant import get_tenant_id
|
||||
from ...models import User
|
||||
from ...schemas.mounted_ai_runtime import (
|
||||
MountedAICutoverStatusPayload,
|
||||
MountedAIEvalSuiteDetailPayload,
|
||||
MountedAIEvalSuiteListPayload,
|
||||
MountedAIRunDetailPayload,
|
||||
MountedAIRunListPayload,
|
||||
MountedAISourceListPayload,
|
||||
MountedAITopicListPayload,
|
||||
)
|
||||
from ...services.mounted_ai_runtime_service import MountedAIRuntimeService
|
||||
from .shared import *
|
||||
|
||||
|
||||
@router.get("/mounted-ai/sources", response_model=ApiResponse[MountedAISourceListPayload])
|
||||
async def list_mounted_ai_sources(
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await MountedAIRuntimeService(db).list_sources(tenant_id=effective_tenant_id)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/topics", response_model=ApiResponse[MountedAITopicListPayload])
|
||||
async def list_mounted_ai_topics(
|
||||
mode: str | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await MountedAIRuntimeService(db).list_topics(
|
||||
tenant_id=effective_tenant_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
mode=mode,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/runs", response_model=ApiResponse[MountedAIRunListPayload])
|
||||
async def list_mounted_ai_runs(
|
||||
status: str | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await MountedAIRuntimeService(db).list_runs(
|
||||
tenant_id=effective_tenant_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/runs/{run_id}", response_model=ApiResponse[MountedAIRunDetailPayload])
|
||||
async def get_mounted_ai_run_detail(
|
||||
run_id: int,
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await MountedAIRuntimeService(db).get_run_detail(
|
||||
tenant_id=effective_tenant_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/cutover-status", response_model=ApiResponse[MountedAICutoverStatusPayload])
|
||||
async def get_mounted_ai_cutover_status(
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await MountedAIRuntimeService(db).get_cutover_status(tenant_id=effective_tenant_id)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/eval-suites", response_model=ApiResponse[MountedAIEvalSuiteListPayload])
|
||||
async def list_mounted_ai_eval_suites(
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await MountedAIRuntimeService(db).list_eval_suites(tenant_id=effective_tenant_id)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/mounted-ai/eval-suites/{dataset_id}", response_model=ApiResponse[MountedAIEvalSuiteDetailPayload])
|
||||
async def get_mounted_ai_eval_suite_detail(
|
||||
dataset_id: int,
|
||||
current_user: User = Depends(require_manager_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await MountedAIRuntimeService(db).get_eval_suite_detail(
|
||||
tenant_id=effective_tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(data)
|
||||
|
|
@ -73,6 +73,13 @@ from .plan_bundle import PlanBundle
|
|||
from .reply_receipt import ReplyReceipt
|
||||
from .portfolio_snapshot import PortfolioSnapshot
|
||||
from .conversation_session import ConversationSession
|
||||
from .mounted_ai_runtime import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedToolCapabilityPolicy,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
|
|
@ -153,4 +160,9 @@ __all__ = [
|
|||
"ReplyReceipt",
|
||||
"PortfolioSnapshot",
|
||||
"ConversationSession",
|
||||
"MountedAgentTrace",
|
||||
"MountedConversationRun",
|
||||
"MountedDraftWorkspace",
|
||||
"MountedToolCapabilityPolicy",
|
||||
"MountedTopicWorkspace",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
"""Mounted AI Runtime 核心运行时对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
from .types import JSONB
|
||||
|
||||
|
||||
class MountedTopicWorkspace(Base):
|
||||
__tablename__ = "mounted_topic_workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"topic_key",
|
||||
name="uq_mounted_topic_workspace_source_topic",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
topic_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||
channel_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
thread_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
|
||||
root_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
mode: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="chat", index=True)
|
||||
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="active", index=True)
|
||||
facts_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
goals_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
constraints_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
options_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
open_questions: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
pending_actions: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
bound_entities: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
readiness_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, default=0.0)
|
||||
last_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
last_triggered_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||
metadata_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
|
||||
class MountedDraftWorkspace(Base):
|
||||
__tablename__ = "mounted_draft_workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"topic_workspace_id",
|
||||
name="uq_mounted_draft_workspace_topic",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
topic_workspace_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
state: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="collecting", index=True)
|
||||
draft_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
requirement_card: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
draft_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
required_confirmations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
last_ready_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||
confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
|
||||
|
||||
class MountedConversationRun(Base):
|
||||
__tablename__ = "mounted_conversation_runs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||
topic_workspace_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||
source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
mode_before: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True)
|
||||
mode_after: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True)
|
||||
active_agent: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="queued", index=True)
|
||||
trigger_reason: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
|
||||
context_layers_hit: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
tool_calls: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
final_reply: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
execution_outcome: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
error_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||
completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||
|
||||
|
||||
class MountedAgentTrace(Base):
|
||||
__tablename__ = "mounted_agent_traces"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_run_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
agent_name: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="completed", index=True)
|
||||
latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
input_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
output_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
tool_trace: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
failure_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class MountedToolCapabilityPolicy(Base):
|
||||
__tablename__ = "mounted_tool_capability_policies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"tool_name",
|
||||
name="uq_mounted_tool_capability_policy_tool",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tool_name: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||
risk_level: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="medium", index=True)
|
||||
confirm_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
auto_execute_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
source_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
tenant_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
policy_metadata: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MountedAgentTrace",
|
||||
"MountedConversationRun",
|
||||
"MountedDraftWorkspace",
|
||||
"MountedToolCapabilityPolicy",
|
||||
"MountedTopicWorkspace",
|
||||
]
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MountedAISourceItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
source_system: str
|
||||
run_count: int = 0
|
||||
topic_count: int = 0
|
||||
active_topic_count: int = 0
|
||||
last_seen_at: Optional[str] = None
|
||||
|
||||
|
||||
class MountedAISourceListPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
items: list[MountedAISourceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MountedAITopicItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: int
|
||||
topic_key: str
|
||||
source_system: str
|
||||
channel_ref: Optional[str] = None
|
||||
thread_key: Optional[str] = None
|
||||
mode: str
|
||||
status: str
|
||||
readiness_score: Optional[float] = None
|
||||
draft_state: Optional[str] = None
|
||||
draft_version: Optional[int] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class MountedAITopicListPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
items: list[MountedAITopicItem] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class MountedAIRunItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: int
|
||||
trace_id: str
|
||||
source_system: str
|
||||
source_message_id: Optional[str] = None
|
||||
mode_before: Optional[str] = None
|
||||
mode_after: Optional[str] = None
|
||||
active_agent: Optional[str] = None
|
||||
status: str
|
||||
trigger_reason: Optional[str] = None
|
||||
final_reply: Optional[str] = None
|
||||
trace_count: int = 0
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class MountedAIRunListPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
items: list[MountedAIRunItem] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class MountedAIAgentTraceItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: int
|
||||
agent_name: str
|
||||
status: str
|
||||
latency_ms: Optional[int] = None
|
||||
failure_reason: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MountedAIRunDetailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
item: MountedAIRunItem
|
||||
agent_traces: list[MountedAIAgentTraceItem] = Field(default_factory=list)
|
||||
context_layers_hit: Optional[Any] = None
|
||||
tool_calls: Optional[Any] = None
|
||||
execution_outcome: Optional[Any] = None
|
||||
|
||||
|
||||
class MountedAICutoverStatusPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
mounted_runtime_ready: bool
|
||||
topic_workspace_count: int = 0
|
||||
conversation_run_count: int = 0
|
||||
tool_policy_count: int = 0
|
||||
observed_source_systems: list[str] = Field(default_factory=list)
|
||||
legacy_cutover_state: str
|
||||
retired_item_count: int = 0
|
||||
active_item_count: int = 0
|
||||
legacy_cutover_items: list["MountedAILegacyCutoverItem"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MountedAILegacyCutoverItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
key: str
|
||||
title: str
|
||||
status: str
|
||||
summary: str
|
||||
evidence: str
|
||||
|
||||
|
||||
class MountedAIEvalSuiteItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: int
|
||||
dataset_type: str
|
||||
version: str
|
||||
status: str
|
||||
suite_kind: str
|
||||
sample_count: int = 0
|
||||
latest_score: Optional[float] = None
|
||||
last_evaluated_at: Optional[str] = None
|
||||
|
||||
|
||||
class MountedAIEvalSuiteListPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
items: list[MountedAIEvalSuiteItem] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class MountedAIEvalResultItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
evaluation_type: str
|
||||
status: str
|
||||
score: Optional[float] = None
|
||||
|
||||
|
||||
class MountedAIEvalSuiteDetailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
item: MountedAIEvalSuiteItem
|
||||
selection_rules: Optional[Any] = None
|
||||
notes: Optional[str] = None
|
||||
evaluations: list[MountedAIEvalResultItem] = Field(default_factory=list)
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .... import ai_pmo_kernel_common_core as _common
|
||||
from ....consultant_dispatch import build_consultant_dispatch_contract
|
||||
from ....workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
for _name, _value in vars(_common).items():
|
||||
|
|
@ -25,17 +24,7 @@ class KernelIngressRenderMixin:
|
|||
) -> dict[str, Any]:
|
||||
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
|
||||
direct_reply_text = str(render.get("text") or "").strip()
|
||||
primary_operation_code = self._primary_operation_code(str(intent.name or ""))
|
||||
risk_level = self._risk_level_for_intent(str(intent.name or ""))
|
||||
missing_fields = (
|
||||
execution_result.get("missing_fields")
|
||||
if isinstance(execution_result.get("missing_fields"), list)
|
||||
else (
|
||||
(intent.metadata or {}).get("missing_fields")
|
||||
if isinstance(intent.metadata, dict) and isinstance((intent.metadata or {}).get("missing_fields"), list)
|
||||
else []
|
||||
)
|
||||
)
|
||||
workflow_route = str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None
|
||||
mixed_sidecar_reply = (
|
||||
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
|
||||
if isinstance(intent.metadata, dict)
|
||||
|
|
@ -62,17 +51,6 @@ class KernelIngressRenderMixin:
|
|||
signal_ids = [primary_signal_id, *signal_ids]
|
||||
if signal_ids and primary_signal_id is None:
|
||||
primary_signal_id = signal_ids[0]
|
||||
consultant_dispatch = build_consultant_dispatch_contract(
|
||||
intent_name=str(intent.name or ""),
|
||||
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
|
||||
source_system=str(getattr(work_item, "source_system", "") or "pmflow"),
|
||||
status=status,
|
||||
classification=classification,
|
||||
primary_operation_code=primary_operation_code,
|
||||
risk_level=risk_level,
|
||||
missing_fields=missing_fields,
|
||||
direct_reply_text=direct_reply_text,
|
||||
)
|
||||
result = {
|
||||
"handled": True,
|
||||
"status": status,
|
||||
|
|
@ -97,10 +75,9 @@ class KernelIngressRenderMixin:
|
|||
}
|
||||
],
|
||||
"intent": intent.name,
|
||||
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
|
||||
"workflow_route": workflow_route,
|
||||
"direct_reply_required": bool(direct_reply_required),
|
||||
"direct_reply_text": direct_reply_text,
|
||||
"consultant_dispatch": consultant_dispatch,
|
||||
"reply_template_key": str(render.get("template_key") or "").strip(),
|
||||
"work_item_id": int(work_item.id),
|
||||
"decision_level": work_item.decision_level,
|
||||
|
|
@ -121,7 +98,7 @@ class KernelIngressRenderMixin:
|
|||
intent_name=str(intent.name or ""),
|
||||
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
|
||||
source_system=str(getattr(work_item, "source_system", "") or "unknown"),
|
||||
workflow_route=str(work_item.intent or execution_result.get("workflow_route") or "").strip(),
|
||||
workflow_route=str(workflow_route or ""),
|
||||
project_id=_safe_int(work_item.project_id),
|
||||
metadata=intent.metadata if isinstance(intent.metadata, dict) else {},
|
||||
).to_dict()
|
||||
|
|
@ -135,11 +112,8 @@ class KernelIngressRenderMixin:
|
|||
draft_expires_at = payload.get("draft_expires_at")
|
||||
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
|
||||
result["draft_expires_at"] = draft_expires_at.strip()
|
||||
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
|
||||
rag_result = dict(execution_result["rag_result"])
|
||||
if not isinstance(rag_result.get("consultant_dispatch"), dict):
|
||||
rag_result["consultant_dispatch"] = consultant_dispatch
|
||||
result["rag_result"] = rag_result
|
||||
if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("rag_result"), dict):
|
||||
result["rag_result"] = dict(execution_result["rag_result"])
|
||||
if intent.name == INTENT_PROJECT_CREATE:
|
||||
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
|
||||
result["project"] = raw_payload.get("project")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
|
|||
QUERY_SCENE_BUSINESS_REVIEW,
|
||||
QUERY_SCENE_CORRECTION_ESCALATION,
|
||||
)
|
||||
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
|
||||
|
||||
class KernelIngressFlowEntryMixin:
|
||||
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
|
||||
|
|
@ -291,6 +292,8 @@ class KernelIngressFlowEntryMixin:
|
|||
perf_work_item_id: Optional[int] = None
|
||||
perf_intent_name = ""
|
||||
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
|
||||
mounted_runtime = MountedAIRuntimeWriteService(self.db)
|
||||
mounted_tracking = None
|
||||
def _log_perf(stage_name: str, stage_started_at: float) -> None:
|
||||
elapsed_seconds = time.perf_counter() - stage_started_at
|
||||
perf_line = (
|
||||
|
|
@ -300,6 +303,86 @@ class KernelIngressFlowEntryMixin:
|
|||
)
|
||||
logger.info(perf_line)
|
||||
print(perf_line, flush=True)
|
||||
|
||||
async def _safe_begin_mounted_runtime(*, runtime_message_id: str, runtime_message_text: str) -> None:
|
||||
nonlocal mounted_tracking
|
||||
if mounted_tracking is not None:
|
||||
return
|
||||
try:
|
||||
mounted_tracking = await mounted_runtime.begin_ingress_run(
|
||||
tenant_id=int(self.tenant_id),
|
||||
source_system=source_system,
|
||||
source_message_id=runtime_message_id,
|
||||
message_text=runtime_message_text,
|
||||
sender=sender,
|
||||
channel_ref=channel_ref,
|
||||
thread_context=thread_context,
|
||||
)
|
||||
except Exception as runtime_exc:
|
||||
logger.warning(
|
||||
"mounted_ai_runtime_begin_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
source_system=source_system,
|
||||
source_message_id=runtime_message_id,
|
||||
error=str(runtime_exc),
|
||||
)
|
||||
|
||||
async def _safe_record_mounted_decision(
|
||||
*,
|
||||
workflow_route: str,
|
||||
intent_name: str,
|
||||
intent_confidence: float,
|
||||
trigger_reason: str,
|
||||
auto_execute: bool,
|
||||
work_item_id: Optional[int],
|
||||
route_metadata: Optional[dict[str, Any]],
|
||||
mode_override: Optional[str] = None,
|
||||
) -> None:
|
||||
if mounted_tracking is None:
|
||||
return
|
||||
try:
|
||||
await mounted_runtime.record_orchestrator_decision(
|
||||
tracking=mounted_tracking,
|
||||
workflow_route=workflow_route,
|
||||
intent_name=intent_name,
|
||||
intent_confidence=float(intent_confidence or 0.0),
|
||||
trigger_reason=trigger_reason,
|
||||
auto_execute=auto_execute,
|
||||
work_item_id=work_item_id,
|
||||
route_metadata=route_metadata,
|
||||
mode_override=mode_override,
|
||||
)
|
||||
except Exception as runtime_exc:
|
||||
logger.warning(
|
||||
"mounted_ai_runtime_record_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
source_system=source_system,
|
||||
source_message_id=str(getattr(getattr(mounted_tracking, "conversation_run", None), "source_message_id", "") or ""),
|
||||
error=str(runtime_exc),
|
||||
)
|
||||
|
||||
async def _safe_finalize_mounted_runtime(
|
||||
*,
|
||||
result: Optional[dict[str, Any]] = None,
|
||||
error_text: Optional[str] = None,
|
||||
) -> None:
|
||||
if mounted_tracking is None:
|
||||
return
|
||||
try:
|
||||
await mounted_runtime.finalize_run(
|
||||
tracking=mounted_tracking,
|
||||
result=result if isinstance(result, dict) else None,
|
||||
error_text=error_text,
|
||||
)
|
||||
except Exception as runtime_exc:
|
||||
logger.warning(
|
||||
"mounted_ai_runtime_finalize_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
source_system=source_system,
|
||||
source_message_id=str(getattr(getattr(mounted_tracking, "conversation_run", None), "source_message_id", "") or ""),
|
||||
error=str(runtime_exc),
|
||||
)
|
||||
|
||||
ingress_prepare = await self._prepare_ingress_envelope(
|
||||
source_system=source_system,
|
||||
source_message_id=source_message_id,
|
||||
|
|
@ -321,6 +404,10 @@ class KernelIngressFlowEntryMixin:
|
|||
operator = ingress_prepare["operator"]
|
||||
work_item = ingress_prepare["work_item"]
|
||||
session = ingress_prepare["session"]
|
||||
await _safe_begin_mounted_runtime(
|
||||
runtime_message_id=synthetic_message_id,
|
||||
runtime_message_text=clean_text,
|
||||
)
|
||||
self._merge_ingress_thread_context(
|
||||
ingest_event=ingest_event,
|
||||
ingress_options=ingress_options,
|
||||
|
|
@ -344,6 +431,25 @@ class KernelIngressFlowEntryMixin:
|
|||
ingress_options=ingress_options,
|
||||
)
|
||||
if draft_capture is not None:
|
||||
draft_mode_override = (
|
||||
"draft_ready"
|
||||
if str(draft_capture.get("reply_template_key") or "").strip().lower() == "draft_pending_confirm"
|
||||
else "plan"
|
||||
)
|
||||
await _safe_record_mounted_decision(
|
||||
workflow_route=str(draft_capture.get("workflow_route") or ROUTE_FOLLOWUP_CONFIRM).strip() or ROUTE_FOLLOWUP_CONFIRM,
|
||||
intent_name=INTENT_FOLLOWUP_CONFIRM,
|
||||
intent_confidence=0.95,
|
||||
trigger_reason="draft_confirmation",
|
||||
auto_execute=False,
|
||||
work_item_id=_safe_int(draft_capture.get("work_item_id")) or perf_work_item_id,
|
||||
route_metadata={
|
||||
"draft_confirmation": True,
|
||||
"reply_template_key": str(draft_capture.get("reply_template_key") or "").strip() or None,
|
||||
},
|
||||
mode_override=draft_mode_override,
|
||||
)
|
||||
await _safe_finalize_mounted_runtime(result=draft_capture)
|
||||
return draft_capture
|
||||
mixed_meta: dict[str, Any] = {}
|
||||
if not self._is_short_confirmation_message(
|
||||
|
|
@ -454,7 +560,7 @@ class KernelIngressFlowEntryMixin:
|
|||
resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id"))
|
||||
fallback_intent = intent
|
||||
if intent.name == INTENT_IGNORE:
|
||||
return await self._finalize_ignored_ingress(
|
||||
capture_result = await self._finalize_ignored_ingress(
|
||||
work_item=work_item,
|
||||
ingest_event=ingest_event,
|
||||
session=session,
|
||||
|
|
@ -465,6 +571,18 @@ class KernelIngressFlowEntryMixin:
|
|||
sender=sender,
|
||||
resolved_project_for_session=resolved_project_for_session,
|
||||
)
|
||||
await _safe_record_mounted_decision(
|
||||
workflow_route=workflow_route.key,
|
||||
intent_name=intent.name,
|
||||
intent_confidence=float(intent.confidence or 0.0),
|
||||
trigger_reason="ignored",
|
||||
auto_execute=False,
|
||||
work_item_id=int(work_item.id),
|
||||
route_metadata=route_metadata,
|
||||
mode_override="chat",
|
||||
)
|
||||
await _safe_finalize_mounted_runtime(result=capture_result)
|
||||
return capture_result
|
||||
|
||||
work_item.intent = workflow_route.key
|
||||
work_item.project_id = workflow_route.project_id
|
||||
|
|
@ -562,9 +680,24 @@ class KernelIngressFlowEntryMixin:
|
|||
started=started,
|
||||
)
|
||||
_log_perf("_build_capture_result", stage_started_at)
|
||||
await _safe_record_mounted_decision(
|
||||
workflow_route=workflow_route.key,
|
||||
intent_name=intent.name,
|
||||
intent_confidence=float(intent.confidence or 0.0),
|
||||
trigger_reason=manual_review_code if requires_manual_review else str(gate.gate_code or "manual_review"),
|
||||
auto_execute=False,
|
||||
work_item_id=int(work_item.id),
|
||||
route_metadata=route_metadata,
|
||||
mode_override=(
|
||||
"draft_ready"
|
||||
if str(capture_result.get("reply_template_key") or "").strip().lower() == "draft_pending_confirm"
|
||||
else None
|
||||
),
|
||||
)
|
||||
await _safe_finalize_mounted_runtime(result=capture_result)
|
||||
return capture_result
|
||||
|
||||
return await self._execute_auto_ingress_flow(
|
||||
capture_result = await self._execute_auto_ingress_flow(
|
||||
work_item=work_item,
|
||||
intent=intent,
|
||||
world_model=world_model,
|
||||
|
|
@ -586,7 +719,19 @@ class KernelIngressFlowEntryMixin:
|
|||
session=session,
|
||||
log_perf_fn=_log_perf,
|
||||
)
|
||||
await _safe_record_mounted_decision(
|
||||
workflow_route=workflow_route.key,
|
||||
intent_name=intent.name,
|
||||
intent_confidence=float(intent.confidence or 0.0),
|
||||
trigger_reason="auto_execute",
|
||||
auto_execute=True,
|
||||
work_item_id=int(work_item.id),
|
||||
route_metadata=route_metadata,
|
||||
)
|
||||
await _safe_finalize_mounted_runtime(result=capture_result)
|
||||
return capture_result
|
||||
except Exception as exc:
|
||||
await _safe_finalize_mounted_runtime(error_text=str(exc))
|
||||
await self._persist_kernel_failure_with_fresh_session(
|
||||
work_item_id=_safe_int(getattr(work_item, "id", None)),
|
||||
ingest_event_id=_safe_int(getattr(ingest_event, "id", None)),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.consultant_dispatch import build_channel_consultant_reply
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
|
||||
class FeishuReplyLLMCoreMixin:
|
||||
|
|
@ -80,19 +79,6 @@ class FeishuReplyLLMCoreMixin:
|
|||
)
|
||||
return
|
||||
|
||||
consultant_reply = self._build_consultant_dispatch_reply(capture_result)
|
||||
if consultant_reply:
|
||||
await self._safe_reply_text(
|
||||
chat_id=chat_id,
|
||||
mention_open_id=sender_open_id,
|
||||
mention_name=sender_name,
|
||||
text=consultant_reply,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
perf_t0=perf_t0,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
status = str(capture_result.get("status") or "unknown")
|
||||
classification = str(capture_result.get("classification") or "unknown")
|
||||
|
|
@ -246,14 +232,8 @@ class FeishuReplyLLMCoreMixin:
|
|||
|
||||
@staticmethod
|
||||
def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]:
|
||||
if not isinstance(capture_result, dict):
|
||||
_ = capture_result
|
||||
return None
|
||||
reply = build_channel_consultant_reply(
|
||||
consultant_dispatch=capture_result.get("consultant_dispatch"),
|
||||
direct_reply_text=str(capture_result.get("direct_reply_text") or "").strip(),
|
||||
channel="feishu",
|
||||
)
|
||||
return str(reply or "").strip() or None
|
||||
|
||||
@classmethod
|
||||
def _build_action_request_ack_reply(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||||
from app.utils.thread_context import extract_thread_anchor
|
||||
|
||||
|
||||
class FeishuPipelineFollowupAMixin:
|
||||
|
|
@ -18,229 +16,18 @@ class FeishuPipelineFollowupAMixin:
|
|||
process_started_at: float,
|
||||
thread_context: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if not source_chat_id:
|
||||
_ = (
|
||||
message,
|
||||
sender,
|
||||
source_chat_id,
|
||||
source_sender_open_id,
|
||||
feishu_message_id,
|
||||
process_started_at,
|
||||
thread_context,
|
||||
)
|
||||
# Legacy feishu_items queue follow-up is retired. Runtime callers should route
|
||||
# status/continue requests through mounted work-item/topic flows instead.
|
||||
return None
|
||||
wants_continue = self._looks_like_continue_followup(message)
|
||||
wants_status = WorkflowSemanticPlanner.wants_followup_status(message)
|
||||
if not wants_continue and not wants_status:
|
||||
return None
|
||||
|
||||
recent_items = await self._load_recent_chat_items(
|
||||
source_chat_id=source_chat_id,
|
||||
source_sender_open_id=source_sender_open_id,
|
||||
sender=sender,
|
||||
)
|
||||
thread_anchor = extract_thread_anchor(thread_context)
|
||||
anchor_item = (
|
||||
next(
|
||||
(
|
||||
item
|
||||
for item in recent_items
|
||||
if thread_anchor and str(getattr(item, "feishu_message_id", "") or "").strip() == thread_anchor
|
||||
),
|
||||
None,
|
||||
)
|
||||
if thread_anchor
|
||||
else None
|
||||
)
|
||||
if thread_anchor and anchor_item is None:
|
||||
return None
|
||||
if not recent_items:
|
||||
reply_text = "当前群里还没有可追踪的飞书处理项。"
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "群上下文中不存在可追踪的最近处理项。"
|
||||
classification = "question"
|
||||
status = "answered"
|
||||
mutations: list[dict[str, Any]] = []
|
||||
results: list[dict[str, Any]] = []
|
||||
elif wants_continue:
|
||||
if anchor_item is not None and self._normalize_route_status(anchor_item.route_status) == ROUTE_STATUS_QUEUED:
|
||||
pending_items = [anchor_item]
|
||||
else:
|
||||
pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED]
|
||||
if len(pending_items) != 1:
|
||||
reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3])
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
operator_user = await self._resolve_command_operator_user(sender=sender)
|
||||
if operator_user is None:
|
||||
reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。"
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "继续处理被阻断:当前租户下没有可用操作者。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
target_item = pending_items[0]
|
||||
if (target_item.level or "").lower() == "project":
|
||||
suggestion = await self._get_project_suggestion(int(target_item.id))
|
||||
if suggestion is None:
|
||||
reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。"
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion)
|
||||
if not candidate_fields["ready"]:
|
||||
reply_text = (
|
||||
str(target_item.blocking_reason or "").strip()
|
||||
or self._build_project_clarification_message(candidate_fields["missing_fields"])
|
||||
)
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
|
||||
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
|
||||
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = self._summarize_db_mutations(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"item_id": int(target_item.id),
|
||||
"level": target_item.level,
|
||||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||||
"project_id": confirm_result.get("project_id"),
|
||||
"signal_id": confirm_result.get("signal_id"),
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
results = [
|
||||
{
|
||||
"item_id": int(target_item.id),
|
||||
"level": target_item.level,
|
||||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||||
"project_id": _safe_int(confirm_result.get("project_id")),
|
||||
"signal_id": _safe_int(confirm_result.get("signal_id")),
|
||||
}
|
||||
]
|
||||
else:
|
||||
signal = await self._get_signal_by_item(int(target_item.id))
|
||||
if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)):
|
||||
reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。"
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "继续处理被阻断:最近待处理信号缺少项目归属。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
|
||||
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
|
||||
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
|
||||
classification = "command"
|
||||
status = "captured"
|
||||
mutations = self._summarize_db_mutations(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"item_id": int(target_item.id),
|
||||
"level": target_item.level,
|
||||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||||
"project_id": confirm_result.get("project_id"),
|
||||
"signal_id": confirm_result.get("signal_id"),
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
results = [
|
||||
{
|
||||
"item_id": int(target_item.id),
|
||||
"level": target_item.level,
|
||||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||||
"project_id": _safe_int(confirm_result.get("project_id")),
|
||||
"signal_id": _safe_int(confirm_result.get("signal_id")),
|
||||
}
|
||||
]
|
||||
else:
|
||||
target_item = anchor_item or self._select_followup_status_target(
|
||||
message=message,
|
||||
recent_items=recent_items,
|
||||
thread_context=thread_context,
|
||||
)
|
||||
if target_item is None:
|
||||
reply_text = self._build_followup_pending_clarification(recent_items[:3])
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||
reason = "状态追问缺少唯一锚点,系统拒绝猜测最近一条处理项。"
|
||||
classification = "question"
|
||||
status = "answered"
|
||||
mutations = []
|
||||
results = []
|
||||
else:
|
||||
reply_text = await self._build_followup_status_reply(target_item, message=message)
|
||||
decision = ROUTING_QUEUE_FOLLOWUP_QUERY
|
||||
reason = "命中进度/状态追问,已直接基于真实 DB 返回唯一处理项状态。"
|
||||
classification = "question"
|
||||
status = "answered"
|
||||
mutations = []
|
||||
results = []
|
||||
|
||||
await self._write_processing_log(
|
||||
feishu_message_id=feishu_message_id,
|
||||
feishu_item_id=None,
|
||||
raw_message=message,
|
||||
ai_raw_output=json.dumps(
|
||||
{
|
||||
"followup_reply": reply_text,
|
||||
"recent_item_ids": [int(item.id) for item in recent_items[:3]],
|
||||
"wants_continue": wants_continue,
|
||||
"wants_status": wants_status,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)[:4000],
|
||||
project_score=None,
|
||||
signal_confidence=None,
|
||||
level="followup",
|
||||
routing_decision=decision,
|
||||
routing_reason=reason,
|
||||
reasoning_bullets=[
|
||||
"follow-up 查询/指令命中飞书线程锚点或唯一目标。",
|
||||
"回复内容只基于真实 DB 状态,不使用自由生成。",
|
||||
f"recent_items={len(recent_items)}",
|
||||
],
|
||||
processing_status="success",
|
||||
error_message=None,
|
||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||
)
|
||||
|
||||
latest = recent_items[0] if recent_items else None
|
||||
linked_project_ids: list[int] = []
|
||||
if results and results[0].get("project_id") is not None:
|
||||
linked_project_ids.append(int(results[0]["project_id"]))
|
||||
if latest and latest.matched_project_id is not None:
|
||||
linked_project_ids.append(int(latest.matched_project_id))
|
||||
linked_project_ids = sorted(set(linked_project_ids))
|
||||
return {
|
||||
"status": status,
|
||||
"classification": classification,
|
||||
"project_scope": classification,
|
||||
"linked_project_ids": linked_project_ids,
|
||||
"signal_id": str(results[0].get("signal_id")) if results else None,
|
||||
"created_project_id": str(results[0].get("project_id")) if results else None,
|
||||
"auto_tasks": [],
|
||||
"ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}",
|
||||
"results": results,
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": reply_text,
|
||||
"db_mutations": mutations,
|
||||
}
|
||||
|
||||
async def _load_recent_chat_items(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,528 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedToolCapabilityPolicy,
|
||||
MountedTopicWorkspace,
|
||||
TrainingDataset,
|
||||
TrainingEvaluation,
|
||||
TrainingJob,
|
||||
)
|
||||
|
||||
|
||||
class MountedAIRuntimeService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _dt(value: Any) -> Optional[str]:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _backend_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
@classmethod
|
||||
def _read_backend_source(cls, relative_path: str) -> str:
|
||||
try:
|
||||
return (cls._backend_root() / relative_path).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _legacy_cutover_item(
|
||||
*,
|
||||
key: str,
|
||||
title: str,
|
||||
retired: bool,
|
||||
summary: str,
|
||||
evidence: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"key": key,
|
||||
"title": title,
|
||||
"status": "retired" if retired else "active",
|
||||
"summary": summary,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_legacy_cutover_items(cls) -> list[dict[str, Any]]:
|
||||
ingress_render = cls._read_backend_source("app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py")
|
||||
feishu_reply = cls._read_backend_source("app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py")
|
||||
signal_capture_service = cls._read_backend_source("app/services/signal/signal_capture_service/service.py")
|
||||
feishu_pipeline_service = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/service.py")
|
||||
feishu_process_core = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py")
|
||||
feishu_followup_a = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py")
|
||||
|
||||
return [
|
||||
cls._legacy_cutover_item(
|
||||
key="ai_pmo_ingress_consultant_capture",
|
||||
title="AI PMO ingress consultant capture",
|
||||
retired="build_consultant_dispatch_contract" not in ingress_render and '"consultant_dispatch"' not in ingress_render,
|
||||
summary="capture result 不再生成或透传 consultant_dispatch 到 AI PMO ingress 主链。",
|
||||
evidence="ai_pmo_kernel_mixins_ingress_render.py 不再包含 consultant dispatch contract/import 与 rag_result 注入。",
|
||||
),
|
||||
cls._legacy_cutover_item(
|
||||
key="feishu_mention_consultant_reply",
|
||||
title="Feishu mention consultant reply",
|
||||
retired="build_channel_consultant_reply" not in feishu_reply and "_build_consultant_dispatch_reply(capture_result)" not in feishu_reply,
|
||||
summary="飞书 mention 主回复不再走 consultant reply 分支。",
|
||||
evidence="mixin_reply_llm_core.py 已移除 consultant reply import 和 active reply branch;helper 仅保留 no-op。",
|
||||
),
|
||||
cls._legacy_cutover_item(
|
||||
key="signal_capture_legacy_entry",
|
||||
title="Signal capture legacy entry",
|
||||
retired="SignalCaptureLegacyMixin" not in signal_capture_service,
|
||||
summary="主动信号捕获服务不再继承 legacy capture mixin。",
|
||||
evidence="signal_capture_service/service.py 的 ProactiveSignalCaptureService 继承链已去掉 SignalCaptureLegacyMixin。",
|
||||
),
|
||||
cls._legacy_cutover_item(
|
||||
key="legacy_feishu_queue_followup",
|
||||
title="Legacy Feishu queue follow-up",
|
||||
retired=(
|
||||
"FeishuPipelineFollowupAMixin" not in feishu_pipeline_service
|
||||
and "FeishuPipelineFollowupBMixin" not in feishu_pipeline_service
|
||||
and "_maybe_handle_queue_followup(" not in feishu_process_core
|
||||
and "return None" in feishu_followup_a
|
||||
and "Legacy feishu_items queue follow-up is retired." in feishu_followup_a
|
||||
),
|
||||
summary="旧 feishu_items 队列 continue/status follow-up 已从真实入口退役。",
|
||||
evidence="service.py 不再混入 FollowupA/B,process_message_core 不再调用 queue followup,mixin_followup_a 已降为 retired stub。",
|
||||
),
|
||||
]
|
||||
|
||||
def _topic_snapshot(
|
||||
self,
|
||||
row: MountedTopicWorkspace,
|
||||
*,
|
||||
draft: Optional[MountedDraftWorkspace] = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"topic_key": row.topic_key,
|
||||
"source_system": row.source_system,
|
||||
"channel_ref": row.channel_ref,
|
||||
"thread_key": row.thread_key,
|
||||
"mode": row.mode,
|
||||
"status": row.status,
|
||||
"readiness_score": float(row.readiness_score or 0.0) if row.readiness_score is not None else None,
|
||||
"draft_state": draft.state if draft is not None else None,
|
||||
"draft_version": int(draft.draft_version) if draft is not None else None,
|
||||
"updated_at": self._dt(row.updated_at),
|
||||
}
|
||||
|
||||
def _run_snapshot(self, row: MountedConversationRun, *, trace_count: int = 0) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"trace_id": row.trace_id,
|
||||
"source_system": row.source_system,
|
||||
"source_message_id": row.source_message_id,
|
||||
"mode_before": row.mode_before,
|
||||
"mode_after": row.mode_after,
|
||||
"active_agent": row.active_agent,
|
||||
"status": row.status,
|
||||
"trigger_reason": row.trigger_reason,
|
||||
"final_reply": row.final_reply,
|
||||
"trace_count": int(trace_count),
|
||||
"updated_at": self._dt(row.updated_at),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _trace_snapshot(row: MountedAgentTrace) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"agent_name": row.agent_name,
|
||||
"status": row.status,
|
||||
"latency_ms": int(row.latency_ms) if row.latency_ms is not None else None,
|
||||
"failure_reason": row.failure_reason,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _evaluation_snapshot(row: TrainingEvaluation) -> dict[str, Any]:
|
||||
return {
|
||||
"evaluation_type": row.evaluation_type,
|
||||
"status": row.status,
|
||||
"score": float(row.score or 0.0) if row.score is not None else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _eval_suite_snapshot(
|
||||
row: TrainingDataset,
|
||||
*,
|
||||
latest_score: Optional[float],
|
||||
last_evaluated_at: Optional[Any],
|
||||
) -> dict[str, Any]:
|
||||
selection_rules = row.selection_rules if isinstance(row.selection_rules, dict) else {}
|
||||
suite_kind = str(selection_rules.get("suite_kind") or "custom").strip() or "custom"
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"dataset_type": row.dataset_type,
|
||||
"version": row.version,
|
||||
"status": row.status,
|
||||
"suite_kind": suite_kind,
|
||||
"sample_count": int(row.sample_count or 0),
|
||||
"latest_score": latest_score,
|
||||
"last_evaluated_at": MountedAIRuntimeService._dt(last_evaluated_at),
|
||||
}
|
||||
|
||||
async def list_sources(self, *, tenant_id: int) -> dict[str, Any]:
|
||||
source_map: dict[str, dict[str, Any]] = {}
|
||||
|
||||
run_rows = (
|
||||
await self.db.execute(
|
||||
select(
|
||||
MountedConversationRun.source_system,
|
||||
func.count(MountedConversationRun.id).label("run_count"),
|
||||
func.max(MountedConversationRun.updated_at).label("last_seen_at"),
|
||||
)
|
||||
.where(MountedConversationRun.tenant_id == int(tenant_id))
|
||||
.group_by(MountedConversationRun.source_system)
|
||||
)
|
||||
).all()
|
||||
for row in run_rows:
|
||||
source_system = str(row.source_system or "").strip()
|
||||
source_map[source_system] = {
|
||||
"source_system": source_system,
|
||||
"run_count": int(row.run_count or 0),
|
||||
"topic_count": 0,
|
||||
"active_topic_count": 0,
|
||||
"last_seen_at": self._dt(row.last_seen_at),
|
||||
}
|
||||
|
||||
topic_rows = (
|
||||
await self.db.execute(
|
||||
select(
|
||||
MountedTopicWorkspace.source_system,
|
||||
func.count(MountedTopicWorkspace.id).label("topic_count"),
|
||||
func.sum(
|
||||
case((MountedTopicWorkspace.status == "active", 1), else_=0)
|
||||
).label("active_topic_count"),
|
||||
func.max(MountedTopicWorkspace.updated_at).label("topic_last_seen_at"),
|
||||
)
|
||||
.where(MountedTopicWorkspace.tenant_id == int(tenant_id))
|
||||
.group_by(MountedTopicWorkspace.source_system)
|
||||
)
|
||||
).all()
|
||||
for row in topic_rows:
|
||||
source_system = str(row.source_system or "").strip()
|
||||
payload = source_map.setdefault(
|
||||
source_system,
|
||||
{
|
||||
"source_system": source_system,
|
||||
"run_count": 0,
|
||||
"topic_count": 0,
|
||||
"active_topic_count": 0,
|
||||
"last_seen_at": None,
|
||||
},
|
||||
)
|
||||
payload["topic_count"] = int(row.topic_count or 0)
|
||||
payload["active_topic_count"] = int(row.active_topic_count or 0)
|
||||
topic_last_seen_at = self._dt(row.topic_last_seen_at)
|
||||
if topic_last_seen_at and not payload.get("last_seen_at"):
|
||||
payload["last_seen_at"] = topic_last_seen_at
|
||||
|
||||
items = sorted(source_map.values(), key=lambda item: (item["source_system"] or ""))
|
||||
return {"items": items}
|
||||
|
||||
async def list_topics(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
limit: int,
|
||||
offset: int,
|
||||
mode: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
base_stmt = select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == int(tenant_id))
|
||||
if mode:
|
||||
base_stmt = base_stmt.where(MountedTopicWorkspace.mode == str(mode).strip())
|
||||
|
||||
total = (
|
||||
await self.db.execute(
|
||||
select(func.count()).select_from(base_stmt.order_by(None).subquery())
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
base_stmt.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc())
|
||||
.offset(int(offset))
|
||||
.limit(int(limit))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
topic_ids = [int(row.id) for row in rows]
|
||||
draft_map: dict[int, MountedDraftWorkspace] = {}
|
||||
if topic_ids:
|
||||
draft_rows = (
|
||||
await self.db.execute(
|
||||
select(MountedDraftWorkspace).where(
|
||||
MountedDraftWorkspace.tenant_id == int(tenant_id),
|
||||
MountedDraftWorkspace.topic_workspace_id.in_(topic_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
draft_map = {int(row.topic_workspace_id): row for row in draft_rows}
|
||||
|
||||
return {
|
||||
"items": [self._topic_snapshot(row, draft=draft_map.get(int(row.id))) for row in rows],
|
||||
"total": int(total or 0),
|
||||
}
|
||||
|
||||
async def list_runs(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
limit: int,
|
||||
offset: int,
|
||||
status: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
base_stmt = select(MountedConversationRun).where(MountedConversationRun.tenant_id == int(tenant_id))
|
||||
if status:
|
||||
base_stmt = base_stmt.where(MountedConversationRun.status == str(status).strip())
|
||||
|
||||
total = (
|
||||
await self.db.execute(
|
||||
select(func.count()).select_from(base_stmt.order_by(None).subquery())
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
rows = (
|
||||
await self.db.execute(
|
||||
base_stmt.order_by(MountedConversationRun.updated_at.desc(), MountedConversationRun.id.desc())
|
||||
.offset(int(offset))
|
||||
.limit(int(limit))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
run_ids = [int(row.id) for row in rows]
|
||||
trace_counts: dict[int, int] = {}
|
||||
if run_ids:
|
||||
trace_rows = (
|
||||
await self.db.execute(
|
||||
select(
|
||||
MountedAgentTrace.conversation_run_id,
|
||||
func.count(MountedAgentTrace.id).label("trace_count"),
|
||||
)
|
||||
.where(
|
||||
MountedAgentTrace.tenant_id == int(tenant_id),
|
||||
MountedAgentTrace.conversation_run_id.in_(run_ids),
|
||||
)
|
||||
.group_by(MountedAgentTrace.conversation_run_id)
|
||||
)
|
||||
).all()
|
||||
trace_counts = {int(row.conversation_run_id): int(row.trace_count or 0) for row in trace_rows}
|
||||
|
||||
return {
|
||||
"items": [self._run_snapshot(row, trace_count=trace_counts.get(int(row.id), 0)) for row in rows],
|
||||
"total": int(total or 0),
|
||||
}
|
||||
|
||||
async def get_run_detail(self, *, tenant_id: int, run_id: int) -> dict[str, Any]:
|
||||
row = (
|
||||
await self.db.execute(
|
||||
select(MountedConversationRun).where(
|
||||
MountedConversationRun.tenant_id == int(tenant_id),
|
||||
MountedConversationRun.id == int(run_id),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise ValueError("mounted ai run not found")
|
||||
|
||||
trace_rows = (
|
||||
await self.db.execute(
|
||||
select(MountedAgentTrace)
|
||||
.where(
|
||||
MountedAgentTrace.tenant_id == int(tenant_id),
|
||||
MountedAgentTrace.conversation_run_id == int(row.id),
|
||||
)
|
||||
.order_by(MountedAgentTrace.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"item": self._run_snapshot(row, trace_count=len(trace_rows)),
|
||||
"agent_traces": [self._trace_snapshot(trace) for trace in trace_rows],
|
||||
"context_layers_hit": row.context_layers_hit,
|
||||
"tool_calls": row.tool_calls,
|
||||
"execution_outcome": row.execution_outcome,
|
||||
}
|
||||
|
||||
async def get_cutover_status(self, *, tenant_id: int) -> dict[str, Any]:
|
||||
topic_workspace_count = (
|
||||
await self.db.execute(
|
||||
select(func.count(MountedTopicWorkspace.id)).where(
|
||||
MountedTopicWorkspace.tenant_id == int(tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
conversation_run_count = (
|
||||
await self.db.execute(
|
||||
select(func.count(MountedConversationRun.id)).where(
|
||||
MountedConversationRun.tenant_id == int(tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
tool_policy_count = (
|
||||
await self.db.execute(
|
||||
select(func.count(MountedToolCapabilityPolicy.id)).where(
|
||||
MountedToolCapabilityPolicy.tenant_id == int(tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
observed_sources = (
|
||||
await self.db.execute(
|
||||
select(MountedConversationRun.source_system)
|
||||
.where(MountedConversationRun.tenant_id == int(tenant_id))
|
||||
.distinct()
|
||||
.order_by(MountedConversationRun.source_system.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
legacy_cutover_items = self._build_legacy_cutover_items()
|
||||
active_item_count = sum(1 for item in legacy_cutover_items if item["status"] == "active")
|
||||
retired_item_count = sum(1 for item in legacy_cutover_items if item["status"] == "retired")
|
||||
legacy_cutover_state = "retired" if active_item_count == 0 else "active_legacy_paths_remaining"
|
||||
return {
|
||||
"mounted_runtime_ready": True,
|
||||
"topic_workspace_count": int(topic_workspace_count or 0),
|
||||
"conversation_run_count": int(conversation_run_count or 0),
|
||||
"tool_policy_count": int(tool_policy_count or 0),
|
||||
"observed_source_systems": [str(item) for item in observed_sources if str(item or "").strip()],
|
||||
"legacy_cutover_state": legacy_cutover_state,
|
||||
"retired_item_count": int(retired_item_count),
|
||||
"active_item_count": int(active_item_count),
|
||||
"legacy_cutover_items": legacy_cutover_items,
|
||||
}
|
||||
|
||||
async def list_eval_suites(self, *, tenant_id: int) -> dict[str, Any]:
|
||||
datasets = (
|
||||
await self.db.execute(
|
||||
select(TrainingDataset)
|
||||
.where(
|
||||
TrainingDataset.tenant_id == int(tenant_id),
|
||||
TrainingDataset.dataset_type == "eval",
|
||||
)
|
||||
.order_by(TrainingDataset.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
if not datasets:
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
dataset_ids = [int(row.id) for row in datasets]
|
||||
jobs = (
|
||||
await self.db.execute(
|
||||
select(TrainingJob)
|
||||
.where(
|
||||
TrainingJob.tenant_id == int(tenant_id),
|
||||
TrainingJob.dataset_id.in_(dataset_ids),
|
||||
)
|
||||
.order_by(TrainingJob.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
latest_job_by_dataset: dict[int, TrainingJob] = {}
|
||||
for job in jobs:
|
||||
latest_job_by_dataset.setdefault(int(job.dataset_id), job)
|
||||
|
||||
job_ids = [int(job.id) for job in latest_job_by_dataset.values()]
|
||||
evaluations = []
|
||||
if job_ids:
|
||||
evaluations = (
|
||||
await self.db.execute(
|
||||
select(TrainingEvaluation)
|
||||
.where(
|
||||
TrainingEvaluation.tenant_id == int(tenant_id),
|
||||
TrainingEvaluation.training_job_id.in_(job_ids),
|
||||
)
|
||||
.order_by(TrainingEvaluation.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
evaluations_by_job: dict[int, list[TrainingEvaluation]] = {}
|
||||
for item in evaluations:
|
||||
evaluations_by_job.setdefault(int(item.training_job_id), []).append(item)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for dataset in datasets:
|
||||
latest_job = latest_job_by_dataset.get(int(dataset.id))
|
||||
latest_evaluations = evaluations_by_job.get(int(latest_job.id), []) if latest_job is not None else []
|
||||
completed_scores = [
|
||||
float(row.score or 0.0)
|
||||
for row in latest_evaluations
|
||||
if row.score is not None and str(row.status or "").strip().lower() == "completed"
|
||||
]
|
||||
latest_score = round(sum(completed_scores) / len(completed_scores), 2) if completed_scores else None
|
||||
last_evaluated_at = None
|
||||
if latest_evaluations:
|
||||
last_evaluated_at = latest_evaluations[0].updated_at
|
||||
items.append(
|
||||
self._eval_suite_snapshot(
|
||||
dataset,
|
||||
latest_score=latest_score,
|
||||
last_evaluated_at=last_evaluated_at,
|
||||
)
|
||||
)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
async def get_eval_suite_detail(self, *, tenant_id: int, dataset_id: int) -> dict[str, Any]:
|
||||
dataset = (
|
||||
await self.db.execute(
|
||||
select(TrainingDataset).where(
|
||||
TrainingDataset.tenant_id == int(tenant_id),
|
||||
TrainingDataset.id == int(dataset_id),
|
||||
TrainingDataset.dataset_type == "eval",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dataset is None:
|
||||
raise ValueError("mounted ai eval suite not found")
|
||||
|
||||
latest_job = (
|
||||
await self.db.execute(
|
||||
select(TrainingJob)
|
||||
.where(
|
||||
TrainingJob.tenant_id == int(tenant_id),
|
||||
TrainingJob.dataset_id == int(dataset.id),
|
||||
)
|
||||
.order_by(TrainingJob.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
evaluations: list[TrainingEvaluation] = []
|
||||
if latest_job is not None:
|
||||
evaluations = (
|
||||
await self.db.execute(
|
||||
select(TrainingEvaluation)
|
||||
.where(
|
||||
TrainingEvaluation.tenant_id == int(tenant_id),
|
||||
TrainingEvaluation.training_job_id == int(latest_job.id),
|
||||
)
|
||||
.order_by(TrainingEvaluation.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
completed_scores = [
|
||||
float(row.score or 0.0)
|
||||
for row in evaluations
|
||||
if row.score is not None and str(row.status or "").strip().lower() == "completed"
|
||||
]
|
||||
latest_score = round(sum(completed_scores) / len(completed_scores), 2) if completed_scores else None
|
||||
last_evaluated_at = evaluations[-1].updated_at if evaluations else None
|
||||
return {
|
||||
"item": self._eval_suite_snapshot(
|
||||
dataset,
|
||||
latest_score=latest_score,
|
||||
last_evaluated_at=last_evaluated_at,
|
||||
),
|
||||
"selection_rules": dataset.selection_rules if isinstance(dataset.selection_rules, dict) else {},
|
||||
"notes": dataset.notes,
|
||||
"evaluations": [self._evaluation_snapshot(row) for row in evaluations],
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import MountedAgentTrace, MountedConversationRun, MountedTopicWorkspace
|
||||
from ..utils.timezone import now_cn_naive
|
||||
|
||||
|
||||
@dataclass
|
||||
class MountedIngressTracking:
|
||||
topic_workspace: MountedTopicWorkspace
|
||||
conversation_run: MountedConversationRun
|
||||
context_layers_hit: dict[str, bool]
|
||||
|
||||
|
||||
class MountedAIRuntimeWriteService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _compact(value: Any, *, max_len: int = 128) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return text[:max_len]
|
||||
|
||||
@classmethod
|
||||
def build_topic_key(
|
||||
cls,
|
||||
*,
|
||||
source_system: str,
|
||||
channel_ref: Optional[str],
|
||||
source_message_id: Optional[str],
|
||||
sender: str,
|
||||
message_text: str,
|
||||
thread_context: Optional[dict[str, Any]],
|
||||
) -> str:
|
||||
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
|
||||
normalized_channel = cls._compact(channel_ref, max_len=128) or "direct"
|
||||
root_message_id = cls._compact((thread_context or {}).get("root_message_id"), max_len=128)
|
||||
if root_message_id:
|
||||
return f"{normalized_source}:{normalized_channel}:root:{root_message_id}"
|
||||
thread_id = cls._compact((thread_context or {}).get("thread_id"), max_len=128)
|
||||
if thread_id:
|
||||
return f"{normalized_source}:{normalized_channel}:thread:{thread_id}"
|
||||
message_id = cls._compact(source_message_id, max_len=128)
|
||||
if message_id:
|
||||
return f"{normalized_source}:{normalized_channel}:message:{message_id}"
|
||||
digest = hashlib.sha1(
|
||||
f"{normalized_source}|{normalized_channel}|{sender}|{message_text}".encode("utf-8"),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()
|
||||
return f"{normalized_source}:{normalized_channel}:synthetic:{digest[:20]}"
|
||||
|
||||
@classmethod
|
||||
def build_run_trace_id(
|
||||
cls,
|
||||
*,
|
||||
source_system: str,
|
||||
source_message_id: Optional[str],
|
||||
topic_key: str,
|
||||
) -> str:
|
||||
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
|
||||
normalized_message_id = cls._compact(source_message_id, max_len=128)
|
||||
if normalized_message_id:
|
||||
return f"mounted:{normalized_source}:{normalized_message_id}"[:64]
|
||||
digest = hashlib.sha1(topic_key.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
return f"mounted:{normalized_source}:{digest[:48]}"[:64]
|
||||
|
||||
@staticmethod
|
||||
def _mode_for_route(
|
||||
*,
|
||||
workflow_route: str,
|
||||
auto_execute: bool,
|
||||
mode_override: Optional[str] = None,
|
||||
) -> str:
|
||||
if str(mode_override or "").strip():
|
||||
return str(mode_override).strip()
|
||||
normalized_route = str(workflow_route or "").strip().lower()
|
||||
if auto_execute:
|
||||
return "execute"
|
||||
if normalized_route.startswith("query."):
|
||||
return "chat"
|
||||
if normalized_route.startswith("followup."):
|
||||
return "plan"
|
||||
return "plan"
|
||||
|
||||
async def begin_ingress_run(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
source_system: str,
|
||||
source_message_id: Optional[str],
|
||||
message_text: str,
|
||||
sender: str,
|
||||
channel_ref: Optional[str],
|
||||
thread_context: Optional[dict[str, Any]],
|
||||
) -> MountedIngressTracking:
|
||||
topic_key = self.build_topic_key(
|
||||
source_system=source_system,
|
||||
channel_ref=channel_ref,
|
||||
source_message_id=source_message_id,
|
||||
sender=sender,
|
||||
message_text=message_text,
|
||||
thread_context=thread_context,
|
||||
)
|
||||
topic_row = (
|
||||
await self.db.execute(
|
||||
select(MountedTopicWorkspace).where(
|
||||
MountedTopicWorkspace.tenant_id == int(tenant_id),
|
||||
MountedTopicWorkspace.source_system == (self._compact(source_system, max_len=32) or "unknown"),
|
||||
MountedTopicWorkspace.topic_key == topic_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
topic_exists = topic_row is not None
|
||||
now_at = now_cn_naive()
|
||||
if topic_row is None:
|
||||
topic_row = MountedTopicWorkspace(
|
||||
tenant_id=int(tenant_id),
|
||||
topic_key=topic_key,
|
||||
source_system=self._compact(source_system, max_len=32) or "unknown",
|
||||
channel_ref=self._compact(channel_ref, max_len=128),
|
||||
thread_key=self._compact((thread_context or {}).get("thread_id"), max_len=255),
|
||||
root_message_id=self._compact((thread_context or {}).get("root_message_id"), max_len=128),
|
||||
mode="chat",
|
||||
status="active",
|
||||
metadata_json={},
|
||||
)
|
||||
self.db.add(topic_row)
|
||||
await self.db.flush()
|
||||
topic_row.channel_ref = self._compact(channel_ref, max_len=128)
|
||||
topic_row.thread_key = self._compact((thread_context or {}).get("thread_id"), max_len=255)
|
||||
topic_row.root_message_id = self._compact((thread_context or {}).get("root_message_id"), max_len=128)
|
||||
topic_row.last_message_id = self._compact(source_message_id, max_len=128)
|
||||
topic_row.last_triggered_at = now_at
|
||||
topic_row.metadata_json = {
|
||||
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
|
||||
"last_sender": self._compact(sender, max_len=128),
|
||||
"last_message_preview": self._compact(message_text, max_len=200),
|
||||
"last_reply_to_message_id": self._compact((thread_context or {}).get("reply_to_message_id"), max_len=128),
|
||||
}
|
||||
|
||||
context_layers_hit = {
|
||||
"current_message": True,
|
||||
"thread": bool((thread_context or {}).get("root_message_id") or (thread_context or {}).get("thread_id")),
|
||||
"topic": bool(topic_exists),
|
||||
}
|
||||
trace_id = self.build_run_trace_id(
|
||||
source_system=source_system,
|
||||
source_message_id=source_message_id,
|
||||
topic_key=topic_key,
|
||||
)
|
||||
run_row = (
|
||||
await self.db.execute(
|
||||
select(MountedConversationRun).where(
|
||||
MountedConversationRun.tenant_id == int(tenant_id),
|
||||
MountedConversationRun.trace_id == trace_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if run_row is None:
|
||||
run_row = MountedConversationRun(
|
||||
tenant_id=int(tenant_id),
|
||||
trace_id=trace_id,
|
||||
topic_workspace_id=int(topic_row.id),
|
||||
source_system=self._compact(source_system, max_len=32) or "unknown",
|
||||
source_message_id=self._compact(source_message_id, max_len=128),
|
||||
mode_before=self._compact(topic_row.mode, max_len=24) if topic_exists else None,
|
||||
active_agent="ConversationOrchestrator",
|
||||
status="running",
|
||||
trigger_reason=f"ingress:{self._compact(source_system, max_len=32) or 'unknown'}",
|
||||
context_layers_hit=context_layers_hit,
|
||||
started_at=now_at,
|
||||
)
|
||||
self.db.add(run_row)
|
||||
await self.db.flush()
|
||||
else:
|
||||
run_row.topic_workspace_id = int(topic_row.id)
|
||||
run_row.status = "running"
|
||||
run_row.active_agent = "ConversationOrchestrator"
|
||||
run_row.context_layers_hit = context_layers_hit
|
||||
if run_row.started_at is None:
|
||||
run_row.started_at = now_at
|
||||
|
||||
return MountedIngressTracking(
|
||||
topic_workspace=topic_row,
|
||||
conversation_run=run_row,
|
||||
context_layers_hit=context_layers_hit,
|
||||
)
|
||||
|
||||
async def record_orchestrator_decision(
|
||||
self,
|
||||
*,
|
||||
tracking: MountedIngressTracking,
|
||||
workflow_route: str,
|
||||
intent_name: str,
|
||||
intent_confidence: float,
|
||||
trigger_reason: str,
|
||||
auto_execute: bool,
|
||||
work_item_id: Optional[int],
|
||||
route_metadata: Optional[dict[str, Any]],
|
||||
mode_override: Optional[str] = None,
|
||||
) -> str:
|
||||
mode_after = self._mode_for_route(
|
||||
workflow_route=workflow_route,
|
||||
auto_execute=auto_execute,
|
||||
mode_override=mode_override,
|
||||
)
|
||||
topic_row = tracking.topic_workspace
|
||||
run_row = tracking.conversation_run
|
||||
topic_row.mode = mode_after
|
||||
topic_row.metadata_json = {
|
||||
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
|
||||
"last_workflow_route": str(workflow_route or "").strip() or None,
|
||||
"last_intent_name": str(intent_name or "").strip() or None,
|
||||
"last_intent_confidence": float(intent_confidence or 0.0),
|
||||
"last_work_item_id": int(work_item_id) if work_item_id is not None else None,
|
||||
}
|
||||
run_row.mode_after = mode_after
|
||||
run_row.active_agent = "ConversationOrchestrator"
|
||||
run_row.trigger_reason = self._compact(trigger_reason, max_len=128)
|
||||
self.db.add(
|
||||
MountedAgentTrace(
|
||||
tenant_id=int(topic_row.tenant_id),
|
||||
conversation_run_id=int(run_row.id),
|
||||
agent_name="ConversationOrchestrator",
|
||||
status="completed",
|
||||
input_summary={
|
||||
"mode_before": run_row.mode_before,
|
||||
"context_layers_hit": tracking.context_layers_hit,
|
||||
},
|
||||
output_summary={
|
||||
"mode_after": mode_after,
|
||||
"workflow_route": str(workflow_route or "").strip() or None,
|
||||
"intent_name": str(intent_name or "").strip() or None,
|
||||
"auto_execute": bool(auto_execute),
|
||||
"route_metadata": dict(route_metadata or {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.db.flush()
|
||||
return mode_after
|
||||
|
||||
async def finalize_run(
|
||||
self,
|
||||
*,
|
||||
tracking: MountedIngressTracking,
|
||||
result: Optional[dict[str, Any]] = None,
|
||||
error_text: Optional[str] = None,
|
||||
) -> None:
|
||||
topic_row = tracking.topic_workspace
|
||||
run_row = tracking.conversation_run
|
||||
now_at = now_cn_naive()
|
||||
normalized_error = str(error_text or "").strip()
|
||||
final_reply = str((result or {}).get("direct_reply_text") or "").strip() if isinstance(result, dict) else ""
|
||||
if normalized_error:
|
||||
run_row.status = "failed"
|
||||
run_row.active_agent = "ConversationOrchestrator"
|
||||
run_row.error_text = normalized_error
|
||||
else:
|
||||
run_row.status = "completed"
|
||||
run_row.active_agent = "ReplyComposer"
|
||||
run_row.final_reply = final_reply or None
|
||||
run_row.execution_outcome = {
|
||||
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
|
||||
"status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "",
|
||||
"classification": str((result or {}).get("classification") or "").strip() if isinstance(result, dict) else "",
|
||||
"workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "",
|
||||
"work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None,
|
||||
}
|
||||
run_row.completed_at = now_at
|
||||
self.db.add(
|
||||
MountedAgentTrace(
|
||||
tenant_id=int(topic_row.tenant_id),
|
||||
conversation_run_id=int(run_row.id),
|
||||
agent_name="ReplyComposer" if not normalized_error else "ConversationOrchestrator",
|
||||
status="failed" if normalized_error else "completed",
|
||||
output_summary=(
|
||||
{"final_reply": final_reply or None, "status": str((result or {}).get("status") or "").strip() or None}
|
||||
if not normalized_error
|
||||
else None
|
||||
),
|
||||
failure_reason=normalized_error or None,
|
||||
)
|
||||
)
|
||||
await self.db.flush()
|
||||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
from .mixin_analysis_normalize import SignalCaptureAnalysisNormalizeMixin
|
||||
from .mixin_capture_entry import SignalCaptureEntryMixin
|
||||
from .mixin_capture_legacy import SignalCaptureLegacyMixin
|
||||
from .mixin_external_sources import SignalCaptureExternalSourcesMixin
|
||||
from .mixin_new_project import SignalCaptureNewProjectMixin
|
||||
from .mixin_pipeline_cache import SignalCapturePipelineCacheMixin
|
||||
|
|
@ -14,7 +13,6 @@ from .mixin_task_orchestration_team import SignalCaptureTaskOrchestrationTeamMix
|
|||
|
||||
class ProactiveSignalCaptureService(
|
||||
SignalCaptureEntryMixin,
|
||||
SignalCaptureLegacyMixin,
|
||||
SignalCapturePipelineCacheMixin,
|
||||
SignalCaptureExternalSourcesMixin,
|
||||
SignalCaptureTaskOrchestrationMainMixin,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution
|
|||
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer
|
||||
from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
|
||||
from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render
|
||||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual
|
||||
from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project
|
||||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
|
||||
|
|
@ -218,6 +219,92 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) ->
|
|||
assert "草稿" in result["direct_reply_text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {"calls": []}
|
||||
|
||||
class FakeMountedAIRuntimeWriteService:
|
||||
def __init__(self, db) -> None:
|
||||
captured["db"] = db
|
||||
|
||||
async def begin_ingress_run(self, **kwargs):
|
||||
captured["calls"].append(("begin", kwargs))
|
||||
return SimpleNamespace(
|
||||
topic_workspace=SimpleNamespace(id=91, tenant_id=1),
|
||||
conversation_run=SimpleNamespace(id=92),
|
||||
context_layers_hit={"current_message": True, "thread": True, "topic": False},
|
||||
)
|
||||
|
||||
async def record_orchestrator_decision(self, **kwargs):
|
||||
captured["calls"].append(("record", kwargs))
|
||||
return "plan"
|
||||
|
||||
async def finalize_run(self, **kwargs):
|
||||
captured["calls"].append(("finalize", kwargs))
|
||||
|
||||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||||
def __init__(self) -> None:
|
||||
self.db = object()
|
||||
self.tenant_id = 1
|
||||
|
||||
async def _prepare_ingress_envelope(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"early_result": None,
|
||||
"clean_text": "确认",
|
||||
"synthetic_message_id": "s-msg-mounted-1",
|
||||
"closure_payload": {},
|
||||
"ingest_event": SimpleNamespace(id=100),
|
||||
"operator": SimpleNamespace(id=8),
|
||||
"work_item": SimpleNamespace(id=55),
|
||||
"session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||||
}
|
||||
|
||||
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"handled": True,
|
||||
"status": "answered",
|
||||
"classification": "command",
|
||||
"workflow_route": "followup.confirm",
|
||||
"reply_template_key": "action_succeeded",
|
||||
"work_item_id": 55,
|
||||
"direct_reply_required": True,
|
||||
"direct_reply_text": "已确认草稿并继续执行。",
|
||||
}
|
||||
|
||||
def _is_short_confirmation_message(self, **kwargs):
|
||||
_ = kwargs
|
||||
return False
|
||||
|
||||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||||
_ = kwargs
|
||||
raise AssertionError("draft confirmation flow should bypass mixed sidecar precheck")
|
||||
|
||||
monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService)
|
||||
|
||||
kernel = FakeKernel()
|
||||
result = await kernel.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="feishu-mounted-1",
|
||||
message_text="确认",
|
||||
sender="tester",
|
||||
channel_ref="chat-1",
|
||||
source_sender_open_id="ou_xxx",
|
||||
ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}},
|
||||
)
|
||||
|
||||
assert result["status"] == "answered"
|
||||
assert [name for name, _ in captured["calls"]] == ["begin", "record", "finalize"]
|
||||
begin_kwargs = captured["calls"][0][1]
|
||||
record_kwargs = captured["calls"][1][1]
|
||||
finalize_kwargs = captured["calls"][2][1]
|
||||
assert begin_kwargs["source_system"] == "feishu"
|
||||
assert begin_kwargs["source_message_id"] == "s-msg-mounted-1"
|
||||
assert record_kwargs["workflow_route"] == "followup.confirm"
|
||||
assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。"
|
||||
|
||||
|
||||
def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None:
|
||||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||||
|
||||
|
|
@ -1113,6 +1200,51 @@ def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None:
|
|||
assert "/rag/ask" not in dispatch["next_action"]
|
||||
|
||||
|
||||
def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None:
|
||||
class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin):
|
||||
tenant_id = 1
|
||||
|
||||
work_item = SimpleNamespace(
|
||||
id=18,
|
||||
project_id=7,
|
||||
signal_id=None,
|
||||
state="succeeded",
|
||||
intent="query.answer",
|
||||
reply_template_key="action_succeeded",
|
||||
decision_level="team",
|
||||
review_summary={"verified": True},
|
||||
raw_message="现在进展怎么样?",
|
||||
source_system="pmflow_manual",
|
||||
draft_version=0,
|
||||
)
|
||||
intent = CanonicalIntent(
|
||||
name="query.answer",
|
||||
confidence=0.93,
|
||||
project_id=7,
|
||||
signal_title="",
|
||||
signal_summary="当前主链正常。",
|
||||
metadata={},
|
||||
)
|
||||
result = _FakeKernel()._build_capture_result(
|
||||
work_item=work_item,
|
||||
intent=intent,
|
||||
execution_result={
|
||||
"workflow_route": "query.answer",
|
||||
"rag_result": {"answer": "当前主链正常。"},
|
||||
},
|
||||
render={
|
||||
"template_key": "action_succeeded",
|
||||
"text": "当前主链正常。",
|
||||
},
|
||||
direct_reply_required=True,
|
||||
status="answered",
|
||||
classification="question",
|
||||
)
|
||||
|
||||
assert "consultant_dispatch" not in result
|
||||
assert "consultant_dispatch" not in result["rag_result"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {"logs": []}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> No
|
|||
assert contract["next_action"]
|
||||
|
||||
|
||||
def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge() -> None:
|
||||
def test_feishu_reply_llm_core_no_longer_uses_consultant_dispatch_reply() -> None:
|
||||
clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
|
||||
{
|
||||
"direct_reply_text": "",
|
||||
|
|
@ -217,22 +217,8 @@ def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge()
|
|||
},
|
||||
}
|
||||
)
|
||||
challenge = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
|
||||
{
|
||||
"direct_reply_text": "",
|
||||
"consultant_dispatch": {
|
||||
"domain": "project",
|
||||
"mode": "challenge",
|
||||
"risk_level": "high",
|
||||
"missing_fields": [],
|
||||
"allow_execute": False,
|
||||
"next_action": "建议先拆分范围,再确认是否继续推进。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert "补充" in str(clarify or "")
|
||||
assert "建议" in str(challenge or "")
|
||||
assert clarify is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -986,9 +972,28 @@ def test_adaptive_rag_process_entry_and_mixed_format() -> None:
|
|||
assert mixed.endswith(",已入库处理")
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
|
||||
class _FakePromptResult:
|
||||
@staticmethod
|
||||
def scalar_one_or_none():
|
||||
return None
|
||||
|
||||
|
||||
class _FakePromptDB:
|
||||
async def execute(self, query): # noqa: ARG002
|
||||
return _FakePromptResult()
|
||||
|
||||
|
||||
def _make_adaptive_rag_mixin() -> AdaptiveRAGDeepGuardAnswerMixin:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
mixin.db = _FakePromptDB()
|
||||
mixin.tenant_id = 1
|
||||
return mixin
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="现在进展怎么样?",
|
||||
project_id=7,
|
||||
asked_via="pmflow_manual",
|
||||
|
|
@ -1007,9 +1012,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
|
|||
assert "新增任务确认流" in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="帮我总结这个项目",
|
||||
project_id=9,
|
||||
asked_via="pmflow_manual",
|
||||
|
|
@ -1027,9 +1033,10 @@ def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
|
|||
assert "帮我总结这个项目" in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="如果 AI 助手第一次上线只允许一种提醒,你会把它做成桌面通知还是飞书消息?为什么?",
|
||||
project_id=None,
|
||||
asked_via="feishu",
|
||||
|
|
@ -1048,9 +1055,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
|
|||
assert "飞书消息" in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="帮我看看宠物行业最新动态",
|
||||
project_id=None,
|
||||
asked_via="feishu",
|
||||
|
|
@ -1069,9 +1077,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project
|
|||
assert "project_context" not in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="杨宇现在负载情况如何?",
|
||||
project_id=None,
|
||||
asked_via="feishu",
|
||||
|
|
@ -1094,9 +1103,10 @@ def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None
|
|||
assert '"projects"' not in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="帮我看看宠物行业最新动态,重点只说消费侧和产品侧的变化,不要提项目。",
|
||||
project_id=7,
|
||||
asked_via="feishu",
|
||||
|
|
@ -1115,9 +1125,10 @@ def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_tr
|
|||
assert "project_context" not in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="这个项目所在行业最新动态怎么样?",
|
||||
project_id=7,
|
||||
asked_via="feishu",
|
||||
|
|
@ -1136,9 +1147,10 @@ def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_contex
|
|||
assert "外部搜索结果" in prompt
|
||||
|
||||
|
||||
def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
|
||||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||||
scene_key, prompt = mixin._select_scene_prompt(
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
|
||||
mixin = _make_adaptive_rag_mixin()
|
||||
scene_key, prompt = await mixin._select_scene_prompt(
|
||||
question="这个项目最新进展怎么样?",
|
||||
project_id=7,
|
||||
asked_via="feishu",
|
||||
|
|
|
|||
|
|
@ -214,6 +214,12 @@ def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins(
|
|||
assert "FeishuPipelineFollowupBMixin" not in source
|
||||
|
||||
|
||||
def test_proactive_signal_capture_service_runtime_excludes_legacy_capture_mixin() -> None:
|
||||
source = inspect.getsource(ProactiveSignalCaptureService)
|
||||
|
||||
assert "SignalCaptureLegacyMixin" not in source
|
||||
|
||||
|
||||
def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
|
||||
result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||||
|
||||
from app.api.ai_steward_api import routes_mounted_ai
|
||||
from app.core.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.dependencies.tenant import get_tenant_id
|
||||
from app.models.base import Base
|
||||
from app.models.mounted_ai_runtime import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedToolCapabilityPolicy,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
from app.models.training_pipeline import TrainingDataset, TrainingEvaluation, TrainingJob
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
MountedToolCapabilityPolicy.__table__,
|
||||
TrainingDataset.__table__,
|
||||
TrainingJob.__table__,
|
||||
TrainingEvaluation.__table__,
|
||||
):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"facts_json",
|
||||
"goals_json",
|
||||
"constraints_json",
|
||||
"options_json",
|
||||
"open_questions",
|
||||
"pending_actions",
|
||||
"bound_entities",
|
||||
"metadata_json",
|
||||
"requirement_card",
|
||||
"draft_payload",
|
||||
"required_confirmations",
|
||||
"context_layers_hit",
|
||||
"tool_calls",
|
||||
"execution_outcome",
|
||||
"input_summary",
|
||||
"output_summary",
|
||||
"tool_trace",
|
||||
"source_scope",
|
||||
"tenant_scope",
|
||||
"policy_metadata",
|
||||
"selection_rules",
|
||||
"channel_scope",
|
||||
"metrics_json",
|
||||
"evaluation_summary",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
MountedToolCapabilityPolicy.__table__,
|
||||
TrainingDataset.__table__,
|
||||
TrainingJob.__table__,
|
||||
TrainingEvaluation.__table__,
|
||||
],
|
||||
)
|
||||
)
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _build_app(db_session):
|
||||
app = FastAPI()
|
||||
app.include_router(routes_mounted_ai.router)
|
||||
|
||||
async def _fake_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _fake_get_db
|
||||
app.dependency_overrides[get_tenant_id] = lambda: 1
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9001,
|
||||
tenant_id=1,
|
||||
role="ADMIN",
|
||||
name="管理员",
|
||||
username="admin",
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mounted_ai_admin_routes_return_runtime_views(db_session) -> None:
|
||||
topic = MountedTopicWorkspace(
|
||||
tenant_id=1,
|
||||
topic_key="feishu:chat_1:topic_1",
|
||||
source_system="feishu",
|
||||
channel_ref="chat_1",
|
||||
thread_key="thread_1",
|
||||
mode="plan",
|
||||
status="active",
|
||||
open_questions=["负责人是谁"],
|
||||
readiness_score=0.4,
|
||||
)
|
||||
db_session.add(topic)
|
||||
await db_session.flush()
|
||||
|
||||
draft = MountedDraftWorkspace(
|
||||
tenant_id=1,
|
||||
topic_workspace_id=topic.id,
|
||||
state="collecting",
|
||||
draft_version=2,
|
||||
requirement_card={"goals": ["做挂载 AI"]},
|
||||
)
|
||||
run = MountedConversationRun(
|
||||
tenant_id=1,
|
||||
trace_id="mounted-run-1",
|
||||
topic_workspace_id=topic.id,
|
||||
source_system="feishu",
|
||||
source_message_id="om_123",
|
||||
mode_before="plan",
|
||||
mode_after="clarify",
|
||||
active_agent="Clarifier",
|
||||
status="completed",
|
||||
trigger_reason="missing_owner",
|
||||
context_layers_hit={"thread": True, "topic": True},
|
||||
final_reply="还差负责人信息。",
|
||||
)
|
||||
db_session.add_all([draft, run])
|
||||
await db_session.flush()
|
||||
|
||||
trace = MountedAgentTrace(
|
||||
tenant_id=1,
|
||||
conversation_run_id=run.id,
|
||||
agent_name="Clarifier",
|
||||
status="completed",
|
||||
latency_ms=128,
|
||||
input_summary={"mode": "plan"},
|
||||
output_summary={"question": "这件事打算谁来负责?"},
|
||||
)
|
||||
policy = MountedToolCapabilityPolicy(
|
||||
tenant_id=1,
|
||||
tool_name="project.create",
|
||||
risk_level="high",
|
||||
confirm_required=True,
|
||||
auto_execute_allowed=False,
|
||||
)
|
||||
eval_dataset = TrainingDataset(
|
||||
tenant_id=1,
|
||||
dataset_type="eval",
|
||||
version="teacher-frozen-v1",
|
||||
status="ready",
|
||||
sample_count=18,
|
||||
selection_rules={"suite_kind": "teacher_eval_frozen_set"},
|
||||
)
|
||||
db_session.add_all([trace, policy, eval_dataset])
|
||||
await db_session.flush()
|
||||
|
||||
eval_job = TrainingJob(
|
||||
tenant_id=1,
|
||||
dataset_id=eval_dataset.id,
|
||||
job_type="sft",
|
||||
base_model_family="gpt-5.4",
|
||||
target_slot="high",
|
||||
status="queued",
|
||||
provider_backend="openai",
|
||||
)
|
||||
db_session.add(eval_job)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add(
|
||||
TrainingEvaluation(
|
||||
tenant_id=1,
|
||||
training_job_id=eval_job.id,
|
||||
evaluation_type="offline_judge",
|
||||
status="completed",
|
||||
score=91.5,
|
||||
evaluation_summary={"pass": True},
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
app = _build_app(db_session)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
sources_resp = await client.get("/ai-steward/mounted-ai/sources")
|
||||
assert sources_resp.status_code == 200
|
||||
assert sources_resp.json()["data"]["items"][0]["source_system"] == "feishu"
|
||||
assert sources_resp.json()["data"]["items"][0]["topic_count"] == 1
|
||||
|
||||
topics_resp = await client.get("/ai-steward/mounted-ai/topics")
|
||||
assert topics_resp.status_code == 200
|
||||
assert topics_resp.json()["data"]["items"][0]["topic_key"] == "feishu:chat_1:topic_1"
|
||||
assert topics_resp.json()["data"]["items"][0]["draft_state"] == "collecting"
|
||||
|
||||
runs_resp = await client.get("/ai-steward/mounted-ai/runs")
|
||||
assert runs_resp.status_code == 200
|
||||
assert runs_resp.json()["data"]["items"][0]["active_agent"] == "Clarifier"
|
||||
|
||||
detail_resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["data"]["agent_traces"][0]["agent_name"] == "Clarifier"
|
||||
|
||||
cutover_resp = await client.get("/ai-steward/mounted-ai/cutover-status")
|
||||
assert cutover_resp.status_code == 200
|
||||
assert cutover_resp.json()["data"]["mounted_runtime_ready"] is True
|
||||
assert cutover_resp.json()["data"]["tool_policy_count"] == 1
|
||||
assert cutover_resp.json()["data"]["legacy_cutover_state"] == "retired"
|
||||
assert cutover_resp.json()["data"]["retired_item_count"] >= 4
|
||||
assert cutover_resp.json()["data"]["active_item_count"] == 0
|
||||
assert {
|
||||
item["key"]: item["status"]
|
||||
for item in cutover_resp.json()["data"]["legacy_cutover_items"]
|
||||
} == {
|
||||
"ai_pmo_ingress_consultant_capture": "retired",
|
||||
"feishu_mention_consultant_reply": "retired",
|
||||
"signal_capture_legacy_entry": "retired",
|
||||
"legacy_feishu_queue_followup": "retired",
|
||||
}
|
||||
|
||||
eval_suites_resp = await client.get("/ai-steward/mounted-ai/eval-suites")
|
||||
assert eval_suites_resp.status_code == 200
|
||||
assert eval_suites_resp.json()["data"]["items"][0]["suite_kind"] == "teacher_eval_frozen_set"
|
||||
assert eval_suites_resp.json()["data"]["items"][0]["latest_score"] == 91.5
|
||||
|
||||
eval_suite_detail_resp = await client.get(f"/ai-steward/mounted-ai/eval-suites/{eval_dataset.id}")
|
||||
assert eval_suite_detail_resp.status_code == 200
|
||||
assert eval_suite_detail_resp.json()["data"]["item"]["sample_count"] == 18
|
||||
assert eval_suite_detail_resp.json()["data"]["evaluations"][0]["evaluation_type"] == "offline_judge"
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "20260405_add_mounted_ai_runtime_tables.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("mounted_ai_runtime_tables_migration", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_mounted_ai_runtime_migration_adds_tables() -> None:
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
|
||||
with engine.begin() as conn:
|
||||
context = MigrationContext.configure(conn)
|
||||
migration.op = Operations(context)
|
||||
migration.upgrade()
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
assert "mounted_topic_workspaces" in tables
|
||||
assert "mounted_draft_workspaces" in tables
|
||||
assert "mounted_conversation_runs" in tables
|
||||
assert "mounted_agent_traces" in tables
|
||||
assert "mounted_tool_capability_policies" in tables
|
||||
|
||||
migration.downgrade()
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
assert "mounted_topic_workspaces" not in tables
|
||||
assert "mounted_draft_workspaces" not in tables
|
||||
assert "mounted_conversation_runs" not in tables
|
||||
assert "mounted_agent_traces" not in tables
|
||||
assert "mounted_tool_capability_policies" not in tables
|
||||
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import JSON, create_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.mounted_ai_runtime import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedToolCapabilityPolicy,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
MountedToolCapabilityPolicy.__table__,
|
||||
):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"facts_json",
|
||||
"goals_json",
|
||||
"constraints_json",
|
||||
"options_json",
|
||||
"open_questions",
|
||||
"pending_actions",
|
||||
"bound_entities",
|
||||
"metadata_json",
|
||||
"requirement_card",
|
||||
"draft_payload",
|
||||
"required_confirmations",
|
||||
"context_layers_hit",
|
||||
"tool_calls",
|
||||
"execution_outcome",
|
||||
"input_summary",
|
||||
"output_summary",
|
||||
"tool_trace",
|
||||
"source_scope",
|
||||
"tenant_scope",
|
||||
"policy_metadata",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
def _create_session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
MountedToolCapabilityPolicy.__table__,
|
||||
],
|
||||
)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None:
|
||||
session = _create_session()
|
||||
|
||||
topic = MountedTopicWorkspace(
|
||||
tenant_id=1,
|
||||
topic_key="feishu:chat_1:topic_1",
|
||||
source_system="feishu",
|
||||
channel_ref="chat_1",
|
||||
thread_key="thread_1",
|
||||
root_message_id="om_root_1",
|
||||
mode="plan",
|
||||
facts_json={"problem": "直播复盘"},
|
||||
open_questions=["负责人是谁"],
|
||||
readiness_score=0.35,
|
||||
)
|
||||
session.add(topic)
|
||||
session.flush()
|
||||
|
||||
draft = MountedDraftWorkspace(
|
||||
tenant_id=1,
|
||||
topic_workspace_id=topic.id,
|
||||
draft_version=1,
|
||||
requirement_card={"goals": ["整理复盘需求"]},
|
||||
required_confirmations=[{"type": "user_confirm"}],
|
||||
)
|
||||
run = MountedConversationRun(
|
||||
tenant_id=1,
|
||||
trace_id="trace-mounted-1",
|
||||
topic_workspace_id=topic.id,
|
||||
source_system="feishu",
|
||||
source_message_id="om_123",
|
||||
mode_before="plan",
|
||||
mode_after="clarify",
|
||||
active_agent="Clarifier",
|
||||
trigger_reason="missing_owner",
|
||||
context_layers_hit={"thread": True, "topic": True},
|
||||
final_reply="还差负责人信息。",
|
||||
)
|
||||
session.add_all([draft, run])
|
||||
session.flush()
|
||||
|
||||
trace = MountedAgentTrace(
|
||||
tenant_id=1,
|
||||
conversation_run_id=run.id,
|
||||
agent_name="Clarifier",
|
||||
status="completed",
|
||||
latency_ms=128,
|
||||
input_summary={"mode": "plan"},
|
||||
output_summary={"question": "这件事打算谁来负责?"},
|
||||
)
|
||||
policy = MountedToolCapabilityPolicy(
|
||||
tenant_id=1,
|
||||
tool_name="project.create",
|
||||
risk_level="high",
|
||||
source_scope={"systems": ["feishu"]},
|
||||
)
|
||||
session.add_all([trace, policy])
|
||||
session.commit()
|
||||
|
||||
saved_topic = session.query(MountedTopicWorkspace).one()
|
||||
saved_draft = session.query(MountedDraftWorkspace).one()
|
||||
saved_run = session.query(MountedConversationRun).one()
|
||||
saved_trace = session.query(MountedAgentTrace).one()
|
||||
saved_policy = session.query(MountedToolCapabilityPolicy).one()
|
||||
|
||||
assert saved_topic.status == "active"
|
||||
assert saved_draft.state == "collecting"
|
||||
assert saved_run.status == "queued"
|
||||
assert saved_trace.tool_trace is None
|
||||
assert saved_policy.confirm_required is True
|
||||
assert saved_policy.auto_execute_allowed is False
|
||||
assert saved_policy.is_active is True
|
||||
|
||||
|
||||
def test_mounted_ai_runtime_models_enforce_unique_scopes() -> None:
|
||||
session = _create_session()
|
||||
|
||||
session.add(
|
||||
MountedTopicWorkspace(
|
||||
tenant_id=9,
|
||||
topic_key="feishu:chat_9:topic_9",
|
||||
source_system="feishu",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
MountedToolCapabilityPolicy(
|
||||
tenant_id=9,
|
||||
tool_name="project.create",
|
||||
risk_level="high",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.add(
|
||||
MountedTopicWorkspace(
|
||||
tenant_id=9,
|
||||
topic_key="feishu:chat_9:topic_9",
|
||||
source_system="feishu",
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
session.rollback()
|
||||
|
||||
session.add(
|
||||
MountedToolCapabilityPolicy(
|
||||
tenant_id=9,
|
||||
tool_name="project.create",
|
||||
risk_level="high",
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import JSON, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.mounted_ai_runtime import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"facts_json",
|
||||
"goals_json",
|
||||
"constraints_json",
|
||||
"options_json",
|
||||
"open_questions",
|
||||
"pending_actions",
|
||||
"bound_entities",
|
||||
"metadata_json",
|
||||
"context_layers_hit",
|
||||
"tool_calls",
|
||||
"execution_outcome",
|
||||
"input_summary",
|
||||
"output_summary",
|
||||
"tool_trace",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
],
|
||||
)
|
||||
)
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_ingress_run_uses_thread_anchor_and_sets_runtime_context(db_session) -> None:
|
||||
service = MountedAIRuntimeWriteService(db_session)
|
||||
|
||||
tracking = await service.begin_ingress_run(
|
||||
tenant_id=1,
|
||||
source_system="feishu",
|
||||
source_message_id="om_message_1",
|
||||
message_text="帮我复盘一下这个直播需求。",
|
||||
sender="林宇航",
|
||||
channel_ref="chat_1",
|
||||
thread_context={
|
||||
"thread_id": "thread_1",
|
||||
"root_message_id": "om_root_1",
|
||||
"reply_to_message_id": "om_reply_1",
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
saved_topic = (
|
||||
await db_session.execute(select(MountedTopicWorkspace))
|
||||
).scalar_one()
|
||||
saved_run = (
|
||||
await db_session.execute(select(MountedConversationRun))
|
||||
).scalar_one()
|
||||
|
||||
assert tracking.topic_workspace.id == saved_topic.id
|
||||
assert saved_topic.topic_key == "feishu:chat_1:root:om_root_1"
|
||||
assert saved_topic.thread_key == "thread_1"
|
||||
assert saved_topic.last_message_id == "om_message_1"
|
||||
assert saved_run.status == "running"
|
||||
assert saved_run.mode_before is None
|
||||
assert saved_run.context_layers_hit == {
|
||||
"current_message": True,
|
||||
"thread": True,
|
||||
"topic": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_decision_and_finalize_run_persist_topic_mode_and_traces(db_session) -> None:
|
||||
service = MountedAIRuntimeWriteService(db_session)
|
||||
|
||||
tracking = await service.begin_ingress_run(
|
||||
tenant_id=1,
|
||||
source_system="feishu",
|
||||
source_message_id="om_message_2",
|
||||
message_text="分配给谁做比较合适?",
|
||||
sender="林宇航",
|
||||
channel_ref="chat_2",
|
||||
thread_context={"root_message_id": "om_root_2"},
|
||||
)
|
||||
|
||||
await service.record_orchestrator_decision(
|
||||
tracking=tracking,
|
||||
workflow_route="project_signal.create",
|
||||
intent_name="signal_create",
|
||||
intent_confidence=0.82,
|
||||
trigger_reason="manual_review",
|
||||
auto_execute=False,
|
||||
work_item_id=321,
|
||||
route_metadata={"workflow_family": "project_signal", "workflow_action": "create"},
|
||||
)
|
||||
await service.finalize_run(
|
||||
tracking=tracking,
|
||||
result={
|
||||
"handled": True,
|
||||
"status": "captured",
|
||||
"classification": "command",
|
||||
"work_item_id": 321,
|
||||
"workflow_route": "project_signal.create",
|
||||
"direct_reply_text": "我先帮你收敛需求,再给负责人建议。",
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
saved_topic = (
|
||||
await db_session.execute(select(MountedTopicWorkspace))
|
||||
).scalar_one()
|
||||
saved_run = (
|
||||
await db_session.execute(select(MountedConversationRun))
|
||||
).scalar_one()
|
||||
traces = (
|
||||
await db_session.execute(
|
||||
select(MountedAgentTrace).order_by(MountedAgentTrace.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
assert saved_topic.mode == "plan"
|
||||
assert saved_topic.metadata_json["last_workflow_route"] == "project_signal.create"
|
||||
assert saved_run.mode_after == "plan"
|
||||
assert saved_run.status == "completed"
|
||||
assert saved_run.final_reply == "我先帮你收敛需求,再给负责人建议。"
|
||||
assert len(traces) == 2
|
||||
assert traces[0].agent_name == "ConversationOrchestrator"
|
||||
assert traces[1].agent_name == "ReplyComposer"
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { api } from "@/lib/api";
|
||||
|
||||
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||
|
||||
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||
return (raw as ApiEnvelope<T>).data;
|
||||
}
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
export interface MountedAISourceItem {
|
||||
source_system: string;
|
||||
run_count: number;
|
||||
topic_count: number;
|
||||
active_topic_count: number;
|
||||
last_seen_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MountedAISourceListPayload {
|
||||
items: MountedAISourceItem[];
|
||||
}
|
||||
|
||||
export interface MountedAITopicItem {
|
||||
id: number;
|
||||
topic_key: string;
|
||||
source_system: string;
|
||||
channel_ref?: string | null;
|
||||
thread_key?: string | null;
|
||||
mode: string;
|
||||
status: string;
|
||||
readiness_score?: number | null;
|
||||
draft_state?: string | null;
|
||||
draft_version?: number | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MountedAITopicListPayload {
|
||||
items: MountedAITopicItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MountedAIRunItem {
|
||||
id: number;
|
||||
trace_id: string;
|
||||
source_system: string;
|
||||
source_message_id?: string | null;
|
||||
mode_before?: string | null;
|
||||
mode_after?: string | null;
|
||||
active_agent?: string | null;
|
||||
status: string;
|
||||
trigger_reason?: string | null;
|
||||
final_reply?: string | null;
|
||||
trace_count: number;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MountedAIRunListPayload {
|
||||
items: MountedAIRunItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MountedAIAgentTraceItem {
|
||||
id: number;
|
||||
agent_name: string;
|
||||
status: string;
|
||||
latency_ms?: number | null;
|
||||
failure_reason?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MountedAIRunDetailPayload {
|
||||
item: MountedAIRunItem;
|
||||
agent_traces: MountedAIAgentTraceItem[];
|
||||
context_layers_hit?: Record<string, unknown> | null;
|
||||
tool_calls?: unknown;
|
||||
execution_outcome?: unknown;
|
||||
}
|
||||
|
||||
export interface MountedAICutoverStatusPayload {
|
||||
mounted_runtime_ready: boolean;
|
||||
topic_workspace_count: number;
|
||||
conversation_run_count: number;
|
||||
tool_policy_count: number;
|
||||
observed_source_systems: string[];
|
||||
legacy_cutover_state: string;
|
||||
retired_item_count: number;
|
||||
active_item_count: number;
|
||||
legacy_cutover_items: MountedAILegacyCutoverItem[];
|
||||
}
|
||||
|
||||
export interface MountedAILegacyCutoverItem {
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface MountedAIEvalSuiteItem {
|
||||
id: number;
|
||||
dataset_type: string;
|
||||
version: string;
|
||||
status: string;
|
||||
suite_kind: string;
|
||||
sample_count: number;
|
||||
latest_score?: number | null;
|
||||
last_evaluated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MountedAIEvalSuiteListPayload {
|
||||
items: MountedAIEvalSuiteItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const mountedAiApi = {
|
||||
async getSources(): Promise<MountedAISourceListPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAISourceListPayload>>("/ai-steward/mounted-ai/sources");
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getTopics(params?: { limit?: number; offset?: number; mode?: string }): Promise<MountedAITopicListPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAITopicListPayload>>("/ai-steward/mounted-ai/topics", {
|
||||
params: {
|
||||
limit: params?.limit ?? 20,
|
||||
offset: params?.offset ?? 0,
|
||||
mode: params?.mode,
|
||||
},
|
||||
});
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getRuns(params?: { limit?: number; offset?: number; status?: string }): Promise<MountedAIRunListPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAIRunListPayload>>("/ai-steward/mounted-ai/runs", {
|
||||
params: {
|
||||
limit: params?.limit ?? 20,
|
||||
offset: params?.offset ?? 0,
|
||||
status: params?.status,
|
||||
},
|
||||
});
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getRunDetail(runId: number): Promise<MountedAIRunDetailPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAIRunDetailPayload>>(`/ai-steward/mounted-ai/runs/${runId}`);
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getCutoverStatus(): Promise<MountedAICutoverStatusPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAICutoverStatusPayload>>("/ai-steward/mounted-ai/cutover-status");
|
||||
return unwrap(data);
|
||||
},
|
||||
|
||||
async getEvalSuites(): Promise<MountedAIEvalSuiteListPayload> {
|
||||
const { data } = await api.get<ApiEnvelope<MountedAIEvalSuiteListPayload>>("/ai-steward/mounted-ai/eval-suites");
|
||||
return unwrap(data);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,168 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bot, RefreshCw, ShieldCheck, Waypoints } from "lucide-react";
|
||||
|
||||
import { mountedAiApi } from "@/api/mountedAi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MountedAIAcceptancePanel } from "./components/MountedAIAcceptancePanel";
|
||||
import { MountedAIRunReplayPanel } from "./components/MountedAIRunReplayPanel";
|
||||
import { MountedAIRunsPanel } from "./components/MountedAIRunsPanel";
|
||||
import { MountedAISourceStatusPanel } from "./components/MountedAISourceStatusPanel";
|
||||
import { MountedAITopicsPanel } from "./components/MountedAITopicsPanel";
|
||||
|
||||
export default function SchedulerCenterPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
||||
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "sources"],
|
||||
queryFn: () => mountedAiApi.getSources(),
|
||||
});
|
||||
const topicsQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "topics"],
|
||||
queryFn: () => mountedAiApi.getTopics({ limit: 10 }),
|
||||
});
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "runs"],
|
||||
queryFn: () => mountedAiApi.getRuns({ limit: 10 }),
|
||||
});
|
||||
const cutoverQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "cutover-status"],
|
||||
queryFn: () => mountedAiApi.getCutoverStatus(),
|
||||
});
|
||||
const evalSuitesQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "eval-suites"],
|
||||
queryFn: () => mountedAiApi.getEvalSuites(),
|
||||
});
|
||||
const selectedRunDetailQuery = useQuery({
|
||||
queryKey: ["mounted-ai", "runs", "detail", selectedRunId],
|
||||
queryFn: () => mountedAiApi.getRunDetail(selectedRunId as number),
|
||||
enabled: selectedRunId != null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId == null && (runsQuery.data?.items.length || 0) > 0) {
|
||||
setSelectedRunId(runsQuery.data?.items[0]?.id ?? null);
|
||||
}
|
||||
}, [runsQuery.data?.items, selectedRunId]);
|
||||
|
||||
const loading =
|
||||
sourcesQuery.isLoading || topicsQuery.isLoading || runsQuery.isLoading || cutoverQuery.isLoading || evalSuitesQuery.isLoading;
|
||||
const runtimeReady = !!cutoverQuery.data?.mounted_runtime_ready;
|
||||
const frozenEvalSuite =
|
||||
evalSuitesQuery.data?.items.find((item) => item.suite_kind === "teacher_eval_frozen_set") ||
|
||||
evalSuitesQuery.data?.items[0] ||
|
||||
null;
|
||||
const observedSourceText = useMemo(() => {
|
||||
const items = cutoverQuery.data?.observed_source_systems || [];
|
||||
return items.length > 0 ? items.join(" / ") : "暂无";
|
||||
}, [cutoverQuery.data?.observed_source_systems]);
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
sourcesQuery.refetch(),
|
||||
topicsQuery.refetch(),
|
||||
runsQuery.refetch(),
|
||||
cutoverQuery.refetch(),
|
||||
evalSuitesQuery.refetch(),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
调度中心功能建设中
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-3xl border border-slate-200/80 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 px-6 py-6 text-white shadow-xl">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-medium text-white/80">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Mounted AI Console
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">挂载 Runtime 调度中心</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-200/80">
|
||||
这里展示当前 Mounted Runtime 的来源观测、topic workspace、conversation runs 与 cutover 状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/20 bg-white/10 text-white hover:bg-white/15"
|
||||
onClick={() => void refreshAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{loading ? "刷新中..." : "刷新控制台"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Runtime 就绪度</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{runtimeReady ? "已就绪" : "待接管"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
|
||||
legacy 状态:{cutoverQuery.data?.legacy_cutover_state || "-"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>已观测来源</CardDescription>
|
||||
<CardTitle className="text-base">{observedSourceText}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
|
||||
来源数量:{cutoverQuery.data?.observed_source_systems.length || 0}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Topic Workspace</CardDescription>
|
||||
<CardTitle className="text-base">{cutoverQuery.data?.topic_workspace_count ?? "-"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
|
||||
当前活跃主题总数
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Conversation Runs</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Waypoints className="h-4 w-4" />
|
||||
{cutoverQuery.data?.conversation_run_count ?? "-"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Tool Policy:{cutoverQuery.data?.tool_policy_count ?? "-"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<MountedAISourceStatusPanel items={sourcesQuery.data?.items || []} loading={sourcesQuery.isLoading} />
|
||||
<MountedAITopicsPanel items={topicsQuery.data?.items || []} loading={topicsQuery.isLoading} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<MountedAIRunsPanel
|
||||
items={runsQuery.data?.items || []}
|
||||
loading={runsQuery.isLoading}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={setSelectedRunId}
|
||||
/>
|
||||
<MountedAIRunReplayPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
</div>
|
||||
|
||||
<MountedAIAcceptancePanel frozenSuite={frozenEvalSuite} cutoverStatus={cutoverQuery.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { AlertTriangle, ShieldCheck, Target } from "lucide-react";
|
||||
|
||||
import type { MountedAICutoverStatusPayload, MountedAIEvalSuiteItem } from "@/api/mountedAi";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface MountedAIAcceptancePanelProps {
|
||||
frozenSuite?: MountedAIEvalSuiteItem | null;
|
||||
cutoverStatus?: MountedAICutoverStatusPayload | null;
|
||||
}
|
||||
|
||||
export function MountedAIAcceptancePanel({ frozenSuite, cutoverStatus }: MountedAIAcceptancePanelProps) {
|
||||
const latestScore = frozenSuite?.latest_score ?? null;
|
||||
const passed = latestScore != null && latestScore >= 90;
|
||||
const legacyItems = cutoverStatus?.legacy_cutover_items || [];
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Acceptance Gates</CardTitle>
|
||||
<CardDescription>按 PLAN3 固定展示当前终局验收门槛,避免实现阶段和验收标准脱节。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Target className="h-4 w-4 text-cyan-600" />
|
||||
综合分
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{latestScore != null ? latestScore.toFixed(1) : "≥ 90"}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{latestScore != null ? "当前读取的是 frozen suite 最新均分" : "低于 90 不予验收"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-600" />
|
||||
严重误执行
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">0</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">任何高危误执行直接判失败</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
当前状态
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Badge variant={passed ? "secondary" : "outline"}>
|
||||
{passed ? "已达到门槛" : frozenSuite ? "尚未达标" : "待教师冻结集评测"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{frozenSuite
|
||||
? `${frozenSuite.version} · ${frozenSuite.sample_count} 样本 · ${formatBeijingDateTime(frozenSuite.last_evaluated_at)}`
|
||||
: "训练后置,但门槛已锁定"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`mode accuracy` ≥ 95
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`topic binding` ≥ 93
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`clarify correctness` ≥ 90
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`draft readiness` ≥ 90
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`execution safety` ≥ 99
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`assignee recommendation` ≥ 90
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
`reply quality` ≥ 90
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
|
||||
高危回归 = 0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-200/80 pt-4 dark:border-slate-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">Legacy Cutover</div>
|
||||
<Badge variant={cutoverStatus?.active_item_count ? "outline" : "secondary"}>
|
||||
{cutoverStatus?.legacy_cutover_state || "unknown"}
|
||||
</Badge>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
已退役 {cutoverStatus?.retired_item_count ?? 0} 项 / 剩余活跃 {cutoverStatus?.active_item_count ?? 0} 项
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{legacyItems.length > 0 ? (
|
||||
legacyItems.map((item) => (
|
||||
<div key={item.key} className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<Badge variant={item.status === "retired" ? "secondary" : "outline"}>{item.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-slate-600 dark:text-slate-300">{item.summary}</div>
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.evidence}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
cutover 检查项尚未返回。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface MountedAIRunReplayPanelProps {
|
||||
detail?: MountedAIRunDetailPayload;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "暂无";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function MountedAIRunReplayPanel({ detail, loading }: MountedAIRunReplayPanelProps) {
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Run Replay</CardTitle>
|
||||
<CardDescription>查看单次 run 的模式切换、agent trace、context hit 与执行结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
正在加载 replay...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !detail ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
选择一条 run 后,这里会展示 replay 细节。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && detail ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Trace</div>
|
||||
<div className="mt-2 break-all text-sm font-medium">{detail.item.trace_id}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">模式切换</div>
|
||||
<div className="mt-2 text-sm font-medium">{`${detail.item.mode_before || "-"} -> ${detail.item.mode_after || "-"}`}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Active Agent</div>
|
||||
<div className="mt-2 text-sm font-medium">{detail.item.active_agent || "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">更新时间</div>
|
||||
<div className="mt-2 text-sm font-medium">{formatBeijingDateTime(detail.item.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Agent Trace</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">按实际落库顺序展示本次编排轨迹。</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{detail.agent_traces.length} 条</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.agent_traces.map((trace) => (
|
||||
<div
|
||||
key={trace.id}
|
||||
className="rounded-xl border border-slate-200/80 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-950/40"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{trace.agent_name}</span>
|
||||
<Badge variant={trace.status === "failed" ? "destructive" : "secondary"}>{trace.status}</Badge>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{trace.latency_ms != null ? `${trace.latency_ms} ms` : "无耗时"}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatBeijingDateTime(trace.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{trace.failure_reason ? (
|
||||
<div className="mt-2 text-sm text-rose-600 dark:text-rose-400">{trace.failure_reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Context Layers Hit</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.context_layers_hit)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Execution Outcome</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.execution_outcome)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { MountedAIRunItem } from "@/api/mountedAi";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface MountedAIRunsPanelProps {
|
||||
items: MountedAIRunItem[];
|
||||
loading: boolean;
|
||||
selectedRunId?: number | null;
|
||||
onSelectRun: (runId: number) => void;
|
||||
}
|
||||
|
||||
export function MountedAIRunsPanel({ items, loading, selectedRunId, onSelectRun }: MountedAIRunsPanelProps) {
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Conversation Runs</CardTitle>
|
||||
<CardDescription>最近的 mounted runs、活跃 agent、模式切换与回复摘要。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Trace</TableHead>
|
||||
<TableHead>模式</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>Trace 数</TableHead>
|
||||
<TableHead>最后回复</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-[96px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id} data-state={selectedRunId === item.id ? "selected" : undefined}>
|
||||
<TableCell className="max-w-[180px] truncate font-medium" title={item.trace_id}>
|
||||
{item.trace_id}
|
||||
</TableCell>
|
||||
<TableCell>{`${item.mode_before || "-"} -> ${item.mode_after || "-"}`}</TableCell>
|
||||
<TableCell>{item.active_agent || "-"}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>{item.trace_count}</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate" title={item.final_reply || ""}>
|
||||
{item.final_reply || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="outline" size="sm" onClick={() => onSelectRun(item.id)}>
|
||||
查看
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-slate-500 dark:text-slate-400">
|
||||
暂无 run 数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-slate-500 dark:text-slate-400">
|
||||
读取中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { MountedAISourceItem } from "@/api/mountedAi";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface MountedAISourceStatusPanelProps {
|
||||
items: MountedAISourceItem[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function MountedAISourceStatusPanel({ items, loading }: MountedAISourceStatusPanelProps) {
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">来源状态</CardTitle>
|
||||
<CardDescription>当前 Mounted Runtime 已观测到的来源、topic 数和 run 数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>Run</TableHead>
|
||||
<TableHead>Topic</TableHead>
|
||||
<TableHead>活跃 Topic</TableHead>
|
||||
<TableHead>最后观测</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.source_system}>
|
||||
<TableCell className="font-medium">{item.source_system}</TableCell>
|
||||
<TableCell>{item.run_count}</TableCell>
|
||||
<TableCell>{item.topic_count}</TableCell>
|
||||
<TableCell>{item.active_topic_count}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.last_seen_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
|
||||
暂无来源观测数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
|
||||
读取中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { MountedAITopicItem } from "@/api/mountedAi";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
interface MountedAITopicsPanelProps {
|
||||
items: MountedAITopicItem[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function MountedAITopicsPanel({ items, loading }: MountedAITopicsPanelProps) {
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Topic Workspace</CardTitle>
|
||||
<CardDescription>当前活跃主题、模式、readiness 和草稿状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Topic</TableHead>
|
||||
<TableHead>模式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>Readiness</TableHead>
|
||||
<TableHead>草稿</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="max-w-[260px] truncate font-medium" title={item.topic_key}>
|
||||
{item.topic_key}
|
||||
</TableCell>
|
||||
<TableCell>{item.mode}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>{item.readiness_score != null ? item.readiness_score.toFixed(2) : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{item.draft_state ? `${item.draft_state}${item.draft_version != null ? ` v${item.draft_version}` : ""}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 dark:text-slate-400">
|
||||
暂无 topic 数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 dark:text-slate-400">
|
||||
读取中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue