aps-agent/server/gateway/app.py

2270 lines
109 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 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)
2026-07-21 11:05:57 +08:00
# 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
2026-07-21 11:05:57 +08:00
import uuid # 会话 ID 生成
from pathlib import Path
2026-07-21 11:05:57 +08:00
from typing import Any, AsyncIterator # 类型标注
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
2026-07-21 11:05:57 +08:00
from fastapi.middleware.cors import CORSMiddleware # 跨域(开发期前端直连用)
from fastapi.responses import StreamingResponse # SSE 响应
from fastapi.staticfiles import StaticFiles
2026-07-21 11:05:57 +08:00
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,
)
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
# ---------------- 请求体契约 ----------------
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 = "phone"
username: str | None = None
password: str | None = None
mobile: str | None = None
code: str | None = None
identifier: str | None = None
class LicenseActivationRequest(BaseModel):
code: str = Field(min_length=1, max_length=256)
deviceId: str | None = Field(default=None, max_length=256)
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
# ---------------- 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.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)
users = await get_auth_provider().search_users(query, identity)
return {"users": users}
2026-07-21 11:05:57 +08:00
# ---------------- 对话: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
2026-07-21 11:05:57 +08:00
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()
2026-07-21 11:05:57 +08:00
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)
2026-07-21 11:05:57 +08:00
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()
2026-07-21 11:05:57 +08:00
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()}
2026-07-21 11:05:57 +08:00
@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"):
2026-07-21 11:05:57 +08:00
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)}
2026-07-21 11:05:57 +08:00
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"},
2026-07-21 11:05:57 +08:00
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)}
2026-07-21 11:05:57 +08:00
@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}
2026-07-21 11:05:57 +08:00
# ---------------- 时间线导轨(§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
2026-07-21 11:05:57 +08:00
# ---------------- 采用沙盒方案(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)}"},
)
2026-07-21 11:05:57 +08:00
@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")
2026-07-21 11:05:57 +08:00
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")
2026-07-21 11:05:57 +08:00
return app # 返回配置完成的应用