aps-agent/server/gateway/app.py

5299 lines
259 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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 # 返回配置完成的应用