4 Commits

Author SHA1 Message Date
jiang 1712ecd4c7 feat(api): add web search endpoint 2026-06-09 16:13:24 +08:00
jiang 441979f581 修改默认超时时间 2026-06-05 19:11:53 +08:00
jiang e336ffcd46 移除存在无效数据的 cli 命令 2026-06-05 16:42:03 +08:00
jiang 52b8f07abd 更新 cli 命令,新增 network 其他元素的属性查询 2026-06-05 15:48:53 +08:00
16 changed files with 795 additions and 280 deletions
+8
View File
@@ -48,3 +48,11 @@ METADATA_DB_PASSWORD="password"
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account"
# ============================================
# Bocha Web Search API
# ============================================
BOCHA_API_KEY="sk-your-bocha-api-key"
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
+29
View File
@@ -0,0 +1,29 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-search",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+2
View File
@@ -18,6 +18,7 @@ from app.api.v1.endpoints import (
user_management, # 新增:用户管理
audit, # 新增:审计日志
meta,
web_search,
)
from app.api.v1.endpoints.network import (
general,
@@ -93,6 +94,7 @@ api_router.include_router(schemes.router, tags=["Schemes"])
api_router.include_router(misc.router, tags=["Misc"])
api_router.include_router(risk.router, tags=["Risk"])
api_router.include_router(cache.router, tags=["Cache"])
api_router.include_router(web_search.router, tags=["Web Search"])
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
api_router.include_router(
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
+5
View File
@@ -64,6 +64,11 @@ class Settings(BaseSettings):
KEYCLOAK_ALGORITHM: str = "RS256"
KEYCLOAK_AUDIENCE: str = ""
# Bocha Web Search API
BOCHA_API_KEY: str = ""
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD)
+4 -35
View File
@@ -1,36 +1,5 @@
from app.services.network_import import network_update, submit_scada_info
from app.services.scheme_management import (
create_user,
delete_user,
scheme_name_exists,
store_scheme_info,
delete_scheme_info,
query_scheme_list,
upload_shp_to_pg,
submit_risk_probability_result,
)
from app.services.valve_isolation import analyze_valve_isolation
from app.services.simulation_ops import (
project_management,
scheduling_simulation,
daily_scheduling_simulation,
)
from app.services.leakage_identifier import run_leakage_identification
"""Service package.
__all__ = [
"network_update",
"submit_scada_info",
"create_user",
"delete_user",
"scheme_name_exists",
"store_scheme_info",
"delete_scheme_info",
"query_scheme_list",
"upload_shp_to_pg",
"submit_risk_probability_result",
"project_management",
"scheduling_simulation",
"daily_scheduling_simulation",
"analyze_valve_isolation",
"run_leakage_identification",
]
Keep package initialization lightweight. Import concrete service modules directly,
for example: `from app.services.tjnetwork import open_project`.
"""
+93
View File
@@ -0,0 +1,93 @@
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field
from app.core.config import settings
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
class WebSearchRequest(BaseModel):
query: str = Field(..., min_length=1, description="搜索关键词")
freshness: Freshness | str = Field(
default="noLimit",
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
)
summary: bool = Field(default=True, description="是否返回网页摘要")
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
include: list[str] | None = Field(default=None, description="限定搜索域名")
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
class BochaSearchConfigError(RuntimeError):
pass
class BochaSearchAPIError(RuntimeError):
def __init__(self, status_code: int, detail: Any):
super().__init__("Bocha Web Search API request failed")
self.status_code = status_code
self.detail = detail
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
payload = request.model_dump(exclude_none=True)
if request.include:
payload["include"] = ",".join(request.include)
if request.exclude:
payload["exclude"] = ",".join(request.exclude)
return payload
async def search_bocha_web(
request: WebSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.BOCHA_API_KEY:
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
headers = {
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
"Content-Type": "application/json",
}
payload = _build_payload(request)
if client is not None:
response = await client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
async with httpx.AsyncClient(
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
def _parse_response(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise BochaSearchAPIError(
exc.response.status_code,
_response_detail(exc.response),
) from exc
return response.json()
def _response_detail(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+325 -12
View File
@@ -71,14 +71,14 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
def fake_request_json(ctx, **kwargs):
observed_runtime_ids.append(id(ctx))
assert ctx.auth.access_token == "token-1"
assert kwargs["params"] == {"network": "tjwater", "node": "11"}
return {"node": "11"}, 5
assert kwargs["params"] == {"network": "tjwater", "junction": "11"}
return {"id": "11"}, 5
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(
app,
["--auth-stdin", "network", "get-node-properties", "--node", "11"],
["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
input=json.dumps(
{
"server": "http://server",
@@ -93,37 +93,70 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"node": "11"}
assert payload["data"] == {"id": "11"}
assert len(observed_runtime_ids) == 1
def test_network_get_all_junction_properties_uses_network_context(monkeypatch):
def test_network_get_junction_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "J1"}], 5
return {"id": "J1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-junction-properties"])
result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "J1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
assert payload["data"] == {"id": "J1"}
assert captured == {
"access_token": "abc",
"path": "/getjunctionproperties/",
"params": {"network": "tjwater", "junction": "J1"},
}
def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
def test_network_get_pipe_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "P1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "P1"}
assert captured == {
"access_token": "abc",
"path": "/getpipeproperties/",
"params": {"network": "tjwater", "pipe": "P1"},
}
def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "P1"}], 5
@@ -132,13 +165,233 @@ def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pipe-properties"])
result = runner.invoke(app, ["network", "get-all-pipes-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "P1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
assert captured == {
"access_token": "abc",
"path": "/getallpipeproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "R1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "R1"}
assert captured == {
"access_token": "abc",
"path": "/getreservoirproperties/",
"params": {"network": "tjwater", "reservoir": "R1"},
}
def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "R1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "R1"}]
assert captured == {
"access_token": "abc",
"path": "/getallreservoirproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_tank_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "T1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "T1"}
assert captured == {
"access_token": "abc",
"path": "/gettankproperties/",
"params": {"network": "tjwater", "tank": "T1"},
}
def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "T1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "T1"}]
assert captured == {
"access_token": "abc",
"path": "/getalltankproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_pump_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "PU1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "PU1"}
assert captured == {
"access_token": "abc",
"path": "/getpumpproperties/",
"params": {"network": "tjwater", "pump": "PU1"},
}
def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "PU1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "PU1"}]
assert captured == {
"access_token": "abc",
"path": "/getallpumpproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_valve_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "V1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "V1"}
assert captured == {
"access_token": "abc",
"path": "/getvalveproperties/",
"params": {"network": "tjwater", "valve": "V1"},
}
def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "V1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-valves-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "V1"}]
assert captured == {
"access_token": "abc",
"path": "/getallvalveproperties/",
"params": {"network": "tjwater"},
}
def test_help_outputs_json_lists_commands():
@@ -598,6 +851,66 @@ def test_main_invalid_scada_field_is_rejected_before_request(capsys):
assert "cleaned_value" in stdout
def test_data_scada_get_rejects_removed_kind_before_request(capsys):
exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"])
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "INVALID_PARAMETER"' in stdout
assert "device" in stdout
assert "info" in stdout
def test_data_scada_list_help_only_shows_info_kind():
result = runner.invoke(app, ["data", "scada", "list", "--help"])
assert result.exit_code == 0
assert "info" in result.stdout
assert "device" not in result.stdout
assert "element" not in result.stdout
def test_data_scada_help_no_longer_lists_schema():
result = runner.invoke(app, ["data", "scada", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
commands = {command["command"] for command in payload["commands"]}
assert "data scada get" in commands
assert "data scada list" in commands
assert "data scada schema" not in commands
def test_data_scada_schema_command_is_removed():
result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"])
assert result.exit_code == 2
assert "No such command 'schema'" in result.output
def test_data_help_no_longer_lists_extension_or_misc():
result = runner.invoke(app, ["data", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
commands = {command["command"] for command in payload["commands"]}
assert "data timeseries" in commands
assert "data scada" in commands
assert "data scheme" in commands
assert "data extension" not in commands
assert "data misc" not in commands
def test_removed_data_extension_and_misc_commands_fail():
extension_result = runner.invoke(app, ["data", "extension", "list"])
misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"])
assert extension_result.exit_code == 2
assert "No such command 'extension'" in extension_result.output
assert misc_result.exit_code == 2
assert "No such command 'misc'" in misc_result.output
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
exit_code = main(["analysis"])
stdout = capsys.readouterr().out
-6
View File
@@ -26,8 +26,6 @@ data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
app.add_typer(network_app, name="network")
app.add_typer(component_app, name="component")
@@ -50,8 +48,6 @@ data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite")
data_app.add_typer(data_scada_app, name="scada")
data_app.add_typer(data_scheme_app, name="scheme")
data_app.add_typer(data_extension_app, name="extension")
data_app.add_typer(data_misc_app, name="misc")
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(network_app, ("network",)),
@@ -75,8 +71,6 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(data_timeseries_composite_app, ("data", "timeseries", "composite")),
(data_scada_app, ("data", "scada")),
(data_scheme_app, ("data", "scheme")),
(data_extension_app, ("data", "extension")),
(data_misc_app, ("data", "misc")),
]
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
+2 -105
View File
@@ -5,8 +5,6 @@ from typing import Annotated
import typer
from .apps import (
data_extension_app,
data_misc_app,
data_scada_app,
data_scheme_app,
data_timeseries_composite_app,
@@ -22,7 +20,6 @@ from .option_types import (
JUNCTION_TIMESERIES_FIELDS,
SCADA_TIMESERIES_FIELDS,
ScadaListKind,
ScadaSchemaKind,
SimulationQuery,
timeseries_fields_for_element_type,
)
@@ -414,15 +411,6 @@ def data_composite_pipeline_health(
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
mapping = {
("device", "schema"): ("/getscadadeviceschema/", {}),
("device", "get"): ("/getscadadevice/", {"id_param": "id"}),
("device", "list"): ("/getallscadadevices/", {}),
("device-data", "schema"): ("/getscadadevicedataschema/", {}),
("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}),
("element", "schema"): ("/getscadaelementschema/", {}),
("element", "get"): ("/getscadaelement/", {"id_param": "id"}),
("element", "list"): ("/getscadaelements/", {}),
("info", "schema"): ("/getscadainfoschema/", {}),
("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
("info", "list"): ("/getallscadainfo/", {}),
}
@@ -437,28 +425,10 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
return result
@data_scada_app.command("schema")
def data_scada_schema(
ctx: typer.Context,
kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind.value, "schema")
emit_api(
ctx,
summary="读取 SCADA schema 成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scada_app.command("get")
def data_scada_get(
ctx: typer.Context,
kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")],
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
id: Annotated[str, typer.Option("--id", help="记录 ID")],
) -> None:
runtime = runtime_context(ctx)
@@ -478,7 +448,7 @@ def data_scada_get(
@data_scada_app.command("list")
def data_scada_list(
ctx: typer.Context,
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|element|infodevice-data 无 list 接口")],
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind.value, "list")
@@ -536,76 +506,3 @@ def data_scheme_list(ctx: typer.Context) -> None:
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("keys")
def data_extension_keys(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据键成功",
method="GET",
path="/getallextensiondatakeys/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("get")
def data_extension_get(
ctx: typer.Context,
key: Annotated[str, typer.Option("--key", help="扩展键")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据成功",
method="GET",
path="/getextensiondata/",
params={"network": require_network(runtime), "key": key},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("list")
def data_extension_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据列表成功",
method="GET",
path="/getallextensiondata/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("sensor-placements")
def data_misc_sensor_placements(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取传感器位置成功",
method="GET",
path="/getallsensorplacements/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("burst-location-results")
def data_misc_burst_location_results(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管定位结果成功",
method="GET",
path="/getallburstlocateresults/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
+137 -27
View File
@@ -10,56 +10,42 @@ from .core import CLIError, require_network
from .option_types import ComponentOptionKind
@network_app.command("get-node-properties")
def network_get_node_properties(
@network_app.command("get-junction-properties")
def network_get_junction_properties(
ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")],
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取节点属性成功",
method="GET",
path="/getnodeproperties/",
params={"network": require_network(runtime), "node": node},
path="/getjunctionproperties/",
params={"network": require_network(runtime), "junction": junction},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-link-properties")
def network_get_link_properties(
@network_app.command("get-pipe-properties")
def network_get_pipe_properties(
ctx: typer.Context,
link: Annotated[str, typer.Option("--link", help="线 ID")],
pipe: Annotated[str, typer.Option("--pipe", help=" ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取管线属性成功",
summary="读取管属性成功",
method="GET",
path="/getlinkproperties/",
params={"network": require_network(runtime), "link": link},
path="/getpipeproperties/",
params={"network": require_network(runtime), "pipe": pipe},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-junction-properties")
def network_get_all_junction_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部节点属性成功",
method="GET",
path="/getalljunctionproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pipe-properties")
def network_get_all_pipe_properties(ctx: typer.Context) -> None:
@network_app.command("get-all-pipes-properties")
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
@@ -72,6 +58,130 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None:
)
@network_app.command("get-reservoir-properties")
def network_get_reservoir_properties(
ctx: typer.Context,
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水库属性成功",
method="GET",
path="/getreservoirproperties/",
params={"network": require_network(runtime), "reservoir": reservoir},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-reservoirs-properties")
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水库属性成功",
method="GET",
path="/getallreservoirproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-tank-properties")
def network_get_tank_properties(
ctx: typer.Context,
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水箱属性成功",
method="GET",
path="/gettankproperties/",
params={"network": require_network(runtime), "tank": tank},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-tanks-properties")
def network_get_all_tank_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水箱属性成功",
method="GET",
path="/getalltankproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-pump-properties")
def network_get_pump_properties(
ctx: typer.Context,
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水泵属性成功",
method="GET",
path="/getpumpproperties/",
params={"network": require_network(runtime), "pump": pump},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pumps-properties")
def network_get_all_pump_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水泵属性成功",
method="GET",
path="/getallpumpproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-valve-properties")
def network_get_valve_properties(
ctx: typer.Context,
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取阀门属性成功",
method="GET",
path="/getvalveproperties/",
params={"network": require_network(runtime), "valve": valve},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-valves-properties")
def network_get_all_valve_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部阀门属性成功",
method="GET",
path="/getallvalveproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@component_option_app.command("schema")
def component_option_schema(
ctx: typer.Context,
+1 -1
View File
@@ -15,7 +15,7 @@ import typer
SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 60
DEFAULT_TIMEOUT = 180
DEFAULT_SERVER = "http://192.168.1.114:8000"
+2 -3
View File
@@ -100,9 +100,8 @@ def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
(("component", "option", "schema"), "kind"): "time",
(("component", "option", "get"), "kind"): "time",
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
(("data", "scada", "schema"), "kind"): "device",
(("data", "scada", "get"), "kind"): "device",
(("data", "scada", "list"), "kind"): "device",
(("data", "scada", "get"), "kind"): "info",
(("data", "scada", "list"), "kind"): "info",
}
if (path, option_name) in path_specific_samples:
return path_specific_samples[(path, option_name)]
-9
View File
@@ -36,16 +36,7 @@ class DataSource(str, Enum):
SIMULATION = "simulation"
class ScadaSchemaKind(str, Enum):
DEVICE = "device"
DEVICE_DATA = "device-data"
ELEMENT = "element"
INFO = "info"
class ScadaListKind(str, Enum):
DEVICE = "device"
ELEMENT = "element"
INFO = "info"
+71 -77
View File
@@ -16,7 +16,7 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
("analysis", "risk"): "风险分析相关命令。",
("analysis", "sensor-placement"): "传感器选址相关命令。",
("data",): "时序、SCADA、方案和扩展数据查询命令。",
("data",): "时序、SCADA 和方案数据查询命令。",
("data", "timeseries"): "时序数据查询命令。",
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
("data", "timeseries", "scheme"): "方案时序查询命令。",
@@ -24,8 +24,6 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("data", "timeseries", "composite"): "复合时序查询命令。",
("data", "scada"): "SCADA 元数据查询命令。",
("data", "scheme"): "方案数据查询命令。",
("data", "extension"): "扩展数据查询命令。",
("data", "misc"): "其他结果数据查询命令。",
}
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
@@ -34,31 +32,77 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
)
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("network", "get-node-properties"): CommandDoc(
path=("network", "get-node-properties"),
("network", "get-junction-properties"): CommandDoc(
path=("network", "get-junction-properties"),
summary="读取节点属性",
description="调用 /getnodeproperties/。",
options=(CommandOptionDoc("node", "节点 ID", required=True),),
examples=("tjwater-cli network get-node-properties --node J1",),
description="调用 /getjunctionproperties/。",
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
examples=("tjwater-cli network get-junction-properties --junction J1",),
),
("network", "get-link-properties"): CommandDoc(
path=("network", "get-link-properties"),
summary="读取管线属性",
description="调用 /getlinkproperties/。",
options=(CommandOptionDoc("link", "线 ID", required=True),),
examples=("tjwater-cli network get-link-properties --link P1",),
("network", "get-pipe-properties"): CommandDoc(
path=("network", "get-pipe-properties"),
summary="读取管属性",
description="调用 /getpipeproperties/。",
options=(CommandOptionDoc("pipe", " ID", required=True),),
examples=("tjwater-cli network get-pipe-properties --pipe P1",),
),
("network", "get-all-junction-properties"): CommandDoc(
path=("network", "get-all-junction-properties"),
summary="读取全部节点属性",
description="调用 /getalljunctionproperties/。",
examples=("tjwater-cli network get-all-junction-properties",),
),
("network", "get-all-pipe-properties"): CommandDoc(
path=("network", "get-all-pipe-properties"),
("network", "get-all-pipes-properties"): CommandDoc(
path=("network", "get-all-pipes-properties"),
summary="读取全部管道属性",
description="调用 /getallpipeproperties/。",
examples=("tjwater-cli network get-all-pipe-properties",),
examples=("tjwater-cli network get-all-pipes-properties",),
),
("network", "get-reservoir-properties"): CommandDoc(
path=("network", "get-reservoir-properties"),
summary="读取水库属性",
description="调用 /getreservoirproperties/。",
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
),
("network", "get-all-reservoirs-properties"): CommandDoc(
path=("network", "get-all-reservoirs-properties"),
summary="读取全部水库属性",
description="调用 /getallreservoirproperties/。",
examples=("tjwater-cli network get-all-reservoirs-properties",),
),
("network", "get-tank-properties"): CommandDoc(
path=("network", "get-tank-properties"),
summary="读取水箱属性",
description="调用 /gettankproperties/。",
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
examples=("tjwater-cli network get-tank-properties --tank T1",),
),
("network", "get-all-tanks-properties"): CommandDoc(
path=("network", "get-all-tanks-properties"),
summary="读取全部水箱属性",
description="调用 /getalltankproperties/。",
examples=("tjwater-cli network get-all-tanks-properties",),
),
("network", "get-pump-properties"): CommandDoc(
path=("network", "get-pump-properties"),
summary="读取水泵属性",
description="调用 /getpumpproperties/。",
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
examples=("tjwater-cli network get-pump-properties --pump PU1",),
),
("network", "get-all-pumps-properties"): CommandDoc(
path=("network", "get-all-pumps-properties"),
summary="读取全部水泵属性",
description="调用 /getallpumpproperties/。",
examples=("tjwater-cli network get-all-pumps-properties",),
),
("network", "get-valve-properties"): CommandDoc(
path=("network", "get-valve-properties"),
summary="读取阀门属性",
description="调用 /getvalveproperties/。",
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
examples=("tjwater-cli network get-valve-properties --valve V1",),
),
("network", "get-all-valves-properties"): CommandDoc(
path=("network", "get-all-valves-properties"),
summary="读取全部阀门属性",
description="调用 /getallvalveproperties/。",
examples=("tjwater-cli network get-all-valves-properties",),
),
("component", "option", "schema"): CommandDoc(
path=("component", "option", "schema"),
@@ -422,41 +466,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
),
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
),
("data", "scada", "schema"): CommandDoc(
path=("data", "scada", "schema"),
summary="读取 SCADA schema",
description="kind 支持 device、device-data、element、info。",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=(
"tjwater-cli data scada schema --kind device",
"tjwater-cli data scada schema --kind device-data",
"tjwater-cli data scada schema --kind element",
"tjwater-cli data scada schema --kind info",
),
),
("data", "scada", "get"): CommandDoc(
path=("data", "scada", "get"),
summary="读取单条 SCADA 元数据",
description="kind 支持 device、device-data、element、info。",
description="kind 支持 info。",
options=(
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
CommandOptionDoc("id", "记录 ID", required=True),
),
examples=(
"tjwater-cli data scada get --kind device --id D1",
"tjwater-cli data scada get --kind element --id E1",
),
examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
),
("data", "scada", "list"): CommandDoc(
path=("data", "scada", "list"),
summary="列出 SCADA 元数据",
description="kind 支持 device、element、infodevice-data 当前后端无 list 接口",
description="kind 支持 info",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=(
"tjwater-cli data scada list --kind device",
"tjwater-cli data scada list --kind element",
"tjwater-cli data scada list --kind info",
),
examples=("tjwater-cli data scada list --kind info",),
),
("data", "scheme", "schema"): CommandDoc(
path=("data", "scheme", "schema"),
@@ -477,37 +502,6 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
description="调用 /getallschemes/。",
examples=("tjwater-cli data scheme list",),
),
("data", "extension", "keys"): CommandDoc(
path=("data", "extension", "keys"),
summary="列出扩展数据键",
description="调用 /getallextensiondatakeys/。",
examples=("tjwater-cli data extension keys",),
),
("data", "extension", "get"): CommandDoc(
path=("data", "extension", "get"),
summary="读取扩展数据",
description="调用 /getextensiondata/。",
options=(CommandOptionDoc("key", "扩展键", required=True),),
examples=("tjwater-cli data extension get --key my_key",),
),
("data", "extension", "list"): CommandDoc(
path=("data", "extension", "list"),
summary="列出扩展数据",
description="调用 /getallextensiondata/。",
examples=("tjwater-cli data extension list",),
),
("data", "misc", "sensor-placements"): CommandDoc(
path=("data", "misc", "sensor-placements"),
summary="列出传感器布置结果",
description="调用 /getallsensorplacements/。",
examples=("tjwater-cli data misc sensor-placements",),
),
("data", "misc", "burst-location-results"): CommandDoc(
path=("data", "misc", "burst-location-results"),
summary="列出爆管定位结果",
description="调用 /getallburstlocateresults/。",
examples=("tjwater-cli data misc burst-location-results",),
),
}
+1 -5
View File
@@ -259,12 +259,8 @@ app/api/v1/endpoints/project_data.py
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range``GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` |
| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py``GET` 查询接口 | `SCADA` 元数据 |
| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/``GET /getallscadainfo/` | `SCADA info` 元数据 |
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py``GET` 接口 | 当前 project 方案查询 |
| `tjwater-cli data extension keys\|get\|list` | `extension.py``GET` 查询接口 | 当前 project 扩展数据查询 |
| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 |
| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 |
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
- `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
+115
View File
@@ -0,0 +1,115 @@
import asyncio
import importlib.util
from pathlib import Path
import httpx
import pytest
def _load_web_search_module():
module_path = (
Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py"
)
spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
web_search = _load_web_search_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def post(self, url, *, headers, json):
self.calls.append({"url": url, "headers": headers, "json": json})
return self.response
def test_search_bocha_web_posts_expected_payload(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
monkeypatch.setattr(
web_search.settings,
"BOCHA_WEB_SEARCH_URL",
"https://api.bochaai.com/v1/web-search",
)
response = httpx.Response(
200,
json={"data": {"webPages": {"value": []}}},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
client = FakeClient(response)
result = asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(
query="天津水务",
freshness="oneWeek",
summary=True,
count=5,
include=["example.com", "news.example.com"],
exclude=["spam.example.com"],
),
client=client,
)
)
assert result == {"data": {"webPages": {"value": []}}}
assert client.calls == [
{
"url": "https://api.bochaai.com/v1/web-search",
"headers": {
"Authorization": "Bearer sk-test",
"Content-Type": "application/json",
},
"json": {
"query": "天津水务",
"freshness": "oneWeek",
"summary": True,
"count": 5,
"include": "example.com,news.example.com",
"exclude": "spam.example.com",
},
}
]
def test_search_bocha_web_requires_api_key(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "")
with pytest.raises(web_search.BochaSearchConfigError):
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_search_bocha_web_surfaces_upstream_error(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
response = httpx.Response(
401,
json={"error": "invalid api key"},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
with pytest.raises(web_search.BochaSearchAPIError) as exc_info:
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == {"error": "invalid api key"}
def test_web_search_request_validates_count_range():
with pytest.raises(ValueError):
web_search.WebSearchRequest(query="天津水务", count=51)