app.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. # -*- coding: utf-8 -*-
  2. r"""算法服务(FastAPI):把 `app_algorithmModel` 的既有函数包成 HTTP 接口 + OpenAPI。
  3. 启动:
  4. python scripts/algorithm_service.py --host 127.0.0.1 --port 18050
  5. (或 `python -m app_algorithmModel.app_algorithmModel_guanlan.service.app --port 18050`)
  6. 接口(全部 GET,只读):
  7. /healthz 存活与版本
  8. /api/endpoints 端点自述(登记表)
  9. /api/{端点名} 见 service/registry.py(判级矩阵/可靠性/可用率/融合面/变桨面/曲线…)
  10. /docs · /openapi.json Swagger UI 与 OpenAPI 文档(FastAPI 自带)
  11. **只读保证**:带写盘副作用的参数在登记表里被固定成只读值(如 curves 的 write=False),
  12. 不会因为调一次接口改了盘上产物。
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import pathlib
  18. import sys
  19. import time
  20. from typing import Any
  21. _HERE = pathlib.Path(__file__).resolve()
  22. try: # 走公共层的安装根(包结构异常时按位置上跳)
  23. from app_common.app_common_guanlan.api import install_root as _install_root
  24. except ImportError: # pragma: no cover
  25. def _install_root(_f):
  26. return _HERE.parents[4]
  27. ROOT = _install_root(__file__)
  28. if str(ROOT) not in sys.path:
  29. sys.path.insert(0, str(ROOT))
  30. from . import jsonable, registry # noqa: E402
  31. from .deps import ensure_fastapi # noqa: E402
  32. fastapi, uvicorn = ensure_fastapi()
  33. from fastapi import FastAPI, HTTPException, Query, Request # noqa: E402
  34. from fastapi.responses import JSONResponse, PlainTextResponse, Response # noqa: E402
  35. try: # 版本走公共层公开面(模块边界:不直连旧路径壳)
  36. from app_common.app_common_guanlan.api import version as V
  37. _VER = V.VERSION
  38. except Exception: # noqa: BLE001
  39. _VER = 'unknown'
  40. CACHE_TTL = 300.0 # 秒;判级矩阵这类重算是"按窗口"的,缓存与页面的按窗缓存同源
  41. _cached: dict[str, tuple[float, str]] = {}
  42. def create_app() -> FastAPI:
  43. app = FastAPI(title='观澜 · 算法服务', version=_VER,
  44. description='算法层的 HTTP 封装(P10):判级矩阵、可靠性、可用率、融合面、变桨面、曲线检查。'
  45. '数据来源与页面同源,只读不写盘。',
  46. docs_url='/docs', redoc_url='/redoc', openapi_url='/openapi.json')
  47. @app.get('/healthz', summary='存活与版本')
  48. def healthz():
  49. return {'ok': True, 'version': _VER, 'service': 'guanlan-algorithm',
  50. 'endpoints': len(registry.ENDPOINTS), 'time': time.strftime('%Y-%m-%d %H:%M:%S')}
  51. @app.get('/api/endpoints', summary='端点自述')
  52. def endpoints():
  53. return {'count': len(registry.ENDPOINTS), 'items': registry.spec_list()}
  54. @app.post('/api/ask_run', summary='问答管线(同步跑一次,返回末态)')
  55. def ask_run(payload: dict | None = None):
  56. """P12:搬运自详情层 `_ask_worker`;详情层是"线程+全局状态",这里同步返回末态。"""
  57. from . import ask_views
  58. p = payload or {}
  59. return ask_views.ask_run(q=p.get('q', ''), model=p.get('model', 'qwen3:8b'),
  60. cat=p.get('cat', ''), xrev=bool(p.get('xrev', False)),
  61. lang=p.get('lang', 'zh'))
  62. @app.post('/api/rpt_export_raw', summary='报告导出(POST:可带前端渲染的图片,保证与页面一致)')
  63. def rpt_export_raw_post(payload: dict | None = None):
  64. p = payload or {}
  65. from . import report_views
  66. data, mime, fn = report_views.export_bytes(
  67. fmt=str(p.get('fmt') or 'docx'), blocks=str(p.get('blocks') or ''), aud=str(p.get('aud') or 'in'),
  68. win=str(p.get('win') or '2026年'), narr=p.get('narr'), images=p.get('images') or None)
  69. from urllib.parse import quote
  70. return Response(content=data, media_type=mime,
  71. headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(fn)})
  72. @app.get('/api/rpt_export_raw', summary='报告导出(docx/xlsx 字节)')
  73. def rpt_export_raw(fmt: str = 'docx', blocks: str = '', aud: str = 'in', win: str = '2026年',
  74. narr: str | None = None):
  75. """报告导出:返回字节(非 JSON);组装逻辑见 service/report_views.py。"""
  76. from urllib.parse import quote
  77. from . import report_views
  78. data, mime, fn = report_views.export_bytes(fmt=fmt, blocks=blocks, aud=aud, win=win, narr=narr)
  79. return Response(content=data, media_type=mime,
  80. headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(fn)})
  81. @app.get('/api/{name}', summary='算法端点(见 /api/endpoints)')
  82. def algorithm(name: str, request: Request,
  83. ttl: float | None = Query(None, description='缓存秒数(默认 300;0 = 不走缓存)')):
  84. """通用取数:**按登记表声明的参数**从查询串透传(P12 起不再写死参数名)。
  85. 实逮:原先把参数写死成 span/since/month_from/all_turbines,新增端点(如 ont_list 的 type/t/limit)
  86. 就只能改代码;改为按 spec 解析后,登记表成为唯一需要维护的清单。
  87. """
  88. spec = registry.ENDPOINTS.get(name)
  89. if not spec:
  90. raise HTTPException(status_code=404,
  91. detail=f'未登记端点 {name!r};可用: {", ".join(sorted(registry.ENDPOINTS))}')
  92. params: dict[str, Any] = {}
  93. for qname, (typ, fname, fixed) in (spec.get('args') or {}).items():
  94. if fixed is not None:
  95. continue
  96. if qname not in request.query_params:
  97. continue
  98. raw = request.query_params.get(qname)
  99. try:
  100. params[fname] = raw if typ == 'str' else (int(raw) if typ == 'int' else raw.lower() in ('1', 'true', 'yes'))
  101. except Exception: # noqa: BLE001
  102. raise HTTPException(status_code=400, detail=f'参数 {qname} 类型应为 {typ}')
  103. key = name + '|' + json.dumps(params, sort_keys=True)
  104. ttl = CACHE_TTL if ttl is None else max(0.0, ttl)
  105. now = time.time()
  106. if ttl and key in _cached:
  107. ts, body = _cached[key]
  108. if now - ts < ttl:
  109. return JSONResponse(content=json.loads(body), headers={'x-guanlan-cache': 'hit'})
  110. t0 = time.time()
  111. try:
  112. payload = registry.call(name, params)
  113. except Exception as e: # noqa: BLE001
  114. raise HTTPException(status_code=500, detail=f'{type(e).__name__}: {e}') from e
  115. text = jsonable.dumps(payload)
  116. if ttl:
  117. _cached[key] = (now, text)
  118. return JSONResponse(content=json.loads(text),
  119. headers={'x-guanlan-cache': 'miss', 'x-guanlan-seconds': '%.2f' % (time.time() - t0)})
  120. @app.get('/api/{name}/raw', summary='同一端点的确定性 JSON 文本(对拍用)',
  121. response_class=PlainTextResponse)
  122. def algorithm_raw(name: str):
  123. if name not in registry.ENDPOINTS:
  124. raise HTTPException(status_code=404, detail=f'未登记端点 {name!r}')
  125. return jsonable.dumps(registry.call(name, {}))
  126. return app
  127. app = create_app()
  128. def main(argv: list[str] | None = None) -> int:
  129. ap = argparse.ArgumentParser(description='观澜 · 算法服务(FastAPI)')
  130. ap.add_argument('--host', default='127.0.0.1')
  131. ap.add_argument('--port', type=int, default=18050)
  132. ap.add_argument('--reload', action='store_true')
  133. ap.add_argument('--log-level', default='info')
  134. a = ap.parse_args(argv)
  135. # 统一日志格式(用户令 2):stdout/stderr 每行加 `时间戳 级别 组件` 前缀 ——
  136. # 与其它服务(serve/detail/cms/gateway/static_server)同一套设施。不这么做,日志门禁会报
  137. # "logs/xxx.log 末尾 N 行都不符合统一格式"(2026-09-29 实逮)。
  138. try:
  139. from app_common.app_common_guanlan.api import logfile as _lf
  140. _lf.prefix_stdout('algorithm')
  141. except Exception: # noqa: BLE001
  142. pass
  143. print(f'观澜 · 算法服务 v{_VER} → http://{a.host}:{a.port}/docs ({len(registry.ENDPOINTS)} 个端点)', flush=True)
  144. uvicorn.run(app if not a.reload else 'app_algorithmModel.app_algorithmModel_guanlan.service.app:app',
  145. host=a.host, port=a.port, reload=a.reload, log_level=a.log_level)
  146. return 0
  147. if __name__ == '__main__':
  148. sys.exit(main())