2270 lines
109 KiB
Python
2270 lines
109 KiB
Python
# ============================================================
|
||
# 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 = "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)
|
||
|
||
|
||
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.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}
|
||
|
||
# ---------------- 对话: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 # 返回配置完成的应用
|