feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from backend.services.operations import (
|
||||
get_model,
|
||||
get_monthly_monitoring_result,
|
||||
list_models,
|
||||
parse_monitor_month,
|
||||
)
|
||||
from common.config import Settings
|
||||
from common.db.models.operations import (
|
||||
OperationsBase,
|
||||
OpsBank,
|
||||
OpsModelInstance,
|
||||
OpsModelVersion,
|
||||
OpsMonitorBatch,
|
||||
OpsMonitorEvaluation,
|
||||
OpsMonitorResult,
|
||||
OpsMonitorReview,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = "W" * 26
|
||||
OTHER_WORKSPACE_ID = "X" * 26
|
||||
MODEL_INSTANCE_ID = "M" * 26
|
||||
MODEL_VERSION_ID = "V" * 26
|
||||
MODEL_ID = "JC-STD-001"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session():
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(OperationsBase.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as database_session:
|
||||
await _seed(database_session)
|
||||
await database_session.commit()
|
||||
yield database_session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _seed(session) -> None:
|
||||
bank = OpsBank(
|
||||
bank_id="B" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
bank_code="JC",
|
||||
bank_name="江城银行",
|
||||
is_wuji_bank=1,
|
||||
bank_status="active",
|
||||
source_bank_ref="bank-jc",
|
||||
)
|
||||
version = OpsModelVersion(
|
||||
model_version_id=MODEL_VERSION_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
model_instance_id=MODEL_INSTANCE_ID,
|
||||
version_label="v2.3",
|
||||
version_status="active",
|
||||
last_iteration_date=datetime.date(2026, 6, 18),
|
||||
source_version_ref="version-jc-std-v23",
|
||||
)
|
||||
model = OpsModelInstance(
|
||||
model_instance_id=MODEL_INSTANCE_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
bank_id=bank.bank_id,
|
||||
category_code="std",
|
||||
model_id=MODEL_ID,
|
||||
model_name="标准A卡",
|
||||
model_status="normal",
|
||||
current_version_id=version.model_version_id,
|
||||
common_model_name="标准A卡通用版 v2.3",
|
||||
source_model_ref="model-jc-std",
|
||||
)
|
||||
old_batch = OpsMonitorBatch(
|
||||
batch_id="1" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
source_batch_no="2026-07-r1",
|
||||
monitor_month=datetime.date(2026, 7, 1),
|
||||
revision_no=1,
|
||||
batch_status="published",
|
||||
published_at=datetime.datetime(2026, 7, 20, 8, 0),
|
||||
)
|
||||
new_batch = OpsMonitorBatch(
|
||||
batch_id="2" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
source_batch_no="2026-07-r2",
|
||||
monitor_month=datetime.date(2026, 7, 1),
|
||||
revision_no=2,
|
||||
batch_status="published",
|
||||
published_at=datetime.datetime(2026, 7, 21, 8, 0),
|
||||
)
|
||||
old_result = OpsMonitorResult(
|
||||
monitor_result_id="3" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
batch_id=old_batch.batch_id,
|
||||
model_instance_id=MODEL_INSTANCE_ID,
|
||||
model_version_id=MODEL_VERSION_ID,
|
||||
monitor_month=datetime.date(2026, 7, 1),
|
||||
ranking_result="matched",
|
||||
ks_value=Decimal("0.41000000"),
|
||||
psi_value=Decimal("0.09000000"),
|
||||
)
|
||||
new_result = OpsMonitorResult(
|
||||
monitor_result_id="4" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
batch_id=new_batch.batch_id,
|
||||
model_instance_id=MODEL_INSTANCE_ID,
|
||||
model_version_id=MODEL_VERSION_ID,
|
||||
monitor_month=datetime.date(2026, 7, 1),
|
||||
ranking_result="unmatched",
|
||||
ks_value=Decimal("0.36840000"),
|
||||
psi_value=Decimal("0.27400000"),
|
||||
)
|
||||
evaluation = OpsMonitorEvaluation(
|
||||
evaluation_id="5" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
monitor_result_id=new_result.monitor_result_id,
|
||||
rule_version_id="6" * 26,
|
||||
rule_item_id="7" * 26,
|
||||
ks_mom_drop_rate=Decimal("0.24100000"),
|
||||
secondary_level2_hits_6m=3,
|
||||
abnormal_level="level3",
|
||||
monitor_grade="C",
|
||||
reason_code="R-C-001",
|
||||
reason_text_snapshot="排序性不符、KS低、PSI高",
|
||||
action_snapshot="建议启动模型微调或重构评估",
|
||||
is_current=1,
|
||||
evaluated_at=datetime.datetime(2026, 7, 21, 9, 0),
|
||||
)
|
||||
review = OpsMonitorReview(
|
||||
review_id="8" * 26,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
monitor_result_id=new_result.monitor_result_id,
|
||||
evaluation_id=evaluation.evaluation_id,
|
||||
review_stage="model_initial",
|
||||
review_status="handled",
|
||||
decision_code="tune_or_rebuild",
|
||||
handling_note="建议评估重构",
|
||||
handled_by="U" * 26,
|
||||
handled_at=datetime.datetime(2026, 7, 22, 10, 30),
|
||||
)
|
||||
other_bank = OpsBank(
|
||||
bank_id="C" * 26,
|
||||
workspace_id=OTHER_WORKSPACE_ID,
|
||||
bank_code="OTHER",
|
||||
bank_name="其他银行",
|
||||
bank_status="active",
|
||||
source_bank_ref="bank-other",
|
||||
)
|
||||
other_model = OpsModelInstance(
|
||||
model_instance_id="N" * 26,
|
||||
workspace_id=OTHER_WORKSPACE_ID,
|
||||
bank_id=other_bank.bank_id,
|
||||
category_code="std",
|
||||
model_id="OTHER-STD-001",
|
||||
model_name="其他模型",
|
||||
model_status="normal",
|
||||
source_model_ref="model-other",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
bank,
|
||||
version,
|
||||
model,
|
||||
old_batch,
|
||||
new_batch,
|
||||
old_result,
|
||||
new_result,
|
||||
evaluation,
|
||||
review,
|
||||
other_bank,
|
||||
other_model,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_list_models_uses_latest_revision_and_workspace(session) -> None:
|
||||
models = await list_models(session, WORKSPACE_ID)
|
||||
assert len(models) == 1
|
||||
model = models[0]
|
||||
assert model.model_id == MODEL_ID
|
||||
assert model.ks == 36.84
|
||||
assert model.psi == 27.4
|
||||
assert model.ks_mom_drop == 24.1
|
||||
assert model.ranking_result == "不符"
|
||||
assert model.last_processed_at == "2026-07-22"
|
||||
assert model.is_wuji_bank is True
|
||||
|
||||
|
||||
async def test_list_models_filters_and_detail(session) -> None:
|
||||
assert len(await list_models(session, WORKSPACE_ID, bank="江城银行")) == 1
|
||||
assert len(await list_models(session, WORKSPACE_ID, category="big")) == 0
|
||||
assert len(await list_models(session, WORKSPACE_ID, keyword="v2.3")) == 1
|
||||
model = await get_model(session, WORKSPACE_ID, MODEL_ID)
|
||||
assert model is not None
|
||||
assert model.model_version == "v2.3"
|
||||
assert model.common_model_name == "标准A卡通用版 v2.3"
|
||||
assert await get_model(session, OTHER_WORKSPACE_ID, MODEL_ID) is None
|
||||
|
||||
|
||||
async def test_get_monthly_monitoring_result(session) -> None:
|
||||
result = await get_monthly_monitoring_result(
|
||||
session,
|
||||
WORKSPACE_ID,
|
||||
MODEL_ID,
|
||||
datetime.date(2026, 7, 1),
|
||||
)
|
||||
assert result is not None
|
||||
assert result.monitor_month == "2026-07"
|
||||
assert result.ks == 36.84
|
||||
assert result.psi == 27.4
|
||||
assert result.secondary_hits_6m == 3
|
||||
|
||||
|
||||
def test_monitor_month_and_operations_url() -> None:
|
||||
assert parse_monitor_month("2026-07") == datetime.date(2026, 7, 1)
|
||||
with pytest.raises(ValueError):
|
||||
parse_monitor_month("2026-13")
|
||||
settings = Settings(
|
||||
database_url="mysql+asyncmy://user:pass@db:3306/model_platform?charset=utf8mb4",
|
||||
_env_file=None,
|
||||
)
|
||||
assert settings.operations_database_url is not None
|
||||
assert "/model_operations?" in settings.operations_database_url
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
|
||||
from backend.services.operations import OperationsCache, OperationsEventStream
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, str] = {}
|
||||
self.expirations: dict[str, int] = {}
|
||||
self.stream_entries: list[tuple[str, dict[str, str]]] = []
|
||||
self.groups: set[str] = set()
|
||||
|
||||
async def get(self, key: str):
|
||||
return self.values.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, *, ex: int):
|
||||
self.values[key] = value
|
||||
self.expirations[key] = ex
|
||||
return True
|
||||
|
||||
async def unlink(self, *keys: str):
|
||||
removed = 0
|
||||
for key in keys:
|
||||
if key in self.values:
|
||||
removed += 1
|
||||
self.values.pop(key)
|
||||
self.expirations.pop(key, None)
|
||||
return removed
|
||||
|
||||
async def scan_iter(self, *, match: str, count: int):
|
||||
del count
|
||||
for key in list(self.values):
|
||||
if fnmatch.fnmatch(key, match):
|
||||
yield key
|
||||
|
||||
async def xadd(self, stream: str, fields, *, maxlen: int, approximate: bool):
|
||||
assert maxlen == 1000
|
||||
assert approximate is True
|
||||
message_id = f"1-{len(self.stream_entries)}"
|
||||
self.stream_entries.append((message_id, dict(fields)))
|
||||
return message_id
|
||||
|
||||
async def xgroup_create(self, stream: str, group: str, *, id: str, mkstream: bool):
|
||||
assert stream == "ops:events"
|
||||
assert id == "0-0"
|
||||
assert mkstream is True
|
||||
self.groups.add(group)
|
||||
return True
|
||||
|
||||
async def xreadgroup(self, group, consumer, streams, *, count: int, block: int):
|
||||
del group, consumer, count, block
|
||||
stream = next(iter(streams))
|
||||
return [(stream, list(self.stream_entries))]
|
||||
|
||||
async def xack(self, stream: str, group: str, *message_ids: str):
|
||||
del stream, group
|
||||
return len(message_ids)
|
||||
|
||||
|
||||
async def test_cache_round_trip_and_workspace_invalidation() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = OperationsCache(
|
||||
redis, # type: ignore[arg-type]
|
||||
prefix="ops",
|
||||
default_ttl_seconds=300,
|
||||
)
|
||||
key = cache.build_key("W1", "model-overview", {"month": "2026-07", "bank": "江城"})
|
||||
same_key = cache.build_key("W1", "model-overview", {"bank": "江城", "month": "2026-07"})
|
||||
other_key = cache.build_key("W2", "model-overview", {"bank": "江城"})
|
||||
assert key == same_key
|
||||
assert key != other_key
|
||||
assert await cache.set_json(key, {"total": 3, "name": "标准A卡"}) is True
|
||||
assert redis.expirations[key] == 300
|
||||
assert await cache.get_json(key) == {"total": 3, "name": "标准A卡"}
|
||||
await cache.set_json(other_key, {"total": 1})
|
||||
assert await cache.invalidate_resource("W1", "model-overview") == 1
|
||||
assert await cache.get_json(key) is None
|
||||
assert await cache.get_json(other_key) == {"total": 1}
|
||||
|
||||
|
||||
async def test_cache_without_redis_is_a_noop() -> None:
|
||||
cache = OperationsCache(None, prefix="ops", default_ttl_seconds=300)
|
||||
assert await cache.get_json("missing") is None
|
||||
assert await cache.set_json("missing", {"value": 1}) is False
|
||||
assert await cache.delete("missing") == 0
|
||||
|
||||
|
||||
async def test_stream_publish_group_read_and_ack() -> None:
|
||||
redis = FakeRedis()
|
||||
stream = OperationsEventStream(
|
||||
redis, # type: ignore[arg-type]
|
||||
stream_name="ops:events",
|
||||
maxlen=1000,
|
||||
)
|
||||
assert await stream.ensure_group("notifications") is True
|
||||
message_id = await stream.publish(
|
||||
"monitor.review.due",
|
||||
{"review_id": "R1", "bank": "江城银行"},
|
||||
event_id="E1",
|
||||
idempotency_key="review:R1:due",
|
||||
trace_id="T1",
|
||||
)
|
||||
assert message_id == "1-0"
|
||||
messages = await stream.read_group("notifications", "worker-1")
|
||||
assert messages[0][0] == "1-0"
|
||||
assert messages[0][1]["event_type"] == "monitor.review.due"
|
||||
assert '"review_id":"R1"' in messages[0][1]["payload"]
|
||||
assert await stream.acknowledge("notifications", message_id) == 1
|
||||
Reference in New Issue
Block a user