# ============================================================ # 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 载荷序列化 from contextlib import asynccontextmanager # 自动化后台 tick 生命周期 # SSE 载荷序列化 import os 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.intent import recognize # 意图两级管线 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 # ---------------- 请求体契约 ---------------- 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 query: str = "换线" 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 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 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="RULE_FAST", ) 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 DrawingStageRequest(BaseModel): sessionId: str = "web" candidateIds: list[str] = Field(default_factory=list) def _drawing_allowed_roots(project_id: str | None = None) -> list[Path]: """项目工程目录 + APS_DRAWING_ALLOWED_ROOTS 构成图纸路径白名单。""" 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 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 _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: candidate = Path(raw_path).expanduser().resolve(strict=True) if candidate.suffix.lower() != ".dxf" or not candidate.is_file(): raise ValueError("仅允许读取存在的 DXF 文件") roots = _drawing_allowed_roots(project_id) if not roots: raise PermissionError("没有可用的工程目录;请设置项目 workDir 或 APS_DRAWING_ALLOWED_ROOTS") if _path_within_roots(candidate, roots): return candidate if project_id: # 当前项目目录未命中时回退到全量白名单(其他项目工程目录 + 环境变量 # 允许目录),避免跨项目引用图纸被误拦;白名单之外的任意路径仍拒绝。 if _path_within_roots(candidate, _drawing_allowed_roots(None)): return candidate raise PermissionError( "图纸路径不在项目工程目录或允许的本地目录内" f"(当前允许目录:{';'.join(str(root) for root in roots)})") 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": "CANDIDATE"}) 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": "CANDIDATE", "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": source.get("sourcePath") or asset.get("path"), "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 []} 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 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.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.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 import dialog # M-F 多轮对话状态机 from server.agent_core.context import normalize_history, resolve_followup from server.contracts import AgentReply as _AgentReply 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 follow = resolve_followup(user_text, history) if follow.get("kind") == "deny" and follow.get("reply"): reply = _AgentReply(text=str(follow["reply"])) for i in range(0, len(reply.text), 24): yield _sse({"type": "token", "text": reply.text[i:i + 24]}) await asyncio.sleep(0.02) yield _sse({"type": "done"}) return if follow.get("kind") in ("affirm", "refer") and follow.get("text"): user_text = str(follow["text"]) emit_thinking("结合上文", follow.get("note") or user_text[:40], status="done") intent = None reply = None emit_thinking("理解意图", "正在识别你要做什么…", pct=5) for ev in drain_thinking(): yield _sse(ev) pre = dialog.pre_route(store, session_id, user_text) # ②a 向导/澄清上下文优先 if isinstance(pre, _AgentReply): reply = pre # 向导继续对话 elif pre is not None: intent = pre # 澄清回填出的意图 if intent is None and reply is None: intent = await recognize(user_text, store.data) # ② 意图识别(两级管线) if intent is not None and not intent.params.get("query"): intent.params["query"] = user_text # 保证助理/分析能拿到原话 if intent is not None: intent.params["_history"] = history intent.params["_rawUserText"] = req.text if follow.get("note"): intent.params["_contextNote"] = follow["note"] if reply is None and intent is not None: clarify = dialog.post_route(store, session_id, user_text, intent) # ②b 低置信 → 澄清卡 if clarify is not None: reply = clarify if intent is not None: dump = intent.model_dump() dump.get("params", {}).pop("_history", None) yield _sse({"type": "intent", "intent": dump}) _THINK_BY_INTENT = { "data.analyze": "深度分析项目数据(可能含大 SQL,请稍候)", "folder.analyze": "解析工程目录表格", "folder.schedule": "导入目录数据并准备试排", "flex.schedule": "运行柔性排产引擎", "schedule.run": "运行排产引擎", "schedule.wizard": "启动排产向导", } tip = _THINK_BY_INTENT.get(intent.intent, f"执行 {intent.intent}") emit_thinking("开始执行", tip, pct=8) for ev in drain_thinking(): yield _sse(ev) if reply is None: # 重活放到线程,避免堵住事件循环 → Thinking 才能边做边推 request_context = contextvars.copy_context() def _run_intent(): # 统一工具运行时:LLM 提议意图先经登记校验(矩阵 111 行) 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 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: # 兜底:任何异常转为 error 事件(连接不中断) yield _sse({"type": "error", "message": f"处理失败:{exc}"}) 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.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, build_drawing_master_candidates try: path = _resolve_allowed_drawing_path(req.path, req.projectId) parsed = 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 change_set = build_drawing_master_candidates(parsed) store = get_store() asset = {**(parsed.get("asset") or {}), "projectId": req.projectId, "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"] = req.projectId 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": req.projectId, "sourcePath": str(path), "status": "SUCCEEDED", "parsedAt": parsed.get("parsedAt")}) store.save() return {"message": "图纸解析完成,候选已生成待确认", "drawing": _drawing_api_payload(asset, change_set), "parsed": parsed} @app.get("/api/projects/{project_id}/drawings") async def project_drawings(project_id: str) -> dict: world = get_store().data; 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) -> dict: world = get_store().data; 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) -> dict: from server.aps_domain.drawing_dxf import render_dxf_preview asset = _drawing_asset(get_store().data, drawing_id) if asset is None: raise HTTPException(status_code=404, detail="图纸不存在或已删除") source = (asset.get("parsed") or {}).get("asset", {}).get("sourcePath") or asset.get("sourcePath") try: 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.post("/api/drawings/{drawing_id}/stage") async def drawing_stage(drawing_id: str, req: DrawingStageRequest) -> dict: store = get_store(); 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 # ---------------- 现场完整生产路线 → 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.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(query: str = "换线") -> dict: """SOP 编译预览(IND-02,P0)。""" from server.aps_domain.sop_rules import compile_sop_by_query from server.knowledge import get_knowledge return compile_sop_by_query(get_knowledge().assets, query) @app.post("/api/sop/stage") async def sop_stage(req: SopStageRequest) -> dict: """SOP 规则包应用暂存确认卡(P2)。""" from server.aps_domain.sop_rules import compile_sop_by_query, 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_query(get_knowledge().assets, req.query) 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", {"pack": pack, "query": req.query}, 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), ) 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 "") 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 _PROJECT_UPLOAD_EXTS = {".xlsx", ".xlsm", ".csv", ".txt", ".sql"} _PROJECT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024 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:{', '.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 written = 0 try: with target.open("wb") as handle: while chunk := upload.file.read(1024 * 1024): written += len(chunk) if written > _PROJECT_UPLOAD_MAX_BYTES: raise ValueError("文件超过 100MB 上限") handle.write(chunk) old_id = existing.get(name) if old_id: ps.delete_file(old_id) ps.create_file(project_id, name, "import", "浏览器上传") saved.append(name) except Exception as exc: # noqa: BLE001 - 单文件失败不影响其余文件 errors.append({"name": name, "error": str(exc)}) return { "message": f"已上传 {len(saved)} 个文件到项目目录", "saved": saved, "errors": errors, "workDir": work, "workspace": ps.snapshot(include_messages=False), } @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="RULE_FAST") # 统一工具运行时:直连端点同门禁(矩阵 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 唯一执行通道) ---------------- @app.post("/api/actions/confirm") async def confirm(req: ConfirmRequest) -> dict: """批准/驳回一条 P2/P3 动作(门禁放行后的执行入口,§3.3)。""" from server.auth.context import get_identity identity = get_identity(required=True) store = get_store() # 世界状态 existed = harness.is_confirmation_pending(req.confirmId) message = execute_confirmed(store, req.confirmId, req.approve, # 执行(内部写审计+落盘) actor=identity.username, note=req.note) second = req.approve and harness.is_confirmation_pending(req.confirmId) return {"message": message, "refresh": req.approve and existed and not second, "secondConfirmRequired": 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 = execute_confirmed(store, confirm_id, req.approve, actor=identity.username, note=req.note) second = req.approve and harness.is_confirmation_pending(confirm_id) 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 queue = get_job_queue() return {"jobs": queue.list(status=status), "stats": queue.stats()} @app.post("/api/jobs") async def jobs_submit(req: JobSubmitRequest) -> dict: """提交后台任务(P1):敏感性/蒙特卡洛重算放后台,沙盒执行不写主干。""" import copy from server.agent_core.async_jobs import get_job_queue kind = req.kind 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, ) else: return {"error": f"未知任务类型:{kind}(支持 sensitivity.recompute / montecarlo.recompute)"} job_id = get_job_queue().submit(kind, _fn, params=req.params, actor=req.actor) return {"jobId": job_id, "job": get_job_queue().poll(job_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 job = get_job_queue().poll(job_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 cancelled = get_job_queue().cancel(job_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/{report_type}") async def report_export(report_type: str, orderNo: str | None = None, format: str | None = None) -> Any: """报告导出:Markdown 或 Excel(plan 默认 xlsx)。""" from urllib.parse import quote from fastapi.responses import PlainTextResponse, Response from server.aps_domain.reports import build_plan_report_docx, build_report, persist_report_xlsx fmt = (format or ("xlsx" if report_type in ("plan", "schedule-plan", "flex-plan") 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}"}, ) 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 = 降级为规则解析 "fallback": "规则快路解析器(离线可用)", # 降级去向(固定文案) } @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 # 返回配置完成的应用