aps-agent/server/gateway/app.py

2307 lines
111 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# ============================================================
# FastAPI 应用与路由(moduleId: gateway-app, 可重生 ✅)
# 端点清单:
# POST /api/chat 对话(SSE 流:meta/intent/token/command/block/done)
# GET /api/world/summary KPI 摘要
# GET /api/world/gantt 甘特视图数据
# GET /api/world/load 负荷热力数据
# GET /api/world/due 交期承诺看板数据
# GET /api/orders 订单管理页数据(订单 + 成品选项)
# POST /api/orders/stage 订单写入动作暂存确认卡(order.upsert/cancel/complete)
# GET /api/master 主数据管理页数据(资源树/物料BOM/日历维保)
# POST /api/master/stage 主数据写入动作暂存确认卡(master.*.upsert)
# GET /api/mrp MRP 建议单(采购/委外,订单分解产物)
# POST /api/mrp/decompose 订单分解(P1:产出 DRAFT 建议)
# GET /api/timeline 时间线导轨(检查点 + 版本,§4.4)
# 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 # 返回配置完成的应用