mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
分析能力入口(一):设置加「分析」一组(api/settings.py 的 analysis:判定用的阈值、一次运行最多分析几次、最多下钻几层、列出前几段),键、缺省、取值范围、叫法全取 app/analysis/contract.py(DEFAULTS / RANGES / LABELS / settings_problem),设置接口里和 ledger 组同一个写法:GET 按生效的样子给(存坏了的项按缺省,「明显超出」不大于「超出常规波动」、最多用几期历史小于至少几期的那一对一起按缺省),PUT 先查后写(不认识的键、类型、范围、两两之间的大小,说法用设置项的叫法),浮点的项存成浮点;analysis_settings 给发起时的快照和没有快照时现读用
This commit is contained in:
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.analysis import contract as analysis_contract
|
||||
from app.api import health
|
||||
from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException
|
||||
from app.core import public_url
|
||||
@@ -872,6 +873,59 @@ async def eval_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
return eval_values(row.value if row else None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 分析:判定用的阈值和次数上限(docs/design/analysis-round/01-算子与判定.md §10)。键、缺省、取值范围、叫法、校验都在
|
||||
# app/analysis/contract.py 一处:设置页、发起时的快照(run.options["analysis"],engine/launch.py)、分析卡上的覆盖按同一张表。
|
||||
# agent 改不了这些:分析工具的参数里没有阈值。搭建者只读,管理员写(整个设置接口的规矩)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 设置里 analysis 这一组(contract.SETTINGS_KEY)
|
||||
ANALYSIS_GROUP = analysis_contract.SETTINGS_KEY
|
||||
#: 每一项的叫法(设置页、报错都用它,句子里不裸写键名)
|
||||
ANALYSIS_SETTING_LABEL = analysis_contract.LABELS
|
||||
|
||||
|
||||
def analysis_problem(value: Any) -> str | None:
|
||||
"""设置页写进来的 analysis 这一组有什么问题(不认识的键、类型、范围、两两之间的大小),没问题返回 None。写进来的这一组
|
||||
整个替换存着的(同别的组),缺的项按缺省,所以两两之间的大小也按缺省补齐了比。"""
|
||||
if not isinstance(value, dict):
|
||||
return f"「分析」设置格式有误,需要是一个对象,当前为「{value}」"
|
||||
for key in value:
|
||||
if key not in analysis_contract.DEFAULTS:
|
||||
return (f"「分析」设置中不支持「{key}」,可设置的项:"
|
||||
+ "、".join(f"「{ANALYSIS_SETTING_LABEL[k]}」" for k in analysis_contract.DEFAULTS))
|
||||
return analysis_contract.settings_problem(value)
|
||||
|
||||
|
||||
def _analysis_item_ok(key: str, value: Any) -> bool:
|
||||
"""一项本身对不对(类型、范围),不看和别的项的大小:存着的一组逐项认,两两之间的另看。"""
|
||||
kind, low, high = analysis_contract.RANGES[key]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind is int and not isinstance(value, int)):
|
||||
return False
|
||||
return low <= value <= high
|
||||
|
||||
|
||||
def analysis_values(stored: Any) -> dict[str, Any]:
|
||||
"""库里存的(或刚写进来的)analysis 一组 → 补齐缺省;存坏了的项按缺省。两两之间的大小对不上的(「明显超出」不大于
|
||||
「超出常规波动」、最多用几期历史小于至少几期)那一对都按缺省:只换掉一项,可能换出另一种对不上。"""
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
out = dict(analysis_contract.DEFAULTS)
|
||||
for key in analysis_contract.DEFAULTS:
|
||||
if key in stored and _analysis_item_ok(key, stored[key]):
|
||||
out[key] = float(stored[key]) if analysis_contract.RANGES[key][0] is float else stored[key]
|
||||
for pair in (("k_warn", "k_alert"), ("min_history", "max_history")):
|
||||
if analysis_contract.settings_problem({k: out[k] for k in pair}) is not None:
|
||||
out.update({k: analysis_contract.DEFAULTS[k] for k in pair})
|
||||
return out
|
||||
|
||||
|
||||
async def analysis_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
"""设置里的 analysis 一组,没存过的补缺省。发起运行时快照这一份(engine/launch.py),没有快照的运行由分析工具、分析卡、
|
||||
口径卡的可比性检查现读(app/tools/analysis.py)。"""
|
||||
row = await session.get(Setting, ANALYSIS_GROUP)
|
||||
return analysis_values(row.value if row else None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -954,6 +1008,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
LEDGER_GROUP: ledger_values(None),
|
||||
# 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关
|
||||
EVAL_GROUP: eval_values(None),
|
||||
# 分析:判定用的阈值、一次运行最多分析几次、最多下钻几层(app/analysis/contract.py)
|
||||
ANALYSIS_GROUP: analysis_values(None),
|
||||
# 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关
|
||||
MAIL_GROUP: _mail_public(None),
|
||||
# 运行并发(app/cluster/slots.py):全站同时运行的上限,交互、应用、定时、系统四类各自的上限和优先级。多进程时几个进程合起来算
|
||||
@@ -1024,6 +1080,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
|
||||
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
|
||||
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
|
||||
merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP))
|
||||
merged[ANALYSIS_GROUP] = analysis_values(stored.get(ANALYSIS_GROUP))
|
||||
merged[CONCURRENCY_GROUP] = concurrency_values(stored.get(CONCURRENCY_GROUP))
|
||||
# 口令不回(password 恒为 null),只说配没配(password_set)
|
||||
merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP))
|
||||
@@ -1136,6 +1193,10 @@ async def put_settings(
|
||||
if problem := eval_problem(payload.values[EVAL_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP])
|
||||
if ANALYSIS_GROUP in payload.values:
|
||||
if problem := analysis_problem(payload.values[ANALYSIS_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
payload.values[ANALYSIS_GROUP] = analysis_values(payload.values[ANALYSIS_GROUP])
|
||||
if CONCURRENCY_GROUP in payload.values:
|
||||
from app.cluster import slots
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""分析设置(设置 → 偏好设置 → 分析,docs/design/analysis-round/01-算子与判定.md §10)。
|
||||
|
||||
- 设置接口的 analysis 一组:键、缺省、范围、叫法全取 app/analysis/contract.py;GET 按生效的样子给(存坏了的项按缺省),
|
||||
PUT 先查后写(不认识的键、类型、范围、两两之间的大小)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.analysis import contract
|
||||
from app.api.settings import ANALYSIS_GROUP, analysis_problem, analysis_values
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Setting
|
||||
from app.engine.runner import run_manager
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def engine_up():
|
||||
await run_manager.setup()
|
||||
yield
|
||||
await run_manager.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean_group():
|
||||
yield
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Setting, ANALYSIS_GROUP)
|
||||
if row is not None:
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 设置接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_group_is_the_contract():
|
||||
assert ANALYSIS_GROUP == contract.SETTINGS_KEY
|
||||
assert analysis_values(None) == contract.DEFAULTS
|
||||
# 存坏了的项按缺省;两两之间的大小对不上的那一对都按缺省
|
||||
broken = {"main_share": "一半", "max_calls": 3.5, "k_warn": 4, "k_alert": 3.5, "min_history": 20, "max_history": 10,
|
||||
"table_top_k": 5, "trend_alpha": True}
|
||||
assert analysis_values(broken) == {**contract.DEFAULTS, "table_top_k": 5}
|
||||
# 浮点的项存成浮点:发起时的快照、结论里记的阈值一个写法
|
||||
assert analysis_values({"k_warn": 3, "k_alert": 4})["k_warn"] == 3.0
|
||||
assert isinstance(analysis_values({"k_warn": 3, "k_alert": 4})["k_alert"], float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, why", [
|
||||
("宽松", "需要是一个对象"),
|
||||
({"bogus": 1}, "不支持「bogus」"),
|
||||
({"main_share": 0.95}, f"{contract.LABELS['main_share']}要在 0.3 到 0.9 之间"),
|
||||
({"max_calls": 2.5}, f"{contract.LABELS['max_calls']}要填整数"),
|
||||
({"k_warn": 3.5}, f"{contract.LABELS['k_alert']}要大于{contract.LABELS['k_warn']}"),
|
||||
({"min_history": 60}, f"{contract.LABELS['max_history']}不能小于{contract.LABELS['min_history']}"),
|
||||
])
|
||||
def test_problems_are_said_with_labels(value, why):
|
||||
assert why in (analysis_problem(value) or "")
|
||||
|
||||
|
||||
async def test_get_put_round_trip(client):
|
||||
got = (await client.get("/api/settings")).json()
|
||||
assert got[ANALYSIS_GROUP] == contract.DEFAULTS
|
||||
put = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "max_calls": 5}}})
|
||||
assert put.status_code == 200, put.text
|
||||
got = (await client.get("/api/settings")).json()[ANALYSIS_GROUP]
|
||||
assert got == {**contract.DEFAULTS, "main_share": 0.6, "max_calls": 5}
|
||||
# 整组替换:这次没写的项回到缺省
|
||||
await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"k_warn": 2.5}}})
|
||||
assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == {**contract.DEFAULTS, "k_warn": 2.5}
|
||||
|
||||
|
||||
async def test_put_refuses_bad_values_and_stores_nothing(client):
|
||||
r = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "k_alert": 1.5}}})
|
||||
assert r.status_code == 422 and contract.LABELS["k_alert"] in r.json()["detail"]
|
||||
assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == contract.DEFAULTS
|
||||
Reference in New Issue
Block a user