# ============================================================ # 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) # POST /api/actions/confirm 确认卡回传(批准/驳回 P2 动作) # POST /api/actions/scenario/apply 采用某个沙盒方案(P1 正式排产) # GET /api/gov/pending 门禁管理台·待审批队列(§6.10.2) # GET /api/gov/audit 门禁管理台·审计链 + 完整性校验(§3.6) # 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) # ⚠ 文档同步铁律(plan.md §12.9):新增端点必同步 docs/architecture/harness.md 只读端点备案 # ============================================================ from __future__ import annotations # 前向类型引用 import asyncio # 流式分片的微延时 import json # 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 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 # ---------------- 请求体契约 ---------------- class ChatRequest(BaseModel): """对话请求:会话 ID + 用户文本 + 可选多轮上文。""" sessionId: str | None = None 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) 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=驳回 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 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 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 MesReportRequest(BaseModel): sessionId: str | None = None woId: int track: str = "flex" progressPct: int | None = None finish: bool = False # ---------------- SSE 工具 ---------------- def _sse(payload: dict[str, Any]) -> str: """把一个事件对象编码为 SSE 帧(data: {json}\\n\\n)。""" return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n" # 标准 SSE 数据帧 def create_app() -> FastAPI: """应用工厂:注册中间件与全部路由。""" app = FastAPI(title="APS Planning Agent", version="0.1.0") # 应用实例 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) 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", ) @app.get("/api/health") async def health(request: Request) -> dict: return { "ok": True, "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, )) try: tenants = await get_auth_provider().tenants() except AuthError as exc: return auth_error_response(exc) return {"tenants": tenants} @app.get("/api/auth/login/precheck") async def auth_login_precheck(request: Request, username: str = "", tenantCode: 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]) 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)。""" if req.sessionId: from server.state.projects import get_project_store try: get_project_store().get_messages(req.sessionId) except (ValueError, PermissionError) as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc store = get_store() # 世界状态 session_id = req.sessionId or uuid.uuid4().hex[:12] # 会话 ID(缺省新建) 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 才能边做边推 def _run_intent(): return 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()}) 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"): sync_flex_orders_to_sales(world) store.save() mrp = list_mrp(world) 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 [], "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: """订单分解(P1 直通:只写 DRAFT 建议表,经 harness.guard 校验权级)。""" store = get_store() try: result = harness.guard("order.decompose", {"orderNo": req.orderNo}, lambda: decompose_orders(store.data, store.next_id, req.orderNo)) except ValueError as exc: return {"error": str(exc)} from server.agent_core.audit import write_audit write_audit(store.data, store.next_id, actor=req.sessionId or "web", category="ALGO_RUN", action="order.decompose", target={"type": "MRP", "id": req.orderNo or "ALL"}, power="P1", rationale={"purchase": len(result["purchase"]), "outsource": len(result["outsource"])}) 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 class ImportCommitRequest(BaseModel): sessionId: str = "" filename: str = "upload" batches: list[dict[str, Any]] = Field(default_factory=list) @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()} # ---------------- 现场完整生产路线 → 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)} class TemplateApplyRequest(BaseModel): sessionId: str = "web" templateCode: str productCode: str productName: str = "" actor: str = "planner" @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()} 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 @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: """执行柔性排产(P1 直通:能力池选设备 + 组虚拟产线,只写 flex* 草稿表)。""" 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)} vl = result["vlCount"] win = result.get("window") or "full" return {"message": f"柔性排产完成({result['sortMode']}/{win}):生成 {vl} 条虚拟产线 / " f"{result['woCount']} 个工单,冲突 {result['conflictCount']}," f"延期窗外 {result.get('deferredCount', 0)}。", "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)。""" 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.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)} @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()} @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} # 两类锚点 # ---------------- AG-08 项目 / 会话工作区 ---------------- @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} @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 @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") reply = await handle_intent(store, req.sessionId or "web", intent) # 走标准工作流(含审计落盘) # 偏好信号:采用比试排权重更高(明确的方案选择 §8.3) from server.knowledge import get_preferences # 局部导入(避免循环) get_preferences().record(req.strategy, source="scenario.apply", actor=req.sessionId or "web") return {"message": reply.text, "refresh": True} # 前端刷新世界与时间线 # ---------------- 确认卡回传(P2 唯一执行通道) ---------------- @app.post("/api/actions/confirm") async def confirm(req: ConfirmRequest) -> dict: """批准/驳回一条 P2 动作(门禁放行后的执行入口,§3.3)。""" store = get_store() # 世界状态 message = execute_confirmed(store, req.confirmId, req.approve, # 执行(内部写审计+落盘) actor=req.sessionId or "planner") return {"message": message, "refresh": req.approve} # 批准后前端应刷新世界数据 # ---------------- 治理可视化(§6.10 门禁管理台 / 重生中心 数据源) ---------------- @app.get("/api/gov/pending") async def gov_pending() -> dict: """门禁管理台·待审批队列(聊天确认卡与此为同一数据的两个视图)。""" return {"pending": harness.list_pending()} # 待确认动作投影 @app.get("/api/gov/audit") async def gov_audit(limit: int = 50) -> dict: """门禁管理台·审计链(最近 N 条 + 全链完整性校验结果,§3.6)。""" events = get_store().data.get("auditEvents", []) # 全部审计事件 return {"events": events[-limit:], # 最近 N 条(新在后) "chain": verify_audit_chain(events)} # 哈希链完整性校验 @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) # ---------------- 知识库与报告(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": "资产不存在"} # 缺失给错误体(前端兜底) class KnowledgeImportRequest(BaseModel): sessionId: str = "web" path: str | None = None filePath: str | None = None kind: str = "sop" title: str | None = None actor: str = "planner" @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)} 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" @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()} class SkillEnableRequest(BaseModel): sessionId: str = "web" enabled: bool = True actor: str = "planner" @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 @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_report, persist_report_xlsx fmt = (format or ("xlsx" if report_type in ("plan", "schedule-plan", "flex-plan") else "md")).lower() 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": "规则快路解析器(离线可用)", # 降级去向(固定文案) } 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 # 返回配置完成的应用