5299 lines
259 KiB
Python
5299 lines
259 KiB
Python
# ============================================================
|
||
# FastAPI 应用与路由(moduleId: gateway-app, 可重生 ✅)
|
||
# 端点清单:
|
||
# POST /api/chat 对话(SSE 流:meta/intent/token/command/block/done)
|
||
# GET /api/world/summary KPI 摘要
|
||
# GET /api/world/gantt 甘特视图数据
|
||
# GET /api/world/load 负荷热力数据
|
||
# GET /api/world/due 交期承诺看板数据
|
||
# GET /api/orders 订单管理页数据(订单 + 成品选项)
|
||
# POST /api/orders/stage 订单写入动作暂存确认卡(order.upsert/cancel/complete)
|
||
# GET /api/master 主数据管理页数据(资源树/物料BOM/日历维保)
|
||
# POST /api/master/stage 主数据写入动作暂存确认卡(master.*.upsert)
|
||
# GET /api/mrp MRP 建议单(采购/委外,订单分解产物)
|
||
# POST /api/mrp/decompose 订单分解(P1:产出 DRAFT 建议)
|
||
# GET /api/timeline 时间线导轨(检查点 + 版本,§4.4)
|
||
# GET /api/checkpoints[/{pairId}] 检查点世界侧投影(矩阵 54 行:逐字段 diff 数据源)
|
||
# GET /api/params/experiments 参数实验列表/状态(矩阵 87,P0 只读)
|
||
# POST /api/params/optimize 参数优化闭环触发(P1:split→回放→候选→GRAY 灰度实验)
|
||
# POST /api/params/experiments/{id}/promote 参数实验升级确认卡(P2 → /api/actions/confirm)
|
||
# POST /api/params/experiments/{id}/rollback 参数实验手动回滚(P1)
|
||
# POST /api/params/observations 线上生产 KPI 观测回调(连续劣化自动回滚,P1)
|
||
# POST /api/actions/confirm 确认卡回传(批准/驳回 P2 动作)
|
||
# POST /api/actions/scenario/apply 采用某个沙盒方案(P1 正式排产)
|
||
# GET /api/gov/pending 门禁管理台·待审批队列(§6.10.2)
|
||
# GET /api/gov/approval-history 门禁管理台·审批历史(§6.10.2)
|
||
# GET /api/gov/audit 门禁管理台·审计链 + 完整性校验(§3.6)
|
||
# POST /api/gov/audit/events 桌面端/登录态审计事件上报(矩阵 103;nonce 门禁 / 登录身份,幂等可选)
|
||
# GET /api/gov/modules 重生中心·可重生模块注册表(§6.10.3)
|
||
# GET /api/gov/policy 门禁管理台·权力矩阵投影(M2.5,docs/architecture/harness.md 的 API 化)
|
||
# GET /api/gov/tests 重生中心·黄金测试看板(M2.5,读缓存 / ?run=true 重跑)
|
||
# GET /api/settings/llm 设置中心·LLM Provider 状态(M2.5)
|
||
# GET /api/knowledge/assets 知识资产清单(M3 §8.1)
|
||
# GET /api/knowledge/assets/{id} 单个知识资产全文(M3)
|
||
# POST /api/knowledge/import 知识文档导入(P2 确认卡)
|
||
# POST /api/knowledge/import/upload 上传文件导入(P2)
|
||
# GET /api/skills · /api/skills/health 外部算法 Skill
|
||
# POST /api/skills/register 登记 Skill(P2)
|
||
# GET /api/reports/{type} 报告导出(Markdown 下载,M3 §9.10)
|
||
# GET /api/integrations/mes/readiness MES HTTP 适配器对接就绪检查(round-46 方向 LL · 矩阵 73 现场准备)
|
||
# POST /api/integrations/mes/readiness/probe 触发 MES 连通性探测(P1)
|
||
# ⚠ 文档同步铁律(plan.md §12.9):新增端点必同步 docs/architecture/harness.md 只读端点备案
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import asyncio # 流式分片的微延时
|
||
import contextvars # 线程池任务继承登录/租户上下文
|
||
import json # SSE 载荷序列化
|
||
import logging # 记录异常,避免把内部细节返回给用户
|
||
from contextlib import asynccontextmanager # 自动化后台 tick 生命周期 # SSE 载荷序列化
|
||
import os
|
||
import threading
|
||
import uuid # 会话 ID 生成
|
||
from pathlib import Path
|
||
from typing import Any, AsyncIterator # 类型标注
|
||
|
||
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware # 跨域(开发期前端直连用)
|
||
from fastapi.responses import StreamingResponse # SSE 响应
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field # 请求体契约
|
||
|
||
from server.agent_core import harness # 门禁(待审批队列投影)
|
||
from server.agent_core.registry import scan_modules, verify_audit_chain # 注册表与审计校验
|
||
from server.aps_domain.views import due_view, gantt_view, load_view, world_summary # 世界视图
|
||
from server.aps_domain.workflow import execute_confirmed, handle_intent # 工作流编排
|
||
from server.aps_domain.orders import (
|
||
confirmation_for_order_action, list_orders, list_products, pool_summary, ORDER_ACTIONS,
|
||
)
|
||
from server.aps_domain.masterdata import MASTER_ACTIONS, confirmation_for_master_action, master_overview
|
||
from server.aps_domain.mrp import decompose_orders, list_mrp, summarize_decomposition
|
||
from server.contracts import INTERFACE_VERSION, IntentResult # 跨层握手与方案采用意图
|
||
from server.state.checkpoints import get_checkpoints # 成对快照仓(时间线)
|
||
from server.state.store import get_store # 世界状态单例
|
||
from server.auth.context import get_identity
|
||
from server.auth.middleware import AuthenticationMiddleware
|
||
from server.auth.licenses import DESKTOP_COOKIE, get_license_provider, is_desktop_request
|
||
from server.auth.providers import AuthError, get_auth_provider
|
||
from server.gateway.plan_api import router as plan_router
|
||
from server.gateway.masterdata_api import router as masterdata_router
|
||
from server.gateway.governance_api import router as governance_router, gov_router as governance_audit_router
|
||
from server.gateway.mps_api import router as mps_router
|
||
from server.gateway.agent_api import router as agent_router
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ---------------- 请求体契约 ----------------
|
||
class ChatRequest(BaseModel):
|
||
"""对话请求:会话 ID + 用户文本 + 可选多轮上文。"""
|
||
sessionId: str | None = None
|
||
projectId: str | None = Field(default=None, max_length=64)
|
||
text: str = Field(min_length=1, max_length=8000)
|
||
history: list[dict[str, Any]] = Field(default_factory=list) # [{role,text}] 不含本轮
|
||
|
||
|
||
class AuthLoginRequest(BaseModel):
|
||
method: str = "password"
|
||
tenantCode: str | None = Field(default=None, max_length=128)
|
||
tenantName: str | None = Field(default=None, max_length=128)
|
||
username: str | None = Field(default=None, max_length=128)
|
||
password: str | None = Field(default=None, max_length=512)
|
||
captchaId: str | None = Field(default=None, max_length=128)
|
||
captchaCode: str | None = Field(default=None, max_length=32)
|
||
|
||
|
||
class LicenseActivationRequest(BaseModel):
|
||
code: str = Field(min_length=1, max_length=256)
|
||
deviceId: str | None = Field(default=None, max_length=256)
|
||
|
||
|
||
class ConfirmRequest(BaseModel):
|
||
"""确认卡回传:确认 ID + 批准与否 + 审批意见(可选)。"""
|
||
sessionId: str | None = None # 会话 ID(审计维度)
|
||
confirmId: str # 待确认动作的一次性令牌
|
||
approve: bool # True=批准执行 False=驳回
|
||
note: str | None = None # 审批意见(可选,进审批历史)
|
||
|
||
|
||
class TransferRequest(BaseModel):
|
||
"""审批转派:把待确认项转派给指定用户(P2;P3 拒绝)。"""
|
||
confirmId: str # 待转派的确认令牌
|
||
toUserId: int # 目标用户 ID
|
||
|
||
|
||
class ConfirmBatchRequest(BaseModel):
|
||
"""批量确认回传:同一决定应用于多条确认卡(复用单条语义逐条执行)。"""
|
||
sessionId: str | None = None # 会话 ID(审计维度)
|
||
confirmIds: list[str] # 待确认动作令牌列表
|
||
approve: bool # True=批量批准 False=批量驳回
|
||
note: str | None = None # 公共审批意见(可选,逐条落库)
|
||
|
||
|
||
class ScenarioApplyRequest(BaseModel):
|
||
"""采用沙盒方案:策略 + 引擎(引擎确定性 → 重跑即得卡片同款结果)。"""
|
||
sessionId: str | None = None # 会话 ID(审计维度)
|
||
strategy: str # 选中方案的策略模板
|
||
engine: str = "RULE" # 引擎类型
|
||
|
||
|
||
class OrderStageRequest(BaseModel):
|
||
"""订单管理写入动作:只暂存确认卡,不直接改世界。"""
|
||
sessionId: str | None = None # 发起会话
|
||
action: str # order.upsert / order.cancel / order.complete / order.delete
|
||
payload: dict[str, Any] = Field(default_factory=dict) # 订单载荷
|
||
|
||
|
||
class MasterStageRequest(BaseModel):
|
||
"""主数据维护写入动作:只暂存确认卡,不直接改世界(MD-01/02/03)。"""
|
||
sessionId: str | None = None # 发起会话
|
||
action: str # master.line/material/maintenance/bom/routing.upsert
|
||
payload: dict[str, Any] = Field(default_factory=dict) # 主数据载荷
|
||
|
||
|
||
class ParamsStageRequest(BaseModel):
|
||
"""排产参数更新(OR-02):只暂存确认卡。"""
|
||
sessionId: str | None = None
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class ParamsOptimizeRequest(BaseModel):
|
||
"""参数优化闭环触发(P1):split→回放→候选,产出 GRAY 灰度实验。"""
|
||
sessionId: str | None = None
|
||
strategy: str = "COMPREHENSIVE"
|
||
scope: dict[str, Any] | None = None # 灰度作用域(如 {"lineIds": [1]})
|
||
candidates: list[dict[str, Any]] | None = None # 显式候选(缺省用敏感性分析方向)
|
||
seed: int | None = None
|
||
tolerance: float | None = None
|
||
|
||
|
||
class ParamsPromoteRequest(BaseModel):
|
||
"""参数实验升级请求(P2):只出确认卡,执行走 /api/actions/confirm。"""
|
||
sessionId: str | None = None
|
||
|
||
|
||
class ParamsRollbackRequest(BaseModel):
|
||
"""参数实验手动回滚(P1):直接执行,恢复上一参数版本。"""
|
||
sessionId: str | None = None
|
||
reason: str | None = None
|
||
|
||
|
||
class ParamObservationRequest(BaseModel):
|
||
"""线上生产 KPI 观测回调:观测落盘 + 连续劣化自动回滚监控。"""
|
||
sessionId: str | None = None
|
||
kpi: dict[str, float] # 如 {"tardiness": 120.0}
|
||
source: str = "production"
|
||
maxConsecutiveDegraded: int | None = None # 覆盖默认连续劣化阈值(默认 2)
|
||
|
||
|
||
class ConstraintStageRequest(BaseModel):
|
||
"""约束剖面更新(SC-04):只暂存确认卡。"""
|
||
sessionId: str | None = None
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class SopStageRequest(BaseModel):
|
||
"""SOP 规则包应用(IND-02):只暂存确认卡。"""
|
||
sessionId: str | None = None
|
||
assetId: str = Field(min_length=1)
|
||
pack: dict[str, Any] | None = None
|
||
|
||
|
||
class MrpDecomposeRequest(BaseModel):
|
||
"""订单分解请求(P1:产出 DRAFT 采购/委外建议)。"""
|
||
sessionId: str | None = None # 发起会话
|
||
orderNo: str | None = None # 指定订单号;空=全部可排产订单
|
||
|
||
|
||
class MrpReleaseRequest(BaseModel):
|
||
"""MRP 建议单下达请求(P2:只暂存确认卡,不直接改世界)。"""
|
||
sessionId: str | None = None # 发起会话
|
||
orderNo: str | None = None # 指定订单号;空=全部
|
||
kind: str = "all" # all / purchase / outsource
|
||
|
||
|
||
class FlexScheduleRequest(BaseModel):
|
||
"""柔性排产请求(P1:能力池+虚拟产线,写 flex* 草稿表)。"""
|
||
sessionId: str | None = None
|
||
sortMode: str | None = None # ASC / DESC / BOTTLENECK
|
||
orderIds: list[int] = Field(default_factory=list)
|
||
window: str | None = None # short/mid/long/full(SC-12)
|
||
enforceTeams: bool | None = None # SC-11 班组约束
|
||
|
||
|
||
class TimeUpdateRequest(BaseModel):
|
||
"""工时维护(M-B:P2 确认后写柔性路线 stdTimePerUnit + 来源标记)。"""
|
||
sessionId: str | None = None
|
||
productCode: str | None = None # 空 = 该工序全部产品
|
||
operationCode: str # 工序编码
|
||
stdMin: float # 单件分钟
|
||
source: str | None = None # 实测/推断/模板(缺省实测)
|
||
|
||
|
||
class FlexSiteLoadRequest(BaseModel):
|
||
"""现场完整生产路线加载(P2:替换 flex* 演示种子)。"""
|
||
sessionId: str | None = None
|
||
routePath: str | None = None
|
||
dataDir: str | None = None
|
||
includeSiblings: bool = False
|
||
stationCount: int = 4
|
||
applyNow: bool = False # True=脚本同款直写(开发用);False=确认卡
|
||
|
||
|
||
class FlexRescheduleRequest(BaseModel):
|
||
"""分级重排请求(DY-01 L2/L3/L4 → P2 确认卡)。"""
|
||
sessionId: str | None = None
|
||
level: str = "L2" # L2 短窗 / L3 日窗 / L4 全局
|
||
sortMode: str | None = None
|
||
|
||
|
||
class FlexSwapRequest(BaseModel):
|
||
"""L1 局部换机(P1):池内备机接手。"""
|
||
sessionId: str | None = None
|
||
equipmentCode: str | None = None
|
||
woId: int | None = None
|
||
targetCode: str | None = None
|
||
markFault: bool = True
|
||
|
||
|
||
class FlexSimulateRequest(BaseModel):
|
||
"""交期承诺模拟请求(PL-08,只读沙盒,不改主干)。"""
|
||
sessionId: str | None = None
|
||
productCode: str
|
||
quantity: int = Field(gt=0)
|
||
sortMode: str | None = None
|
||
|
||
|
||
class FlexRushRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
productCode: str
|
||
quantity: int = Field(gt=0)
|
||
dueDate: str | None = None
|
||
priority: int = 1
|
||
|
||
|
||
class RushEvaluateRequest(BaseModel):
|
||
"""OR-04 固定轨插单快评(P1 沙盒)。"""
|
||
sessionId: str | None = None
|
||
orderNo: str | None = None
|
||
customerName: str | None = None
|
||
customerLevel: str | None = None
|
||
productId: int | None = None
|
||
productCode: str | None = None
|
||
quantity: int | None = None
|
||
deliveryDate: str | None = None
|
||
priority: int | None = None
|
||
strategy: str | None = None
|
||
|
||
|
||
class RushApplyRequest(BaseModel):
|
||
"""OR-04 采用插单(P2 确认卡)。"""
|
||
sessionId: str | None = None
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
strategy: str | None = None
|
||
evalId: str | None = None
|
||
|
||
|
||
class WorkspaceReplaceRequest(BaseModel):
|
||
"""AG-08 工作区整包同步。"""
|
||
projects: list[dict[str, Any]] = Field(default_factory=list)
|
||
sessions: list[dict[str, Any]] = Field(default_factory=list)
|
||
files: list[dict[str, Any]] = Field(default_factory=list)
|
||
messages: dict[str, list[Any]] | None = None
|
||
activeProjectId: str | None = None
|
||
activeSessionId: str | None = None
|
||
worldKey: str | None = "default"
|
||
|
||
|
||
class ProjectCreateRequest(BaseModel):
|
||
id: str | None = None
|
||
name: str = "未命名项目"
|
||
scopeLabel: str | None = None
|
||
workDir: str | None = None
|
||
|
||
|
||
class SessionCreateRequest(BaseModel):
|
||
id: str | None = None
|
||
projectId: str
|
||
title: str | None = "新话题"
|
||
|
||
|
||
class MeshAgentCreateRequest(BaseModel):
|
||
"""按需创建智能体:名字 + 角色(能力边界默认取角色模板,可自定义)。"""
|
||
sessionId: str | None = None
|
||
name: str = Field(min_length=1, max_length=64)
|
||
role: str = Field(min_length=1, max_length=64)
|
||
capabilities: list[str] | None = None
|
||
|
||
|
||
class MeshGoalCreateRequest(BaseModel):
|
||
"""挂 Goal:标题 + 模板键(默认 APS 排产全流程),或自定义任务清单。"""
|
||
sessionId: str | None = None
|
||
projectId: str | None = None
|
||
sourceSha256: str | None = None
|
||
runId: str | None = None
|
||
title: str = Field(default="APS 排产全流程", max_length=128)
|
||
template: str | None = "aps-full-flow"
|
||
tasks: list[dict[str, Any]] | None = None
|
||
|
||
|
||
class MeshDispatchRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
|
||
|
||
class MeshMessageRequest(BaseModel):
|
||
"""智能体消息:from → to(None=广播),可选挂到 Goal。"""
|
||
fromAgentId: str = Field(min_length=1, max_length=64)
|
||
toAgentId: str | None = None
|
||
goalId: str | None = None
|
||
kind: str | None = "INFO"
|
||
text: str = Field(min_length=1, max_length=2000)
|
||
|
||
|
||
class MeshWatchdogRequest(BaseModel):
|
||
stallTimeoutSec: float | None = None
|
||
maxRetries: int | None = None
|
||
enabled: bool | None = None
|
||
|
||
|
||
class BranchCreateRequest(BaseModel):
|
||
name: str = "新分支"
|
||
checkpointId: str | None = None
|
||
|
||
|
||
class PreferenceResetRequest(BaseModel):
|
||
projectId: str | None = None
|
||
|
||
|
||
class BranchMergeRequest(BaseModel):
|
||
targetId: str
|
||
|
||
|
||
class RenameRequest(BaseModel):
|
||
name: str | None = None
|
||
title: str | None = None
|
||
onlyIfUntitled: bool = False
|
||
|
||
|
||
class ProjectMemberRequest(BaseModel):
|
||
userId: int
|
||
role: str = "viewer"
|
||
|
||
|
||
class ProjectFileRequest(BaseModel):
|
||
id: str | None = None
|
||
projectId: str
|
||
name: str
|
||
kind: str = "other"
|
||
note: str = ""
|
||
|
||
|
||
class MessagesReplaceRequest(BaseModel):
|
||
messages: list[Any] = Field(default_factory=list)
|
||
|
||
|
||
class MessageAppendRequest(BaseModel):
|
||
message: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class ForecastStageRequest(BaseModel):
|
||
"""OR-05 预测订单 P2 暂存。"""
|
||
sessionId: str | None = None
|
||
action: str
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class FlexFaultRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
equipmentCode: str
|
||
status: str = "MAINTENANCE"
|
||
reschedule: bool = True
|
||
|
||
|
||
class FlexResourcePatchRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
kind: str # equipment | mold
|
||
code: str
|
||
patch: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class ConflictResolveRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
conflictId: int
|
||
action: str
|
||
params: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class FlexAdjustRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
woId: int
|
||
newStart: str # YYYY-MM-DD HH:MM
|
||
|
||
|
||
class ScheduleAdjustRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
woId: int
|
||
newStart: str
|
||
|
||
|
||
class SapSyncRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
|
||
|
||
class MesDispatchRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
track: str = "flex" # flex | fixed
|
||
|
||
|
||
class SchedulePublishStageRequest(BaseModel):
|
||
"""Stage publication of one exact fixed/flex scheduling version (P2)."""
|
||
|
||
sessionId: str | None = None
|
||
track: str = "flex"
|
||
versionId: int | None = None
|
||
|
||
|
||
class MesReportRequest(BaseModel):
|
||
sessionId: str | None = None
|
||
woId: int
|
||
track: str = "flex"
|
||
progressPct: int | None = None
|
||
finish: bool = False
|
||
|
||
|
||
class WmsEventRequest(BaseModel):
|
||
"""WMS 缺料/库存事件上报(round-38 方向 N:缺料→重排闭环 Mock)。
|
||
|
||
eventId 缺省时由 Mock WMS 生成(并录入镜像流供重放);携带时按外部事件处理。
|
||
"""
|
||
sessionId: str | None = None
|
||
eventId: str | None = None
|
||
type: str = "SHORTAGE" # SHORTAGE | INVENTORY_UPDATE
|
||
materialCode: str
|
||
newStock: float | None = None
|
||
shortageQty: float | None = None
|
||
occurredAt: str | None = None
|
||
name: str | None = None
|
||
unit: str | None = None
|
||
idemKey: str | None = None
|
||
|
||
|
||
|
||
class SagaActionRequest(BaseModel):
|
||
"""Saga 人工动作(round-39 方向 P):reason 说明接管/补偿原因。"""
|
||
reason: str | None = None
|
||
sessionId: str | None = None
|
||
# ---------------- SSE 工具 ----------------
|
||
def _sse(payload: dict[str, Any]) -> str:
|
||
"""把一个事件对象编码为 SSE 帧(data: {json}\\n\\n)。"""
|
||
return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n" # 标准 SSE 数据帧
|
||
|
||
|
||
# ---------------- 自动化驱动区段(round-40 方向 S · 矩阵 76) ----------------
|
||
# 网关定时驱动:POST /api/automation/tick 手动驱动 + lifespan 后台自动 tick;
|
||
# 调度器状态(规则/到期基线/暂停/运行记录)随独立状态文件持久化,重启宽容恢复。
|
||
# ⚠ 本区段只新增 /api/automation/* 端点与后台挂钩,不触碰兄弟区段端点。
|
||
|
||
def _automation_state_path() -> str:
|
||
"""自动化状态文件路径(独立文件;可用 APS_AUTOMATION_STATE_PATH 覆盖)。"""
|
||
default = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"data", "automation_state.json",
|
||
)
|
||
return os.environ.get("APS_AUTOMATION_STATE_PATH") or default
|
||
|
||
|
||
class AutomationRuntime:
|
||
"""自动化运行时单例:规则注册表 + 业务动作桥 + 调度器 + 状态持久化。"""
|
||
|
||
def __init__(self, *, state_path: str | None = None) -> None:
|
||
from server.agent_core.automation import AutomationScheduler, RuleExecutor
|
||
from server.aps_domain.workflow import run_automation_intent
|
||
|
||
def _business_bridge(binding, params, ctx):
|
||
"""规则动作 → 真实业务意图(发布/重排):构造意图并走既有门禁。"""
|
||
from server.contracts import IntentResult
|
||
payload = dict(getattr(binding, "params", None) or {})
|
||
payload.update(dict((params or {}).get("payload") or {}))
|
||
intent = IntentResult(
|
||
intent=binding.intent, params=payload,
|
||
confidence=1.0, source="LLM",
|
||
)
|
||
return run_automation_intent(
|
||
get_store(), str(ctx.get("session_id") or "automation"), intent,
|
||
actor=str(ctx.get("actor") or "automation"),
|
||
auto=bool(ctx.get("auto")),
|
||
)
|
||
|
||
self.state_path = state_path or _automation_state_path()
|
||
self.executor = RuleExecutor(actor="automation", business_bridge=_business_bridge)
|
||
self.scheduler = AutomationScheduler(self.executor)
|
||
self.loaded_rules = self._load_state()
|
||
|
||
def _load_state(self) -> int:
|
||
"""宽容加载调度状态;缺文件/损坏 → 0 条规则,不抛错。"""
|
||
try:
|
||
with open(self.state_path, "r", encoding="utf-8") as fh:
|
||
state = json.load(fh)
|
||
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
|
||
return 0
|
||
return self.scheduler.restore_state(state)
|
||
|
||
def tick(self, *, session_id: str = "automation") -> list[dict[str, Any]]:
|
||
"""推进一次时钟:到期规则经业务桥执行(写 AUTOMATION 审计)并落盘状态。"""
|
||
store = get_store()
|
||
runs = self.scheduler.tick(
|
||
world=store.data, next_id=store.next_id,
|
||
session_id=session_id, save=store.save,
|
||
)
|
||
try:
|
||
self.scheduler.save_state(self.state_path)
|
||
except OSError:
|
||
pass # 状态落盘尽力而为,不阻断调度
|
||
return [run.as_dict() for run in runs]
|
||
|
||
def status(self) -> dict[str, Any]:
|
||
return {
|
||
"statePath": self.state_path,
|
||
"ruleCount": len(self.executor.registry.list()),
|
||
"paused": self.scheduler.paused,
|
||
"pausedRules": self.scheduler.paused_rules(),
|
||
"dueRules": [rule.rule_id for rule in self.scheduler.due_rules()],
|
||
"runCount": len(self.executor.runs),
|
||
}
|
||
|
||
|
||
_AUTOMATION_RUNTIME: dict[str, AutomationRuntime | None] = {"runtime": None}
|
||
|
||
|
||
def _get_automation_runtime() -> AutomationRuntime:
|
||
"""惰性构建自动化运行时单例(首次调用时加载持久化状态)。"""
|
||
if _AUTOMATION_RUNTIME["runtime"] is None:
|
||
_AUTOMATION_RUNTIME["runtime"] = AutomationRuntime()
|
||
return _AUTOMATION_RUNTIME["runtime"]
|
||
|
||
|
||
def _reset_automation_runtime() -> None:
|
||
"""清空自动化运行时单例(测试隔离用)。"""
|
||
_AUTOMATION_RUNTIME["runtime"] = None
|
||
|
||
|
||
class AutomationTickRequest(BaseModel):
|
||
"""手动驱动自动化调度时钟的请求体。"""
|
||
sessionId: str | None = None
|
||
|
||
|
||
class DrawingInspectRequest(BaseModel):
|
||
path: str
|
||
projectId: str | None = None
|
||
sessionId: str = "web"
|
||
|
||
|
||
class DrawingProcessTextRequest(BaseModel):
|
||
text: str = Field(min_length=1, max_length=20_000)
|
||
|
||
|
||
class DrawingBatchInspectRequest(BaseModel):
|
||
projectId: str | None = None
|
||
sessionId: str = "web"
|
||
paths: list[str] = Field(default_factory=list)
|
||
directory: str | None = None
|
||
|
||
|
||
class DrawingStageRequest(BaseModel):
|
||
sessionId: str = "web"
|
||
candidateIds: list[str] = Field(default_factory=list)
|
||
|
||
|
||
def _drawing_allowed_roots(project_id: str | None = None) -> list[Path]:
|
||
"""Resolve roots without letting global legacy roots widen a project scope."""
|
||
roots: list[Path] = []
|
||
try:
|
||
from server.state.projects import get_project_store
|
||
snap = get_project_store().snapshot(include_messages=False)
|
||
for project in snap.get("projects") or []:
|
||
if project_id and str(project.get("id") or "") != project_id:
|
||
continue
|
||
work_dir = str(project.get("workDir") or "").strip()
|
||
if work_dir:
|
||
roots.append(Path(work_dir).expanduser().resolve())
|
||
except Exception:
|
||
pass
|
||
if project_id is None:
|
||
for raw in os.getenv("APS_DRAWING_ALLOWED_ROOTS", "").split(os.pathsep):
|
||
if raw.strip():
|
||
roots.append(Path(raw.strip()).expanduser().resolve())
|
||
unique: list[Path] = []
|
||
for root in roots:
|
||
if root not in unique:
|
||
unique.append(root)
|
||
return unique
|
||
|
||
|
||
def _drawing_project_store(project_id: str | None, *, write: bool = False):
|
||
"""Resolve a drawing world from an explicit, authorized project id."""
|
||
if not project_id:
|
||
return get_store()
|
||
normalized = str(project_id).strip()
|
||
from server.state.projects import get_project_store
|
||
from server.state.store import switch_store
|
||
|
||
try:
|
||
snapshot = get_project_store().snapshot(include_messages=False)
|
||
except (PermissionError, ValueError) as exc:
|
||
raise PermissionError("没有权限访问该项目") from exc
|
||
project = next(
|
||
(row for row in snapshot.get("projects") or [] if str(row.get("id") or "") == normalized),
|
||
None,
|
||
)
|
||
if project is None:
|
||
raise PermissionError("项目不存在或无权访问")
|
||
if write and str(project.get("role") or "editor") == "viewer":
|
||
raise PermissionError("当前项目为只读权限")
|
||
return switch_store(normalized)
|
||
|
||
|
||
def _path_within_roots(candidate: Path, roots: list[Path]) -> bool:
|
||
for root in roots:
|
||
try:
|
||
candidate.relative_to(root)
|
||
return True
|
||
except ValueError:
|
||
continue
|
||
return False
|
||
|
||
|
||
def _resolve_allowed_drawing_path(raw_path: str, project_id: str | None = None) -> Path:
|
||
raw = Path(raw_path).expanduser()
|
||
roots = _drawing_allowed_roots(project_id)
|
||
if project_id and not raw.is_absolute():
|
||
if not roots:
|
||
raise PermissionError("没有可用的项目工程目录")
|
||
candidate = (roots[0] / raw).resolve(strict=True)
|
||
else:
|
||
candidate = raw.resolve(strict=True)
|
||
if candidate.suffix.lower() not in {".dxf", ".pdf"} or not candidate.is_file():
|
||
raise ValueError("仅允许读取存在的 DXF/PDF 文件")
|
||
if not roots:
|
||
raise PermissionError("没有可用的工程目录;请设置项目 workDir 或 APS_DRAWING_ALLOWED_ROOTS")
|
||
if _path_within_roots(candidate, roots):
|
||
return candidate
|
||
raise PermissionError("图纸路径不在项目工程目录或允许的图纸目录内,请从项目已上传文件中选择")
|
||
|
||
|
||
def _drawing_asset(world: dict[str, Any], drawing_id: str) -> dict[str, Any] | None:
|
||
return next((row for row in (world.get("drawingAssets") or []) if row.get("id") == drawing_id), None)
|
||
|
||
|
||
def _drawing_api_payload(asset: dict[str, Any], change_set: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""图纸资产 → 前端 API 载荷(含候选字段与预览信息)"""
|
||
parsed = asset.get("parsed") or {}
|
||
source = parsed.get("asset") or asset
|
||
drawing = parsed.get("drawing") or asset.get("drawing") or {}
|
||
fields = []
|
||
for index, row in enumerate(parsed.get("fieldCandidates") or []):
|
||
evidence = row.get("evidence") or {}
|
||
fields.append({"id": row.get("id") or f"field-{index}-{row.get('field')}",
|
||
"field": row.get("field") or "unknown", "label": row.get("field") or "??",
|
||
"value": row.get("value"), "confidence": float(row.get("confidence") or 0),
|
||
"sourceText": evidence.get("text") or evidence.get("filename") or evidence.get("source"),
|
||
"entityHandle": evidence.get("entityHandle"),
|
||
"status": row.get("status") or "PENDING_REVIEW",
|
||
"reviewRequired": bool(row.get("reviewRequired", True))})
|
||
candidates = []
|
||
change_set = change_set or {}
|
||
for group, kind in (("materials", "MATERIAL"), ("bomReferences", "BOM"), ("routingOperations", "ROUTING")):
|
||
for row in change_set.get(group) or []:
|
||
candidates.append({"id": row.get("candidateId") or row.get("id"), "kind": kind,
|
||
"code": row.get("code") or row.get("materialCode") or row.get("operationCode"),
|
||
"name": row.get("name") or row.get("operationName") or row.get("rawReference") or kind,
|
||
"confidence": float(row.get("confidence") or 0),
|
||
"status": row.get("status") or change_set.get("status") or "PENDING_REVIEW",
|
||
"reviewRequired": bool(row.get("reviewRequired", True)), "payload": row, "evidence": []})
|
||
return {"id": source.get("id") or asset.get("id"), "projectId": asset.get("projectId"),
|
||
"fileName": source.get("filename") or asset.get("name") or "drawing.dxf",
|
||
"filePath": None, "drawingNo": drawing.get("drawingNumber"),
|
||
"revision": drawing.get("revision"), "title": None, "status": asset.get("status") or "PARSED",
|
||
"parsedAt": asset.get("parsedAt") or parsed.get("parsedAt"), "updatedAt": asset.get("parsedAt") or parsed.get("parsedAt"),
|
||
"candidateCount": len(candidates), "format": source.get("kind") or "DXF", "fileSize": source.get("sizeBytes"),
|
||
"modelSpaceEntityCount": drawing.get("modelspaceEntityCount"), "paperSpaceEntityCount": 0,
|
||
"layers": [{"name": x.get("name"), "entityCount": x.get("entityCount", 0),
|
||
"visible": not x.get("off") and not x.get("frozen"), "color": str(x.get("color"))}
|
||
for x in parsed.get("layers") or []],
|
||
"entityStats": [{"type": k, "count": v} for k, v in sorted((parsed.get("entityStatistics") or {}).items())],
|
||
"fields": fields, "candidates": candidates, "warnings": parsed.get("warnings") or [],
|
||
"process": {
|
||
"blocks": change_set.get("processBlocks") or [],
|
||
"assemblies": change_set.get("processAssemblies") or [],
|
||
"locations": change_set.get("processLocations") or [],
|
||
"sequenceSteps": change_set.get("processSequenceSteps") or [],
|
||
}}
|
||
|
||
|
||
def _public_drawing_parse(parsed: dict[str, Any]) -> dict[str, Any]:
|
||
"""Return the parse contract without server filesystem details."""
|
||
from copy import deepcopy
|
||
|
||
public = deepcopy(parsed)
|
||
asset = public.get("asset")
|
||
if isinstance(asset, dict):
|
||
asset.pop("sourcePath", None)
|
||
asset.pop("path", None)
|
||
return public
|
||
|
||
|
||
PROJECT_UPLOAD_EXTS = {".xlsx", ".xlsm", ".csv", ".txt", ".sql", ".dxf", ".pdf"}
|
||
PROJECT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024
|
||
_PROJECT_UPLOAD_LOCK = threading.RLock()
|
||
|
||
|
||
def _store_drawing_parse(store: Any, parsed: dict[str, Any], source_path: Path, project_id: str | None) -> str:
|
||
"""持久化单张图纸解析结果与独立候选变更集(按 drawing asset id 隔离)。"""
|
||
from server.aps_domain.drawing_dxf import build_drawing_master_candidates
|
||
|
||
change_set = build_drawing_master_candidates(parsed)
|
||
asset = {**(parsed.get("asset") or {}), "projectId": project_id, "status": "PARSED",
|
||
"parsedAt": parsed.get("parsedAt"), "parsed": parsed}
|
||
asset_id = str(asset.get("id") or "")
|
||
assets = store.data.setdefault("drawingAssets", [])
|
||
assets[:] = [row for row in assets if row.get("id") != asset_id]
|
||
assets.append(asset)
|
||
change_set["drawingAssetId"] = asset_id
|
||
change_set["projectId"] = project_id
|
||
changes = store.data.setdefault("drawingCandidates", [])
|
||
changes[:] = [row for row in changes if row.get("drawingAssetId") != asset_id]
|
||
changes.append(change_set)
|
||
store.data.setdefault("drawingParseRuns", []).append({
|
||
"drawingAssetId": asset_id,
|
||
"projectId": project_id,
|
||
"sourcePath": str(source_path),
|
||
"status": "SUCCEEDED",
|
||
"parsedAt": parsed.get("parsedAt"),
|
||
"entityCount": (parsed.get("drawing") or {}).get("modelspaceEntityCount", 0),
|
||
})
|
||
return asset_id
|
||
|
||
|
||
class MultimodalExtractRequest(BaseModel):
|
||
"""多模态提取请求:kind + 可选 text/filename/dataBase64。"""
|
||
sessionId: str = ""
|
||
kind: str
|
||
text: str | None = None
|
||
filename: str | None = None
|
||
dataBase64: str | None = None
|
||
|
||
|
||
class MultimodalIngestRequest(BaseModel):
|
||
"""多模态候选入库请求:extract 返回的候选 + 可选阈值。"""
|
||
sessionId: str = ""
|
||
candidates: list[dict[str, Any]] = Field(default_factory=list)
|
||
threshold: float | None = None
|
||
|
||
|
||
class DxfParsePathRequest(BaseModel):
|
||
"""DXF 本地路径解析请求(项目工程目录图纸;只读,不写世界状态)。"""
|
||
sessionId: str = ""
|
||
path: str # DXF 绝对路径或可解析路径
|
||
|
||
|
||
class DxfStageRequest(BaseModel):
|
||
"""DXF 主数据候选确认:只暂存 P2 确认卡(master.*.upsert),不直接改世界。"""
|
||
sessionId: str = ""
|
||
drawingId: str = "" # 来源图纸(审计关联)
|
||
target: str # material / bom / routing
|
||
payload: dict[str, Any] = Field(default_factory=dict) # 人工修订后的候选载荷
|
||
|
||
|
||
class ImportCommitRequest(BaseModel):
|
||
sessionId: str = ""
|
||
filename: str = "upload"
|
||
batches: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
class TemplateApplyRequest(BaseModel):
|
||
sessionId: str = "web"
|
||
templateCode: str
|
||
productCode: str
|
||
productName: str = ""
|
||
actor: str = "planner"
|
||
|
||
|
||
class RagQueryRequest(BaseModel):
|
||
query: str
|
||
topK: int = 5
|
||
kinds: list[str] | None = None # 限定知识 kind(sop/process/…)
|
||
tags: list[str] | None = None
|
||
apiKey: str = "" # skill manifest.auth 或 APS_RAG_API_KEY
|
||
|
||
|
||
class RebuildStartRequest(BaseModel):
|
||
"""触发候选构建 + 隔离黄金(开发流水线动作,P1)。"""
|
||
goldenTests: list[str] = Field(default_factory=list)
|
||
sourcePath: str | None = None
|
||
|
||
|
||
class RebuildRollbackRequest(BaseModel):
|
||
"""手动回滚原因。"""
|
||
reason: str = "manual"
|
||
|
||
|
||
class KnowledgeImportRequest(BaseModel):
|
||
sessionId: str = "web"
|
||
path: str | None = None
|
||
filePath: str | None = None
|
||
kind: str = "sop"
|
||
title: str | None = None
|
||
actor: str = "planner"
|
||
|
||
|
||
class SkillRegisterRequest(BaseModel):
|
||
sessionId: str = "web"
|
||
skill_id: str | None = None
|
||
skillId: str | None = None
|
||
name: str | None = None
|
||
endpoint: str
|
||
auth: str = ""
|
||
track: str = "flex"
|
||
enabled: bool = True
|
||
description: str = ""
|
||
actor: str = "planner"
|
||
|
||
|
||
class SkillEnableRequest(BaseModel):
|
||
sessionId: str = "web"
|
||
enabled: bool = True
|
||
actor: str = "planner"
|
||
|
||
|
||
class McpPluginRegisterRequest(BaseModel):
|
||
manifest: dict[str, Any]
|
||
actor: str = "planner"
|
||
|
||
|
||
class McpPluginEnableRequest(BaseModel):
|
||
enabled: bool = True
|
||
actor: str = "planner"
|
||
|
||
|
||
class McpPermissionRequest(BaseModel):
|
||
tool: str
|
||
allow: bool
|
||
reason: str = ""
|
||
actor: str = "planner"
|
||
|
||
|
||
class JobSubmitRequest(BaseModel):
|
||
kind: str
|
||
params: dict[str, Any] = Field(default_factory=dict)
|
||
actor: str = "planner"
|
||
@asynccontextmanager
|
||
async def _automation_lifespan(_app: FastAPI):
|
||
"""后台定时驱动:按 APS_AUTOMATION_TICK_SECONDS 间隔 tick 到期规则(矩阵 76)。
|
||
|
||
APS_AUTOMATION_DRIVER=0 可关闭后台驱动(仅保留手动 POST /api/automation/tick);
|
||
任何单次 tick 异常降级为静默跳过,绝不崩网关。
|
||
"""
|
||
task: asyncio.Task[None] | None = None
|
||
|
||
async def _loop() -> None:
|
||
while True:
|
||
try:
|
||
interval = float(os.environ.get("APS_AUTOMATION_TICK_SECONDS") or "60")
|
||
except ValueError:
|
||
interval = 60.0
|
||
await asyncio.sleep(max(1.0, interval))
|
||
try:
|
||
_get_automation_runtime().tick()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception: # noqa: BLE001 - 后台 tick 尽力而为,异常不崩网关
|
||
pass
|
||
|
||
if os.environ.get("APS_AUTOMATION_DRIVER", "1") != "0":
|
||
task = asyncio.create_task(_loop())
|
||
# 多智能体编排:Goal 看门狗后台节拍(守护线程,幂等)
|
||
try:
|
||
from server.agent_core.mesh import start_watchdog_loop
|
||
start_watchdog_loop()
|
||
except Exception: # noqa: BLE001 - 看门狗启动失败不阻断网关
|
||
pass
|
||
try:
|
||
yield
|
||
finally:
|
||
if task is not None:
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
"""应用工厂:注册中间件与全部路由。"""
|
||
app = FastAPI(title="APS Planning Agent", version="0.1.0",
|
||
lifespan=_automation_lifespan) # 应用实例(round-40 方向 S:后台 tick 驱动)
|
||
origins = [item.strip() for item in (
|
||
os.environ.get("APS_CORS_ORIGINS")
|
||
or "http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174"
|
||
).split(",") if item.strip()]
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
app.add_middleware(AuthenticationMiddleware)
|
||
# 矩阵 103 收口:/api/gov/audit/events 由桌面 updater(Electron main 进程,无浏览器 cookie)上报审计;
|
||
# desktop 模式身份由 SidecarIdentityApp nonce 门禁承担(updater 带 nonce 即可),web 模式在端点内
|
||
# 显式校验登录身份(与其他 gov 端点语义一致),因此该路径豁免 AuthenticationMiddleware 的 cookie 鉴权。
|
||
from server.auth.middleware import PUBLIC_API_PATHS
|
||
PUBLIC_API_PATHS.add("/api/gov/audit/events")
|
||
app.include_router(plan_router)
|
||
app.include_router(masterdata_router)
|
||
app.include_router(governance_router)
|
||
app.include_router(governance_audit_router)
|
||
app.include_router(mps_router)
|
||
app.include_router(agent_router)
|
||
|
||
@app.exception_handler(PermissionError)
|
||
async def permission_error_handler(_request: Request, exc: PermissionError) -> Response:
|
||
return Response(
|
||
content=json.dumps({
|
||
"error": {
|
||
"code": "FORBIDDEN",
|
||
"message": str(exc) or "当前身份无权执行该操作",
|
||
},
|
||
}, ensure_ascii=False),
|
||
status_code=403,
|
||
media_type="application/json",
|
||
)
|
||
|
||
def set_session_cookie(response: Response, token: str) -> None:
|
||
response.set_cookie(
|
||
"aps_session",
|
||
token,
|
||
httponly=True,
|
||
secure=(os.environ.get("APS_AUTH_COOKIE_SECURE") or "0") == "1",
|
||
samesite="lax",
|
||
max_age=int(os.environ.get("APS_AUTH_TTL_SECONDS") or "28800"),
|
||
path="/",
|
||
)
|
||
|
||
def set_license_cookie(response: Response, token: str, expires_at: int | None) -> None:
|
||
import time
|
||
remaining = max(1, (expires_at - int(time.time()))) if expires_at else 10 * 365 * 24 * 60 * 60
|
||
response.set_cookie(
|
||
DESKTOP_COOKIE,
|
||
token,
|
||
httponly=True,
|
||
secure=(os.environ.get("APS_AUTH_COOKIE_SECURE") or "0") == "1",
|
||
samesite="lax",
|
||
max_age=remaining,
|
||
path="/",
|
||
)
|
||
|
||
def auth_error_response(exc: AuthError) -> Response:
|
||
return Response(
|
||
content=json.dumps({"error": {"code": exc.code, "message": exc.message}}, ensure_ascii=False),
|
||
status_code=exc.status_code,
|
||
media_type="application/json",
|
||
)
|
||
|
||
def _app_version() -> str | None:
|
||
"""应用版本(round-44 FF):优先 APS_APP_VERSION 环境变量——桌面端由 Electron
|
||
main 经 sidecar 子进程注入 app.getVersion()(即 apps/desktop/package.json 的
|
||
version);Web/开发模式未设置时返回 null(不硬编码常量,避免与包版本漂移)。"""
|
||
raw = os.environ.get("APS_APP_VERSION") or ""
|
||
return raw.strip() or None
|
||
|
||
@app.get("/api/health")
|
||
async def health(request: Request) -> dict:
|
||
return {
|
||
"ok": True,
|
||
"version": _app_version(),
|
||
"interfaceVersion": INTERFACE_VERSION,
|
||
"clientMode": "desktop" if is_desktop_request(request) else "web",
|
||
"authProvider": get_auth_provider().mode,
|
||
"licenseProvider": get_license_provider().mode,
|
||
}
|
||
|
||
@app.get("/api/features")
|
||
async def feature_flags() -> dict:
|
||
"""功能开关(P0 只读):文件化配置哪些功能上线可见、哪些隐藏不用。
|
||
|
||
可用性开关而非权限边界;文件缺失/损坏 fail-open 默认全开并显式携带 error。
|
||
"""
|
||
from server.agent_core.feature_flags import load_feature_flags
|
||
|
||
return load_feature_flags()
|
||
|
||
@app.post("/api/auth/login")
|
||
async def auth_login(req: AuthLoginRequest, request: Request, response: Response):
|
||
if is_desktop_request(request):
|
||
return auth_error_response(AuthError(
|
||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
||
))
|
||
try:
|
||
identity, token = await get_auth_provider().login(req.model_dump())
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
set_session_cookie(response, token)
|
||
return {"user": identity.to_dict(), "provider": get_auth_provider().mode}
|
||
|
||
@app.get("/api/auth/tenants")
|
||
async def auth_tenants(request: Request):
|
||
"""公开租户列表已下线(避免暴露企业信息);请手填企业名称登录。"""
|
||
if is_desktop_request(request):
|
||
return auth_error_response(AuthError(
|
||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
||
))
|
||
return auth_error_response(AuthError(
|
||
"AUTH_TENANT_LIST_REMOVED",
|
||
"已取消租户列表接口,请直接输入企业名称登录",
|
||
410,
|
||
))
|
||
|
||
@app.get("/api/auth/login/precheck")
|
||
async def auth_login_precheck(
|
||
request: Request,
|
||
username: str = "",
|
||
tenantCode: str = "",
|
||
tenantName: str = "",
|
||
):
|
||
if is_desktop_request(request):
|
||
return auth_error_response(AuthError(
|
||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
||
))
|
||
try:
|
||
return await get_auth_provider().precheck(
|
||
username[:128], tenantCode[:128], tenantName[:128],
|
||
)
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
|
||
@app.get("/api/auth/captcha")
|
||
async def auth_captcha(request: Request):
|
||
if is_desktop_request(request):
|
||
return auth_error_response(AuthError(
|
||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
||
))
|
||
try:
|
||
return await get_auth_provider().captcha()
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
|
||
@app.post("/api/auth/license/activate")
|
||
async def auth_license_activate(req: LicenseActivationRequest, request: Request, response: Response):
|
||
provider = get_license_provider()
|
||
try:
|
||
identity, token = await provider.activate(request, req.model_dump())
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
set_license_cookie(response, token, identity.license_expires_at)
|
||
return {
|
||
"user": identity.to_dict(),
|
||
"provider": provider.mode,
|
||
"license": provider.describe(identity),
|
||
}
|
||
|
||
@app.get("/api/auth/me")
|
||
async def auth_me(request: Request) -> dict:
|
||
identity = get_identity(required=True)
|
||
if is_desktop_request(request):
|
||
provider = get_license_provider()
|
||
return {
|
||
"user": identity.to_dict(),
|
||
"provider": provider.mode,
|
||
"license": provider.describe(identity),
|
||
}
|
||
return {"user": identity.to_dict(), "provider": get_auth_provider().mode, "license": None}
|
||
|
||
@app.post("/api/auth/refresh")
|
||
async def auth_refresh(request: Request, response: Response):
|
||
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
|
||
try:
|
||
identity, token = await provider.refresh(request)
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
if is_desktop_request(request):
|
||
set_license_cookie(response, token, identity.license_expires_at)
|
||
else:
|
||
set_session_cookie(response, token)
|
||
return {
|
||
"user": identity.to_dict(),
|
||
"provider": provider.mode,
|
||
"license": provider.describe(identity) if is_desktop_request(request) else None,
|
||
}
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def auth_logout(request: Request, response: Response) -> dict:
|
||
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
|
||
await provider.logout(request)
|
||
response.delete_cookie("aps_session", path="/")
|
||
response.delete_cookie(DESKTOP_COOKIE, path="/")
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/auth/users")
|
||
async def auth_users(query: str = "") -> dict:
|
||
identity = get_identity(required=True)
|
||
try:
|
||
users = await get_auth_provider().search_users(query, identity)
|
||
except AuthError as exc:
|
||
return auth_error_response(exc)
|
||
return {"users": users}
|
||
|
||
# ---------------- 对话:SSE 会话流 ----------------
|
||
@app.post("/api/chat")
|
||
async def chat(req: ChatRequest) -> StreamingResponse:
|
||
"""一轮对话(权力等级:随意图而定;本端点只编排不执行 P2)。"""
|
||
from server.state.projects import get_project_store
|
||
session_id = req.sessionId or uuid.uuid4().hex[:12]
|
||
if req.sessionId:
|
||
# 前端常先本地建话题再异步同步;未入库时自动 ensure,避免误报 404
|
||
try:
|
||
get_project_store().ensure_session(
|
||
session_id,
|
||
project_id=req.projectId,
|
||
title=(req.text or "")[:24] or "新话题",
|
||
)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc) or "无权访问该会话") from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc) or "会话无效") from exc
|
||
store = get_store() # 世界状态
|
||
|
||
async def stream() -> AsyncIterator[str]: # SSE 事件生成器
|
||
from server.agent_core.progress import (
|
||
begin_progress, drain_thinking, emit_thinking, end_progress,
|
||
)
|
||
begin_progress()
|
||
yield _sse({"type": "meta", "sessionId": session_id}) # ① 会话元信息
|
||
try:
|
||
from server.agent_core.context import normalize_history
|
||
from server.state.projects import get_project_store
|
||
|
||
emit_thinking("收到请求", (req.text or "")[:48], pct=1)
|
||
for ev in drain_thinking():
|
||
yield _sse(ev)
|
||
|
||
# 多轮上文:优先前端带来的;否则读会话仓
|
||
history = normalize_history(req.history)
|
||
if not history:
|
||
try:
|
||
history = normalize_history(get_project_store().get_messages(session_id))
|
||
except Exception:
|
||
history = []
|
||
|
||
user_text = req.text
|
||
# 所有自然语言统一交给 Pi 理解。assistant.reply 只是内部受控信封;
|
||
# Pi 通过结构化工具请求调用业务能力,服务端继续强制登记、齐备度和审批门禁。
|
||
intent = IntentResult(
|
||
intent="assistant.reply",
|
||
params={
|
||
"query": user_text,
|
||
"_history": history,
|
||
"_rawUserText": user_text,
|
||
"_piPrimary": True,
|
||
},
|
||
confidence=1.0,
|
||
source="LLM",
|
||
)
|
||
emit_thinking("理解需求", "正在结合上下文与业务数据进行分析…", pct=5)
|
||
for ev in drain_thinking():
|
||
yield _sse(ev)
|
||
|
||
# 重活放到线程,避免堵住事件循环 → Thinking 才能边做边推
|
||
request_context = contextvars.copy_context()
|
||
|
||
def _run_intent():
|
||
# 固定信封也经过统一工具运行时;Pi 无法借主入口绕过登记与审批。
|
||
from server.agent_core.tool_runtime import check_tool
|
||
denied = request_context.run(
|
||
lambda: check_tool(store, intent))
|
||
if denied is not None:
|
||
return denied
|
||
return request_context.run(
|
||
lambda: asyncio.run(handle_intent(store, session_id, intent)),
|
||
)
|
||
|
||
fut = asyncio.get_event_loop().run_in_executor(None, _run_intent)
|
||
while not fut.done():
|
||
for ev in drain_thinking():
|
||
yield _sse(ev)
|
||
await asyncio.sleep(0.12)
|
||
for ev in drain_thinking():
|
||
yield _sse(ev)
|
||
reply = await fut
|
||
# 客户可见出口统一产品化,避免底层运行时名称进入正文或确认卡。
|
||
from server.agent_core.fallback_lane import _productize_primary_reply
|
||
reply = _productize_primary_reply(reply)
|
||
emit_thinking("整理结果", "正在生成业务答复…", status="done", pct=100)
|
||
for ev in drain_thinking():
|
||
yield _sse(ev)
|
||
|
||
for i in range(0, len(reply.text), 24): # ⑤ 正文分片(打字机流式感)
|
||
yield _sse({"type": "token", "text": reply.text[i:i + 24]}) # 24 字符一片
|
||
await asyncio.sleep(0.02) # 微延时(不阻塞事件循环)
|
||
for cmd in reply.commands: # ⑥ 视口命令逐条下发
|
||
yield _sse({"type": "command", "command": cmd.model_dump()})
|
||
for block in reply.blocks: # ⑦ UI 块(确认卡等)逐条下发
|
||
yield _sse({"type": "block", "block": block.model_dump()})
|
||
# ⑦b 滚动摘要接线(矩阵 64 行剩余项 · 方向 L):预算门控 + LLM 压缩。
|
||
# 只在本轮回复已流式下发后执行;尽力而为,任何异常降级为不滚动,
|
||
# 绝不阻断对话。策略开关 contextPolicies[session].autoSummarize
|
||
# 默认开启(长会话自动压缩),关闭后永不触发。
|
||
try:
|
||
from server.agent_core.context_policy import ContextBudget
|
||
from server.agent_core.providers import get_provider
|
||
from server.agent_core.summarizer import maybe_roll_session_summary
|
||
snap = dict(store.data)
|
||
proj = get_project_store().snapshot(include_messages=True)
|
||
snap["messages"] = proj.get("messages") or {}
|
||
snap["sessions"] = proj.get("sessions") or []
|
||
snap["projects"] = proj.get("projects") or []
|
||
snap["contextPolicies"] = store.data.setdefault("contextPolicies", {})
|
||
rolled = await maybe_roll_session_summary(
|
||
snap, session_id, provider=get_provider(),
|
||
budget=ContextBudget(), by="planner",
|
||
)
|
||
if rolled is not None:
|
||
store.save()
|
||
except Exception: # noqa: BLE001, S110 - 尽力而为:滚动摘要任何异常降级为不滚动,绝不阻断对话
|
||
pass
|
||
except Exception as exc: # 兜底:异常记录日志,用户只看到可执行提示
|
||
logger.exception("chat request failed", exc_info=exc)
|
||
yield _sse({"type": "error", "message": "本次操作未完成,数据未修改。请稍后重试;如持续发生,请联系管理员。"})
|
||
finally:
|
||
end_progress()
|
||
yield _sse({"type": "done"}) # ⑧ 结束标记
|
||
return StreamingResponse(stream(), media_type="text/event-stream", # SSE 响应
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||
|
||
# ---------------- 世界视图(P0 只读) ----------------
|
||
@app.get("/api/world/summary")
|
||
async def summary() -> dict:
|
||
"""KPI 摘要(视口工具栏)。"""
|
||
return world_summary(get_store().data) # 直接投影
|
||
|
||
@app.get("/api/world/gantt")
|
||
async def gantt() -> dict:
|
||
"""甘特视图数据(最新版本工单 + 骨架 + 标记)。"""
|
||
return gantt_view(get_store().data) # 直接投影
|
||
|
||
@app.get("/api/world/load")
|
||
async def load(days: int = 14) -> dict:
|
||
"""负荷热力数据(产线 × 未来 N 天)。"""
|
||
return load_view(get_store().data, days=days) # 直接投影
|
||
|
||
@app.get("/api/world/due")
|
||
async def due() -> dict:
|
||
"""交期承诺看板数据。"""
|
||
return {"rows": due_view(get_store().data)} # 包一层便于扩展
|
||
|
||
# ---------------- 订单管理(P0 读;P2 写经确认卡) ----------------
|
||
@app.get("/api/orders")
|
||
async def orders() -> dict:
|
||
"""订单管理页:销售/柔性订单 + 生产订单 + 采购/委外建议。"""
|
||
from server.aps_domain.orders import (
|
||
list_orders, list_products, list_production_orders, pool_summary,
|
||
sync_flex_orders_to_sales,
|
||
)
|
||
from server.aps_domain.mrp import list_mrp
|
||
store = get_store()
|
||
world = store.data
|
||
if world.get("flexOrders") and sync_flex_orders_to_sales(world):
|
||
store.save()
|
||
# `list_mrp` maintains backward-compatible preview tables internally; run it
|
||
# against a detached snapshot so a P0 GET cannot dirty the persisted world.
|
||
from copy import deepcopy
|
||
world = deepcopy(world)
|
||
mrp = list_mrp(world)
|
||
closed_loop_problem = (world.get("closedLoopProblems") or [None])[-1]
|
||
closed_loop_payload = None
|
||
closed_loop_status = None
|
||
latest_closed_loop_version = None
|
||
if closed_loop_problem:
|
||
problem_id = str(closed_loop_problem.get("problemId") or "")
|
||
latest_closed_loop_version = next(
|
||
(
|
||
row
|
||
for row in reversed(world.get("flexScheduleVersions") or [])
|
||
if str(row.get("planningProblemId") or "") == problem_id
|
||
),
|
||
None,
|
||
)
|
||
manufacturing_demands = list(world.get("manufacturingDemands") or [])
|
||
supply_events = list(world.get("supplyEvents") or [])
|
||
blockers = list(closed_loop_problem.get("blockers") or [])
|
||
ready_count = sum(
|
||
1
|
||
for row in manufacturing_demands
|
||
if str(row.get("release_status") or "").upper()
|
||
in {"READY", "READY_FOR_SCHEDULING"}
|
||
)
|
||
closed_loop_payload = {
|
||
"problemId": problem_id,
|
||
"sourceHash": closed_loop_problem.get("sourceHash"),
|
||
"businessDate": closed_loop_problem.get("businessDate"),
|
||
"summary": dict(closed_loop_problem.get("summary") or {}),
|
||
"projection": {
|
||
"problemId": problem_id,
|
||
"sourceHash": closed_loop_problem.get("sourceHash"),
|
||
"manufacturingDemandCount": len(manufacturing_demands),
|
||
"supplyEventCount": len(supply_events),
|
||
"blockerCount": len(blockers),
|
||
"readyDemandCount": ready_count,
|
||
},
|
||
"manufacturingDemands": manufacturing_demands,
|
||
"supplyEvents": supply_events,
|
||
"blockers": blockers,
|
||
"schedulingProblemV2": dict(
|
||
closed_loop_problem.get("schedulingProblemV2") or {}
|
||
),
|
||
}
|
||
|
||
gate = None
|
||
if latest_closed_loop_version is not None:
|
||
from server.aps_domain.mes import validate_dispatchable_version
|
||
|
||
gate = validate_dispatchable_version(
|
||
world,
|
||
"flex",
|
||
latest_closed_loop_version.get("id"),
|
||
)
|
||
version_id = (
|
||
latest_closed_loop_version.get("id")
|
||
if latest_closed_loop_version is not None
|
||
else None
|
||
)
|
||
receipt = next(
|
||
(
|
||
row
|
||
for row in reversed(world.get("wmsReceipts") or [])
|
||
if str(row.get("scheduleVersionId")) == str(version_id)
|
||
),
|
||
None,
|
||
) if version_id is not None else None
|
||
closed_loop_status = {
|
||
"problemId": problem_id,
|
||
"latestVersion": latest_closed_loop_version,
|
||
"gate": gate,
|
||
"mesReceipt": receipt,
|
||
}
|
||
|
||
return {
|
||
"orders": list_orders(world),
|
||
"products": list_products(world),
|
||
"productionOrders": list_production_orders(world),
|
||
"purchaseOrders": mrp.get("purchaseOrders") or [],
|
||
"outsourceOrders": mrp.get("outsourceOrders") or [],
|
||
"make": mrp.get("make") or [],
|
||
"closedLoop": closed_loop_payload,
|
||
"closedLoopStatus": closed_loop_status,
|
||
"statuses": ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "CHANGED", "CANCELLED", "COMPLETED"],
|
||
"levels": ["VIP", "A", "B", "C"],
|
||
"pool": pool_summary(world),
|
||
"flexOrderCount": len(world.get("flexOrders") or []),
|
||
"hasSchedule": bool(world.get("flexScheduleVersions") or world.get("scheduleVersions")),
|
||
}
|
||
|
||
@app.get("/api/forecast")
|
||
async def forecast_list() -> dict:
|
||
"""OR-05 预测订单台账(P0)。"""
|
||
from server.aps_domain.forecast import ensure_forecast_table, forecast_summary, list_forecasts
|
||
from server.aps_domain.orders import list_products
|
||
world = get_store().data
|
||
ensure_forecast_table(world)
|
||
return {
|
||
"forecasts": list_forecasts(world),
|
||
"summary": forecast_summary(world),
|
||
"products": list_products(world),
|
||
"statuses": ["DRAFT", "ACTIVE", "CONSUMED", "CANCELLED"],
|
||
"buckets": ["DAY", "WEEK", "MONTH"],
|
||
}
|
||
|
||
@app.get("/api/plan/buckets")
|
||
async def plan_buckets(
|
||
mode: str = "HYBRID",
|
||
horizonDays: int = 90,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
capacityMode: str = "FINITE",
|
||
) -> dict:
|
||
"""PL-01/PL-02 时间分桶 + 有限/无限粗能力(P0)。"""
|
||
from server.aps_domain.planning import build_plan_buckets
|
||
try:
|
||
return build_plan_buckets(
|
||
get_store().data,
|
||
mode=mode,
|
||
start_date=startDate,
|
||
horizon_days=horizonDays,
|
||
include_forecast=includeForecast,
|
||
capacity_mode=capacityMode,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/plan/rccp")
|
||
async def plan_rccp(
|
||
mode: str = "HYBRID",
|
||
horizonDays: int = 90,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
) -> dict:
|
||
"""PL-02 有限/无限粗能力对照(P0)。"""
|
||
from server.aps_domain.planning import build_rccp_compare
|
||
try:
|
||
return build_rccp_compare(
|
||
get_store().data,
|
||
mode=mode,
|
||
start_date=startDate,
|
||
horizon_days=horizonDays,
|
||
include_forecast=includeForecast,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/plan/feasibility")
|
||
async def plan_feasibility(
|
||
horizonDays: int = 90,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
) -> dict:
|
||
"""PL-03 交期可行性(P0,前置于排产)。"""
|
||
from server.aps_domain.planning import build_feasibility
|
||
try:
|
||
return build_feasibility(
|
||
get_store().data,
|
||
start_date=startDate,
|
||
horizon_days=horizonDays,
|
||
include_forecast=includeForecast,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/plan/inventory")
|
||
async def plan_inventory(
|
||
horizonDays: int = 30,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
bucket: str = "DAY",
|
||
materialCode: str | None = None,
|
||
materialType: str | None = None,
|
||
) -> dict:
|
||
"""PL-04 库存投影(P0)。"""
|
||
from server.aps_domain.inventory import build_inventory_projection
|
||
try:
|
||
return build_inventory_projection(
|
||
get_store().data,
|
||
material_code=materialCode,
|
||
material_type=materialType,
|
||
horizon_days=horizonDays,
|
||
bucket=bucket,
|
||
include_forecast=includeForecast,
|
||
start_date=startDate,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/plan/leveling")
|
||
async def plan_leveling(
|
||
mode: str = "WEEK",
|
||
horizonDays: int = 90,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
targetLoad: float = 0.85,
|
||
) -> dict:
|
||
"""PL-05 产能平衡/削峰建议(P0)。"""
|
||
from server.aps_domain.planning import build_leveling
|
||
try:
|
||
return build_leveling(
|
||
get_store().data,
|
||
mode=mode,
|
||
start_date=startDate,
|
||
horizon_days=horizonDays,
|
||
include_forecast=includeForecast,
|
||
target_load=targetLoad,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/plan/supply")
|
||
async def plan_supply(
|
||
mode: str = "WEEK",
|
||
horizonDays: int = 90,
|
||
includeForecast: bool = True,
|
||
startDate: str | None = None,
|
||
targetLoad: float = 0.85,
|
||
) -> dict:
|
||
"""PL-06 产供方向决策(P0)。"""
|
||
from server.aps_domain.planning import build_supply_decisions
|
||
try:
|
||
return build_supply_decisions(
|
||
get_store().data,
|
||
mode=mode,
|
||
start_date=startDate,
|
||
horizon_days=horizonDays,
|
||
include_forecast=includeForecast,
|
||
target_load=targetLoad,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/forecast/stage")
|
||
async def forecast_stage(req: ForecastStageRequest) -> dict:
|
||
"""预测写入动作暂存确认卡(P2)。"""
|
||
from server.aps_domain.forecast import confirmation_for_forecast_action
|
||
if req.action not in ("forecast.upsert", "forecast.delete", "forecast.convert"):
|
||
return {"error": "不支持的预测动作"}
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_forecast_action(store.data, req.action, req.payload)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(req.sessionId or "web", req.action, req.payload,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action=f"{req.action}.stage",
|
||
target={"type": "FORECAST_ORDER",
|
||
"id": req.payload.get("id") or req.payload.get("forecastNo") or "NEW"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列", "block": block.model_dump()}
|
||
|
||
@app.post("/api/orders/submit")
|
||
async def order_submit(req: OrderStageRequest) -> dict:
|
||
"""订单提交审核(OR-03,P1 直通)。"""
|
||
from server.aps_domain.orders import apply_order_submit
|
||
store = get_store()
|
||
try:
|
||
applied = harness.guard("order.submit", req.payload,
|
||
lambda: apply_order_submit(store.data, req.payload))
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
from server.agent_core.audit import write_audit
|
||
o = applied["order"]
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="WORLD_WRITE",
|
||
action="order.submit",
|
||
target={"type": "SALES_ORDER", "id": o["id"], "orderNo": o["orderNo"]},
|
||
power="P1", rationale={"from": applied.get("beforeStatus"), "to": "SUBMITTED"})
|
||
store.save()
|
||
return {"message": f"订单 {o['orderNo']} 已提交审核",
|
||
"order": {"id": o["id"], "orderNo": o["orderNo"], "status": o["status"]}}
|
||
|
||
@app.post("/api/rush/evaluate")
|
||
async def rush_evaluate(req: RushEvaluateRequest) -> dict:
|
||
"""OR-04 插单影响快评(P1 沙盒,不改主干)。"""
|
||
from server.aps_domain import workflow as wf
|
||
from server.aps_domain.rush import evaluate_rush, format_impact_text
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
payload = {k: v for k, v in req.model_dump().items()
|
||
if k != "sessionId" and v is not None}
|
||
try:
|
||
impact = harness.guard("rush.evaluate", payload,
|
||
lambda: evaluate_rush(store.data, payload))
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
wf._LAST_RUSH_EVAL.clear()
|
||
wf._LAST_RUSH_EVAL.update(impact)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web",
|
||
category="ALGO_RUN", action="rush.evaluate",
|
||
target={"type": "SANDBOX", "id": impact["evalId"]}, power="P1",
|
||
rationale={"affected": impact["affectedOrderCount"],
|
||
"delayDelta": impact["delayDelta"],
|
||
"conflictDelta": impact["conflictDelta"]})
|
||
store.save()
|
||
return {"message": format_impact_text(impact), "impact": impact}
|
||
|
||
@app.post("/api/rush/stage")
|
||
async def rush_stage(req: RushApplyRequest) -> dict:
|
||
"""OR-04 采用插单确认卡(P2)。"""
|
||
from server.aps_domain import workflow as wf
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
payload: dict[str, Any] = {
|
||
"payload": req.payload or None,
|
||
"strategy": req.strategy,
|
||
"evalId": req.evalId,
|
||
}
|
||
if not req.payload and wf._LAST_RUSH_EVAL:
|
||
payload = {
|
||
"payload": wf._LAST_RUSH_EVAL.get("payload"),
|
||
"strategy": req.strategy or wf._LAST_RUSH_EVAL.get("strategy"),
|
||
"evalId": req.evalId or wf._LAST_RUSH_EVAL.get("evalId"),
|
||
}
|
||
if not payload.get("payload"):
|
||
return {"error": "缺少插单载荷,请先执行插单快评"}
|
||
rush = payload["payload"]
|
||
label = rush.get("orderNo") or f"{rush.get('customerName', '急单')}×{rush.get('quantity', '?')}"
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "rush.apply", payload,
|
||
title=f"采用紧急插单 · {label}",
|
||
summary_lines=[
|
||
f"策略:{payload.get('strategy') or 'DELIVERY_FIRST'}",
|
||
"将写入主干订单池(APPROVED + isRush),并生成 DRAFT 排产版本",
|
||
"执行前自动建档,可回滚",
|
||
],
|
||
)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web",
|
||
category="GATE", action="rush.apply.stage",
|
||
target={"type": "SALES_ORDER", "id": rush.get("orderNo") or "NEW"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": "已生成采用插单确认卡", "block": block.model_dump()}
|
||
|
||
@app.post("/api/orders/stage")
|
||
async def order_stage(req: OrderStageRequest) -> dict:
|
||
"""订单写入动作暂存为确认卡(P2 唯一路径仍是 /api/actions/confirm)。"""
|
||
if req.action not in ("order.upsert", "order.cancel", "order.complete", "order.delete", "order.clear",
|
||
"order.approve", "order.reject"):
|
||
return {"error": "不支持的订单动作"}
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_order_action(store.data, req.action, req.payload)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(req.sessionId or "web", req.action, req.payload,
|
||
title=title, summary_lines=lines)
|
||
write_action = req.action + ".stage"
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action=write_action, target={"type": "SALES_ORDER", "id": req.payload.get("id") or "ALL"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- 主数据维护(MD-01/02/03:P0 读;P2 写经确认卡) ----------------
|
||
@app.get("/api/master")
|
||
async def master() -> dict:
|
||
"""主数据管理页数据:资源树 + 物料/BOM/路线 + 日历/维保。"""
|
||
return master_overview(get_store().data)
|
||
|
||
@app.get("/api/master/process-tree")
|
||
async def master_process_tree(productCode: str = "") -> dict:
|
||
"""工艺路线树:物料↔工序↔BOM 子件嵌套(对标现场主数据树)。"""
|
||
from server.aps_domain.sourcing import annotate_world_sourcing, build_process_tree
|
||
world = get_store().data
|
||
annotate_world_sourcing(world)
|
||
code = (productCode or "").strip()
|
||
if not code:
|
||
# 默认取第一张可排销售单成品,或第一张有工艺的料号
|
||
for so in world.get("salesOrders") or []:
|
||
items = so.get("items") or []
|
||
if items and items[0].get("productCode"):
|
||
code = items[0]["productCode"]
|
||
break
|
||
if not code:
|
||
for r in world.get("flexRoutings") or []:
|
||
if r.get("productCode"):
|
||
code = str(r["productCode"])
|
||
break
|
||
if not code:
|
||
return {"error": "请指定 productCode"}
|
||
return {"productCode": code, "tree": build_process_tree(world, code)}
|
||
|
||
@app.post("/api/master/stage")
|
||
async def master_stage(req: MasterStageRequest) -> dict:
|
||
"""主数据写入动作暂存为确认卡(P2 唯一执行路径仍是 /api/actions/confirm)。"""
|
||
if req.action not in MASTER_ACTIONS:
|
||
return {"error": "不支持的主数据动作"}
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_master_action(store.data, req.action, req.payload)
|
||
except ValueError as exc: # 校验失败:不出卡,直接回错误
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(req.sessionId or "web", req.action, req.payload,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action=req.action + ".stage", target={"type": "MASTER_DATA", "id": req.payload.get("id")},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- MRP 订单分解(P0 读;P1 分解直通) ----------------
|
||
@app.get("/api/mrp")
|
||
async def mrp() -> dict:
|
||
"""MRP 建议单投影:采购建议 + 委外建议。"""
|
||
return list_mrp(get_store().data)
|
||
|
||
@app.post("/api/mrp/decompose")
|
||
async def mrp_decompose(req: MrpDecomposeRequest) -> dict:
|
||
"""Decompose commercial demand and persist the closed-loop planning graph (P1)."""
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from server.agent_core.audit import write_audit
|
||
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
|
||
from server.aps_domain.closed_loop_runtime import (
|
||
closed_loop_to_problem_v2,
|
||
persist_closed_loop_projection,
|
||
)
|
||
|
||
store = get_store()
|
||
business_date = str(
|
||
store.data.get("businessDate")
|
||
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
|
||
)[:10]
|
||
|
||
def execute_decomposition() -> dict:
|
||
decomposition = decompose_orders(store.data, store.next_id, req.orderNo)
|
||
closed_loop = build_closed_loop_problem(
|
||
store.data,
|
||
business_date=business_date,
|
||
order_nos=[req.orderNo] if req.orderNo else None,
|
||
strict=True,
|
||
)
|
||
problem = closed_loop_to_problem_v2(store.data, closed_loop)
|
||
projection = persist_closed_loop_projection(store.data, closed_loop, problem)
|
||
decomposition["closedLoop"] = {
|
||
"problemId": closed_loop.problem_id,
|
||
"sourceHash": closed_loop.source_revision,
|
||
"businessDate": closed_loop.business_date,
|
||
"summary": dict(closed_loop.stats),
|
||
"projection": projection,
|
||
"manufacturingDemands": [
|
||
row for row in closed_loop.to_dict()["manufacturingDemands"]
|
||
],
|
||
"supplyEvents": [row for row in closed_loop.to_dict()["supplyEvents"]],
|
||
"blockers": [row for row in closed_loop.to_dict()["blockers"]],
|
||
"schedulingProblemV2": problem.model_dump(mode="json"),
|
||
}
|
||
return decomposition
|
||
|
||
try:
|
||
result = harness.guard(
|
||
"order.decompose",
|
||
{"orderNo": req.orderNo, "businessDate": business_date},
|
||
execute_decomposition,
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
closed_loop = result.get("closedLoop") or {}
|
||
summary = closed_loop.get("summary") or {}
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=req.sessionId or "web",
|
||
category="ALGO_RUN",
|
||
action="order.decompose",
|
||
target={"type": "MRP", "id": req.orderNo or "ALL"},
|
||
power="P1",
|
||
rationale={
|
||
"make": int(summary.get("makeCount") or len(result.get("make") or [])),
|
||
"purchase": int(summary.get("buyCount") or len(result.get("purchase") or [])),
|
||
"outsource": int(summary.get("subcontractCount") or len(result.get("outsource") or [])),
|
||
"blockedRequirements": int(summary.get("blockedRequirementCount") or 0),
|
||
"planningProblemId": closed_loop.get("problemId"),
|
||
"planningSourceHash": closed_loop.get("sourceHash"),
|
||
},
|
||
)
|
||
store.save()
|
||
return {"message": summarize_decomposition(result), "result": result}
|
||
|
||
@app.get("/api/plan/trace")
|
||
async def plan_trace_api(orderNo: str | None = None, track: str | None = None) -> dict:
|
||
"""计划追溯 / 订单钉扎(P0):固定 SO 链或柔性 FO 链(OR-06)。"""
|
||
from server.aps_domain.trace import plan_trace
|
||
try:
|
||
return plan_trace(get_store().data, orderNo, track=track)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/mrp/release")
|
||
async def mrp_release(req: MrpReleaseRequest) -> dict:
|
||
"""MRP 建议单下达暂存为确认卡(P2 唯一执行路径仍是 /api/actions/confirm)。"""
|
||
from server.aps_domain.mrp import confirmation_for_mrp_release
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_mrp_release(store.data, req.orderNo, req.kind)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
params = {"orderNo": req.orderNo, "kind": req.kind}
|
||
block = harness.stage_confirmation(req.sessionId or "web", "mrp.release", params,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="mrp.release.stage", target={"type": "MRP", "id": req.orderNo or "ALL"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"], "kind": req.kind})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- MD-04 Excel/CSV 导入(preview P1 + commit P2) ----------------
|
||
@app.post("/api/import/preview")
|
||
async def import_preview(file: UploadFile = File(...)) -> dict:
|
||
"""上传 Excel/CSV,校验并返回可入库批次(不写主干)。"""
|
||
from server.aps_domain.importers import preview_file
|
||
raw = await file.read()
|
||
try:
|
||
result = preview_file(file.filename or "upload.xlsx", raw, get_store().data)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
return result
|
||
|
||
@app.get("/api/import/template")
|
||
async def import_template(request: Request, profileId: str | None = None) -> Any:
|
||
"""下载与当前工作簿配置一致的排产数据采集模板 Excel(带 ETag,支持条件请求)。"""
|
||
import hashlib
|
||
from urllib.parse import quote
|
||
from fastapi.responses import Response
|
||
from server.importers.template_workbook import build_intake_template
|
||
try:
|
||
template = build_intake_template(profileId)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
etag = '"' + hashlib.sha256(template["xlsxBytes"]).hexdigest()[:32] + '"'
|
||
headers = {
|
||
"ETag": etag,
|
||
"X-APS-Template-Id": template["templateId"],
|
||
"X-APS-Profile-Id": template["profileId"],
|
||
"X-APS-Profile-Digest": template["profileDigest"],
|
||
}
|
||
# 条件请求:模板内容由 profile 摘要决定,内容没变就不重传整份 Excel。
|
||
cached = [tag.strip() for tag in (request.headers.get("if-none-match") or "").split(",")]
|
||
if etag in cached or "*" in cached:
|
||
return Response(status_code=304, headers=headers)
|
||
fname = quote(template["filename"])
|
||
return Response(
|
||
content=template["xlsxBytes"],
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={**headers, "Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
|
||
)
|
||
|
||
|
||
@app.post("/api/import/commit")
|
||
async def import_commit(req: ImportCommitRequest) -> dict:
|
||
"""将 preview 通过的批次暂存为 P2 确认卡。"""
|
||
from server.aps_domain.importers import confirmation_for_import_commit
|
||
if not req.batches or not any(b.get("okRows") for b in req.batches):
|
||
return {"error": "没有可入库的有效行"}
|
||
preview = {
|
||
"filename": req.filename,
|
||
"batches": req.batches,
|
||
"totalOk": sum(len(b.get("okRows") or []) for b in req.batches),
|
||
"totalErrors": sum(len(b.get("errors") or []) for b in req.batches),
|
||
}
|
||
title, lines = confirmation_for_import_commit(preview)
|
||
# 确认卡只带 okRows,避免整份文件进会话
|
||
slim = [{"kind": b["kind"], "sheet": b.get("sheet"), "okRows": b.get("okRows") or []}
|
||
for b in req.batches if b.get("okRows")]
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "import.commit",
|
||
{"filename": req.filename, "batches": slim},
|
||
title=title, summary_lines=lines,
|
||
)
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="import.commit.stage", target={"type": "IMPORT", "id": req.filename},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"],
|
||
"totalOk": preview["totalOk"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- 多模态输入管线(矩阵 101 方向 Y:语音/图片/文件 + 低置信确认) ----------------
|
||
# ---------------- 工程图纸 DXF:解析/预览/入库(P2 门禁) ----------------
|
||
@app.post("/api/drawings/inspect")
|
||
async def drawings_inspect(req: DrawingInspectRequest) -> dict:
|
||
from server.aps_domain.drawing_dxf import inspect_dxf
|
||
from server.aps_domain.drawing_pdf import inspect_pdf
|
||
try:
|
||
store = _drawing_project_store(req.projectId, write=True)
|
||
path = _resolve_allowed_drawing_path(req.path, req.projectId)
|
||
parsed = inspect_pdf(path) if path.suffix.lower() == ".pdf" else inspect_dxf(path)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
except (OSError, ValueError) as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
asset_id = _store_drawing_parse(store, parsed, path, req.projectId)
|
||
asset = next(row for row in store.data["drawingAssets"] if row.get("id") == asset_id)
|
||
change_set = next(row for row in store.data["drawingCandidates"] if row.get("drawingAssetId") == asset_id)
|
||
store.save()
|
||
return {"message": "图纸解析完成,候选已生成待确认",
|
||
"drawing": _drawing_api_payload(asset, change_set),
|
||
"parsed": _public_drawing_parse(parsed)}
|
||
|
||
@app.get("/api/projects/{project_id}/drawing-files")
|
||
async def project_drawing_files(project_id: str) -> dict:
|
||
from datetime import UTC, datetime
|
||
|
||
from server.state.projects import get_project_store
|
||
|
||
try:
|
||
snap = get_project_store().snapshot(include_messages=False)
|
||
except (PermissionError, ValueError) as exc:
|
||
raise HTTPException(status_code=403, detail="没有权限访问该项目") from exc
|
||
project = next((p for p in snap.get("projects") or [] if str(p.get("id") or "") == project_id), None)
|
||
if project is None:
|
||
raise HTTPException(status_code=404, detail="项目不存在或无权限")
|
||
work = str(project.get("workDir") or "").strip()
|
||
if not work:
|
||
return {"files": [], "count": 0, "workDir": None}
|
||
root = Path(work).expanduser().resolve()
|
||
if not root.is_dir():
|
||
raise HTTPException(status_code=400, detail="项目工程目录不可访问,请检查项目目录设置")
|
||
files: list[dict[str, Any]] = []
|
||
try:
|
||
children = sorted(
|
||
child for pattern in ("*.dxf", "*.pdf")
|
||
for child in root.glob(pattern)
|
||
if child.is_file() and not child.name.startswith(("~$", "."))
|
||
)
|
||
for child in children:
|
||
files.append({
|
||
"name": child.name,
|
||
"path": child.name,
|
||
"sizeBytes": child.stat().st_size,
|
||
"modifiedAt": datetime.fromtimestamp(child.stat().st_mtime, tz=UTC).isoformat(),
|
||
})
|
||
except OSError as exc:
|
||
raise HTTPException(status_code=400, detail="工程目录不可读,请检查项目目录权限") from exc
|
||
return {"files": files, "count": len(files), "workDir": None}
|
||
|
||
@app.post("/api/drawings/inspect-batch")
|
||
async def drawings_inspect_batch(req: DrawingBatchInspectRequest) -> dict:
|
||
from server.aps_domain.drawing_dxf import inspect_dxf
|
||
from server.aps_domain.drawing_pdf import inspect_pdf
|
||
|
||
raw_paths: list[str] = []
|
||
if req.directory:
|
||
try:
|
||
directory = Path(req.directory).expanduser().resolve(strict=True)
|
||
except OSError as exc:
|
||
raise HTTPException(status_code=400, detail="图纸目录不可访问,请检查目录路径") from exc
|
||
if not directory.is_dir():
|
||
raise HTTPException(status_code=400, detail="图纸目录不可访问,请检查目录路径")
|
||
for pattern in ("*.dxf", "*.pdf"):
|
||
raw_paths.extend(str(child) for child in sorted(directory.glob(pattern))
|
||
if child.is_file() and not child.name.startswith(("~$", ".")))
|
||
raw_paths.extend(p for p in (req.paths or []) if p and str(p).strip())
|
||
unique: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in raw_paths:
|
||
key = str(Path(raw).expanduser().resolve(strict=False))
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
unique.append(raw)
|
||
if not unique:
|
||
raise HTTPException(status_code=400, detail="未找到可解析的 DXF/PDF 图纸文件")
|
||
try:
|
||
store = _drawing_project_store(req.projectId, write=True)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
drawings: list[dict[str, Any]] = []
|
||
errors: list[dict[str, str]] = []
|
||
for raw in unique:
|
||
try:
|
||
path = _resolve_allowed_drawing_path(raw, req.projectId)
|
||
parsed = inspect_pdf(path) if path.suffix.lower() == ".pdf" else inspect_dxf(path)
|
||
asset_id = _store_drawing_parse(store, parsed, path, req.projectId)
|
||
asset = next(row for row in store.data["drawingAssets"] if row.get("id") == asset_id)
|
||
change = next(row for row in store.data["drawingCandidates"] if row.get("drawingAssetId") == asset_id)
|
||
drawings.append(_drawing_api_payload(asset, change))
|
||
except PermissionError as exc:
|
||
errors.append({"path": Path(raw).name, "error": str(exc)})
|
||
except (OSError, ValueError) as exc:
|
||
errors.append({"path": Path(raw).name, "error": str(exc)})
|
||
store.save()
|
||
message = f"批量解析完成:{len(drawings)} 张成功"
|
||
if errors:
|
||
message += f",{len(errors)} 张失败"
|
||
return {"message": message, "drawings": drawings, "count": len(drawings), "errors": errors}
|
||
|
||
@app.get("/api/projects/{project_id}/drawings")
|
||
async def project_drawings(project_id: str) -> dict:
|
||
try:
|
||
world = _drawing_project_store(project_id).data
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
rows = []
|
||
for asset in world.get("drawingAssets") or []:
|
||
if str(asset.get("projectId") or "") != project_id: continue
|
||
change = next((x for x in world.get("drawingCandidates") or [] if x.get("drawingAssetId") == asset.get("id")), None)
|
||
payload = _drawing_api_payload(asset, change)
|
||
rows.append({k: payload.get(k) for k in ("id", "projectId", "fileName", "filePath", "drawingNo", "revision", "title", "status", "parsedAt", "updatedAt", "candidateCount")})
|
||
return {"drawings": rows, "count": len(rows)}
|
||
|
||
@app.get("/api/drawings/{drawing_id}")
|
||
async def drawing_detail(drawing_id: str, project_id: str | None = None) -> dict:
|
||
try:
|
||
world = _drawing_project_store(project_id).data
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
asset = _drawing_asset(world, drawing_id)
|
||
if asset is None: raise HTTPException(status_code=404, detail="图纸不存在或已删除")
|
||
change = next((x for x in world.get("drawingCandidates") or [] if x.get("drawingAssetId") == drawing_id), None)
|
||
return _drawing_api_payload(asset, change)
|
||
|
||
@app.get("/api/drawings/{drawing_id}/preview")
|
||
async def drawing_preview(drawing_id: str, project_id: str | None = None) -> dict:
|
||
from server.aps_domain.drawing_dxf import render_dxf_preview
|
||
from server.aps_domain.drawing_pdf import render_pdf_preview
|
||
try:
|
||
asset = _drawing_asset(_drawing_project_store(project_id).data, drawing_id)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
if asset is None: raise HTTPException(status_code=404, detail="图纸不存在或已删除")
|
||
source = (asset.get("parsed") or {}).get("asset", {}).get("sourcePath") or asset.get("sourcePath")
|
||
kind = str((asset.get("parsed") or {}).get("asset", {}).get("kind") or "DXF").upper()
|
||
try:
|
||
if kind == "PDF":
|
||
return render_pdf_preview(source)
|
||
svg = render_dxf_preview(source)
|
||
except (OSError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
return {"mimeType": "image/svg+xml", "svg": svg}
|
||
|
||
@app.delete("/api/drawings/{drawing_id}")
|
||
async def drawing_delete(drawing_id: str, project_id: str | None = None) -> dict:
|
||
"""清除单张图纸的解析结果与候选(P1,保留审计)。"""
|
||
from server.agent_core.audit import write_audit
|
||
try:
|
||
store = _drawing_project_store(project_id, write=True)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
asset = _drawing_asset(store.data, drawing_id)
|
||
if asset is None:
|
||
raise HTTPException(status_code=404, detail="图纸不存在或已删除")
|
||
store.data["drawingAssets"] = [
|
||
row for row in store.data.get("drawingAssets", []) if row.get("id") != drawing_id
|
||
]
|
||
store.data["drawingCandidates"] = [
|
||
row for row in store.data.get("drawingCandidates", []) if row.get("drawingAssetId") != drawing_id
|
||
]
|
||
runs = store.data.get("drawingParseRuns", [])
|
||
store.data["drawingParseRuns"] = [
|
||
row for row in runs if row.get("drawingAssetId") != drawing_id
|
||
]
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="drawing.delete", target={"type": "DRAWING", "id": drawing_id},
|
||
power="P1", rationale={
|
||
"fileName": asset.get("filename") or asset.get("name"),
|
||
"projectId": asset.get("projectId"),
|
||
})
|
||
store.save()
|
||
return {"message": f"已清除图纸 {asset.get('filename') or drawing_id}", "deleted": True,
|
||
"drawingId": drawing_id}
|
||
|
||
@app.delete("/api/projects/{project_id}/drawings")
|
||
async def project_drawings_clear(project_id: str) -> dict:
|
||
"""清空项目内全部图纸解析结果与候选(P1,保留审计)。"""
|
||
from server.agent_core.audit import write_audit
|
||
try:
|
||
store = _drawing_project_store(project_id, write=True)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
ids = [
|
||
row.get("id") for row in store.data.get("drawingAssets", [])
|
||
if str(row.get("projectId") or "") == project_id
|
||
]
|
||
if not ids:
|
||
return {"message": "项目内没有可清除的图纸", "deleted": 0}
|
||
id_set = set(ids)
|
||
store.data["drawingAssets"] = [
|
||
row for row in store.data.get("drawingAssets", []) if row.get("id") not in id_set
|
||
]
|
||
store.data["drawingCandidates"] = [
|
||
row for row in store.data.get("drawingCandidates", []) if row.get("drawingAssetId") not in id_set
|
||
]
|
||
store.data["drawingParseRuns"] = [
|
||
row for row in store.data.get("drawingParseRuns", []) if row.get("drawingAssetId") not in id_set
|
||
]
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="drawing.clear.all", target={"type": "PROJECT", "id": project_id},
|
||
power="P1", rationale={"count": len(ids)})
|
||
store.save()
|
||
return {"message": f"已清空 {len(ids)} 张图纸解析结果", "deleted": len(ids)}
|
||
|
||
@app.post("/api/drawings/analyze-text")
|
||
async def drawings_analyze_text(req: DrawingProcessTextRequest) -> dict:
|
||
"""工艺/建造说明文本结构化解析:分段/组立/工序/场地/顺序(P0 只读)。"""
|
||
from server.aps_domain.drawing_process import recognize_process_text
|
||
return recognize_process_text(req.text)
|
||
|
||
@app.post("/api/drawings/{drawing_id}/ai-analyze")
|
||
async def drawing_ai_analyze(drawing_id: str, project_id: str | None = None) -> dict:
|
||
"""AI 精确解析:LLM 结构化提取;未配置/失败时 fail-closed 回退确定性算法。"""
|
||
from server.agent_core.providers import get_provider
|
||
from server.aps_domain.drawing_ai import analyze_drawing_ai
|
||
try:
|
||
asset = _drawing_asset(_drawing_project_store(project_id).data, drawing_id)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
if asset is None:
|
||
raise HTTPException(status_code=404, detail="图纸不存在或已删除")
|
||
parsed = asset.get("parsed") or {}
|
||
return await analyze_drawing_ai(parsed, provider=get_provider())
|
||
|
||
@app.post("/api/drawings/{drawing_id}/stage")
|
||
async def drawing_stage(
|
||
drawing_id: str,
|
||
req: DrawingStageRequest,
|
||
project_id: str | None = None,
|
||
) -> dict:
|
||
try:
|
||
store = _drawing_project_store(project_id, write=True)
|
||
except PermissionError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
asset = _drawing_asset(store.data, drawing_id)
|
||
change = next((x for x in store.data.get("drawingCandidates") or [] if x.get("drawingAssetId") == drawing_id), None)
|
||
if asset is None or change is None: raise HTTPException(status_code=404, detail="图纸或候选不存在")
|
||
from server.state.projects import get_project_store
|
||
stage_session_id = req.sessionId or "web"
|
||
if stage_session_id == "web" and not asset.get("projectId"):
|
||
stage_session_id = f"drawing-{drawing_id}"[:64]
|
||
try:
|
||
get_project_store().ensure_session(stage_session_id, project_id=asset.get("projectId"), title="图纸候选入库")
|
||
except (PermissionError, ValueError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
all_candidates = [*(change.get("materials") or []), *(change.get("bomReferences") or []), *(change.get("routingOperations") or [])]
|
||
selected = [x for x in all_candidates if (x.get("candidateId") or x.get("id")) in set(req.candidateIds)] if req.candidateIds else all_candidates
|
||
params = {"drawingId": drawing_id, "candidateIds": req.candidateIds, "selectedCandidates": selected,
|
||
"changeSetId": change.get("changeSetId"), "sourceSha256": (asset.get("parsed") or {}).get("asset", {}).get("sha256"),
|
||
"executionSupported": True}
|
||
block = harness.stage_confirmation(stage_session_id, "drawing.master.apply", params,
|
||
title="图纸主数据入库确认", summary_lines=[f"共 {len(selected)} 条物料/BOM 引用/工艺候选",
|
||
"BOM 引用将在确认后写入主数据,不直接改写世界状态", "确认后经 P2 门禁落库并全程留痕"])
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="drawing.master.stage", target={"type": "DRAWING", "id": drawing_id}, power="P2",
|
||
rationale={"confirmId": block.props.get("confirmId"), "executionSupported": True})
|
||
asset["status"] = "STAGED"; store.save()
|
||
return {"message": "已生成 P2 确认卡,请在会话中确认后写入主数据",
|
||
"status": "staged", "masterCommitted": False, "confirmId": block.props.get("confirmId"), "stagedCount": len(selected), "block": block.model_dump()}
|
||
|
||
@app.post("/api/multimodal/extract")
|
||
async def multimodal_extract(req: MultimodalExtractRequest) -> dict:
|
||
"""多模态输入 → 结构化候选 + 置信度(只读,不写世界状态)。
|
||
|
||
kind:file_import / text_order(内置 stub 提取器);真实 ASR/VLM 实现
|
||
Extractor 协议后经 ExtractorRegistry.register() 接入,未注册 kind 显式报错不编造。
|
||
阈值默认 0.7(APS_MULTIMODAL_CONFIDENCE_THRESHOLD 或 /api/multimodal/ingest 的 threshold 可覆盖)。
|
||
"""
|
||
from server.aps_domain.multimodal import (
|
||
DEFAULT_CONFIDENCE_THRESHOLD,
|
||
ExtractorNotRegisteredError,
|
||
build_raw,
|
||
default_registry,
|
||
)
|
||
store = get_store()
|
||
try:
|
||
registry = default_registry(world=store.data)
|
||
raw = build_raw(req.kind, text=req.text, filename=req.filename,
|
||
data_base64=req.dataBase64)
|
||
candidates = registry.extract(req.kind, raw)
|
||
except (ExtractorNotRegisteredError, ValueError, TypeError) as exc:
|
||
return {"error": str(exc)}
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="TOOL",
|
||
action="multimodal.extract", target={"type": "MULTIMODAL", "id": req.kind},
|
||
power="P0",
|
||
rationale={"candidateCount": len(candidates),
|
||
"sources": [c.source for c in candidates]})
|
||
store.save()
|
||
return {
|
||
"kind": req.kind,
|
||
"candidates": [c.to_dict() for c in candidates],
|
||
"registeredKinds": registry.list_kinds(),
|
||
"defaultThreshold": DEFAULT_CONFIDENCE_THRESHOLD,
|
||
}
|
||
|
||
@app.post("/api/multimodal/ingest")
|
||
async def multimodal_ingest(req: MultimodalIngestRequest) -> dict:
|
||
"""多模态候选入库门禁:低置信 → P2 确认卡;高置信且字段完整 → 直通导入。
|
||
|
||
低置信确认卡复用既有 P2 动作 import.commit(/api/actions/confirm 通道),
|
||
确认前绝不写世界状态;批准后经 execute_confirmed 写,驳回不写。
|
||
"""
|
||
from server.aps_domain.multimodal import ingest_candidates
|
||
store = get_store()
|
||
try:
|
||
result = ingest_candidates(
|
||
store.data, store.next_id, req.candidates,
|
||
session_id=req.sessionId or "web", actor=req.sessionId or "web",
|
||
threshold=req.threshold,
|
||
)
|
||
except (ValueError, TypeError) as exc:
|
||
return {"error": str(exc)}
|
||
store.save()
|
||
return result
|
||
|
||
# ---------------- 工程图纸 DXF 自动识别(解析 P0 只读;写主数据走 master.* P2) ----------------
|
||
@app.post("/api/dxf/parse")
|
||
async def dxf_parse_upload(file: UploadFile = File(...)) -> dict:
|
||
"""上传 DXF → ParsedDrawing + 物料/BOM/工艺路线候选(只读,不写世界状态)。
|
||
|
||
返回 drawingId 供 /api/dxf/{drawingId}/svg 与 /api/dxf/stage 复用(缓存)。
|
||
"""
|
||
from server.aps_domain.dxf_drawing import (
|
||
drawing_to_master_candidates, parse_dxf_bytes)
|
||
raw = await file.read()
|
||
if not raw:
|
||
return {"error": "上传内容为空"}
|
||
store = get_store()
|
||
try:
|
||
parsed = parse_dxf_bytes(file.filename or "upload.dxf", raw)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor="web", category="TOOL",
|
||
action="dxf.parse", target={"type": "DRAWING", "id": parsed["drawingId"]},
|
||
power="P0",
|
||
rationale={"filename": parsed["filename"],
|
||
"dxfVersion": parsed["dxfVersion"],
|
||
"titleBlockFound": bool(parsed["titleBlock"].get("found"))})
|
||
store.save()
|
||
return {"drawing": parsed, "candidates": drawing_to_master_candidates(parsed)}
|
||
|
||
@app.post("/api/dxf/parse-path")
|
||
async def dxf_parse_path(req: DxfParsePathRequest) -> dict:
|
||
"""本地路径 DXF → ParsedDrawing + 候选(项目工程目录图纸;只读)。"""
|
||
from server.aps_domain.dxf_drawing import (
|
||
drawing_to_master_candidates, parse_dxf)
|
||
path = str(req.path or "").strip()
|
||
if not path:
|
||
return {"error": "缺少 DXF 路径"}
|
||
store = get_store()
|
||
try:
|
||
parsed = parse_dxf(path)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="TOOL",
|
||
action="dxf.parse", target={"type": "DRAWING", "id": parsed["drawingId"]},
|
||
power="P0",
|
||
rationale={"filename": parsed["filename"], "path": parsed["path"],
|
||
"dxfVersion": parsed["dxfVersion"],
|
||
"titleBlockFound": bool(parsed["titleBlock"].get("found"))})
|
||
store.save()
|
||
return {"drawing": parsed, "candidates": drawing_to_master_candidates(parsed)}
|
||
|
||
@app.get("/api/dxf/{drawing_id}/svg")
|
||
async def dxf_svg(drawing_id: str, layers: str | None = None) -> Response:
|
||
"""图纸 SVG 预览(白底;解析缓存复用,未解析/已淘汰显式报错)。
|
||
|
||
layers:逗号分隔图层名(前端图层开关),缺省渲染全部图层。
|
||
"""
|
||
from server.aps_domain.dxf_drawing import dxf_svg_cached
|
||
include = ([part.strip() for part in layers.split(",") if part.strip()]
|
||
if layers else None)
|
||
try:
|
||
svg = dxf_svg_cached(drawing_id, include_layers=include)
|
||
except ValueError as exc:
|
||
return Response(content=str(exc), status_code=404, media_type="text/plain")
|
||
return Response(content=svg, media_type="image/svg+xml")
|
||
|
||
@app.get("/api/dxf/discover")
|
||
async def dxf_discover(dir: str, recursive: bool = True) -> dict:
|
||
"""项目工程目录 DXF 发现(只读扫描;目录不存在显式报错)。"""
|
||
from server.aps_domain.dxf_drawing import discover_dxf_files
|
||
root = str(dir or "").strip()
|
||
if not root:
|
||
return {"error": "缺少工程目录路径(dir)"}
|
||
try:
|
||
return discover_dxf_files(root, recursive=recursive)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/dxf/stage")
|
||
async def dxf_stage(req: DxfStageRequest) -> dict:
|
||
"""DXF 主数据候选 → P2 确认卡(唯一执行路径仍是 /api/actions/confirm)。
|
||
|
||
target=material → master.material.upsert;target=bom → master.bom.upsert;
|
||
target=routing → master.routing.upsert。载荷为人工修订后的候选,
|
||
校验失败(缺编码/名称、产品不存在等)不出卡直接报错(fail closed)。
|
||
"""
|
||
action_by_target = {
|
||
"material": "master.material.upsert",
|
||
"bom": "master.bom.upsert",
|
||
"routing": "master.routing.upsert",
|
||
}
|
||
action = action_by_target.get(str(req.target or "").strip())
|
||
if action is None:
|
||
return {"error": "target 必须是 material / bom / routing"}
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_master_action(store.data, action, req.payload)
|
||
except ValueError as exc: # 校验失败:不出卡,直接回错误
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(req.sessionId or "web", action, req.payload,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action=f"dxf.stage.{req.target}",
|
||
target={"type": "DRAWING",
|
||
"id": req.drawingId or req.payload.get("code")
|
||
or req.payload.get("productId") or "NEW"},
|
||
power="P2",
|
||
rationale={"confirmId": block.props["confirmId"], "action": action,
|
||
"drawingId": req.drawingId})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列(来源图纸 {req.drawingId or '未关联'})。",
|
||
"block": block.model_dump()}
|
||
|
||
# ---------------- 现场完整生产路线 → flex*(P2 确认卡;applyNow 供脚本/联调) ----------------
|
||
@app.post("/api/flex/site/load")
|
||
async def flex_site_load(req: FlexSiteLoadRequest) -> dict:
|
||
"""加载现场「完整生产路线」xlsx,替换柔性演示种子。"""
|
||
from server.aps_domain.kangni_intake import (
|
||
build_flex_bundle, confirmation_for_site_load,
|
||
)
|
||
from server.importers import import_site_excel
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
try:
|
||
bundle = build_flex_bundle(
|
||
req.routePath, req.dataDir,
|
||
include_sibling_orders=req.includeSiblings,
|
||
station_count=req.stationCount,
|
||
)
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
return {"error": str(exc)}
|
||
meta = bundle.get("_meta") or {}
|
||
if req.applyNow:
|
||
meta = import_site_excel(
|
||
store.data, route_path=req.routePath, data_dir=req.dataDir,
|
||
include_sibling_orders=req.includeSiblings, station_count=req.stationCount,
|
||
)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web",
|
||
category="WORLD_WRITE", action="flex.site.load",
|
||
target={"type": "FLEX_SITE", "id": meta.get("primaryOrder")}, power="P2",
|
||
rationale={"applyNow": True, "meta": meta})
|
||
store.save()
|
||
return {"message": f"现场路线已加载:主单 {meta.get('primaryOrder')},"
|
||
f"订单 {meta.get('orderCount')}。", "meta": meta}
|
||
title, lines = confirmation_for_site_load(meta)
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "flex.site.load",
|
||
{"routePath": req.routePath, "dataDir": req.dataDir,
|
||
"includeSiblings": req.includeSiblings, "stationCount": req.stationCount},
|
||
title=title, summary_lines=lines,
|
||
)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="flex.site.load.stage",
|
||
target={"type": "FLEX_SITE", "id": meta.get("primaryOrder")}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump(), "meta": meta}
|
||
|
||
# ---------------- M-B 数据齐备度 + 工时维护 ----------------
|
||
@app.get("/api/readiness")
|
||
async def readiness_report() -> dict:
|
||
"""数据齐备度检查(P0 只读):产品→路线→工时→资源→日历逐项。"""
|
||
from server.aps_domain.readiness import check_readiness
|
||
return check_readiness(get_store().data)
|
||
|
||
@app.get("/api/times")
|
||
async def times_matrix() -> dict:
|
||
"""工时维护视图数据源(P0):产品×工序矩阵 + 来源标记。"""
|
||
from server.aps_domain.readiness import time_matrix
|
||
rows = time_matrix(get_store().data)
|
||
return {"rows": rows,
|
||
"summary": {"total": len(rows),
|
||
"pending": sum(1 for r in rows if r["source"] == "待维护"),
|
||
"inferred": sum(1 for r in rows if r["source"] == "推断")}}
|
||
|
||
@app.post("/api/times/update")
|
||
async def times_update(req: TimeUpdateRequest) -> dict:
|
||
"""工时更新 → P2 确认卡(执行走 /api/actions/confirm)。"""
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
if not req.operationCode or req.stdMin is None or req.stdMin <= 0:
|
||
return {"error": "operationCode 与 stdMin(>0) 必填"}
|
||
title = f"更新工时:{req.productCode or '全部产品'} × {req.operationCode} → {req.stdMin} 分钟/件"
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "flex.time.update",
|
||
{"productCode": req.productCode or "", "operationCode": req.operationCode,
|
||
"stdMin": req.stdMin, "source": req.source or "实测"},
|
||
title=title,
|
||
summary_lines=[f"单件工时 → {req.stdMin} 分钟(来源={req.source or '实测'})",
|
||
"确认后写入柔性工艺路线并同步数据库。"])
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="flex.time.update.stage",
|
||
target={"type": "ROUTING_TIME", "id": f"{req.productCode or '*'}/{req.operationCode}"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"], "stdMin": req.stdMin})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- M-A 项目 / 数据包(多项目主数据管理) ----------------
|
||
@app.get("/api/projects")
|
||
async def projects_list() -> dict:
|
||
"""项目清单(SQLite 主数据按项目隔离;P0)。"""
|
||
try:
|
||
from server.db.sync import get_active_project, list_projects
|
||
return {"projects": list_projects(), "active": get_active_project()}
|
||
except Exception as exc:
|
||
return {"projects": [], "active": None, "error": str(exc)}
|
||
|
||
@app.get("/api/packs")
|
||
async def packs_list() -> dict:
|
||
"""可重放数据包清单(演示厂/行业种子;P0)。"""
|
||
from server.state.packs import list_packs
|
||
return {"packs": list_packs()}
|
||
|
||
# ---------------- M-E RAG 平台:模板库 / Skill 消费 API / 索引管理 ----------------
|
||
@app.get("/api/templates")
|
||
async def templates_list() -> dict:
|
||
"""行业工艺路线模板库(P0):结构化模板 + 关联知识资产。"""
|
||
from server.knowledge.routing_templates import list_templates
|
||
try:
|
||
return {"templates": list_templates()}
|
||
except Exception as exc:
|
||
return {"templates": [], "error": str(exc)}
|
||
|
||
@app.get("/api/templates/recommend")
|
||
async def templates_recommend(q: str, topK: int = 3) -> dict:
|
||
"""按产品名/特征推荐工艺模板(P0;缺路线时向导调用)。"""
|
||
from server.knowledge.routing_templates import recommend_templates
|
||
return {"query": q, "templates": recommend_templates(q, top_k=topK)}
|
||
|
||
|
||
@app.post("/api/templates/apply")
|
||
async def templates_apply(req: TemplateApplyRequest) -> dict:
|
||
"""模板实例化为产品路线 → P2 确认卡。"""
|
||
from server.agent_core.audit import write_audit
|
||
from server.knowledge.routing_templates import get_template
|
||
store = get_store()
|
||
tpl = get_template(req.templateCode)
|
||
if not tpl:
|
||
return {"error": f"未找到工艺模板:{req.templateCode}"}
|
||
title = f"用模板「{tpl['name']}」生成 {req.productCode} 工艺路线"
|
||
lines = [f"模板:{tpl['code']}({tpl['category']},{len(tpl['steps'])} 步)",
|
||
"工时取模板区间中值,来源标记「模板」,可后续实测覆盖。",
|
||
f"知识出处:{tpl.get('assetId') or '内置'}"]
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "routing.template.apply",
|
||
{"templateCode": req.templateCode, "productCode": req.productCode,
|
||
"productName": req.productName},
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=req.actor, category="GATE",
|
||
action="routing.template.apply.stage",
|
||
target={"type": "TEMPLATE", "id": req.templateCode}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"],
|
||
"productCode": req.productCode})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
|
||
@app.post("/api/rag/query")
|
||
async def rag_query(req: RagQueryRequest) -> dict:
|
||
"""RAG 查询 API(P0):供外部算法 Skill 消费知识库,按 manifest ragScopes 鉴权。"""
|
||
import os as _os
|
||
from server.agent_core.skills import get_skills
|
||
from server.knowledge.assets import get_knowledge
|
||
from server.knowledge.retrieval import hybrid_search
|
||
|
||
# 鉴权:master key 或已启用 skill 的 auth
|
||
allowed_kinds: set[str] | None = None
|
||
master = _os.environ.get("APS_RAG_API_KEY") or ""
|
||
caller = "master"
|
||
if master and req.apiKey == master:
|
||
pass # 全量放行
|
||
else:
|
||
skill = next((s for s in get_skills().list()
|
||
if s.get("enabled") and s.get("auth") and s["auth"] == req.apiKey), None)
|
||
if not skill:
|
||
return {"error": "鉴权失败:apiKey 不匹配任何已启用 skill(或设置 APS_RAG_API_KEY)",
|
||
"hits": []}
|
||
caller = skill["skill_id"]
|
||
scopes = skill.get("ragScopes") or []
|
||
if scopes:
|
||
allowed_kinds = set(scopes)
|
||
|
||
units = get_knowledge().iter_search_units()
|
||
if allowed_kinds is not None:
|
||
units = [u for u in units
|
||
if u.get("kind") in allowed_kinds
|
||
or (set(u.get("tags") or []) & allowed_kinds)]
|
||
if req.kinds:
|
||
units = [u for u in units if u.get("kind") in set(req.kinds)]
|
||
if req.tags:
|
||
tagset = set(req.tags)
|
||
units = [u for u in units if set(u.get("tags") or []) & tagset]
|
||
|
||
hits = harness.guard("rag.query", {"caller": caller},
|
||
lambda: hybrid_search(units, req.query, top_k=req.topK))
|
||
# 硬规则:必带出处+版本;未命中诚实返回空
|
||
return {"query": req.query, "caller": caller,
|
||
"hits": [{"assetId": h["assetId"], "title": h.get("title"),
|
||
"kind": h.get("kind"), "version": h.get("version"),
|
||
"score": h.get("score"), "snippet": h.get("snippet"),
|
||
"content": h.get("content"), "chunkId": h.get("chunkId"),
|
||
"heading": h.get("heading"), "page": h.get("page")}
|
||
for h in hits]}
|
||
|
||
@app.get("/api/knowledge/index/status")
|
||
async def knowledge_index_status() -> dict:
|
||
"""向量索引状态(P0):后端可用性 + 已索引条数 vs 检索单元总数。"""
|
||
from server.knowledge.assets import get_knowledge
|
||
from server.knowledge.embedding import get_embedding_provider, get_embedding_store
|
||
prov = get_embedding_provider()
|
||
store_v = get_embedding_store()
|
||
units = get_knowledge().iter_search_units()
|
||
return {"backend": getattr(prov, "backend", None) or ("disabled" if not prov.enabled else "unknown"),
|
||
"enabled": prov.enabled,
|
||
"indexed": len(store_v.vectors), "units": len(units),
|
||
"fallback": "bigram 词面检索(无向量时自动降级)"}
|
||
|
||
@app.post("/api/knowledge/index/rebuild")
|
||
async def knowledge_index_rebuild() -> dict:
|
||
"""重建向量索引(P1:只写 embeddings.json)。"""
|
||
from server.knowledge.assets import get_knowledge
|
||
from server.knowledge.embedding import get_embedding_provider, get_embedding_store, index_units
|
||
prov = get_embedding_provider()
|
||
if not prov.enabled:
|
||
return {"ok": False, "added": 0,
|
||
"message": "无可用嵌入后端(本地模型/API 均未配置),检索走 bigram 降级。"}
|
||
store_v = get_embedding_store()
|
||
store_v.vectors = {}
|
||
added = index_units(get_knowledge().iter_search_units())
|
||
return {"ok": True, "added": added, "message": f"索引重建完成:{added} 条向量。"}
|
||
|
||
# ---------------- 柔性排产(能力池+虚拟产线;P0 读,P1 排产直通) ----------------
|
||
@app.get("/api/flex/world")
|
||
async def flex_world() -> dict:
|
||
"""柔性排产页数据:区域/能力池/设备/模具/订单 + 最新版本虚拟产线与冲突。"""
|
||
from server.aps_domain.flex import flex_overview
|
||
return flex_overview(get_store().data)
|
||
|
||
@app.post("/api/flex/schedule")
|
||
async def flex_schedule(req: FlexScheduleRequest) -> dict:
|
||
"""Run P1 closed-loop flexible scheduling and return honest admission status."""
|
||
|
||
from server.aps_domain.flex import run_flex_schedule
|
||
|
||
store = get_store()
|
||
try:
|
||
result = run_flex_schedule(
|
||
store,
|
||
sort_mode=req.sortMode,
|
||
order_ids=req.orderIds or None,
|
||
actor=req.sessionId or "web",
|
||
window=req.window,
|
||
enforce_teams=req.enforceTeams,
|
||
)
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
solve_status = str(result.get("solveStatus") or "FEASIBLE").upper()
|
||
if solve_status in {"BLOCKED", "REJECTED", "INFEASIBLE", "ERROR"}:
|
||
planning = result.get("planning") or {}
|
||
summary = planning.get("summary") or {}
|
||
blocker_counts = summary.get("blockerCounts") or {}
|
||
blockers = "、".join(
|
||
f"{code}×{count}"
|
||
for code, count in sorted(blocker_counts.items())[:5]
|
||
)
|
||
message = (
|
||
f"闭环排产未生成可执行工单:制造需求 {int(result.get('demandCount') or 0)} 项,"
|
||
f"准入 {int(result.get('admittedDemandCount') or 0)} 项,"
|
||
f"阻断 {int(result.get('unscheduledDemandCount') or 0)} 项。"
|
||
f"{(' 主要阻断:' + blockers) if blockers else ''}"
|
||
)
|
||
else:
|
||
message = (
|
||
f"闭环排产完成({result.get('sortMode') or req.sortMode or 'CLOSED_LOOP'} / "
|
||
f"{result.get('window') or req.window or 'full'}):"
|
||
f"生成 {int(result.get('vlCount') or 0)} 条虚拟产线 / "
|
||
f"{int(result.get('woCount') or 0)} 个工单,"
|
||
f"冲突 {int(result.get('conflictCount') or 0)},"
|
||
f"延后窗口外 {int(result.get('deferredCount') or 0)}。"
|
||
)
|
||
return {"message": message, "result": result}
|
||
|
||
@app.post("/api/flex/reschedule")
|
||
async def flex_reschedule(req: FlexRescheduleRequest) -> dict:
|
||
"""分级重排暂存为确认卡(DY-01 L2/L3/L4 → P2;执行走 /api/actions/confirm)。"""
|
||
from server.aps_domain.flex import preview_reschedule
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
level = (req.level or "L2").upper()
|
||
try:
|
||
title, lines = preview_reschedule(store, level)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
params = {"level": level, "sortMode": req.sortMode}
|
||
block = harness.stage_confirmation(req.sessionId or "web", "flex.reschedule", params,
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="flex.reschedule.stage", target={"type": "FLEX", "id": level},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"], "level": level})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
@app.post("/api/flex/swap")
|
||
async def flex_swap(req: FlexSwapRequest) -> dict:
|
||
"""L1 局部换机(P1):池内备机接手,他单不动。"""
|
||
from server.aps_domain.flex import local_swap_equipment
|
||
store = get_store()
|
||
try:
|
||
result = local_swap_equipment(
|
||
store, equipment_code=req.equipmentCode, wo_id=req.woId,
|
||
target_code=req.targetCode, mark_fault=req.markFault,
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
n = len(result.get("swapped") or [])
|
||
return {"message": f"L1 局部换机完成:{n} 条工单改挂备机"
|
||
+ (f";设备 {result['faulted']} 已标故障" if result.get("faulted") else "")
|
||
+ "。", "result": result}
|
||
|
||
@app.get("/api/flex/gantt")
|
||
async def flex_gantt() -> dict:
|
||
"""柔性甘特视图数据(P0 只读):行=设备(按区域分组),条=柔性工单。"""
|
||
from server.aps_domain.flex import flex_gantt_view
|
||
return flex_gantt_view(get_store().data)
|
||
|
||
@app.post("/api/flex/simulate-due")
|
||
async def flex_simulate_due(req: FlexSimulateRequest) -> dict:
|
||
"""交期承诺模拟(PL-08,P0 只读沙盒):乐观/预计/悲观 + 缺口建议。"""
|
||
from server.aps_domain.flex import simulate_due
|
||
store = get_store()
|
||
result = simulate_due(store, req.productCode, req.quantity, sort_mode=req.sortMode)
|
||
return {"result": result}
|
||
|
||
@app.get("/api/flex/capacity")
|
||
async def flex_capacity() -> dict:
|
||
"""瓶颈产能法分析(PL-07,P0 只读):各能力池日产能 + 限制性瓶颈池。"""
|
||
from server.aps_domain.flex import capacity_analysis
|
||
return capacity_analysis(get_store().data)
|
||
|
||
@app.post("/api/flex/compare")
|
||
async def flex_compare() -> dict:
|
||
"""三模式对比(SC-13,P0 沙盒)。"""
|
||
from server.aps_domain.flex import compare_sort_modes
|
||
return compare_sort_modes(get_store(), compress_due=True)
|
||
|
||
@app.post("/api/flex/rush")
|
||
async def flex_rush(req: FlexRushRequest) -> dict:
|
||
"""紧急插单 + 重排(DY-01,P1)。"""
|
||
from server.aps_domain.flex import insert_rush_order
|
||
from server.agent_core import harness
|
||
store = get_store()
|
||
try:
|
||
harness.guard("flex.rush", {"productCode": req.productCode, "quantity": req.quantity},
|
||
lambda: None)
|
||
result = insert_rush_order(store, req.productCode, req.quantity,
|
||
due_date=req.dueDate, priority=req.priority,
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"急单 {result['order']['orderNo']} 已插入并重排。", "result": result}
|
||
|
||
@app.post("/api/flex/fault")
|
||
async def flex_fault(req: FlexFaultRequest) -> dict:
|
||
"""设备故障/恢复 + 可选重排(DY-01,P1)。"""
|
||
from server.aps_domain.flex import apply_equipment_fault
|
||
from server.agent_core import harness
|
||
store = get_store()
|
||
try:
|
||
harness.guard("flex.fault", {"equipmentCode": req.equipmentCode, "status": req.status},
|
||
lambda: None)
|
||
result = apply_equipment_fault(store, req.equipmentCode, status=req.status,
|
||
reschedule=req.reschedule, actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
eq = result["equipment"]
|
||
level = result.get("responseLevel")
|
||
msg = f"设备 {eq['code']} {eq['beforeStatus']}→{eq['afterStatus']}"
|
||
if level == "L1":
|
||
n = len((result.get("swap") or {}).get("swapped") or [])
|
||
msg += f";已 L1 局部换机 {n} 条。"
|
||
elif result.get("schedule"):
|
||
msg += f";已缩池重排({level or 'L4'})。"
|
||
else:
|
||
msg += "。"
|
||
return {"message": msg, "result": result}
|
||
|
||
@app.post("/api/flex/resource/patch")
|
||
async def flex_resource_patch(req: FlexResourcePatchRequest) -> dict:
|
||
"""柔性资源补丁:设备状态/区域、模具寿命/锁定(EX-10 / MD-07,P1)。"""
|
||
from server.aps_domain.flex import patch_flex_resource
|
||
from server.agent_core import harness
|
||
store = get_store()
|
||
try:
|
||
harness.guard("flex.resource.patch", {"kind": req.kind, "code": req.code},
|
||
lambda: None)
|
||
result = patch_flex_resource(store, req.kind, req.code, req.patch,
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"已更新 {req.kind} {req.code}。", "result": result}
|
||
|
||
@app.get("/api/flex/conflicts")
|
||
async def flex_conflicts(scope: str = "flex") -> dict:
|
||
"""冲突中心(EX-03,P0):最新版本未解决冲突 + 可执行建议。"""
|
||
from server.aps_domain.conflicts import list_conflict_center
|
||
return list_conflict_center(get_store().data, scope=scope)
|
||
|
||
@app.get("/api/attribution")
|
||
async def schedule_attribution(track: str = "flex", versionId: int | None = None) -> dict:
|
||
"""Read-only IIS/conflict attribution bound to one schedule track/version."""
|
||
normalized_track = str(track or "flex").lower()
|
||
if normalized_track not in {"fixed", "flex"}:
|
||
raise HTTPException(status_code=400, detail="track 仅支持 fixed/flex")
|
||
world = get_store().data
|
||
version_key = "flexScheduleVersions" if normalized_track == "flex" else "scheduleVersions"
|
||
versions = world.get(version_key) or []
|
||
resolved_version = versionId
|
||
if resolved_version is None and versions:
|
||
resolved_version = int(versions[-1]["id"])
|
||
if resolved_version is None:
|
||
raise HTTPException(status_code=404, detail="当前轨道还没有可归因的排产版本")
|
||
if resolved_version is not None and not any(
|
||
int(row.get("id")) == int(resolved_version) for row in versions
|
||
):
|
||
raise HTTPException(status_code=404, detail="排产版本不存在或不属于当前轨道")
|
||
from server.aps_domain.attribution import attribution_for_schedule
|
||
|
||
report = attribution_for_schedule(
|
||
world, version_id=resolved_version, track=normalized_track,
|
||
)
|
||
return {
|
||
"schemaVersion": "schedule-attribution.v1",
|
||
"track": normalized_track,
|
||
"versionId": resolved_version,
|
||
**report,
|
||
}
|
||
|
||
@app.post("/api/flex/conflicts/resolve")
|
||
async def flex_conflict_resolve(req: ConflictResolveRequest) -> dict:
|
||
"""冲突一键修复(EX-03):P1 直通 / P2 出确认卡。"""
|
||
from server.aps_domain.conflicts import apply_conflict_fix
|
||
store = get_store()
|
||
try:
|
||
return apply_conflict_fix(
|
||
store, req.conflictId, req.action, params=req.params,
|
||
actor=req.sessionId or "web", session_id=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc), "applied": False}
|
||
|
||
@app.post("/api/flex/adjust/preview")
|
||
async def flex_adjust_preview(req: FlexAdjustRequest) -> dict:
|
||
"""甘特调程预览(EX-04,P0):不改主干。"""
|
||
from server.aps_domain.adjust import preview_flex_adjust
|
||
try:
|
||
return preview_flex_adjust(get_store().data, req.woId, req.newStart)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc), "reasons": [str(exc)], "moves": []}
|
||
|
||
@app.post("/api/flex/adjust/stage")
|
||
async def flex_adjust_stage(req: FlexAdjustRequest) -> dict:
|
||
"""甘特调程暂存确认卡(EX-04,P2)。"""
|
||
from server.aps_domain.adjust import stage_flex_adjust
|
||
store = get_store()
|
||
try:
|
||
return stage_flex_adjust(store, req.woId, req.newStart,
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/schedule/adjust/preview")
|
||
async def schedule_adjust_preview(req: ScheduleAdjustRequest) -> dict:
|
||
"""固定轨甘特调程预览(EX-04,P0)。"""
|
||
from server.aps_domain.adjust import preview_fixed_adjust
|
||
try:
|
||
return preview_fixed_adjust(get_store().data, req.woId, req.newStart)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc), "reasons": [str(exc)], "moves": []}
|
||
|
||
@app.post("/api/schedule/adjust/stage")
|
||
async def schedule_adjust_stage(req: ScheduleAdjustRequest) -> dict:
|
||
"""固定轨甘特调程暂存确认卡(EX-04,P2)。"""
|
||
from server.aps_domain.adjust import stage_fixed_adjust
|
||
store = get_store()
|
||
try:
|
||
return stage_fixed_adjust(store, req.woId, req.newStart,
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/sap/status")
|
||
async def sap_status() -> dict:
|
||
"""SAP 连接状态(MD-05,P0 / Mock 桩)。"""
|
||
from server.aps_domain.sap_sync import sap_connection_status
|
||
return sap_connection_status()
|
||
|
||
@app.get("/api/sap/inbound/preview")
|
||
async def sap_inbound_preview() -> dict:
|
||
"""SAP→APS 入站预览(MD-05,P0)。"""
|
||
from server.aps_domain.sap_sync import preview_inbound
|
||
return preview_inbound(get_store().data)
|
||
|
||
@app.post("/api/sap/inbound/stage")
|
||
async def sap_inbound_stage(req: SapSyncRequest) -> dict:
|
||
"""SAP 入站确认卡(MD-05,P2)。"""
|
||
from server.aps_domain.sap_sync import stage_sap_sync
|
||
return stage_sap_sync(get_store(), "inbound",
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web")
|
||
|
||
@app.get("/api/sap/outbound/preview")
|
||
async def sap_outbound_preview() -> dict:
|
||
"""APS→SAP 出站预览(MD-05,P0)。"""
|
||
from server.aps_domain.sap_sync import preview_outbound
|
||
return preview_outbound(get_store().data)
|
||
|
||
@app.post("/api/sap/outbound/stage")
|
||
async def sap_outbound_stage(req: SapSyncRequest) -> dict:
|
||
"""SAP 出站确认卡(MD-05,P2/P3)。"""
|
||
from server.aps_domain.sap_sync import stage_sap_sync
|
||
return stage_sap_sync(get_store(), "outbound",
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web")
|
||
|
||
@app.post("/api/schedule/publish/stage")
|
||
async def schedule_publish_stage(req: SchedulePublishStageRequest) -> dict:
|
||
"""Stage exact-version publication; execution remains /api/actions/confirm."""
|
||
|
||
from server.aps_domain.workflow import stage_schedule_publish
|
||
|
||
reply = stage_schedule_publish(
|
||
get_store(),
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web",
|
||
track=req.track or "flex",
|
||
version_id=req.versionId,
|
||
)
|
||
blocks = [block.model_dump(mode="json") for block in reply.blocks or []]
|
||
confirm_id = next(
|
||
(
|
||
str((block.get("props") or {}).get("confirmId"))
|
||
for block in blocks
|
||
if block.get("type") == "confirm-card"
|
||
and (block.get("props") or {}).get("confirmId")
|
||
),
|
||
None,
|
||
)
|
||
return {
|
||
"staged": confirm_id is not None,
|
||
"message": reply.text,
|
||
"confirmId": confirm_id,
|
||
"blocks": blocks,
|
||
}
|
||
|
||
|
||
@app.get("/api/mes/status")
|
||
async def mes_status() -> dict:
|
||
"""MES 连接状态(EX-05,P0 / Mock)。"""
|
||
from server.aps_domain.mes import mes_connection_status
|
||
return mes_connection_status()
|
||
|
||
@app.get("/api/mes/dispatch/preview")
|
||
async def mes_dispatch_preview(track: str = "flex") -> dict:
|
||
"""MES 下发预览(EX-05,P0)。"""
|
||
from server.aps_domain.mes import preview_dispatch
|
||
return preview_dispatch(get_store().data, track=track)
|
||
|
||
@app.post("/api/mes/dispatch/stage")
|
||
async def mes_dispatch_stage(req: MesDispatchRequest) -> dict:
|
||
"""MES 下发确认卡(EX-05,P3)。"""
|
||
from server.aps_domain.mes import stage_dispatch
|
||
return stage_dispatch(get_store(), track=req.track or "flex",
|
||
session_id=req.sessionId or "web",
|
||
actor=req.sessionId or "web")
|
||
|
||
@app.get("/api/mes/execution")
|
||
async def mes_execution(track: str = "flex") -> dict:
|
||
"""已下发工单进度(EX-09,P0)。"""
|
||
from server.aps_domain.mes import list_execution
|
||
return list_execution(get_store().data, track=track)
|
||
|
||
@app.post("/api/mes/report")
|
||
async def mes_report(req: MesReportRequest) -> dict:
|
||
"""报工回流(EX-09,P1)。"""
|
||
from server.aps_domain.mes import apply_report
|
||
try:
|
||
return apply_report(
|
||
get_store(), req.woId, track=req.track or "flex",
|
||
progress_pct=req.progressPct, finish=req.finish,
|
||
actor=req.sessionId or "web")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
|
||
# ---------------- WMS 缺料/库存事件(round-38 方向 N:缺料→重排闭环 Mock) ----------------
|
||
@app.post("/api/wms/events")
|
||
async def wms_events_ingest(req: WmsEventRequest) -> dict:
|
||
"""WMS 缺料/库存事件上报(round-38,幂等)。
|
||
|
||
消费 → 库存版本(inventory-version 进证据链)→ 影响半径(沙盒只读)→
|
||
Explore 方案卡(P2 确认,L4 重排)。重复/乱序事件不重复触发。
|
||
"""
|
||
from server.aps_domain.wms_events import consume_shortage_event
|
||
store = get_store()
|
||
try:
|
||
return consume_shortage_event(
|
||
store, req.model_dump(exclude_none=True),
|
||
session_id=req.sessionId or "wms", actor=req.sessionId or "wms")
|
||
except (ValueError, PermissionError) as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/wms/replay")
|
||
async def wms_replay(limit: int | None = None, order: str = "seq") -> dict:
|
||
"""WMS 事件流重放(round-38,P0):seq=逻辑顺序 / arrival=到达顺序。"""
|
||
from server.aps_domain.wms_events import replay_events
|
||
return replay_events(limit=limit, order=order)
|
||
|
||
@app.get("/api/wms/inventory")
|
||
async def wms_inventory() -> dict:
|
||
"""WMS 库存版本/台账投影(round-38,P0 只读)。"""
|
||
from server.aps_domain.wms_events import inventory_projection
|
||
return inventory_projection(get_store().data)
|
||
|
||
|
||
# ---------------- Saga/补偿编排(round-39 方向 P:跨内部世界状态与外部系统的 Saga/补偿) ----------------
|
||
@app.get("/api/saga")
|
||
async def saga_list(limit: int | None = None) -> dict:
|
||
"""Saga 列表(P0 只读):含中间态 steps 明细(status/attempts/idemKey/compensation)。"""
|
||
from server.aps_domain.saga import get_coordinator
|
||
coord = get_coordinator(get_store())
|
||
sagas = coord.list(limit=limit)
|
||
return {"count": len(sagas), "sagas": sagas}
|
||
|
||
@app.get("/api/saga/{saga_id}")
|
||
async def saga_detail(saga_id: str) -> dict:
|
||
"""Saga 详情(P0 只读):状态机 + 每步中间态/重试/补偿/审计引用。"""
|
||
from server.aps_domain.saga import get_coordinator
|
||
record = get_coordinator(get_store()).get(saga_id)
|
||
if record is None:
|
||
return {"error": f"Saga {saga_id} 不存在"}
|
||
return record
|
||
|
||
@app.post("/api/saga/{saga_id}/retry")
|
||
async def saga_retry(saga_id: str) -> dict:
|
||
"""Saga 重试(P2):回滚失败步副作用后重跑;人工接管后可恢复自动化。"""
|
||
from server.aps_domain.saga import get_coordinator
|
||
try:
|
||
return get_coordinator(get_store()).retry(saga_id)
|
||
except KeyError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/saga/{saga_id}/compensate")
|
||
async def saga_compensate(saga_id: str, req: SagaActionRequest) -> dict:
|
||
"""Saga 手动补偿(P2 人工动作):触发补偿链。"""
|
||
from server.aps_domain.saga import get_coordinator
|
||
try:
|
||
return get_coordinator(get_store()).compensate(
|
||
saga_id, reason=req.reason or "manual")
|
||
except KeyError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.post("/api/saga/{saga_id}/takeover")
|
||
async def saga_takeover(saga_id: str, req: SagaActionRequest) -> dict:
|
||
"""Saga 人工接管(P2 人工动作):自动化让位,标记 MANUAL_TAKEOVER。"""
|
||
from server.aps_domain.saga import get_coordinator
|
||
try:
|
||
return get_coordinator(get_store()).takeover(
|
||
saga_id, reason=req.reason or "manual")
|
||
except KeyError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
@app.get("/api/analytics/kpi")
|
||
async def analytics_kpi() -> dict:
|
||
"""KPI 仪表盘(EX-08,P0)。"""
|
||
from server.aps_domain.analytics import build_kpi_dashboard
|
||
return build_kpi_dashboard(get_store().data)
|
||
|
||
@app.get("/api/analytics/utilization")
|
||
async def analytics_utilization(track: str = "flex", days: int = 7) -> dict:
|
||
"""资源利用率增强(EX-06,P0):设备热力 + 班组并发。"""
|
||
from server.aps_domain.analytics import build_utilization_report
|
||
return build_utilization_report(get_store().data, track=track, days=days)
|
||
|
||
@app.get("/api/analytics/compare")
|
||
async def analytics_compare(track: str = "both") -> dict:
|
||
"""方案对比表(EX-07,P0 沙盒)。"""
|
||
from server.aps_domain.analytics import build_compare_table
|
||
return build_compare_table(get_store().data, track=track)
|
||
|
||
@app.get("/api/guidance")
|
||
async def guidance_next(context: str | None = None) -> dict:
|
||
"""主动引导下一步建议(AG-07,P0)。"""
|
||
from server.aps_domain.guidance import build_guidance
|
||
return build_guidance(get_store().data, context=context)
|
||
|
||
@app.get("/api/params")
|
||
async def schedule_params() -> dict:
|
||
"""排产参数投影(OR-02,P0)。"""
|
||
from server.aps_domain.params import get_schedule_params
|
||
return get_schedule_params(get_store().data)
|
||
|
||
@app.get("/api/scenario/sensitivity")
|
||
async def scenario_sensitivity(strategy: str = "COMPREHENSIVE") -> dict:
|
||
"""SC-06 敏感性分析 Tornado(P1 沙盒,只读主干)。"""
|
||
from server.aps_domain.sensitivity import run_sensitivity
|
||
return run_sensitivity(get_store().data, strategy=strategy or "COMPREHENSIVE")
|
||
|
||
@app.post("/api/params/stage")
|
||
async def params_stage(req: ParamsStageRequest) -> dict:
|
||
"""排产参数更新暂存确认卡(P2 → /api/actions/confirm)。"""
|
||
from server.aps_domain.params import confirmation_for_params_update
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_params_update(store.data, req.payload)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "params.update", req.payload,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="params.update.stage",
|
||
target={"type": "SCHEDULE_PARAMS", "id": "scheduleParams"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
# ---------------- 参数优化闭环 / 实验门禁(矩阵 87,/api/params/* 区段) ----------------
|
||
@app.get("/api/params/experiments")
|
||
async def params_experiments() -> dict:
|
||
"""参数实验列表/状态(P0 只读)。"""
|
||
from server.agent_core.param_opt import ParameterOptimizer
|
||
return {"experiments": ParameterOptimizer().history(get_store().data)}
|
||
|
||
@app.post("/api/params/optimize")
|
||
async def params_optimize(req: ParamsOptimizeRequest) -> dict:
|
||
"""触发参数优化闭环(P1):split→回放→候选,产出 GRAY 灰度实验。
|
||
|
||
灰度实验仅受限作用域生效,不写主干 scheduleParams;
|
||
升级须经 P2 确认卡(/api/params/experiments/{id}/promote → /api/actions/confirm)。
|
||
"""
|
||
from server.agent_core.audit import write_audit
|
||
from server.agent_core.param_opt import DEFAULT_SEED, DEFAULT_TOLERANCE, ParameterOptimizer
|
||
store = get_store()
|
||
strategy = req.strategy or "COMPREHENSIVE"
|
||
try:
|
||
out = ParameterOptimizer(
|
||
seed=int(req.seed) if req.seed is not None else DEFAULT_SEED,
|
||
tolerance=req.tolerance if req.tolerance is not None else DEFAULT_TOLERANCE,
|
||
strategy=strategy,
|
||
).propose(store.data, candidates=req.candidates, scope=req.scope,
|
||
strategy=strategy)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web",
|
||
category="ALGO_RUN", action="param.optimize",
|
||
target={"type": "PARAM_OPTIMIZE", "id": "pipeline"},
|
||
power="P1",
|
||
rationale={"strategy": strategy, "scope": req.scope or {},
|
||
"summary": out["summary"]})
|
||
store.save()
|
||
return {"message": f"参数优化闭环完成:产出 {out['summary']['gray']} 个灰度实验。",
|
||
"summary": out["summary"], "results": out["results"]}
|
||
|
||
@app.post("/api/params/experiments/{experiment_id}/promote")
|
||
async def params_experiment_promote(experiment_id: str, req: ParamsPromoteRequest) -> dict:
|
||
"""参数实验升级(P2 门禁):GRAY→ACTIVE/FULL 出确认卡,执行走 /api/actions/confirm。
|
||
|
||
确认卡包含实验摘要/候选参数/回放 KPI;批准后执行验证集
|
||
回放(退化自动回滚);拒绝则留在 GRAY。
|
||
"""
|
||
from server.agent_core.audit import write_audit
|
||
from server.agent_core.param_opt import ParameterOptimizer, confirmation_for_param_promote
|
||
store = get_store()
|
||
rec = ParameterOptimizer().get_experiment(store.data, experiment_id)
|
||
if rec is None:
|
||
return {"error": f"未找到参数实验:{experiment_id}"}
|
||
if rec.get("status") != "GRAY":
|
||
return {"error": f"实验状态为 {rec.get('status')},只能从 GRAY 升级"}
|
||
title, lines = confirmation_for_param_promote(rec)
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "param.experiment.promote",
|
||
{"experimentId": experiment_id, "candidate": rec.get("candidate"),
|
||
"scope": rec.get("scope"), "strategy": rec.get("strategy")},
|
||
title=title, summary_lines=lines)
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="param.promote.stage",
|
||
target={"type": "PARAM_EXPERIMENT", "id": experiment_id},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
@app.post("/api/params/experiments/{experiment_id}/rollback")
|
||
async def params_experiment_rollback(experiment_id: str, req: ParamsRollbackRequest) -> dict:
|
||
"""参数实验手动回滚(P1):直接执行,恢复上一参数版本。"""
|
||
from server.agent_core.audit import write_audit
|
||
from server.agent_core.param_opt import ParameterOptimizer
|
||
store = get_store()
|
||
try:
|
||
rec = ParameterOptimizer().rollback(
|
||
store.data, experiment_id, reason=req.reason or "manual")
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web",
|
||
category="WORLD_WRITE", action="param.rollback",
|
||
target={"type": "PARAM_EXPERIMENT", "id": experiment_id},
|
||
power="P1", rationale={"reason": rec.get("rollbackReason")})
|
||
store.save()
|
||
return {"message": f"参数实验 {experiment_id} 已回滚({rec.get('rollbackReason')})。",
|
||
"experiment": rec}
|
||
|
||
@app.post("/api/params/observations")
|
||
async def params_observation(req: ParamObservationRequest) -> dict:
|
||
"""线上生产 KPI 观测回调:观测数据落盘 + 连续劣化自动回滚(矩阵 87)。
|
||
|
||
每次 schedule.run 后的生产 KPI 采样 POST 到此接口;对每个 ACTIVE/FULL
|
||
实验对比生效前基线,连续劣化达到阈值自动回滚并审计
|
||
param.experiment.auto_rolled_back。
|
||
"""
|
||
from server.agent_core.param_opt import ParameterOptimizer
|
||
store = get_store()
|
||
try:
|
||
out = ParameterOptimizer().record_observation(
|
||
store.data, req.kpi, source=req.source or "production",
|
||
actor=req.sessionId or "web",
|
||
max_consecutive_degraded=req.maxConsecutiveDegraded,
|
||
next_id=store.next_id)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
store.save()
|
||
if out["autoRolledBack"]:
|
||
return {"message": "观测已记录,劣化实验已自动回滚:"
|
||
+ "、".join(out["autoRolledBack"]),
|
||
"observation": out["observation"],
|
||
"autoRolledBack": out["autoRolledBack"]}
|
||
return {"message": "观测已记录。", "observation": out["observation"],
|
||
"autoRolledBack": []}
|
||
|
||
@app.get("/api/constraints")
|
||
async def constraints_profile() -> dict:
|
||
"""约束配置剖面(SC-04,P0)。"""
|
||
from server.aps_domain.constraints import get_constraint_profile
|
||
return get_constraint_profile(get_store().data)
|
||
|
||
@app.post("/api/constraints/stage")
|
||
async def constraints_stage(req: ConstraintStageRequest) -> dict:
|
||
"""约束配置更新暂存确认卡(P2 → /api/actions/confirm)。"""
|
||
from server.aps_domain.constraints import confirmation_for_profile_save
|
||
store = get_store()
|
||
try:
|
||
title, lines = confirmation_for_profile_save(store.data, req.payload)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "constraint.profile.save", req.payload,
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="GATE",
|
||
action="constraint.profile.save.stage",
|
||
target={"type": "CONSTRAINT_PROFILE", "id": "default"},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
@app.get("/api/sop/compilable")
|
||
async def sop_compilable() -> dict:
|
||
"""可编译 SOP 列表(IND-02,P0)。"""
|
||
from server.aps_domain.sop_rules import list_compilable_sops
|
||
from server.knowledge import get_knowledge
|
||
items = list_compilable_sops(get_knowledge().assets)
|
||
return {"items": items, "rulePacks": get_store().data.get("rulePacks") or []}
|
||
|
||
@app.get("/api/sop/compile")
|
||
async def sop_compile(assetId: str | None = None) -> dict:
|
||
"""SOP 编译预览(IND-02,P0)。"""
|
||
from server.aps_domain.sop_rules import compile_sop_by_asset
|
||
from server.knowledge import get_knowledge
|
||
return compile_sop_by_asset(get_knowledge().assets, assetId)
|
||
|
||
@app.post("/api/sop/stage")
|
||
async def sop_stage(req: SopStageRequest) -> dict:
|
||
"""SOP 规则包应用暂存确认卡(P2)。"""
|
||
from server.aps_domain.sop_rules import compile_sop_by_asset, confirmation_for_sop_apply
|
||
from server.knowledge import get_knowledge
|
||
store = get_store()
|
||
session_id = req.sessionId or "web"
|
||
pack = req.pack
|
||
if pack is None:
|
||
compiled = compile_sop_by_asset(get_knowledge().assets, req.assetId)
|
||
if not compiled.get("ok"):
|
||
return {"error": compiled.get("error") or "编译失败"}
|
||
pack = compiled["pack"]
|
||
title, lines = confirmation_for_sop_apply(pack)
|
||
block = harness.stage_confirmation(
|
||
session_id, "sop.apply", {"assetId": req.assetId, "pack": pack},
|
||
title=title, summary_lines=lines)
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor=session_id, category="GATE",
|
||
action="sop.apply.stage",
|
||
target={"type": "RULE_PACK", "id": pack.get("packId")},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump(), "pack": pack}
|
||
|
||
# ---------------- 时间线导轨(§4.4 中观层数据源) ----------------
|
||
@app.get("/api/timeline")
|
||
async def timeline() -> dict:
|
||
"""检查点 + 排产版本的时间线(前端导轨渲染;P0 只读)。"""
|
||
world = get_store().data # 世界状态
|
||
versions = [{ # 版本投影(轻量字段)
|
||
"id": v["id"], "versionNo": v["versionNo"], "status": v["status"],
|
||
"createdAt": v["createdAt"], "conflictCount": v["conflictCount"],
|
||
"engineType": v["engineType"],
|
||
} for v in world["scheduleVersions"]]
|
||
return {"checkpoints": get_checkpoints().list_meta(), "versions": versions} # 两类锚点
|
||
|
||
# ---------------- 检查点世界侧投影(矩阵 54 行:逐字段 diff 数据源) ----------------
|
||
@app.get("/api/checkpoints")
|
||
async def checkpoints_projection_batch(pairIds: str | None = None,
|
||
branchIds: str | None = None,
|
||
sessionId: str | None = None) -> dict:
|
||
"""批量取检查点世界侧投影(Sagan #2:分支 checkpointId → 投影,供「分支 vs 分支」diff)。
|
||
|
||
pairIds:逗号分隔的检查点 pairId;branchIds:逗号分隔的分支 id(需 sessionId 定位分支树);
|
||
两者可混用;响应键为分支 id(branchIds 解析)或 pairId;缺失项列入 missing,不整体报错。
|
||
"""
|
||
ck = get_checkpoints()
|
||
projections: dict[str, dict] = {}
|
||
missing: list[str] = []
|
||
items: list[tuple[str, str]] = [] # (响应键, pairId)
|
||
if pairIds:
|
||
items += [(p, p) for p in (x.strip() for x in pairIds.split(",")) if p]
|
||
if branchIds:
|
||
bs = None # 惰性取分支树(需 sessionId 定位)
|
||
if sessionId:
|
||
from server.state.branches import get_branches
|
||
store = get_store()
|
||
bs = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default"))
|
||
for bid in (x.strip() for x in branchIds.split(",")):
|
||
if not bid:
|
||
continue
|
||
cpid = None
|
||
if bs is not None:
|
||
try:
|
||
br = next((b for b in bs.tree(sessionId)["branches"]
|
||
if b.get("id") == bid), None)
|
||
cpid = (br or {}).get("checkpointId")
|
||
except Exception:
|
||
cpid = None
|
||
if not cpid:
|
||
missing.append(bid)
|
||
continue
|
||
items.append((bid, cpid))
|
||
for key, pid in items:
|
||
pair = ck.get(pid)
|
||
if pair is None:
|
||
missing.append(key)
|
||
continue
|
||
projections[key] = {"meta": ck.meta(pair), "worldProjection": ck.world_projection(pid)}
|
||
return {"projections": projections, "missing": missing}
|
||
|
||
@app.get("/api/checkpoints/{pair_id}")
|
||
async def checkpoint_world(pair_id: str) -> dict:
|
||
"""检查点世界侧完整投影:{meta, worldProjection}(P0 只读;矩阵 54 行)。
|
||
|
||
投影在快照世界深拷贝上计算,不触达当前世界;缺失 pair 返回 404 + 中文提示。
|
||
"""
|
||
ck = get_checkpoints()
|
||
pair = ck.get(pair_id)
|
||
if pair is None:
|
||
raise HTTPException(status_code=404, detail=f"检查点不存在:{pair_id}")
|
||
return {"meta": ck.meta(pair), "worldProjection": ck.world_projection(pair_id)}
|
||
|
||
# ---------------- AG-08 项目 / 会话工作区 ----------------
|
||
# ---------------- 多智能体编排 Mesh(P0 读 / P1 编排) ----------------
|
||
@app.get("/api/mesh/state")
|
||
async def mesh_state() -> dict:
|
||
"""多智能体编排快照:智能体/Goal/任务/消息/看门狗(P0 只读,顺带驱动一次看门狗节拍)。"""
|
||
from server.agent_core.mesh import get_mesh
|
||
mesh = get_mesh()
|
||
try:
|
||
mesh.watchdog_tick()
|
||
except Exception: # noqa: BLE001 - 读路径不让看门狗异常冒泡
|
||
pass
|
||
return mesh.snapshot()
|
||
|
||
@app.post("/api/mesh/agents")
|
||
async def mesh_agent_create(req: MeshAgentCreateRequest) -> dict:
|
||
"""按需创建智能体(P1):声明角色与能力边界。"""
|
||
from server.agent_core.mesh import ROLE_TEMPLATES, get_mesh
|
||
try:
|
||
agent = harness.guard(
|
||
"mesh.agent.create", {"name": req.name, "role": req.role},
|
||
lambda: get_mesh().create_agent(
|
||
req.name, req.role, req.capabilities, created_by=req.sessionId or "web"),
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"已创建智能体 {agent['name']}({ROLE_TEMPLATES.get(agent['role'], {}).get('label', agent['role'])})",
|
||
"agent": agent, "state": get_mesh().snapshot()}
|
||
|
||
@app.post("/api/mesh/goals")
|
||
async def mesh_goal_create(req: MeshGoalCreateRequest) -> dict:
|
||
"""挂 Goal(P1):按模板或自定义任务清单创建,进入看门狗监控。"""
|
||
from server.agent_core.mesh import get_mesh
|
||
store = get_store()
|
||
try:
|
||
goal = harness.guard(
|
||
"mesh.goal.create", {"title": req.title, "template": req.template},
|
||
lambda: get_mesh().create_goal(
|
||
req.title, template=req.template, tasks=req.tasks,
|
||
created_by=req.sessionId or "web",
|
||
world_key=getattr(store, "world_key", "default"),
|
||
session_id=req.sessionId,
|
||
project_id=req.projectId or getattr(store, "world_key", "default"),
|
||
source_sha256=(
|
||
req.sourceSha256
|
||
or (store.data.get("planningContext") or {}).get("sourceSha256")
|
||
),
|
||
run_id=req.runId),
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"Goal「{goal['title']}」已挂载({len(goal['tasks'])} 个任务)",
|
||
"goal": goal, "state": get_mesh().snapshot()}
|
||
|
||
@app.post("/api/mesh/goals/{goal_id}/dispatch")
|
||
async def mesh_goal_dispatch(goal_id: str, req: MeshDispatchRequest | None = None) -> dict:
|
||
"""分发执行 Goal(P1):任务按依赖分发给有能力的智能体,缺人自动新建。"""
|
||
from server.agent_core.mesh import dispatch_goal
|
||
try:
|
||
result = harness.guard(
|
||
"mesh.dispatch", {"goalId": goal_id},
|
||
lambda: dispatch_goal(goal_id, session_id=(req.sessionId if req else None)),
|
||
)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
return result
|
||
|
||
@app.post("/api/mesh/messages")
|
||
async def mesh_message_post(req: MeshMessageRequest) -> dict:
|
||
"""智能体消息互通(P1):点对点或广播;被点名的智能体回报自身状态。"""
|
||
from server.agent_core.mesh import WATCHDOG_AGENT_ID, get_mesh
|
||
mesh = get_mesh()
|
||
if req.fromAgentId != WATCHDOG_AGENT_ID and not any(
|
||
a.get("id") == req.fromAgentId for a in mesh.data["agents"]):
|
||
raise HTTPException(status_code=404, detail=f"发送方智能体不存在:{req.fromAgentId}")
|
||
msg = mesh.post_message(from_agent_id=req.fromAgentId, to_agent_id=req.toAgentId,
|
||
goal_id=req.goalId, kind=req.kind or "INFO", text=req.text)
|
||
reply = None
|
||
if req.toAgentId:
|
||
target = next((a for a in mesh.data["agents"] if a.get("id") == req.toAgentId), None)
|
||
if target is not None:
|
||
reply = mesh.post_message(
|
||
from_agent_id=target["id"], to_agent_id=req.fromAgentId,
|
||
goal_id=req.goalId, kind="RESPONSE",
|
||
text=(f"收到。我是 {target['name']}({target.get('label')}),状态 {target['status']},"
|
||
f"能力边界:{'、'.join(target.get('capabilities') or [])}。"))
|
||
return {"message": msg, "reply": reply, "state": mesh.snapshot()}
|
||
|
||
@app.post("/api/mesh/watchdog")
|
||
async def mesh_watchdog_config(req: MeshWatchdogRequest) -> dict:
|
||
"""看门狗配置/手动节拍(P1):超期阈值、重试次数、启停。"""
|
||
from server.agent_core.mesh import get_mesh
|
||
mesh = get_mesh()
|
||
wd = mesh.configure_watchdog(stall_timeout_sec=req.stallTimeoutSec,
|
||
max_retries=req.maxRetries, enabled=req.enabled)
|
||
tick = mesh.watchdog_tick()
|
||
return {"watchdog": wd, "tick": tick, "state": mesh.snapshot()}
|
||
|
||
@app.post("/api/mesh/reset")
|
||
async def mesh_reset() -> dict:
|
||
"""多智能体编排恢复起始状态(P1):清空 Goal/消息,只留系统看门狗。"""
|
||
from server.agent_core.mesh import get_mesh
|
||
state = harness.guard("mesh.reset", {}, lambda: get_mesh().reset())
|
||
return {"message": "多智能体编排已恢复起始状态", "state": state}
|
||
|
||
# ---------------- AG-08 项目 / 会话工作区(续) ----------------
|
||
# POST /api/projects/{id}/files/upload 浏览器上传本地工程数据文件(P1)
|
||
@app.get("/api/workspace")
|
||
async def workspace_get() -> dict:
|
||
"""工作区快照(P0):项目/会话/文件/消息 + worldKey 绑定。"""
|
||
from server.state.projects import get_project_store
|
||
return get_project_store().snapshot(include_messages=True)
|
||
|
||
@app.put("/api/workspace")
|
||
async def workspace_put(req: WorkspaceReplaceRequest) -> dict:
|
||
"""同步当前用户工作区选择;服务端忽略客户端提供的租户和 worldKey。"""
|
||
from server.state.projects import get_project_store
|
||
from server.state.store import switch_store
|
||
store = get_project_store()
|
||
payload = {
|
||
"projects": req.projects, "sessions": req.sessions, "files": req.files,
|
||
"messages": req.messages, "activeProjectId": req.activeProjectId,
|
||
"activeSessionId": req.activeSessionId,
|
||
}
|
||
try:
|
||
snap = harness.guard("workspace.replace", {"activeSessionId": req.activeSessionId},
|
||
lambda: store.replace_workspace(payload))
|
||
wk = snap.get("worldKey") or "default"
|
||
switch_store(wk)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
return {"message": "工作区已同步", "workspace": snap, "worldKey": wk}
|
||
|
||
@app.post("/api/projects")
|
||
async def project_create(req: ProjectCreateRequest) -> dict:
|
||
"""新建项目(P1)。"""
|
||
from server.state.projects import get_project_store
|
||
ps = get_project_store()
|
||
try:
|
||
result = harness.guard("project.create", {"name": req.name},
|
||
lambda: ps.create_project(
|
||
req.name,
|
||
scope_label=req.scopeLabel or "未设定作用域",
|
||
work_dir=req.workDir,
|
||
project_id=req.id,
|
||
))
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
return {"message": f"已创建项目 {result['project']['name']}", **result,
|
||
"workspace": ps.snapshot(include_messages=False)}
|
||
|
||
@app.delete("/api/projects/{project_id}")
|
||
async def project_delete(project_id: str) -> dict:
|
||
"""删除项目元数据(P1);明确不删除世界。"""
|
||
from server.state.projects import get_project_store
|
||
ps = get_project_store()
|
||
world = get_store()
|
||
order_n = len(world.data.get("salesOrders") or [])
|
||
try:
|
||
result = harness.guard("project.delete", {"projectId": project_id},
|
||
lambda: ps.delete_project(project_id))
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
result["worldOrdersUnchanged"] = len(world.data.get("salesOrders") or []) == order_n
|
||
return {"message": f"已删除项目 {project_id}(世界未动)", **result,
|
||
"workspace": ps.snapshot(include_messages=False)}
|
||
|
||
@app.post("/api/sessions")
|
||
async def session_create(req: SessionCreateRequest) -> dict:
|
||
"""新建会话(P1)。"""
|
||
from server.state.projects import get_project_store
|
||
ps = get_project_store()
|
||
try:
|
||
result = harness.guard(
|
||
"session.create", {"projectId": req.projectId, "title": req.title},
|
||
lambda: ps.create_session(req.projectId, req.title or "新话题", session_id=req.id))
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
return {"message": "已新建话题", **result, "workspace": ps.snapshot(include_messages=False)}
|
||
|
||
@app.get("/api/sessions/{session_id}/messages")
|
||
async def session_messages_get(session_id: str) -> dict:
|
||
"""读取会话消息(P0)。"""
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
messages = get_project_store().get_messages(session_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
return {"sessionId": session_id, "messages": messages}
|
||
|
||
@app.put("/api/sessions/{session_id}/messages")
|
||
async def session_messages_put(session_id: str, req: MessagesReplaceRequest) -> dict:
|
||
"""整包替换会话消息(P1)。"""
|
||
from server.state.projects import get_project_store
|
||
ps = get_project_store()
|
||
try:
|
||
result = harness.guard(
|
||
"message.replace", {"sessionId": session_id, "count": len(req.messages)},
|
||
lambda: ps.replace_messages(session_id, req.messages))
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
return {"message": f"已同步 {result['count']} 条消息", **result}
|
||
|
||
@app.post("/api/sessions/{session_id}/messages")
|
||
async def session_messages_append(session_id: str, req: MessageAppendRequest) -> dict:
|
||
"""追加一条消息(P1)。"""
|
||
from server.state.projects import get_project_store
|
||
ps = get_project_store()
|
||
try:
|
||
result = harness.guard(
|
||
"message.append", {"sessionId": session_id},
|
||
lambda: ps.append_message(session_id, req.message))
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
return {"message": "消息已追加", **result}
|
||
|
||
# ---------------- 分支树(矩阵 51 行:项目->会话->分支三级树) ----------------
|
||
@app.get("/api/sessions/{session_id}/branches")
|
||
async def session_branches_get(session_id: str) -> dict:
|
||
"""读取会话分支树(P0 只读)。"""
|
||
from server.state.branches import BranchError, get_branches
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
try:
|
||
tree = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default")).tree(session_id)
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=404, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
return {"sessionId": session_id, "tree": tree}
|
||
|
||
@app.post("/api/sessions/{session_id}/branches")
|
||
async def session_branches_fork(session_id: str, req: BranchCreateRequest) -> dict:
|
||
"""从当前活动分支派生新分支(P1:只建分支,不写世界)。"""
|
||
from server.state.branches import BranchError, get_branches
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
try:
|
||
branch = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default")).fork(
|
||
session_id, req.name or "新分支", checkpoint_id=req.checkpointId)
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="branch.fork", target={"type": "BRANCH", "id": branch["id"],
|
||
"sessionId": session_id},
|
||
power="P1", rationale={"name": branch["name"], "parentId": branch["parentId"]})
|
||
store.save()
|
||
return {"message": f"已从活动分支派生出「{branch['name']}」", "branch": branch}
|
||
|
||
@app.patch("/api/sessions/{session_id}/branches/{branch_id}")
|
||
async def session_branches_rename(session_id: str, branch_id: str, req: RenameRequest) -> dict:
|
||
"""重命名分支(P1)。"""
|
||
from server.state.branches import BranchError, get_branches
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
try:
|
||
branch = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default")).rename(
|
||
session_id, branch_id, req.name or "")
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
return {"message": f"分支已重命名为「{branch['name']}」", "branch": branch}
|
||
|
||
@app.post("/api/sessions/{session_id}/branches/{branch_id}/switch")
|
||
async def session_branches_switch(session_id: str, branch_id: str) -> dict:
|
||
"""切换活动分支(P1→P2):若目标分支锚定 checkpoint,自动成对恢复世界+对话侧。
|
||
|
||
矩阵 52 行:任意分支切换后对话和世界逐字段一致(P1 安全探索验收);
|
||
无锚点则仅切换 active_node,不触碰世界。
|
||
"""
|
||
from server.state.branches import BranchError, get_branches
|
||
from server.state.checkpoints import get_checkpoints
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
try:
|
||
branch = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default")).switch(session_id, branch_id)
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
restored = False
|
||
plan_view = None
|
||
checkpoint_id = branch.get("checkpointId")
|
||
if checkpoint_id:
|
||
pair = get_checkpoints().get(checkpoint_id)
|
||
if pair is not None:
|
||
store.restore(pair["world"]) # 成对恢复世界侧
|
||
try: # 成对恢复对话侧(矩阵 52 行)
|
||
from server.aps_domain.workflow import _restore_conversation_side
|
||
_restore_conversation_side(pair.get("conversationSide"))
|
||
except Exception:
|
||
pass
|
||
restored = True
|
||
store.save()
|
||
# 矩阵 52 行:plan 版本随分支切换回退(无归档/无锚定则保持既有行为)
|
||
try:
|
||
from server.agent_core.plan_runtime import get_plan_store
|
||
side = pair.get("conversationSide")
|
||
archived = side.get("plans") if isinstance(side, dict) else None
|
||
plan_view = get_plan_store().restore_for_branch(
|
||
branch_id, checkpoint_id=checkpoint_id, archived=archived)
|
||
except Exception: # noqa: BLE001 - plan 视图恢复尽力而为,不阻断切换
|
||
plan_view = None
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="branch.switch", target={"type": "BRANCH", "id": branch_id,
|
||
"sessionId": session_id},
|
||
power="P1", rationale={"checkpointId": checkpoint_id, "restored": restored,
|
||
"planView": plan_view})
|
||
store.save()
|
||
return {"message": f"已切换到分支「{branch['name']}」"
|
||
+ ("(已恢复该分支锚定的检查点状态)" if restored else ""),
|
||
"branch": branch, "restored": restored, "checkpointId": checkpoint_id,
|
||
"planView": plan_view}
|
||
|
||
@app.post("/api/sessions/{session_id}/branches/{branch_id}/discard")
|
||
async def session_branches_discard(session_id: str, branch_id: str) -> dict:
|
||
"""丢弃分支(P1:标记 DISCARDED 保留审计,不删历史)。"""
|
||
from server.state.branches import BranchError, get_branches
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
try:
|
||
branch = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default")).discard(session_id, branch_id)
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="branch.discard", target={"type": "BRANCH", "id": branch_id,
|
||
"sessionId": session_id},
|
||
power="P1", rationale={"name": branch["name"]})
|
||
store.save()
|
||
return {"message": f"分支「{branch['name']}」已丢弃", "branch": branch}
|
||
|
||
@app.post("/api/sessions/{session_id}/branches/{source_id}/merge")
|
||
async def session_branches_merge(session_id: str, source_id: str, req: BranchMergeRequest) -> dict:
|
||
"""合并分支(P1:source 并入 target,target 保持活动;附世界差异摘要,矩阵 51 行)。
|
||
|
||
合并语义不变(source 标 MERGED、target 锚定 source 的 checkpoint);
|
||
响应增强 diff 字段:基于两分支锚定 checkpoint 的世界侧差异(缺失时用当前世界),
|
||
结构稳定、可 JSON、可进前端展示与审计。
|
||
"""
|
||
from server.state.branches import BranchError, compute_world_diff, get_branches
|
||
from server.state.checkpoints import get_checkpoints
|
||
from server.state.store import get_store
|
||
store = get_store()
|
||
branches = get_branches(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default"))
|
||
# 合并前捕获两分支锚定 checkpoint(merge 后 target 会改写为 source 的锚点)
|
||
pre_tree = branches.tree(session_id)["branches"]
|
||
source_branch = next((b for b in pre_tree if b.get("id") == source_id), {})
|
||
target_branch = next((b for b in pre_tree if b.get("id") == req.targetId), {})
|
||
source_ckpt = source_branch.get("checkpointId")
|
||
target_ckpt = target_branch.get("checkpointId")
|
||
try:
|
||
result = branches.merge(session_id, source_id, req.targetId)
|
||
except BranchError as exc:
|
||
raise HTTPException(status_code=409, detail={"code": exc.code, "message": str(exc)}) from exc
|
||
# 世界差异:source/target 锚定 checkpoint 的世界侧;缺失时回退当前世界
|
||
ck = get_checkpoints()
|
||
def _world_for(cpid: str | None) -> dict:
|
||
if cpid:
|
||
pair = ck.get(cpid)
|
||
if pair is not None and isinstance(pair.get("world"), dict):
|
||
return pair["world"]
|
||
return store.data
|
||
diff = compute_world_diff(
|
||
_world_for(source_ckpt),
|
||
_world_for(target_ckpt),
|
||
source_label=source_branch.get("name") or source_id,
|
||
target_label=target_branch.get("name") or req.targetId,
|
||
)
|
||
diff["source"]["checkpointId"] = source_ckpt
|
||
diff["target"]["checkpointId"] = target_ckpt
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(store.data, store.next_id, actor="planner", category="GATE",
|
||
action="branch.merge", target={"type": "BRANCH", "id": source_id,
|
||
"sessionId": session_id},
|
||
power="P1", rationale={"targetId": req.targetId, "diff": diff})
|
||
store.save()
|
||
return {"message": "分支已合并", **result, "diff": diff}
|
||
|
||
@app.patch("/api/projects/{project_id}")
|
||
async def project_rename(project_id: str, req: RenameRequest) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().rename_project(project_id, req.name or "")
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.patch("/api/sessions/{session_id}")
|
||
async def session_rename(session_id: str, req: RenameRequest) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().rename_session(
|
||
session_id, req.title or "", only_if_untitled=req.onlyIfUntitled,
|
||
)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.delete("/api/sessions/{session_id}")
|
||
async def session_delete(session_id: str) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().delete_session(session_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
def _project_upload_name(filename: str) -> str:
|
||
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||
return name[:160]
|
||
|
||
@app.post("/api/projects/{project_id}/files/upload")
|
||
async def project_files_upload(project_id: str, files: list[UploadFile] = File(...)) -> dict:
|
||
"""浏览器上传本地工程数据文件(P1):写入项目目录并登记文件。
|
||
|
||
项目没有 workDir 时自动落到服务端项目数据目录,目录分析/文件夹排产可直接读取。
|
||
"""
|
||
from server.aps_home import data_dir
|
||
from server.state.projects import get_project_store
|
||
|
||
uploads = [f for f in files if (f.filename or "").strip()]
|
||
if not uploads:
|
||
raise HTTPException(status_code=400, detail="没有选择文件")
|
||
bad = [f.filename for f in uploads
|
||
if Path(f.filename or "").suffix.lower() not in PROJECT_UPLOAD_EXTS]
|
||
if bad:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"仅支持 .xlsx/.xlsm/.csv/.txt/.sql/.dxf/.pdf:{', '.join(str(x) for x in bad)}",
|
||
)
|
||
|
||
ps = get_project_store()
|
||
snap = ps.snapshot(include_messages=False)
|
||
project = next((p for p in snap.get("projects") or [] if p.get("id") == project_id), None)
|
||
if project is None:
|
||
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
|
||
|
||
work = (project.get("workDir") or "").strip()
|
||
if not work:
|
||
work = str(data_dir() / "project-files" / project_id)
|
||
ps.set_work_dir(project_id, work)
|
||
try:
|
||
Path(work).mkdir(parents=True, exist_ok=True)
|
||
except OSError as exc:
|
||
raise HTTPException(status_code=400, detail=f"工程目录不可访问:{exc}") from exc
|
||
|
||
existing = {f["name"]: f["id"] for f in (snap.get("files") or [])
|
||
if f.get("projectId") == project_id}
|
||
saved: list[str] = []
|
||
errors: list[dict[str, str]] = []
|
||
for upload in uploads:
|
||
name = _project_upload_name(upload.filename or "upload")
|
||
if not name or name in {".", ".."}:
|
||
errors.append({"name": upload.filename or "", "error": "文件名无效"})
|
||
continue
|
||
target = Path(work) / name
|
||
staged = target.with_name(f".{target.name}.{uuid.uuid4().hex}.upload")
|
||
backup = target.with_name(f".{target.name}.{uuid.uuid4().hex}.backup")
|
||
written = 0
|
||
created_id: str | None = None
|
||
original_moved = False
|
||
replacement_done = False
|
||
with _PROJECT_UPLOAD_LOCK:
|
||
try:
|
||
with staged.open("xb") as handle:
|
||
while chunk := upload.file.read(1024 * 1024):
|
||
written += len(chunk)
|
||
if written > PROJECT_UPLOAD_MAX_BYTES:
|
||
raise ValueError("文件超过 100MB 上限")
|
||
handle.write(chunk)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
|
||
created = ps.create_file(project_id, name, "import", "浏览器上传")
|
||
created_id = str((created.get("file") or {}).get("id") or "") or None
|
||
if created_id is None:
|
||
raise RuntimeError("文件元数据登记未返回标识")
|
||
|
||
if target.exists():
|
||
os.replace(target, backup)
|
||
original_moved = True
|
||
os.replace(staged, target)
|
||
replacement_done = True
|
||
|
||
old_id = existing.get(name)
|
||
if old_id:
|
||
ps.delete_file(old_id)
|
||
existing[name] = created_id
|
||
try:
|
||
backup.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
saved.append(name)
|
||
except Exception as exc: # noqa: BLE001 - 单文件失败不影响其余文件
|
||
staged.unlink(missing_ok=True)
|
||
compensation_errors: list[str] = []
|
||
try:
|
||
if original_moved and backup.exists():
|
||
os.replace(backup, target)
|
||
elif replacement_done:
|
||
target.unlink(missing_ok=True)
|
||
except OSError as rollback_exc:
|
||
compensation_errors.append(f"文件回滚失败: {rollback_exc}")
|
||
if created_id:
|
||
try:
|
||
ps.delete_file(created_id)
|
||
except Exception as rollback_exc: # noqa: BLE001
|
||
compensation_errors.append(f"元数据回滚失败: {rollback_exc}")
|
||
detail = str(exc)
|
||
if compensation_errors:
|
||
detail = f"{detail};{';'.join(compensation_errors)}"
|
||
errors.append({"name": name, "error": detail})
|
||
|
||
workspace = ps.snapshot(include_messages=False)
|
||
public_workspace = {
|
||
**workspace,
|
||
"projects": [
|
||
{**row, "workDir": None}
|
||
for row in workspace.get("projects") or []
|
||
],
|
||
}
|
||
return {
|
||
"message": f"已上传 {len(saved)} 个文件到项目目录",
|
||
"saved": saved,
|
||
"errors": errors,
|
||
"workDir": None,
|
||
"workDirConfigured": True,
|
||
"workspace": public_workspace,
|
||
}
|
||
|
||
@app.post("/api/project-files")
|
||
async def project_file_create(req: ProjectFileRequest) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().create_file(
|
||
req.projectId, req.name, req.kind, req.note, file_id=req.id,
|
||
)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.delete("/api/project-files/{file_id}")
|
||
async def project_file_delete(file_id: str) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().delete_file(file_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.get("/api/projects/{project_id}/members")
|
||
async def project_members(project_id: str) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
members = get_project_store().list_members(project_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
identity = get_identity(required=True)
|
||
try:
|
||
directory = await get_auth_provider().search_users("", identity)
|
||
except AuthError:
|
||
directory = []
|
||
names = {int(row["id"]): row for row in directory}
|
||
names[identity.user_id] = {
|
||
"id": identity.user_id,
|
||
"username": identity.username,
|
||
"fullname": identity.fullname,
|
||
}
|
||
for member in members:
|
||
user = names.get(int(member["userId"]), {})
|
||
member["username"] = user.get("username") or f"user-{member['userId']}"
|
||
member["fullname"] = user.get("fullname") or member["username"]
|
||
member["mobile"] = user.get("mobile") or ""
|
||
return {"projectId": project_id, "members": members}
|
||
|
||
async def ensure_tenant_user(user_id: int) -> None:
|
||
identity = get_identity(required=True)
|
||
users = await get_auth_provider().search_users("", identity)
|
||
if not any(int(row.get("id") or 0) == user_id and row.get("tenantUuid") == identity.tenant_uuid for row in users):
|
||
raise HTTPException(status_code=400, detail="只能邀请当前租户内的有效用户")
|
||
|
||
@app.post("/api/projects/{project_id}/members")
|
||
async def project_member_add(project_id: str, req: ProjectMemberRequest) -> dict:
|
||
await ensure_tenant_user(req.userId)
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().add_member(project_id, req.userId, req.role)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.patch("/api/projects/{project_id}/members/{user_id}")
|
||
async def project_member_update(project_id: str, user_id: int, req: ProjectMemberRequest) -> dict:
|
||
if req.userId != user_id:
|
||
raise HTTPException(status_code=400, detail="成员标识不一致")
|
||
await ensure_tenant_user(user_id)
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().update_member(project_id, user_id, req.role)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
@app.delete("/api/projects/{project_id}/members/{user_id}")
|
||
async def project_member_remove(project_id: str, user_id: int) -> dict:
|
||
from server.state.projects import get_project_store
|
||
try:
|
||
return get_project_store().remove_member(project_id, user_id)
|
||
except (ValueError, PermissionError) as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
# ---------------- 采用沙盒方案(P1:正式排产) ----------------
|
||
@app.post("/api/actions/scenario/apply")
|
||
async def scenario_apply(req: ScenarioApplyRequest) -> dict:
|
||
"""把沙盒对比中选中的策略落为正式排产(引擎确定性保证与卡片一致)。"""
|
||
store = get_store() # 世界状态
|
||
intent = IntentResult(intent="schedule.run", # 构造等价的排产意图(复用同一执行路径)
|
||
params={"strategy": req.strategy, "engine": req.engine},
|
||
confidence=1.0, source="LLM")
|
||
# 统一工具运行时:直连端点同门禁(矩阵 111 行)——已登记放行 + TOOL 审计,未登记拒绝
|
||
from server.agent_core.tool_runtime import run_tool_async
|
||
reply = await run_tool_async(store, req.sessionId or "web", intent) # 校验放行后走标准工作流(含审计落盘)
|
||
# 偏好信号:采用比试排权重更高(明确的方案选择 §8.3)
|
||
from server.knowledge import get_preferences # 局部导入(避免循环)
|
||
from server.knowledge.preferences import extract_features # 矩阵 66 特征规则输入
|
||
get_preferences().record(req.strategy, source="scenario.apply", actor=req.sessionId or "web",
|
||
project_id=getattr(store, "world_key", None),
|
||
features=extract_features(store.data))
|
||
return {"message": reply.text, "refresh": True} # 前端刷新世界与时间线
|
||
|
||
# ---------------- 确认卡回传(P2/P3 唯一执行通道) ----------------
|
||
def _resume_mesh_goal_if_needed(confirm_id: str, approve: bool, actor: str) -> None:
|
||
"""Approval persistence must succeed even when Mesh recovery is unavailable."""
|
||
try:
|
||
from server.agent_core.mesh import resume_goal_after_confirmation
|
||
resume_goal_after_confirmation(confirm_id, approve=approve, actor=actor)
|
||
except Exception: # noqa: BLE001 - Mesh recovery is secondary to the approval result
|
||
logger.exception("mesh goal recovery failed for confirmation %s", confirm_id)
|
||
|
||
def _confirm_one(
|
||
store: Any,
|
||
confirm_id: str,
|
||
approve: bool,
|
||
note: str | None,
|
||
actor: str,
|
||
) -> str:
|
||
from server.agent_core.audit import write_audit
|
||
|
||
pending = harness.pending_confirmation(confirm_id)
|
||
if pending is None or pending.get("action") != "automation.gear.escalate":
|
||
return execute_confirmed(
|
||
store, confirm_id, approve, actor=actor, note=note,
|
||
)
|
||
|
||
from server.agent_core.automation import GateRequiredError
|
||
|
||
gate = _get_automation_runtime().executor.gate
|
||
try:
|
||
decision = gate.approve(confirm_id, approve=approve, note=note)
|
||
except PermissionError as exc:
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="GATE",
|
||
action="automation.gear.escalate.envelope.denied",
|
||
target={"type": "AUTOMATION_RULE", "id": (pending.get("params") or {}).get("ruleId")},
|
||
power="P3",
|
||
rationale={"confirmId": confirm_id, "reason": str(exc)},
|
||
result="DENIED",
|
||
)
|
||
store.save()
|
||
return f"自动化升权确认信封校验未通过:{exc}"
|
||
if decision is None:
|
||
return "该自动化升权确认卡已失效(可能已处理过)。"
|
||
|
||
params = decision.get("params") or {}
|
||
evidence_refs = list(decision.get("evidenceRefs") or [])
|
||
before_snapshot = decision.get("beforeSnapshot")
|
||
if not approve:
|
||
result = "DENIED"
|
||
action = "automation.gear.escalate.reject"
|
||
message = "已驳回自动化升权,规则仍按原档位运行。"
|
||
elif decision.get("approvalDenied") and decision.get("separationRequired"):
|
||
result = "DENIED"
|
||
action = "automation.gear.escalate.sod_denied"
|
||
message = "G4 二次审批必须由另一名用户完成;当前批准未执行。"
|
||
elif decision.get("needsSecondConfirm"):
|
||
result = "PENDING_SECOND_CONFIRM"
|
||
action = "automation.gear.escalate.confirm.first"
|
||
message = "第一重 G4 升权确认已记录,请由另一名审批人完成二次确认。"
|
||
else:
|
||
try:
|
||
grant = gate.grant_from_decision(
|
||
confirm_id,
|
||
decision,
|
||
rule_id=str(params.get("ruleId") or ""),
|
||
params=params,
|
||
)
|
||
except GateRequiredError as exc:
|
||
result = "DENIED"
|
||
action = "automation.gear.escalate.grant.denied"
|
||
message = f"自动化升权授权失败:{exc}"
|
||
else:
|
||
result = "SUCCESS"
|
||
action = "automation.gear.escalate.granted"
|
||
message = (
|
||
f"规则 {grant.rule_id} 已获 {grant.target_gear.code} 受控授权;"
|
||
"后续执行仍逐次校验批准的规则与业务载荷。"
|
||
)
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="GATE",
|
||
action=action,
|
||
target={"type": "AUTOMATION_RULE", "id": params.get("ruleId")},
|
||
power="P3",
|
||
rationale={
|
||
"confirmId": confirm_id,
|
||
"gear": params.get("gear"),
|
||
"authorizationDigest": params.get("authorizationDigest"),
|
||
"approvalStep": decision.get("approvalStep"),
|
||
},
|
||
result=result,
|
||
before_snapshot=str(before_snapshot) if before_snapshot else None,
|
||
evidence_refs=evidence_refs,
|
||
)
|
||
store.save()
|
||
return message
|
||
|
||
@app.post("/api/actions/confirm")
|
||
async def confirm(req: ConfirmRequest) -> dict:
|
||
"""批准/驳回一条 P2/P3 动作(门禁放行后的执行入口,§3.3)。"""
|
||
from server.auth.context import get_identity
|
||
from server.aps_domain.planning_intake import PlanningIntakeRejected
|
||
|
||
identity = get_identity(required=True)
|
||
store = get_store() # 世界状态
|
||
existed = harness.is_confirmation_pending(req.confirmId)
|
||
try:
|
||
message = _confirm_one(
|
||
store, req.confirmId, req.approve, req.note, identity.username,
|
||
)
|
||
except PlanningIntakeRejected as exc:
|
||
_resume_mesh_goal_if_needed(
|
||
req.confirmId, req.approve, identity.username,
|
||
)
|
||
return {"message": f"资料未采用:{exc}。请重新检查资料后发起确认。",
|
||
"refresh": False, "secondConfirmRequired": False,
|
||
"errorCode": "INTAKE_REVIEW_REQUIRED", "confirmationHandled": True,
|
||
"recoveryCommand": "分析一下数据文件"}
|
||
second = req.approve and harness.is_confirmation_pending(req.confirmId)
|
||
if not existed:
|
||
return {"message": "这张确认卡已经处理或失效,请重新检查当前资料后继续。", "refresh": False,
|
||
"secondConfirmRequired": False, "confirmationHandled": True,
|
||
"errorCode": "CONFIRMATION_EXPIRED", "recoveryCommand": "分析一下数据文件"}
|
||
if not second:
|
||
_resume_mesh_goal_if_needed(
|
||
req.confirmId, req.approve, identity.username,
|
||
)
|
||
return {"message": message, "refresh": req.approve and existed and not second,
|
||
"secondConfirmRequired": second, "confirmationHandled": not second}
|
||
|
||
@app.post("/api/actions/confirm-batch")
|
||
async def confirm_batch(req: ConfirmBatchRequest) -> dict:
|
||
"""批量批准/驳回多条确认卡(复用单条执行语义,逐条隔离失败)。"""
|
||
from server.auth.context import get_identity
|
||
identity = get_identity(required=True)
|
||
store = get_store()
|
||
results: list[dict] = []
|
||
for confirm_id in req.confirmIds or []:
|
||
if not harness.is_confirmation_pending(confirm_id):
|
||
results.append({ # 令牌不存在/已失效 → 明确失败
|
||
"confirmId": confirm_id,
|
||
"ok": False,
|
||
"message": "确认卡不存在或已失效",
|
||
"secondConfirmRequired": False,
|
||
"refresh": False,
|
||
})
|
||
continue
|
||
try:
|
||
existed = True
|
||
message = _confirm_one(
|
||
store, confirm_id, req.approve, req.note, identity.username,
|
||
)
|
||
second = req.approve and harness.is_confirmation_pending(confirm_id)
|
||
if not second:
|
||
_resume_mesh_goal_if_needed(
|
||
confirm_id, req.approve, identity.username,
|
||
)
|
||
results.append({
|
||
"confirmId": confirm_id,
|
||
"ok": True,
|
||
"message": message,
|
||
"secondConfirmRequired": second,
|
||
"refresh": req.approve and existed and not second,
|
||
})
|
||
except Exception as exc: # 逐条隔离:失败不阻断其余
|
||
results.append({
|
||
"confirmId": confirm_id,
|
||
"ok": False,
|
||
"message": f"处理失败:{exc}",
|
||
"secondConfirmRequired": False,
|
||
"refresh": False,
|
||
})
|
||
return {"results": results, "ok": any(r["ok"] for r in results),
|
||
"refresh": any(r["refresh"] for r in results)}
|
||
|
||
@app.post("/api/actions/transfer")
|
||
async def transfer(req: TransferRequest) -> dict:
|
||
"""转派待确认项给指定用户(P2 单重审批;P3 拒绝保双人分离)。"""
|
||
from server.auth.context import get_identity
|
||
identity = get_identity(required=True)
|
||
pending = harness.transfer_confirmation(req.confirmId, req.toUserId)
|
||
if pending is None:
|
||
return {"ok": False, "message": "转派失败:确认卡不存在/已失效/P3 不可转派或无权操作",
|
||
"refresh": False}
|
||
return {"ok": True, "message": f"已转派给用户 {req.toUserId}", "refresh": True}
|
||
|
||
# ---------------- 治理可视化(§6.10 门禁管理台 / 重生中心 数据源) ----------------
|
||
@app.get("/api/gov/pending")
|
||
async def gov_pending() -> dict:
|
||
"""门禁管理台·待审批队列(聊天确认卡与此为同一数据的两个视图)。"""
|
||
return {"pending": harness.list_pending()} # 待确认动作投影
|
||
|
||
@app.get("/api/gov/approval-history")
|
||
async def gov_approval_history(limit: int = 100) -> dict:
|
||
"""门禁管理台·审批决策历史(按当前租户/项目作用域过滤)。"""
|
||
return {"history": harness.list_approval_history(limit)}
|
||
|
||
@app.get("/api/gov/audit")
|
||
async def gov_audit(limit: int = 50) -> dict:
|
||
"""门禁管理台·审计链(最近 N 条 + 全链校验 + 独立介质锚定状态,§3.6)。"""
|
||
from server.agent_core.audit_ledger import AnchorLedger
|
||
from server.agent_core.audit_mirror import AuditMirror
|
||
store = get_store()
|
||
tenant = getattr(store, "tenant_uuid", "platform") or "platform"
|
||
world_key = getattr(store, "world_key", "default") or "default"
|
||
# 独立介质镜像优先:启用且非空时以镜像为事件源(业务进程改 world 无法抹掉镜像)
|
||
mirror_events = AuditMirror(tenant, world_key).read_events()
|
||
events = mirror_events if mirror_events else store.data.get("auditEvents", [])
|
||
ledger = AnchorLedger(tenant, world_key)
|
||
chain = verify_audit_chain(events) # 哈希链完整性校验
|
||
anchor = ledger.status(events) # 独立介质锚定状态
|
||
source = "mirror" if mirror_events else "world"
|
||
from server.agent_core.audit_alerts import build_alerts
|
||
alerts = build_alerts(chain, anchor, source) # 审计完整性告警
|
||
try: # 告警通知渠道(矩阵 104 行:不阻断读取)
|
||
from server.agent_core.audit_notify import notify_alerts
|
||
notify_alerts(alerts)
|
||
except Exception:
|
||
pass
|
||
from server.agent_core.evidence import verify_audit_trace # 矩阵 114:trace 断链检测
|
||
trace = verify_audit_trace(events) # 重算链哈希逐条比对
|
||
return {"events": events[-limit:], # 最近 N 条(新在后)
|
||
"chain": chain,
|
||
"anchor": anchor,
|
||
"source": source,
|
||
"alerts": alerts,
|
||
"traceAlerts": trace["broken"], # 断链明细(无断链时为空数组)
|
||
"traceChecked": trace["checked"],
|
||
"traceOk": trace["ok"]}
|
||
|
||
@app.post("/api/gov/audit/events")
|
||
async def gov_audit_events(request: Request) -> dict:
|
||
"""桌面端/登录态审计事件上报(矩阵 103 收口)。
|
||
|
||
请求体契约(字段稳定,格式见下;手工校验以保持显式 4xx):
|
||
category: str(1..64) 默认 UPGRADE;action: str(1..128) 必填(updater 的 event 映射为 action);
|
||
power: P0-P3 默认 P0;actor/rationale/target/timestamp/result 可选;idempotencyKey 可选。
|
||
鉴权(端点内显式判定;该路径已豁免 AuthenticationMiddleware 的 cookie 鉴权):
|
||
- desktop 模式:SidecarIdentityApp nonce 门禁即身份(updater 带 nonce 即可),本端点
|
||
再对 nonce 做一次二次校验(缺失/不匹配 → 403);
|
||
- web 模式:要求登录身份(cookie/Bearer,经 auth/license provider;与其他 gov 端点一致),
|
||
未登录 → 401。
|
||
写入:复用 write_audit 追加入审计链(链式哈希 + 独立介质镜像),随后 store.save()。
|
||
幂等:携带 idempotencyKey 时按 fingerprint 命中既有事件则直接返回(不重复落链)。
|
||
"""
|
||
import hashlib
|
||
import hmac
|
||
|
||
def _bad(code: str, message: str):
|
||
raise HTTPException(status_code=400, detail={"code": code, "message": message})
|
||
|
||
try:
|
||
raw = await request.json()
|
||
except Exception:
|
||
_bad("INVALID_JSON", "请求体必须是 JSON 对象")
|
||
if not isinstance(raw, dict):
|
||
_bad("INVALID_BODY", "请求体必须是 JSON 对象")
|
||
|
||
category = raw.get("category", "UPGRADE")
|
||
action = raw.get("action")
|
||
actor = raw.get("actor")
|
||
power = raw.get("power", "P0")
|
||
rationale = raw.get("rationale") or {}
|
||
target = raw.get("target") or {}
|
||
timestamp = raw.get("timestamp")
|
||
result = raw.get("result", "SUCCESS")
|
||
idempotency_key = raw.get("idempotencyKey")
|
||
|
||
if not isinstance(category, str) or not 1 <= len(category) <= 64:
|
||
_bad("INVALID_CATEGORY", "category 必须为非空字符串(≤64)")
|
||
if not isinstance(action, str) or not 1 <= len(action) <= 128:
|
||
_bad("INVALID_ACTION", "action 必须为非空字符串(≤128)")
|
||
if actor is not None and (not isinstance(actor, str) or not 1 <= len(actor) <= 128):
|
||
_bad("INVALID_ACTOR", "actor 必须为字符串(≤128)")
|
||
if not isinstance(power, str) or power not in ("P0", "P1", "P2", "P3"):
|
||
_bad("INVALID_POWER", "power 必须是 P0-P3 之一")
|
||
if not isinstance(rationale, dict) or not isinstance(target, dict):
|
||
_bad("INVALID_OBJECT", "rationale/target 必须是 JSON 对象")
|
||
if timestamp is not None and (not isinstance(timestamp, str) or len(timestamp) > 64):
|
||
_bad("INVALID_TIMESTAMP", "timestamp 必须为字符串(≤64)")
|
||
if not isinstance(result, str) or len(result) > 32:
|
||
_bad("INVALID_RESULT", "result 必须为字符串(≤32)")
|
||
if idempotency_key is not None and (not isinstance(idempotency_key, str) or len(idempotency_key) > 128):
|
||
_bad("INVALID_IDEMPOTENCY_KEY", "idempotencyKey 必须为字符串(≤128)")
|
||
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
expected_nonce = (os.environ.get("APS_SIDECAR_NONCE") or "").strip().lower()
|
||
if expected_nonce: # desktop 模式:nonce 门禁即身份
|
||
supplied = (request.headers.get("x-aps-sidecar-nonce") or "").strip().lower()
|
||
if not hmac.compare_digest(supplied, expected_nonce):
|
||
raise HTTPException(status_code=403,
|
||
detail={"code": "SIDECAR_NONCE_REQUIRED",
|
||
"message": "sidecar nonce 缺失或不匹配"})
|
||
ingest_actor = actor or "desktop-updater" # 本机可信通道(updater 进程)
|
||
else: # web 模式:要求登录身份
|
||
try:
|
||
if is_desktop_request(request):
|
||
identity = await get_license_provider().authenticate(request)
|
||
else:
|
||
identity = await get_auth_provider().authenticate(request)
|
||
except AuthError as exc:
|
||
raise HTTPException(status_code=exc.status_code,
|
||
detail={"code": exc.code, "message": exc.message}) from exc
|
||
ingest_actor = identity.username
|
||
|
||
events = store.data.setdefault("auditEvents", [])
|
||
fingerprint = None
|
||
if idempotency_key:
|
||
fingerprint = hashlib.sha256(
|
||
f"audit-ingest:{idempotency_key}".encode("utf-8")).hexdigest()
|
||
for existing in events:
|
||
if (existing.get("rationale") or {}).get("idempotencyFingerprint") == fingerprint:
|
||
return {"ok": True, "duplicate": True, "eventId": existing.get("id"),
|
||
"event": existing}
|
||
rationale = dict(rationale)
|
||
if timestamp:
|
||
rationale["clientTimestamp"] = timestamp # 客户端时间只归档,不写链时间
|
||
if fingerprint:
|
||
rationale["idempotencyKey"] = idempotency_key
|
||
rationale["idempotencyFingerprint"] = fingerprint
|
||
target = target if target else {"type": category}
|
||
event = write_audit(
|
||
store.data, store.next_id,
|
||
actor=ingest_actor,
|
||
category=category,
|
||
action=action,
|
||
target=target,
|
||
power=power,
|
||
rationale=rationale,
|
||
result=result,
|
||
)
|
||
store.save()
|
||
return {"ok": True, "duplicate": False, "eventId": event.get("id"), "event": event}
|
||
|
||
@app.get("/api/gov/trace/verify")
|
||
async def gov_trace_verify() -> dict:
|
||
"""全量校验 ALGO_RUN 可追溯链(矩阵 114:断链明细 + 汇总,无断链时 broken 为空数组)。"""
|
||
from server.agent_core.audit_mirror import AuditMirror
|
||
from server.agent_core.evidence import verify_audit_trace
|
||
store = get_store()
|
||
tenant = getattr(store, "tenant_uuid", "platform") or "platform"
|
||
world_key = getattr(store, "world_key", "default") or "default"
|
||
mirror_events = AuditMirror(tenant, world_key).read_events()
|
||
events = mirror_events if mirror_events else store.data.get("auditEvents", [])
|
||
result = verify_audit_trace(events)
|
||
return {"ok": result["ok"], "checked": result["checked"],
|
||
"broken": result["broken"],
|
||
"summary": {"ok": result["ok"], "checked": result["checked"],
|
||
"broken": len(result["broken"])}}
|
||
|
||
@app.get("/api/gov/audit/export")
|
||
async def gov_audit_export(format: str = "json") -> dict:
|
||
"""合规导出审计事件(脱敏;json/csv),含链与锚定摘要。"""
|
||
from server.agent_core.audit_export import export_events
|
||
from server.agent_core.audit_ledger import AnchorLedger
|
||
from server.agent_core.audit_mirror import AuditMirror
|
||
store = get_store()
|
||
tenant = getattr(store, "tenant_uuid", "platform") or "platform"
|
||
world_key = getattr(store, "world_key", "default") or "default"
|
||
mirror_events = AuditMirror(tenant, world_key).read_events()
|
||
events = mirror_events if mirror_events else store.data.get("auditEvents", [])
|
||
if format not in ("json", "csv"):
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=400, detail="format must be json or csv")
|
||
body = export_events(events, fmt=format)
|
||
return {"ok": True, "format": format, "count": len(events),
|
||
"chain": verify_audit_chain(events),
|
||
"anchor": AnchorLedger(tenant, world_key).status(events),
|
||
"exportedAt": __import__("datetime").datetime.now().isoformat(),
|
||
"body": body}
|
||
|
||
@app.post("/api/gov/audit/anchor")
|
||
async def gov_audit_anchor() -> dict:
|
||
"""显式锚定当前审计事件到独立账本(append-only,§3.6)。"""
|
||
from server.agent_core.audit_ledger import AnchorLedger
|
||
store = get_store()
|
||
events = store.data.get("auditEvents", [])
|
||
ledger = AnchorLedger(getattr(store, "tenant_uuid", "platform"),
|
||
getattr(store, "world_key", "default"))
|
||
record = ledger.append(events, reason="manual")
|
||
return {"anchored": True, "record": record, "status": ledger.status(events)}
|
||
|
||
@app.get("/api/gov/archive/verify")
|
||
async def gov_archive_verify() -> dict:
|
||
"""WORM 归档完整性验证(P0 只读):逐文件 sha256 + merkle root 校验,
|
||
返回 summary + tampered/missing 明细(矩阵 104/112/116 的 WORM 剩余项;
|
||
物理 WORM 介质(光盘/WORM 盘)为外部验收,本端点为逻辑 WORM 语义 + 篡改检测)。"""
|
||
from server.agent_core.audit_ledger import AnchorLedger
|
||
store = get_store()
|
||
ledger = AnchorLedger(getattr(store, "tenant_uuid", "platform") or "platform",
|
||
getattr(store, "world_key", "default") or "default")
|
||
return ledger.verify_archive()
|
||
|
||
@app.post("/api/gov/archive/run")
|
||
async def gov_archive_run(keep_last: int | None = None) -> dict:
|
||
"""触发归档清理 + 生成/更新 WORM 密封清单(P1;矩阵 116 WORM/加密剩余项)。"""
|
||
from server.agent_core.audit_ledger import AnchorLedger
|
||
store = get_store()
|
||
ledger = AnchorLedger(getattr(store, "tenant_uuid", "platform") or "platform",
|
||
getattr(store, "world_key", "default") or "default")
|
||
try:
|
||
return ledger.archive_old(keep_last)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
@app.get("/api/gov/modules")
|
||
async def gov_modules() -> dict:
|
||
"""重生中心·可重生模块注册表(扫描源码 moduleId 声明,§9.4/§6.10.3)。"""
|
||
return {"modules": scan_modules()} # 注册表清单
|
||
|
||
@app.get("/api/gov/policy")
|
||
async def gov_policy() -> dict:
|
||
"""门禁管理台·权力矩阵(_POWER_MAP 只读投影;P0)。"""
|
||
return {"policy": harness.list_policy(), # 矩阵条目
|
||
"defaultPower": "P3"} # 未登记动作的默认等级(白名单原则)
|
||
|
||
@app.get("/api/gov/tests")
|
||
async def gov_tests(run: bool = False) -> dict:
|
||
"""重生中心·黄金测试看板(P0 只读缓存;?run=true 在线程池重跑,不阻塞事件循环)。"""
|
||
import asyncio # 线程池调度
|
||
from server.gateway.golden import golden_status # 局部导入(避免启动即依赖 pytest)
|
||
# 跑测是同步子进程(~10s):必须扔线程池,否则会饿死其他请求(世界视图全超时)
|
||
return await asyncio.to_thread(golden_status, run)
|
||
|
||
# ---------------- 可重生算法流水线(矩阵 91:/api/rebuild/*)----------------
|
||
|
||
|
||
@app.post("/api/rebuild/{module_id}")
|
||
async def rebuild_start(module_id: str, req: RebuildStartRequest | None = None) -> dict:
|
||
"""触发可重生流水线:候选构建 → 隔离黄金+安全扫描 → shadow 灰度(失败自动回退)。"""
|
||
from server.agent_core.rebuild_orchestrator import ( # 局部导入(避免启动即依赖子进程)
|
||
RebuildOrchestrator, RebuildLookupError,
|
||
)
|
||
store = get_store() # 世界状态(注册表 + 审计落盘)
|
||
orch = RebuildOrchestrator(store) # 流水线执行器(默认仓库根 + 当前解释器)
|
||
try:
|
||
# 子进程跑黄金(阻塞):必须扔线程池,避免饿死其他请求
|
||
return await asyncio.to_thread(
|
||
orch.start_rebuild, module_id,
|
||
golden_tests=list(req.goldenTests) if req else None,
|
||
source_path=req.sourcePath if req else None,
|
||
actor="planner",
|
||
)
|
||
except RebuildLookupError as exc: # 未登记 moduleId → 404 显式失败
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc: # 参数/状态非法 → 400
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
@app.get("/api/rebuild")
|
||
async def rebuild_status() -> dict:
|
||
"""流水线状态列表(候选版本 + 激活版本指针 + 基线)。"""
|
||
from server.agent_core.rebuild_orchestrator import RebuildOrchestrator
|
||
return RebuildOrchestrator(get_store()).status()
|
||
|
||
@app.post("/api/rebuild/{module_id}/promote")
|
||
async def rebuild_promote(module_id: str) -> dict:
|
||
"""灰度转正:shadow 候选成为当前激活版本。"""
|
||
from server.agent_core.rebuild_orchestrator import (
|
||
RebuildOrchestrator, RebuildLookupError,
|
||
)
|
||
store = get_store()
|
||
try:
|
||
return RebuildOrchestrator(store).promote(module_id, actor="planner")
|
||
except RebuildLookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
@app.post("/api/rebuild/{module_id}/rollback")
|
||
async def rebuild_rollback(module_id: str,
|
||
req: RebuildRollbackRequest | None = None) -> dict:
|
||
"""手动回滚:注册表恢复 + 报告(失败自动回退之外的人工兜底)。"""
|
||
from server.agent_core.rebuild_orchestrator import (
|
||
RebuildOrchestrator, RebuildLookupError,
|
||
)
|
||
store = get_store()
|
||
try:
|
||
return RebuildOrchestrator(store).rollback(
|
||
module_id, reason=(req.reason if req else "manual"), actor="planner")
|
||
except RebuildLookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
|
||
|
||
# ---------------- 知识库与报告(M3 §8/§9.10,P0 只读) ----------------
|
||
@app.get("/api/knowledge/assets")
|
||
async def knowledge_assets() -> dict:
|
||
"""知识资产元信息清单(知识面板/命令面板数据源)。"""
|
||
from server.knowledge import get_knowledge # 局部导入
|
||
return {"assets": get_knowledge().list_meta()} # 元信息(不含正文)
|
||
|
||
@app.get("/api/knowledge/assets/{asset_id}")
|
||
async def knowledge_asset(asset_id: str) -> dict:
|
||
"""单个知识资产全文(出处点开查看)。"""
|
||
from server.knowledge import get_knowledge # 局部导入
|
||
asset = get_knowledge().get(asset_id) # 按 ID 取
|
||
return asset or {"error": "资产不存在"} # 缺失给错误体(前端兜底)
|
||
|
||
|
||
@app.post("/api/knowledge/import")
|
||
async def knowledge_import(req: KnowledgeImportRequest) -> dict:
|
||
"""知识文档导入预览并暂存确认卡(P2 → /api/actions/confirm)。"""
|
||
import os
|
||
from server.agent_core.audit import write_audit
|
||
from server.knowledge.ingest import confirmation_for_knowledge_import, preview_ingest
|
||
path = req.path or req.filePath
|
||
if not path:
|
||
return {"error": "缺少 path(本地文件路径)"}
|
||
try:
|
||
preview = preview_ingest(
|
||
os.path.basename(str(path)), path=str(path),
|
||
kind=str(req.kind or "sop"), title=req.title,
|
||
)
|
||
except (FileNotFoundError, ValueError) as exc:
|
||
return {"error": str(exc)}
|
||
title, lines = confirmation_for_knowledge_import(preview)
|
||
slim = {
|
||
"path": str(path), "filename": preview["filename"], "title": preview["title"],
|
||
"kind": preview["kind"], "chunkCount": preview["chunkCount"],
|
||
"sectionCount": preview["sectionCount"],
|
||
}
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "knowledge.import", slim, title=title, summary_lines=lines)
|
||
store = get_store()
|
||
write_audit(store.data, store.next_id, actor=req.actor or "planner",
|
||
category="GATE", action="knowledge.import.stage",
|
||
target={"type": "KNOWLEDGE", "id": preview["filename"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"],
|
||
"chunkCount": preview["chunkCount"]})
|
||
store.save()
|
||
return {"message": f"{title} 属于 P2,确认后入库。", "block": block.model_dump(),
|
||
"preview": {k: preview[k] for k in ("filename", "title", "kind", "chunkCount",
|
||
"sectionCount", "preview")}}
|
||
|
||
@app.post("/api/knowledge/import/upload")
|
||
async def knowledge_import_upload(
|
||
file: UploadFile = File(...),
|
||
sessionId: str = Form("web"),
|
||
kind: str = Form("sop"),
|
||
title: str | None = Form(None),
|
||
) -> dict:
|
||
"""上传文件预览并暂存确认卡;文件落临时路径,确认时再入库。"""
|
||
import os
|
||
import tempfile
|
||
from server.agent_core.audit import write_audit
|
||
from server.knowledge.ingest import confirmation_for_knowledge_import, preview_ingest
|
||
raw = await file.read()
|
||
fname = file.filename or "upload.md"
|
||
suffix = os.path.splitext(fname)[1] or ".md"
|
||
fd, tmp_path = tempfile.mkstemp(prefix="aps-kb-", suffix=suffix)
|
||
os.close(fd)
|
||
with open(tmp_path, "wb") as f:
|
||
f.write(raw)
|
||
try:
|
||
preview = preview_ingest(fname, path=tmp_path, kind=kind or "sop", title=title)
|
||
except ValueError as exc:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
return {"error": str(exc)}
|
||
title_c, lines = confirmation_for_knowledge_import(preview)
|
||
slim = {
|
||
"path": tmp_path, "filename": preview["filename"], "title": preview["title"],
|
||
"kind": preview["kind"], "chunkCount": preview["chunkCount"],
|
||
"sectionCount": preview["sectionCount"],
|
||
}
|
||
block = harness.stage_confirmation(
|
||
sessionId or "web", "knowledge.import", slim, title=title_c, summary_lines=lines)
|
||
store = get_store()
|
||
write_audit(store.data, store.next_id, actor=sessionId or "web",
|
||
category="GATE", action="knowledge.import.stage",
|
||
target={"type": "KNOWLEDGE", "id": preview["filename"]}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"],
|
||
"chunkCount": preview["chunkCount"]})
|
||
store.save()
|
||
return {"message": f"{title_c} 属于 P2,确认后入库。", "block": block.model_dump(),
|
||
"preview": {k: preview[k] for k in ("filename", "title", "kind", "chunkCount",
|
||
"sectionCount", "preview")}}
|
||
|
||
@app.get("/api/system/paths")
|
||
async def system_paths() -> dict:
|
||
"""运行形态与本地目录(桌面 ~/.aps 或 Web server/data)。"""
|
||
from server.aps_home import describe
|
||
return describe()
|
||
|
||
@app.get("/api/skills")
|
||
async def skills_list() -> dict:
|
||
"""外部算法 Skill 清单。"""
|
||
from server.agent_core.skills import get_skills
|
||
from server.aps_home import skills_dir, mode
|
||
return {
|
||
"skills": get_skills().list(),
|
||
"skillsDir": str(skills_dir()),
|
||
"mode": mode(),
|
||
}
|
||
|
||
@app.get("/api/skills/health")
|
||
async def skills_health(skillId: str | None = None) -> dict:
|
||
"""Skill 健康检查。"""
|
||
from server.agent_core.skills import get_skills
|
||
return {"health": get_skills().health(skillId)}
|
||
|
||
|
||
@app.post("/api/skills/register")
|
||
async def skills_register(req: SkillRegisterRequest) -> dict:
|
||
"""登记 skill 暂存确认卡(P2)。"""
|
||
from server.agent_core.audit import write_audit
|
||
sid = req.skill_id or req.skillId
|
||
if not sid or not req.endpoint:
|
||
return {"error": "需要 skill_id 与 endpoint"}
|
||
payload = {
|
||
"skill_id": sid,
|
||
"name": req.name or sid,
|
||
"endpoint": req.endpoint,
|
||
"auth": req.auth or "",
|
||
"track": req.track or "flex",
|
||
"enabled": req.enabled,
|
||
"description": req.description or "",
|
||
}
|
||
title = f"登记算法 Skill {sid}"
|
||
lines = [f"ID:{sid}", f"端点:{req.endpoint}",
|
||
f"名称:{payload['name']}", f"轨道:{payload['track']}"]
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "skill.register", payload, title=title, summary_lines=lines)
|
||
store = get_store()
|
||
write_audit(store.data, store.next_id, actor=req.actor or "planner",
|
||
category="GATE", action="skill.register.stage",
|
||
target={"type": "SKILL", "id": sid}, power="P2",
|
||
rationale={"confirmId": block.props["confirmId"]})
|
||
store.save()
|
||
return {"message": f"{title} 属于 P2,确认后写入配置。", "block": block.model_dump()}
|
||
|
||
|
||
@app.post("/api/skills/{skill_id}/enable")
|
||
async def skill_enable(skill_id: str, req: SkillEnableRequest) -> dict:
|
||
"""启停 skill → P2 确认卡(M-D 管理台 Switch)。"""
|
||
from server.agent_core.audit import write_audit
|
||
store = get_store()
|
||
title = f"{'启用' if req.enabled else '停用'}算法 Skill {skill_id}"
|
||
block = harness.stage_confirmation(
|
||
req.sessionId or "web", "skill.enable",
|
||
{"skill_id": skill_id, "enabled": req.enabled},
|
||
title=title, summary_lines=[f"目标:{skill_id}",
|
||
f"状态 → {'启用' if req.enabled else '停用'}"])
|
||
write_audit(store.data, store.next_id, actor=req.actor, category="GATE",
|
||
action="skill.enable.stage", target={"type": "SKILL", "id": skill_id},
|
||
power="P2", rationale={"confirmId": block.props["confirmId"],
|
||
"enabled": req.enabled})
|
||
store.save()
|
||
return {"message": f"{title} 已进入 P2 确认队列。", "block": block.model_dump()}
|
||
|
||
@app.get("/api/skills/{skill_id}/audit")
|
||
async def skill_audit(skill_id: str, limit: int = 50) -> dict:
|
||
"""按 skill 过滤审计链(M-D 管理台「日志」页签,P0)。"""
|
||
events = []
|
||
for ev in reversed(get_store().data.get("auditEvents") or []):
|
||
target = ev.get("target") or {}
|
||
rationale = ev.get("rationale") or {}
|
||
if (target.get("type") == "SKILL" and target.get("id") == skill_id) \
|
||
or rationale.get("skillId") == skill_id:
|
||
events.append(ev)
|
||
if len(events) >= max(1, min(limit, 200)):
|
||
break
|
||
return {"skillId": skill_id, "events": events}
|
||
|
||
@app.get("/api/skills/{skill_id}/history")
|
||
async def skill_health_history(skill_id: str) -> dict:
|
||
"""健康探测历史(M-D 管理台「健康」页签,P0)。"""
|
||
from server.agent_core.skills import get_skills
|
||
return {"skillId": skill_id, "history": get_skills().history(skill_id)}
|
||
|
||
@app.post("/api/skills/{skill_id}/test")
|
||
async def skill_run_tests(skill_id: str) -> dict:
|
||
"""跑该 skill 声明的黄金测试子集(M-D 管理台「测试」页签,P1)。"""
|
||
import subprocess
|
||
import sys
|
||
from server.agent_core.skills import get_skills
|
||
s = get_skills().get(skill_id)
|
||
if not s:
|
||
return {"error": f"未找到 skill:{skill_id}"}
|
||
tests = s.get("golden_tests") or ["test_external_engine"]
|
||
targets = [f"tests/golden/{t if t.endswith('.py') else t + '.py'}" for t in tests]
|
||
try:
|
||
proc = subprocess.run(
|
||
[sys.executable, "-m", "pytest", *targets, "-q", "--no-header"],
|
||
capture_output=True, text=True, timeout=180, encoding="utf-8", errors="replace")
|
||
tail = "\n".join((proc.stdout or "").strip().splitlines()[-12:])
|
||
return {"skillId": skill_id, "ok": proc.returncode == 0,
|
||
"exitCode": proc.returncode, "tests": targets, "output": tail}
|
||
except subprocess.TimeoutExpired:
|
||
return {"skillId": skill_id, "ok": False, "tests": targets, "output": "超时(180s)"}
|
||
|
||
@app.get("/api/skills/contracts")
|
||
async def skill_contracts() -> dict:
|
||
"""SchedulingProblem/Solution 契约(M-D 管理台「契约」页签,P0)。"""
|
||
import json as _json
|
||
import os as _os
|
||
out = {}
|
||
root = _os.path.join("shared", "schemas")
|
||
for name in ("scheduling_problem", "scheduling_solution"):
|
||
path = _os.path.join(root, f"{name}.schema.json")
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
out[name] = _json.load(f)
|
||
except (FileNotFoundError, _json.JSONDecodeError):
|
||
out[name] = None
|
||
return out
|
||
|
||
# ---------------- 方向 F:MCP 插件管理总线(矩阵 75 行) ----------------
|
||
# 与既有 SkillConsole 集成:插件清单 / 工具契约 / 权限开关 / 健康 / 审计 / 版本兼容
|
||
@app.get("/api/mcp/plugins")
|
||
async def mcp_plugins_list() -> dict:
|
||
"""MCP 插件清单 + 版本兼容报告 + 调用统计(P0 只读)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
bus = get_mcp_bus()
|
||
return {
|
||
"busVersion": bus.BUS_VERSION,
|
||
"plugins": bus.list(),
|
||
"versionReport": bus.version_report(),
|
||
"stats": bus.stats(),
|
||
}
|
||
|
||
@app.get("/api/mcp/plugins/health")
|
||
async def mcp_plugins_health(pluginId: str | None = None) -> dict:
|
||
"""MCP 插件健康探测(P0)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
return {"health": get_mcp_bus().health(pluginId)}
|
||
|
||
|
||
@app.post("/api/mcp/plugins/register")
|
||
async def mcp_plugins_register(req: McpPluginRegisterRequest) -> dict:
|
||
"""登记/更新 MCP 插件(P2 审计留痕;覆盖登记保留既有工具授权,只增不删)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
try:
|
||
plugin = get_mcp_bus().register(req.manifest, actor=req.actor)
|
||
except (ValueError, TypeError) as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"MCP 插件 {plugin['plugin_id']} 已登记。", "plugin": plugin}
|
||
|
||
@app.post("/api/mcp/plugins/seed-skills")
|
||
async def mcp_plugins_seed_skills(actor: str = "planner") -> dict:
|
||
"""把既有外部算法 Skill 桥接为 MCP 插件(向后兼容,只增不删,P2 审计)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
from server.agent_core.skills import get_skills
|
||
count = get_mcp_bus().seed_from_skills(get_skills(), actor=actor)
|
||
return {"message": f"已桥接 {count} 个 Skill 为 MCP 插件。", "seeded": count}
|
||
|
||
|
||
@app.post("/api/mcp/plugins/{plugin_id}/enable")
|
||
async def mcp_plugin_enable(plugin_id: str, req: McpPluginEnableRequest) -> dict:
|
||
"""启停 MCP 插件(P2 审计;停用后工具调用一律拒绝)。"""
|
||
from server.agent_core.mcp_bus import PluginNotFoundError, get_mcp_bus
|
||
try:
|
||
plugin = get_mcp_bus().set_enabled(plugin_id, req.enabled, actor=req.actor)
|
||
except PluginNotFoundError as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"{'启用' if req.enabled else '停用'}插件 {plugin_id}。", "plugin": plugin}
|
||
|
||
|
||
@app.post("/api/mcp/plugins/{plugin_id}/permissions")
|
||
async def mcp_plugin_permission(plugin_id: str, req: McpPermissionRequest) -> dict:
|
||
"""tool 级权限开关(P2 审计):allow=False → 越权调用拒绝 + DENIED 审计。"""
|
||
from server.agent_core.mcp_bus import PluginNotFoundError, ToolNotFoundError, get_mcp_bus
|
||
try:
|
||
plugin = get_mcp_bus().set_permission(
|
||
plugin_id, req.tool, req.allow, actor=req.actor, reason=req.reason,
|
||
)
|
||
except (PluginNotFoundError, ToolNotFoundError) as exc:
|
||
return {"error": str(exc)}
|
||
return {"message": f"工具 {plugin_id}.{req.tool} 权限 → {'放行' if req.allow else '拒绝'}。", "plugin": plugin}
|
||
|
||
@app.get("/api/mcp/plugins/{plugin_id}/audit")
|
||
async def mcp_plugin_audit(plugin_id: str, limit: int = 50) -> dict:
|
||
"""MCP 插件审计事件(P0)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
return {"pluginId": plugin_id, "events": get_mcp_bus().audit(plugin_id, limit)}
|
||
|
||
@app.get("/api/mcp/plugins/{plugin_id}/history")
|
||
async def mcp_plugin_history(plugin_id: str) -> dict:
|
||
"""MCP 插件健康探测历史(P0)。"""
|
||
from server.agent_core.mcp_bus import get_mcp_bus
|
||
return {"pluginId": plugin_id, "history": get_mcp_bus().history(plugin_id)}
|
||
|
||
# ---------------- 方向 F:异步任务队列(矩阵 88 行剩余) ----------------
|
||
@app.get("/api/jobs")
|
||
async def jobs_list(status: str | None = None) -> dict:
|
||
"""异步任务列表 + 统计(P0)。"""
|
||
from server.agent_core.async_jobs import get_job_queue
|
||
from server.auth.context import get_identity
|
||
from server.state.projects import get_project_store
|
||
identity = get_identity(required=True)
|
||
project_id = get_project_store().active_world_key()
|
||
queue = get_job_queue()
|
||
scope = {
|
||
"tenant_uuid": identity.tenant_uuid,
|
||
"project_id": project_id,
|
||
"actor": identity.username or str(identity.user_id),
|
||
}
|
||
return {"jobs": queue.list(status=status, **scope), "stats": queue.stats(**scope)}
|
||
|
||
|
||
@app.post("/api/jobs")
|
||
async def jobs_submit(req: JobSubmitRequest) -> dict:
|
||
"""提交后台任务(P1):敏感性/Sobol/蒙特卡洛重算放后台,沙盒执行不写主干。"""
|
||
import copy
|
||
from server.agent_core.async_jobs import JobQueueFullError, get_job_queue
|
||
from server.auth.context import get_identity
|
||
from server.state.projects import get_project_store
|
||
kind = req.kind
|
||
identity = get_identity(required=True)
|
||
project_id = get_project_store().active_world_key()
|
||
world = copy.deepcopy(get_store().data) # 提交时快照,避免与主线程并发变更
|
||
if kind == "sensitivity.recompute":
|
||
strategy = str(req.params.get("strategy") or "COMPREHENSIVE")
|
||
|
||
def _fn(record):
|
||
from server.aps_domain.sensitivity import run_sensitivity
|
||
return run_sensitivity(world, strategy=strategy)
|
||
elif kind == "montecarlo.recompute":
|
||
strategy = str(req.params.get("strategy") or "COMPREHENSIVE")
|
||
trials = int(req.params.get("trials") or 24)
|
||
seed = int(req.params.get("seed") or 20260731)
|
||
|
||
def _fn(record):
|
||
from server.aps_domain.robustness import run_monte_carlo
|
||
return run_monte_carlo(
|
||
world, strategy=strategy, trials=trials, seed=seed,
|
||
reset_schedule_products=True,
|
||
)
|
||
elif kind == "sobol.recompute":
|
||
from datetime import date
|
||
|
||
strategy_raw = req.params.get("strategy", "COMPREHENSIVE")
|
||
base_samples_raw = req.params.get("baseSamples", 32)
|
||
seed_raw = req.params.get("seed", 20260818)
|
||
metric_raw = req.params.get("metric", "tardiness")
|
||
has_requested_start_date = "startDate" in req.params
|
||
requested_start_date = req.params.get("startDate") if has_requested_start_date else None
|
||
start_date_raw = requested_start_date if has_requested_start_date else world.get("businessDate")
|
||
if not isinstance(strategy_raw, str) or not strategy_raw.strip():
|
||
raise HTTPException(status_code=422, detail="strategy 须为非空字符串")
|
||
if type(base_samples_raw) is not int or base_samples_raw not in {8, 16, 32, 64}:
|
||
raise HTTPException(status_code=422, detail="baseSamples 须为 8、16、32 或 64")
|
||
if type(seed_raw) is not int or not 0 <= seed_raw <= 2**32 - 1:
|
||
raise HTTPException(status_code=422, detail="seed 须为 0~4294967295 的整数")
|
||
allowed_metrics = {"tardiness", "conflicts", "utilization", "changeoverMin"}
|
||
if not isinstance(metric_raw, str) or metric_raw not in allowed_metrics:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"metric 须为 {' / '.join(sorted(allowed_metrics))}",
|
||
)
|
||
if not isinstance(start_date_raw, str) or not start_date_raw.strip():
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="startDate 必填;仅可省略于 world.businessDate 已设置时",
|
||
)
|
||
resolved_start_date = start_date_raw.strip()
|
||
try:
|
||
parsed_start = date.fromisoformat(resolved_start_date)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD") from exc
|
||
if parsed_start.isoformat() != resolved_start_date:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD")
|
||
strategy = strategy_raw.strip()
|
||
base_samples = base_samples_raw
|
||
seed = seed_raw
|
||
metric = metric_raw
|
||
|
||
def _fn(record):
|
||
from server.aps_domain.sensitivity import run_sobol_sensitivity
|
||
return run_sobol_sensitivity(
|
||
world,
|
||
strategy=strategy,
|
||
base_samples=base_samples,
|
||
seed=seed,
|
||
metric=metric,
|
||
start_date=(requested_start_date.strip() if has_requested_start_date else None),
|
||
cancel_check=record.raise_if_cancelled,
|
||
)
|
||
elif kind == "cp-marginal.recompute":
|
||
import math
|
||
from datetime import date
|
||
from server.engines.cp_engine import CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
|
||
|
||
track_raw = req.params.get("track", "fixed")
|
||
strategy_raw = req.params.get("strategy", "COMPREHENSIVE")
|
||
horizon_raw = req.params.get("planningHorizonDays", 14)
|
||
time_limit_raw = req.params.get("timeLimitSeconds", 4.0)
|
||
constraints_raw = req.params.get(
|
||
"constraintIds", sorted(CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS),
|
||
)
|
||
has_requested_start_date = "startDate" in req.params
|
||
requested_start_date = req.params.get("startDate") if has_requested_start_date else None
|
||
start_date_raw = requested_start_date if has_requested_start_date else world.get("businessDate")
|
||
if track_raw != "fixed":
|
||
raise HTTPException(status_code=422, detail="CP 边际重解仅支持 fixed 轨")
|
||
if not isinstance(strategy_raw, str) or not strategy_raw.strip():
|
||
raise HTTPException(status_code=422, detail="strategy 须为非空字符串")
|
||
if type(horizon_raw) is not int or not 1 <= horizon_raw <= 90:
|
||
raise HTTPException(status_code=422, detail="planningHorizonDays 须为 1~90 整数")
|
||
if (
|
||
isinstance(time_limit_raw, bool)
|
||
or not isinstance(time_limit_raw, (int, float))
|
||
or not math.isfinite(float(time_limit_raw))
|
||
or not 0.5 <= float(time_limit_raw) <= 10.0
|
||
):
|
||
raise HTTPException(status_code=422, detail="timeLimitSeconds 须为 0.5~10 的有限数")
|
||
if not isinstance(start_date_raw, str) or not start_date_raw.strip():
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="startDate 必填;仅可省略于 world.businessDate 已设置时",
|
||
)
|
||
resolved_start_date = start_date_raw.strip()
|
||
try:
|
||
parsed_start = date.fromisoformat(resolved_start_date)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD") from exc
|
||
if parsed_start.isoformat() != resolved_start_date:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD")
|
||
if (
|
||
not isinstance(constraints_raw, list)
|
||
or not constraints_raw
|
||
or not all(isinstance(value, str) and value for value in constraints_raw)
|
||
or len(constraints_raw) != len(set(constraints_raw))
|
||
):
|
||
raise HTTPException(status_code=422, detail="constraintIds 须为非空且不重复的字符串数组")
|
||
unknown_constraints = set(constraints_raw) - CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
|
||
if unknown_constraints:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"constraintIds 含不支持约束:{sorted(unknown_constraints)}",
|
||
)
|
||
if not isinstance(start_date_raw, str) or not start_date_raw.strip():
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="startDate 必填;仅可省略于 world.businessDate 已设置时",
|
||
)
|
||
resolved_start_date = start_date_raw.strip()
|
||
try:
|
||
parsed_start = date.fromisoformat(resolved_start_date)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD") from exc
|
||
if parsed_start.isoformat() != resolved_start_date:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD")
|
||
strategy = strategy_raw.strip()
|
||
constraint_ids = sorted(constraints_raw)
|
||
time_limit = float(time_limit_raw)
|
||
|
||
def _fn(record):
|
||
from server.aps_domain.cp_marginal import run_cp_marginal_resolve
|
||
return run_cp_marginal_resolve(
|
||
world,
|
||
start_date=(requested_start_date.strip() if has_requested_start_date else None),
|
||
strategy=strategy,
|
||
planning_horizon_days=horizon_raw,
|
||
time_limit_seconds=time_limit,
|
||
constraint_ids=constraint_ids,
|
||
cancel_check=record.raise_if_cancelled,
|
||
)
|
||
elif kind == "cp-rhs.recompute":
|
||
import math
|
||
from datetime import date, timedelta
|
||
from server.engines.cp_engine import CP_DIAGNOSTIC_RHS_PARAMETERS
|
||
|
||
allowed_params = {
|
||
"track", "strategy", "startDate", "planningHorizonDays",
|
||
"timeLimitSeconds", "parameterIds", "increments", "costRates",
|
||
"instanceIncrements", "instanceCostRates", "currency",
|
||
}
|
||
unknown_params = set(req.params) - allowed_params
|
||
if unknown_params:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"cp-rhs params 含未知字段:{sorted(unknown_params)}",
|
||
)
|
||
track_raw = req.params.get("track", "fixed")
|
||
strategy_raw = req.params.get("strategy", "COMPREHENSIVE")
|
||
horizon_raw = req.params.get("planningHorizonDays", 14)
|
||
time_limit_raw = req.params.get("timeLimitSeconds", 4.0)
|
||
parameter_ids_raw = req.params.get(
|
||
"parameterIds", [
|
||
"C8_due_date_allowance", "C12_team_capacity", "C12_tooling_capacity",
|
||
],
|
||
)
|
||
increments_raw = req.params.get("increments", {})
|
||
cost_rates_raw = req.params.get("costRates", {})
|
||
instance_increments_raw = req.params.get("instanceIncrements", {})
|
||
instance_cost_rates_raw = req.params.get("instanceCostRates", {})
|
||
currency_raw = req.params.get("currency", "CNY")
|
||
has_requested_start_date = "startDate" in req.params
|
||
requested_start_date = req.params.get("startDate") if has_requested_start_date else None
|
||
start_date_raw = requested_start_date if has_requested_start_date else world.get("businessDate")
|
||
if track_raw != "fixed":
|
||
raise HTTPException(status_code=422, detail="CP RHS 重解仅支持 fixed 轨")
|
||
if not isinstance(strategy_raw, str) or not strategy_raw.strip():
|
||
raise HTTPException(status_code=422, detail="strategy 须为非空字符串")
|
||
if type(horizon_raw) is not int or not 1 <= horizon_raw <= 90:
|
||
raise HTTPException(status_code=422, detail="planningHorizonDays 须为 1~90 整数")
|
||
if (
|
||
isinstance(time_limit_raw, bool)
|
||
or not isinstance(time_limit_raw, (int, float))
|
||
or not math.isfinite(float(time_limit_raw))
|
||
or not 0.5 <= float(time_limit_raw) <= 10.0
|
||
):
|
||
raise HTTPException(status_code=422, detail="timeLimitSeconds 须为 0.5~10 的有限数")
|
||
if not isinstance(start_date_raw, str) or not start_date_raw.strip():
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="startDate 必填;仅可省略于 world.businessDate 已设置时",
|
||
)
|
||
resolved_start_date = start_date_raw.strip()
|
||
try:
|
||
parsed_start = date.fromisoformat(resolved_start_date)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD") from exc
|
||
if parsed_start.isoformat() != resolved_start_date:
|
||
raise HTTPException(status_code=422, detail="startDate 须为 YYYY-MM-DD")
|
||
if (
|
||
not isinstance(parameter_ids_raw, list)
|
||
or not parameter_ids_raw
|
||
or not all(isinstance(value, str) and value for value in parameter_ids_raw)
|
||
or len(parameter_ids_raw) != len(set(parameter_ids_raw))
|
||
):
|
||
raise HTTPException(status_code=422, detail="parameterIds 须为非空且不重复的字符串数组")
|
||
unknown_parameter_ids = set(parameter_ids_raw) - CP_DIAGNOSTIC_RHS_PARAMETERS
|
||
if unknown_parameter_ids:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"parameterIds 含不支持参数:{sorted(unknown_parameter_ids)}",
|
||
)
|
||
parameter_ids = sorted(parameter_ids_raw)
|
||
auto_parameter_ids = set(parameter_ids) - {"C7_line_day_capacity_minutes"}
|
||
if not isinstance(increments_raw, dict) or set(increments_raw) - auto_parameter_ids:
|
||
raise HTTPException(status_code=422, detail="increments 只能配置本次请求参数")
|
||
increments = dict(increments_raw)
|
||
for parameter_id, value in increments.items():
|
||
limit = 10_080 if parameter_id == "C8_due_date_allowance" else 100
|
||
if type(value) is not int or not 1 <= value <= limit:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"{parameter_id} increment 须为 1~{limit} 整数",
|
||
)
|
||
if not isinstance(instance_increments_raw, dict) or len(instance_increments_raw) > 128:
|
||
raise HTTPException(status_code=422, detail="instanceIncrements 须为最多 128 项的对象")
|
||
if instance_increments_raw and "C7_line_day_capacity_minutes" not in parameter_ids:
|
||
raise HTTPException(status_code=422, detail="instanceIncrements 仅可用于 C7 参数")
|
||
if (
|
||
"C7_line_day_capacity_minutes" in parameter_ids
|
||
and not instance_increments_raw
|
||
):
|
||
raise HTTPException(status_code=422, detail="C7 参数须显式提供 instanceIncrements")
|
||
instance_increments = dict(instance_increments_raw)
|
||
max_model_days = max(horizon_raw * 2, 7)
|
||
start_day = date.fromisoformat(resolved_start_date)
|
||
|
||
for instance_id, value in instance_increments.items():
|
||
parts = instance_id.split(":") if isinstance(instance_id, str) else []
|
||
if len(parts) != 3 or parts[0] != "line-day":
|
||
raise HTTPException(status_code=422, detail=f"C7 实例 ID 非法:{instance_id}")
|
||
try:
|
||
line_id = int(parts[1])
|
||
bucket_day = date.fromisoformat(parts[2])
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(status_code=422, detail=f"C7 实例 ID 非法:{instance_id}") from exc
|
||
if (
|
||
line_id <= 0
|
||
or bucket_day.isoformat() != parts[2]
|
||
or type(value) is not int
|
||
or not 1 <= value <= 1_440
|
||
):
|
||
raise HTTPException(status_code=422, detail=f"C7 实例增量非法:{instance_id}")
|
||
if not start_day <= bucket_day <= start_day + timedelta(days=max_model_days):
|
||
raise HTTPException(status_code=422, detail=f"C7 实例日期超出 CP 时间域:{instance_id}")
|
||
line = next((
|
||
row for row in world.get("lines", [])
|
||
if int(row.get("id", -1)) == line_id
|
||
and row.get("status", "ACTIVE") == "ACTIVE"
|
||
), None)
|
||
if line is None:
|
||
raise HTTPException(status_code=422, detail=f"C7 产线未启用:{line_id}")
|
||
from server.engines.queries import get_available_minutes
|
||
|
||
if get_available_minutes(world, line_id, parts[2]) <= 0:
|
||
raise HTTPException(status_code=422, detail=f"C7 实例不是有效工作日:{instance_id}")
|
||
if not isinstance(cost_rates_raw, dict) or set(cost_rates_raw) - set(parameter_ids):
|
||
raise HTTPException(status_code=422, detail="costRates 只能配置本次请求参数")
|
||
cost_rates = dict(cost_rates_raw)
|
||
for parameter_id, value in cost_rates.items():
|
||
if (
|
||
isinstance(value, bool)
|
||
or not isinstance(value, (int, float))
|
||
or not math.isfinite(float(value))
|
||
or float(value) < 0
|
||
):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"{parameter_id} cost rate 须为非负有限数",
|
||
)
|
||
if (
|
||
not isinstance(instance_cost_rates_raw, dict)
|
||
or set(instance_cost_rates_raw) - set(instance_increments)
|
||
):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="instanceCostRates 只能配置本次 instanceIncrements 实例",
|
||
)
|
||
instance_cost_rates = dict(instance_cost_rates_raw)
|
||
for instance_id, value in instance_cost_rates.items():
|
||
if (
|
||
isinstance(value, bool)
|
||
or not isinstance(value, (int, float))
|
||
or not math.isfinite(float(value))
|
||
or float(value) < 0
|
||
):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"{instance_id} instance cost rate 须为非负有限数",
|
||
)
|
||
if (
|
||
not isinstance(currency_raw, str)
|
||
or len(currency_raw) != 3
|
||
or not currency_raw.isascii()
|
||
or not currency_raw.isalpha()
|
||
):
|
||
raise HTTPException(status_code=422, detail="currency 须为 3 位 ASCII 字母")
|
||
strategy = strategy_raw.strip()
|
||
time_limit = float(time_limit_raw)
|
||
currency = currency_raw.upper()
|
||
|
||
def _fn(record):
|
||
from server.aps_domain.cp_rhs import run_cp_rhs_resolve
|
||
return run_cp_rhs_resolve(
|
||
world,
|
||
start_date=(requested_start_date.strip() if has_requested_start_date else None),
|
||
strategy=strategy,
|
||
planning_horizon_days=horizon_raw,
|
||
time_limit_seconds=time_limit,
|
||
parameter_ids=parameter_ids,
|
||
increments=increments,
|
||
cost_rates=cost_rates,
|
||
instance_increments=instance_increments,
|
||
instance_cost_rates=instance_cost_rates,
|
||
currency=currency,
|
||
cancel_check=record.raise_if_cancelled,
|
||
)
|
||
else:
|
||
return {
|
||
"error": f"未知任务类型:{kind}"
|
||
"(支持 sensitivity.recompute / montecarlo.recompute / sobol.recompute / "
|
||
"cp-marginal.recompute / cp-rhs.recompute)",
|
||
}
|
||
try:
|
||
job_id = get_job_queue().submit(
|
||
kind, _fn, params=req.params,
|
||
actor=identity.username or str(identity.user_id),
|
||
tenant_uuid=identity.tenant_uuid, project_id=project_id,
|
||
)
|
||
except JobQueueFullError as exc:
|
||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||
return {"jobId": job_id, "job": get_job_queue().poll(
|
||
job_id, tenant_uuid=identity.tenant_uuid, project_id=project_id,
|
||
actor=identity.username or str(identity.user_id))}
|
||
|
||
@app.get("/api/jobs/{job_id}")
|
||
async def jobs_poll(job_id: str) -> dict:
|
||
"""查询任务状态/结果(P0)。"""
|
||
from server.agent_core.async_jobs import get_job_queue
|
||
from server.auth.context import get_identity
|
||
from server.state.projects import get_project_store
|
||
identity = get_identity(required=True)
|
||
job = get_job_queue().poll(
|
||
job_id, tenant_uuid=identity.tenant_uuid,
|
||
project_id=get_project_store().active_world_key(),
|
||
actor=identity.username or str(identity.user_id))
|
||
if job is None:
|
||
return {"error": f"任务不存在:{job_id}"}
|
||
return {"job": job}
|
||
|
||
@app.post("/api/jobs/{job_id}/cancel")
|
||
async def jobs_cancel(job_id: str) -> dict:
|
||
"""取消任务(P1):pending 立即取消,running 置取消标记。"""
|
||
from server.agent_core.async_jobs import get_job_queue
|
||
from server.auth.context import get_identity
|
||
from server.state.projects import get_project_store
|
||
identity = get_identity(required=True)
|
||
cancelled = get_job_queue().cancel(
|
||
job_id, tenant_uuid=identity.tenant_uuid,
|
||
project_id=get_project_store().active_world_key(),
|
||
actor=identity.username or str(identity.user_id))
|
||
if not cancelled:
|
||
return {"message": f"任务 {job_id} 无法取消(不存在或已终态)。", "cancelled": False}
|
||
return {"message": f"任务 {job_id} 已请求取消。", "cancelled": True}
|
||
|
||
@app.get("/api/reports/files/{report_id}")
|
||
async def report_file_download(report_id: str) -> Any:
|
||
"""下载已生成的排产工作计划 Excel。"""
|
||
import glob
|
||
import os
|
||
from urllib.parse import quote
|
||
from fastapi.responses import FileResponse
|
||
root = os.path.join(os.path.dirname(get_store().path), "exports")
|
||
matches = glob.glob(os.path.join(root, f"{report_id}__*"))
|
||
if not matches:
|
||
return {"error": "报告文件不存在或已清理,请重新说「生成排产方案报告」。"}
|
||
path = matches[0]
|
||
fname = os.path.basename(path).split("__", 1)[-1]
|
||
return FileResponse(
|
||
path,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
filename=fname,
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(fname)}"},
|
||
)
|
||
|
||
@app.get("/api/reports/schedule-template")
|
||
async def schedule_template_export(request: Request, reportType: str | None = None) -> Any:
|
||
"""下载排产导出模板:表头级空白工作簿,与真实导出共用同一份合同。"""
|
||
import hashlib
|
||
from urllib.parse import quote
|
||
|
||
from fastapi.responses import Response
|
||
|
||
from server.aps_domain.report_contracts import build_contract_template
|
||
try:
|
||
template = build_contract_template(reportType or "schedule-plan")
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
etag = '"' + hashlib.sha256(template["xlsxBytes"]).hexdigest()[:32] + '"'
|
||
headers = {
|
||
"ETag": etag,
|
||
"X-APS-Template-Id": template["templateId"],
|
||
"X-APS-Contract-Digest": template["contractDigest"],
|
||
"X-APS-Template-Sheets": quote(",".join(sheet["name"] for sheet in template["sheets"])),
|
||
}
|
||
cached = [tag.strip() for tag in (request.headers.get("if-none-match") or "").split(",")]
|
||
if etag in cached or "*" in cached:
|
||
return Response(status_code=304, headers=headers)
|
||
fname = quote(template["filename"])
|
||
return Response(
|
||
content=template["xlsxBytes"],
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={**headers, "Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
|
||
)
|
||
|
||
|
||
@app.get("/api/reports/{report_type}")
|
||
async def report_export(
|
||
report_type: str,
|
||
orderNo: str | None = None,
|
||
format: str | None = None,
|
||
versionId: int | None = None,
|
||
) -> Any:
|
||
"""报告导出:Markdown 或 Excel(plan/schedule-order/schedule-equipment 默认 xlsx)。"""
|
||
from urllib.parse import quote
|
||
from fastapi.responses import PlainTextResponse, Response
|
||
from server.aps_domain.reports import (
|
||
ScheduleExportVersionError, build_plan_report_docx, build_report,
|
||
build_schedule_equipment_export, build_schedule_order_export,
|
||
persist_report_xlsx,
|
||
)
|
||
xlsx_types = ("plan", "schedule-plan", "flex-plan", "schedule-order", "schedule-equipment")
|
||
fmt = (format or ("xlsx" if report_type in xlsx_types else "md")).lower()
|
||
if fmt in ("docx", "word") and report_type in ("plan", "schedule-plan", "flex-plan"):
|
||
word_report = build_plan_report_docx(get_store().data, order_no=orderNo)
|
||
if not word_report.get("docxBytes"):
|
||
return PlainTextResponse(word_report.get("message") or "报告生成失败", status_code=409)
|
||
fname = quote(word_report.get("filename") or "排产计划报告.docx")
|
||
return Response(
|
||
content=word_report["docxBytes"],
|
||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
|
||
)
|
||
if report_type in ("schedule-order", "schedule-equipment"):
|
||
builder = (build_schedule_order_export if report_type == "schedule-order"
|
||
else build_schedule_equipment_export)
|
||
try:
|
||
report = builder(
|
||
get_store().data,
|
||
order_no=orderNo,
|
||
version_id=versionId,
|
||
)
|
||
except ScheduleExportVersionError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
else:
|
||
report = build_report(get_store().data, report_type, order_no=orderNo)
|
||
if fmt == "xlsx" and report.get("xlsxBytes"):
|
||
if report.get("reportId"):
|
||
persist_report_xlsx(report["reportId"], report["xlsxBytes"], report.get("filename"))
|
||
fname = quote((report.get("filename") or f"{report['title']}.xlsx").replace(" ", "_"))
|
||
return Response(
|
||
content=report["xlsxBytes"],
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
|
||
)
|
||
filename = quote(f"{report['title'].replace(' ', '_')}.md")
|
||
return PlainTextResponse(report["markdown"], media_type="text/markdown; charset=utf-8",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
|
||
|
||
@app.get("/api/settings/llm")
|
||
async def settings_llm() -> dict:
|
||
"""设置中心·LLM Provider 状态(P0 只读;不暴露密钥)。"""
|
||
from server.agent_core.providers import get_provider # 单例
|
||
p = get_provider() # 当前配置
|
||
return {
|
||
"provider": p.provider or None, # deepseek / kimi / None
|
||
"model": p.model or None, # 模型名
|
||
"baseUrl": p.base_url or None, # 端点(无敏感信息)
|
||
"enabled": p.enabled, # False = 模型侧不可用(自然语言仍归 Pi Agent)
|
||
"fallback": "无(自然语言统一交给 Pi Agent;未配置模型即显式失败,不再做规则解析)", # 降级去向(固定文案)
|
||
}
|
||
|
||
@app.get("/api/settings/preferences")
|
||
async def settings_preferences(project_id: str | None = None) -> dict:
|
||
"""设置中心·偏好解释(矩阵 66 行):当前默认策略 + 置信度 + 得分明细。
|
||
|
||
project_id 缺省时取当前活动项目(world_key),展示该项目维度的偏好;
|
||
前端无需显式传项目即能看到计划员的真实偏好信号。
|
||
"""
|
||
from server.knowledge import get_preferences
|
||
from server.state.store import get_store
|
||
ps = get_preferences()
|
||
effective = project_id or getattr(get_store(), "world_key", None) or None
|
||
return {
|
||
"explain": ps.explain(project_id=effective),
|
||
"projectId": effective,
|
||
"maxSamples": 200,
|
||
}
|
||
|
||
@app.post("/api/settings/preferences/reset")
|
||
async def settings_preferences_reset(req: PreferenceResetRequest) -> dict:
|
||
"""重置偏好(矩阵 66 行):按项目清除样本。"""
|
||
from server.knowledge import get_preferences
|
||
removed = get_preferences().reset(req.projectId)
|
||
return {"message": f"已清除 {removed} 条偏好样本", "removed": removed}
|
||
|
||
# ---------------- 集成适配器对接状态(round-46 方向 LL · 矩阵 73 现场对接准备) ----------------
|
||
@app.get("/api/integrations/mes/readiness")
|
||
async def integrations_mes_readiness() -> dict:
|
||
"""MES HTTP 适配器对接就绪检查(P0 只读):未配置返回 configured=false + 中文提示,不 500。"""
|
||
from server.integrations.mes_http import get_http_mes_client # 单例(环境变量驱动)
|
||
return get_http_mes_client().readiness()
|
||
|
||
@app.post("/api/integrations/mes/readiness/probe")
|
||
async def integrations_mes_readiness_probe() -> dict:
|
||
"""触发一次 MES 连通性探测(P1):轻量 GET /health(短超时),失败记录 lastError 不抛。"""
|
||
from server.integrations.mes_http import get_http_mes_client # 单例(环境变量驱动)
|
||
return get_http_mes_client().readiness(probe=True)
|
||
|
||
# ---------------- 自动化驱动端点(round-40 方向 S · 矩阵 76) ----------------
|
||
@app.post("/api/automation/tick")
|
||
async def automation_tick(req: AutomationTickRequest) -> dict:
|
||
"""手动驱动一次自动化调度时钟:到期规则触发并写 AUTOMATION 审计 + 状态落盘。"""
|
||
runtime = _get_automation_runtime()
|
||
runs = runtime.tick(session_id=req.sessionId or "automation")
|
||
return {
|
||
"ok": True,
|
||
"triggered": len(runs),
|
||
"runs": runs,
|
||
"status": runtime.status(),
|
||
}
|
||
|
||
@app.get("/api/automation/status")
|
||
async def automation_status() -> dict:
|
||
"""自动化调度状态(只读投影:规则/暂停/到期/运行计数)。"""
|
||
return _get_automation_runtime().status()
|
||
|
||
web_dist = Path(__file__).resolve().parents[2] / "apps" / "web" / "dist"
|
||
if web_dist.exists():
|
||
app.mount("/", StaticFiles(directory=str(web_dist), html=True), name="web")
|
||
|
||
return app # 返回配置完成的应用
|