feat(park): 园区主数据源 park_config + /park 写端点(企业/智能体/知识库/大屏数据)
新增 app/park/park_config.py(serverdata/park/park_config.json 主数据源,企业默认自 sim_engine COMPANIES 39 家导入);routers.py 增 /park/api/park/companies CRUD、/park/api/agent/config、 /park/api/kb/docs CRUD+reindex、/park/api/screen/data GET/PUT;/park/api/config 的 api_base 修正为 /park 前缀(避免大屏 request() /api 双写)。TestClient 冒烟通过。
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""园区主数据源(park_config)—— 入驻企业 / 园区智能体 / 知识库 / 大屏数据。
|
||||
|
||||
园区端(admin-portal carrier)与园区大屏共同的主数据源,落 serverdata/park/park_config.json。
|
||||
企业默认从 sim_engine.COMPANIES(39 家)一次性导入,园区端可增删改 + 状态流转。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config import PARK_DIR, settings
|
||||
from .sim_engine import COMPANIES
|
||||
|
||||
CONFIG_FILE = Path(PARK_DIR) / "park_config.json"
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _default() -> dict:
|
||||
# 首次:从园区名录一次性导入企业(name 为准,其余缺省)
|
||||
companies = [
|
||||
{
|
||||
"id": f"PC-{i:03d}",
|
||||
"name": name,
|
||||
"zone": "",
|
||||
"room": "",
|
||||
"industry": "",
|
||||
"bio": "",
|
||||
"founder": "",
|
||||
"status": "active",
|
||||
"employees": None,
|
||||
"created_at": _now(),
|
||||
}
|
||||
for i, name in enumerate(COMPANIES, start=1)
|
||||
]
|
||||
return {
|
||||
"companies": companies,
|
||||
"agent": {
|
||||
"system_prompt": settings.S2S_INSTRUCTIONS,
|
||||
"model": settings.LLM_MODEL,
|
||||
"enabled_tools": ["get_park_overview", "query_companies", "control_display", "get_time"],
|
||||
"preset_questions": [],
|
||||
"opening": "",
|
||||
},
|
||||
"kb": {"docs": []},
|
||||
"screen": {
|
||||
"name": "昆明市大学生创业园",
|
||||
"region": "云南·昆明",
|
||||
"capacity": 49,
|
||||
"invested": 560,
|
||||
"jobs": 213,
|
||||
"area": 3000,
|
||||
"founded": 2009,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CONFIG_FILE.read_text("utf-8"))
|
||||
# 规范化:缺字段补默认
|
||||
base = _default()
|
||||
base.update(data)
|
||||
return base
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _default()
|
||||
|
||||
|
||||
def _save(data: dict):
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
|
||||
|
||||
|
||||
# ---------------- 入驻企业 ----------------
|
||||
|
||||
def list_companies(status: str | None = None) -> list[dict]:
|
||||
with _lock:
|
||||
data = _load()
|
||||
comps = data.get("companies", [])
|
||||
if status:
|
||||
comps = [c for c in comps if c.get("status") == status]
|
||||
return comps
|
||||
|
||||
|
||||
def create_company(payload: dict) -> dict:
|
||||
with _lock:
|
||||
data = _load()
|
||||
comps = data.setdefault("companies", [])
|
||||
cid = f"PC-{int(time.time())%100000}-{len(comps)+1}"
|
||||
row = {
|
||||
"id": cid,
|
||||
"name": payload.get("name", ""),
|
||||
"zone": payload.get("zone", ""),
|
||||
"room": payload.get("room", ""),
|
||||
"industry": payload.get("industry", ""),
|
||||
"bio": payload.get("bio", ""),
|
||||
"founder": payload.get("founder", ""),
|
||||
"status": payload.get("status", "applying"),
|
||||
"employees": payload.get("employees"),
|
||||
"created_at": _now(),
|
||||
}
|
||||
comps.append(row)
|
||||
_save(data)
|
||||
return row
|
||||
|
||||
|
||||
def update_company(cid: str, payload: dict) -> dict | None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
comps = data.setdefault("companies", [])
|
||||
for c in comps:
|
||||
if c.get("id") == cid:
|
||||
for k, v in payload.items():
|
||||
if v is not None and k in c:
|
||||
c[k] = v
|
||||
_save(data)
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def delete_company(cid: str) -> bool:
|
||||
with _lock:
|
||||
data = _load()
|
||||
comps = data.get("companies", [])
|
||||
before = len(comps)
|
||||
data["companies"] = [c for c in comps if c.get("id") != cid]
|
||||
changed = len(data["companies"]) != before
|
||||
if changed:
|
||||
_save(data)
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------- 园区智能体 ----------------
|
||||
|
||||
def get_agent() -> dict:
|
||||
with _lock:
|
||||
return _load().get("agent", _default()["agent"])
|
||||
|
||||
|
||||
def update_agent(payload: dict) -> dict:
|
||||
with _lock:
|
||||
data = _load()
|
||||
agent = data.setdefault("agent", _default()["agent"])
|
||||
for k, v in payload.items():
|
||||
if v is not None:
|
||||
agent[k] = v
|
||||
_save(data)
|
||||
return agent
|
||||
|
||||
|
||||
# ---------------- 园区知识库 ----------------
|
||||
|
||||
def list_kb_docs() -> list[dict]:
|
||||
with _lock:
|
||||
return _load().get("kb", {}).get("docs", [])
|
||||
|
||||
|
||||
def create_kb_doc(payload: dict) -> dict:
|
||||
with _lock:
|
||||
data = _load()
|
||||
docs = data.setdefault("kb", {}).setdefault("docs", [])
|
||||
row = {
|
||||
"id": f"KB-{int(time.time())%100000}-{len(docs)+1}",
|
||||
"group": payload.get("group", "general"),
|
||||
"title": payload.get("title", ""),
|
||||
"content_md": payload.get("content_md", ""),
|
||||
"created_at": _now(),
|
||||
}
|
||||
docs.append(row)
|
||||
_save(data)
|
||||
return row
|
||||
|
||||
|
||||
def update_kb_doc(did: str, payload: dict) -> dict | None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
docs = data.get("kb", {}).get("docs", [])
|
||||
for d in docs:
|
||||
if d.get("id") == did:
|
||||
for k, v in payload.items():
|
||||
if v is not None and k in d:
|
||||
d[k] = v
|
||||
_save(data)
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def delete_kb_doc(did: str) -> bool:
|
||||
with _lock:
|
||||
data = _load()
|
||||
docs = data.get("kb", {}).get("docs", [])
|
||||
before = len(docs)
|
||||
data["kb"]["docs"] = [d for d in docs if d.get("id") != did]
|
||||
changed = len(data["kb"]["docs"]) != before
|
||||
if changed:
|
||||
_save(data)
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------- 大屏数据 ----------------
|
||||
|
||||
def get_screen() -> dict:
|
||||
with _lock:
|
||||
return _load().get("screen", _default()["screen"])
|
||||
|
||||
|
||||
def update_screen(payload: dict) -> dict:
|
||||
with _lock:
|
||||
data = _load()
|
||||
screen = data.setdefault("screen", _default()["screen"])
|
||||
for k, v in payload.items():
|
||||
if v is not None:
|
||||
screen[k] = v
|
||||
_save(data)
|
||||
return screen
|
||||
+117
-7
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import park_config
|
||||
from .config import settings
|
||||
from .event_bus import bus
|
||||
from .mqtt import hub
|
||||
@@ -52,6 +53,41 @@ class RegisterBody(BaseModel):
|
||||
parent_id: str = ""
|
||||
|
||||
|
||||
class CompanyBody(BaseModel):
|
||||
name: str = ""
|
||||
zone: str = ""
|
||||
room: str = ""
|
||||
industry: str = ""
|
||||
bio: str = ""
|
||||
founder: str = ""
|
||||
status: str = "applying"
|
||||
employees: int | None = None
|
||||
|
||||
|
||||
class AgentBody(BaseModel):
|
||||
system_prompt: str | None = None
|
||||
model: str | None = None
|
||||
enabled_tools: list[str] | None = None
|
||||
preset_questions: list[str] | None = None
|
||||
opening: str | None = None
|
||||
|
||||
|
||||
class KbDocBody(BaseModel):
|
||||
group: str = "general"
|
||||
title: str = ""
|
||||
content_md: str = ""
|
||||
|
||||
|
||||
class ScreenBody(BaseModel):
|
||||
name: str | None = None
|
||||
region: str | None = None
|
||||
capacity: int | None = None
|
||||
invested: int | None = None
|
||||
jobs: int | None = None
|
||||
area: int | None = None
|
||||
founded: int | None = None
|
||||
|
||||
|
||||
# ==================== 健康 / 设置 / 引导 ====================
|
||||
|
||||
@router.get("/api/health")
|
||||
@@ -89,8 +125,10 @@ async def runtime_config(request: Request):
|
||||
"tick_interval": settings.MQTT_TICK_INTERVAL,
|
||||
},
|
||||
"voice_ws": settings.S2S_WS_URL if settings.S2S_ENABLED else "",
|
||||
# 大屏前端 request() 以 `${api_base}/api/...`、`${api_base}/file/...` 拼接,
|
||||
# 故 api_base 返回 `/park` 前缀(不含 /api),避免 /api 双写。
|
||||
"media_base": f"{request.base_url}park/file",
|
||||
"api_base": f"{request.base_url}park/api",
|
||||
"api_base": f"{request.base_url}park",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,18 +144,90 @@ async def dashboard_overview():
|
||||
return sim_engine.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/park/companies")
|
||||
async def park_companies():
|
||||
snap = sim_engine.snapshot()
|
||||
return snap.get("companies", [])
|
||||
|
||||
|
||||
@router.get("/api/park/zones")
|
||||
async def park_zones():
|
||||
snap = sim_engine.snapshot()
|
||||
return snap.get("zones", [])
|
||||
|
||||
|
||||
# ==================== 入驻企业(park_config 主数据源) ====================
|
||||
|
||||
@router.get("/api/park/companies")
|
||||
async def park_companies():
|
||||
return park_config.list_companies()
|
||||
|
||||
|
||||
@router.post("/api/park/companies")
|
||||
async def park_company_create(body: CompanyBody):
|
||||
return park_config.create_company(body.model_dump())
|
||||
|
||||
|
||||
@router.put("/api/park/companies/{cid}")
|
||||
async def park_company_update(cid: str, body: CompanyBody):
|
||||
row = park_config.update_company(cid, body.model_dump(exclude_unset=True))
|
||||
if row is None:
|
||||
return JSONResponse({"ok": False, "error": "企业不存在"}, status_code=404)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/api/park/companies/{cid}")
|
||||
async def park_company_delete(cid: str):
|
||||
ok = park_config.delete_company(cid)
|
||||
return {"ok": ok, "id": cid}
|
||||
|
||||
|
||||
# ==================== 园区智能体 / 知识库 / 大屏数据 ====================
|
||||
|
||||
@router.get("/api/agent/config")
|
||||
async def agent_config_get():
|
||||
return park_config.get_agent()
|
||||
|
||||
|
||||
@router.put("/api/agent/config")
|
||||
async def agent_config_put(body: AgentBody):
|
||||
return park_config.update_agent(body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.get("/api/kb/docs")
|
||||
async def kb_docs_list():
|
||||
return park_config.list_kb_docs()
|
||||
|
||||
|
||||
@router.post("/api/kb/docs")
|
||||
async def kb_docs_create(body: KbDocBody):
|
||||
return park_config.create_kb_doc(body.model_dump())
|
||||
|
||||
|
||||
@router.put("/api/kb/docs/{did}")
|
||||
async def kb_docs_update(did: str, body: KbDocBody):
|
||||
row = park_config.update_kb_doc(did, body.model_dump(exclude_unset=True))
|
||||
if row is None:
|
||||
return JSONResponse({"ok": False, "error": "文档不存在"}, status_code=404)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/api/kb/docs/{did}")
|
||||
async def kb_docs_delete(did: str):
|
||||
ok = park_config.delete_kb_doc(did)
|
||||
return {"ok": ok, "id": did}
|
||||
|
||||
|
||||
@router.post("/api/kb/reindex")
|
||||
async def kb_reindex():
|
||||
# 知识库索引重建(重模块 rag 后续接入);当前仅返回 ok 占位
|
||||
return {"ok": True, "reindexed": False}
|
||||
|
||||
|
||||
@router.get("/api/screen/data")
|
||||
async def screen_data_get():
|
||||
return park_config.get_screen()
|
||||
|
||||
|
||||
@router.put("/api/screen/data")
|
||||
async def screen_data_put(body: ScreenBody):
|
||||
return park_config.update_screen(body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
# ==================== 展示控制(管理端 → MQTT) ====================
|
||||
# 全部 MQTT 前端控制命令白名单(与 park-desktop docs/mqtt-commands.md 一致)
|
||||
_VALID_DISPLAY_ACTIONS = {
|
||||
|
||||
Reference in New Issue
Block a user