From 03ba0810ae27c78250f335f0360633a2c458cf1e Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 24 Aug 2026 17:11:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(park):=20=E5=9B=AD=E5=8C=BA=E4=B8=BB?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=20park=5Fconfig=20+=20/park=20?= =?UTF-8?q?=E5=86=99=E7=AB=AF=E7=82=B9=EF=BC=88=E4=BC=81=E4=B8=9A/?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93/=E7=9F=A5=E8=AF=86=E5=BA=93/?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E6=95=B0=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 冒烟通过。 --- app/park/park_config.py | 223 ++++++++++++++++++++++++++++++++++++++++ app/park/routers.py | 124 ++++++++++++++++++++-- 2 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 app/park/park_config.py diff --git a/app/park/park_config.py b/app/park/park_config.py new file mode 100644 index 0000000..e31f89d --- /dev/null +++ b/app/park/park_config.py @@ -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 diff --git a/app/park/routers.py b/app/park/routers.py index 61466a3..66e1f0f 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -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 = {