feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -14,12 +14,19 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_db
|
||||
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_asset_release_telemetry import AgentAssetReleaseLabel
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservationFeedback
|
||||
from app.schemas.risk_disposition import RiskDispositionActionCreate
|
||||
from app.services.agent_asset_release_telemetry import (
|
||||
AgentAssetReleaseTelemetryService,
|
||||
ReleaseObservationInput,
|
||||
)
|
||||
from app.services.risk_dispositions import (
|
||||
RiskDispositionConflictError,
|
||||
RiskDispositionIdempotencyConflictError,
|
||||
@@ -120,6 +127,121 @@ def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events
|
||||
)
|
||||
|
||||
|
||||
def test_typed_risk_disposition_appends_current_release_label_automatically(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
with _build_session() as db:
|
||||
db.add(_claim())
|
||||
asset = AgentAsset(
|
||||
id="risk-disposition-release-asset",
|
||||
tenant_id="default",
|
||||
scope="tenant",
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code="risk.disposition.release",
|
||||
name="处置学习发布规则",
|
||||
description="",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["travel"],
|
||||
owner="finance",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1",
|
||||
published_version="v1",
|
||||
working_version="v2",
|
||||
config_json={
|
||||
"tenant_id": "default",
|
||||
"detail_mode": "json_risk",
|
||||
"stable_marker": "stable-risk-disposition-release",
|
||||
"release_guard": {
|
||||
"release_id": "risk-disposition-release-1",
|
||||
"stage": "shadow",
|
||||
"candidate_version": "v2",
|
||||
"previous_version": "v1",
|
||||
"previous_config": {
|
||||
"tenant_id": "default",
|
||||
"detail_mode": "json_risk",
|
||||
"enabled": True,
|
||||
"stable_marker": "stable-risk-disposition-release",
|
||||
},
|
||||
"policy": {
|
||||
"shadow_min_samples": 1,
|
||||
"canary_min_samples": 1,
|
||||
"max_error_rate": 1.0,
|
||||
"min_precision": 0.9,
|
||||
"max_precision_drop": 1.0,
|
||||
"canary_traffic_percent": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
db.add(asset)
|
||||
db.commit()
|
||||
telemetry = AgentAssetReleaseTelemetryService(db).record_observation(
|
||||
ReleaseObservationInput(
|
||||
tenant_id="default",
|
||||
asset_id=asset.id,
|
||||
release_id="risk-disposition-release-1",
|
||||
stage="shadow",
|
||||
version="v2",
|
||||
rule_code=asset.code,
|
||||
source_key="claim-risk-1",
|
||||
candidate_hit=True,
|
||||
baseline_hit=True,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
)
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:typed:release-telemetry"),
|
||||
"risk_signal": asset.code,
|
||||
"algorithm_version": "v1",
|
||||
"policy_refs": [asset.code],
|
||||
"decision_trace": {"rule_code": asset.code},
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
mutation = RiskDispositionService(db).execute_action(
|
||||
observation.id,
|
||||
_action("confirm", version=0, request_id="request-release-label-001"),
|
||||
tenant_id="default",
|
||||
actor_id="finance-secret-account",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
label = db.scalar(
|
||||
select(AgentAssetReleaseLabel).where(
|
||||
AgentAssetReleaseLabel.observation_id == telemetry.id
|
||||
)
|
||||
)
|
||||
assert mutation.disposition.adjudication == "confirmed"
|
||||
assert label is not None
|
||||
assert label.label == "confirmed"
|
||||
assert label.verification_source == "typed_risk_disposition"
|
||||
assert "finance-secret-account" not in str(label.__dict__)
|
||||
|
||||
corrected = RiskDispositionService(db).execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"false_positive",
|
||||
version=1,
|
||||
request_id="request-release-label-correction-001",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-secret-account",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
db.expire_all()
|
||||
refreshed_asset = db.get(AgentAsset, asset.id)
|
||||
|
||||
assert corrected.disposition.adjudication == "false_positive"
|
||||
assert refreshed_asset is not None
|
||||
assert refreshed_asset.published_version == "v1"
|
||||
assert refreshed_asset.config_json["stable_marker"] == ("stable-risk-disposition-release")
|
||||
assert refreshed_asset.config_json["release_guard"]["stage"] == "rolled_back"
|
||||
assert refreshed_asset.config_json["release_guard"]["rollback"]["automatic"] is True
|
||||
|
||||
|
||||
def test_risk_disposition_requires_confirmation_before_remediation_or_resolution(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -558,6 +680,7 @@ def test_current_claim_approver_can_manage_disposition_without_pool_access() ->
|
||||
)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
tenant_id="default",
|
||||
username="risk.manager@example.com",
|
||||
name="风险主管",
|
||||
role_codes=["approver"],
|
||||
@@ -571,12 +694,14 @@ def test_current_claim_approver_can_manage_disposition_without_pool_access() ->
|
||||
assert policy.can_manage_disposition(observation, current_user) is True
|
||||
|
||||
unrelated_manager = CurrentUserContext(
|
||||
tenant_id="default",
|
||||
username="unrelated.manager@example.com",
|
||||
name="其他经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
finance_outside_stage = CurrentUserContext(
|
||||
tenant_id="default",
|
||||
username="finance@example.com",
|
||||
name="财务甲",
|
||||
role_codes=["finance"],
|
||||
@@ -618,6 +743,7 @@ def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> No
|
||||
)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
tenant_id="default",
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
@@ -986,11 +1112,7 @@ def _action(
|
||||
action=action,
|
||||
expected_version=version,
|
||||
request_id=request_id,
|
||||
comment=(
|
||||
"风险处置说明"
|
||||
if action in {"false_positive", "request_supplement"}
|
||||
else None
|
||||
),
|
||||
comment=("风险处置说明" if action in {"false_positive", "request_supplement"} else None),
|
||||
**waiver_fields,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user