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:
Pine
2026-08-24 17:11:17 +08:00
parent 4d4d38fe0f
commit 03ba0810ae
2 changed files with 340 additions and 7 deletions
+223
View File
@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
"""园区主数据源(park_config)—— 入驻企业 / 园区智能体 / 知识库 / 大屏数据。
园区端(admin-portal carrier)与园区大屏共同的主数据源,落 serverdata/park/park_config.json。
企业默认从 sim_engine.COMPANIES39 家)一次性导入,园区端可增删改 + 状态流转。
"""
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
View File
@@ -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 = {