Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague
This commit is contained in:
@@ -132,7 +132,7 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None:
|
||||
|
||||
confirm = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
json={"decision": "确认正常", "operator_id": "1"},
|
||||
)
|
||||
assert confirm.status_code == 200
|
||||
assert confirm.json()["code"] == 0
|
||||
@@ -146,7 +146,7 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None:
|
||||
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "settlement", "operator_id": "operator-001"},
|
||||
json={"notification_type": "settlement", "operator_id": "1"},
|
||||
)
|
||||
assert notice.status_code == 200
|
||||
assert notice.json()["data"]["notification_id"]
|
||||
@@ -239,7 +239,7 @@ def test_offsite_nl2sql_success_completes_subscription_plan(
|
||||
|
||||
triggered = client.post(
|
||||
f"/api/tasks/{task_id}/trigger-agent-nl2sql",
|
||||
json={"operator_id": "operator-001", "manual_confirmed": True},
|
||||
json={"operator_id": "1", "manual_confirmed": True},
|
||||
)
|
||||
assert triggered.status_code == 200
|
||||
assert triggered.json()["data"]["status"] == "planned"
|
||||
@@ -250,7 +250,7 @@ def test_offsite_nl2sql_success_completes_subscription_plan(
|
||||
|
||||
confirmed = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
json={"decision": "确认正常", "operator_id": "1"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["code"] == 0
|
||||
@@ -283,7 +283,7 @@ def test_offsite_low_confidence_document_goes_to_recognition_exception() -> None
|
||||
assert document["status"] == "recognition_exception"
|
||||
blocked = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
json={"decision": "确认正常", "operator_id": "1"},
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
assert blocked.json()["code"] == 422
|
||||
@@ -361,7 +361,7 @@ def test_offsite_recognition_retry_recovers_document_without_overwriting_attachm
|
||||
|
||||
retried = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/recognition-retries",
|
||||
json={"operator_id": "operator-001"},
|
||||
json={"operator_id": "1"},
|
||||
)
|
||||
assert retried.status_code == 200
|
||||
assert recognized_fields
|
||||
@@ -440,13 +440,13 @@ def test_offsite_mail_return_send_updates_notification_without_external_call(
|
||||
|
||||
confirmed = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
json={"decision": "确认正常", "operator_id": "1"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
||||
json={"notification_type": "mail_return", "operator_id": "1"},
|
||||
)
|
||||
assert notice.status_code == 200
|
||||
notification_id = int(notice.json()["data"]["notification_id"])
|
||||
@@ -454,7 +454,7 @@ def test_offsite_mail_return_send_updates_notification_without_external_call(
|
||||
sent = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={
|
||||
"operator_id": "operator-001",
|
||||
"operator_id": "1",
|
||||
"operator_confirmed": True,
|
||||
"final_content": "运营确认后的回复正文",
|
||||
},
|
||||
|
||||
@@ -156,6 +156,43 @@ async def _seed_failed_queries(task_id: str) -> None:
|
||||
))
|
||||
|
||||
|
||||
async def _seed_latest_success_after_failure(task_id: str) -> None:
|
||||
await _seed_failed_queries(task_id)
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with SessionFactory() as session, session.begin():
|
||||
session.add(OffsiteQueryRecord(
|
||||
task_id=task_id,
|
||||
rule_code="subscription_holding_ratio",
|
||||
natural_language_request="基金代码为15911,查询基金最新总份额、最新净值和申请前持有份额",
|
||||
script_path="nl2sql_yc.py",
|
||||
result_summary={
|
||||
"status": "success",
|
||||
"data": {"total": 1, "rows": [{
|
||||
"nav": "1.250000",
|
||||
"total_fund_shares": "10000000000.0000",
|
||||
"total_quantity": "100000000.0000",
|
||||
}]},
|
||||
},
|
||||
status="success",
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
))
|
||||
rule_result = await session.scalar(select(OffsiteRuleResult).where(
|
||||
OffsiteRuleResult.task_id == task_id,
|
||||
OffsiteRuleResult.rule_code == "subscription_holding_ratio",
|
||||
))
|
||||
assert rule_result is not None
|
||||
rule_result.result = "正常"
|
||||
rule_result.document_value = {"申购金额元": "200000000.00"}
|
||||
rule_result.database_value = {
|
||||
"最新净值": "1.250000",
|
||||
"基金最新总份额": "10000000000.0000",
|
||||
"申请前持有份额": "100000000.0000",
|
||||
}
|
||||
rule_result.calculation = {"申购后持有比例": "0.026"}
|
||||
rule_result.created_at = now
|
||||
|
||||
|
||||
async def _count_audit(action_type: str) -> int:
|
||||
async with SessionFactory() as session:
|
||||
rows = await session.execute(select(InteractionAudit).where(
|
||||
@@ -244,6 +281,31 @@ async def test_nl2sql_fields_mark_query_failed_when_query_blocked() -> None:
|
||||
TRACE_ID = ""
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_nl2sql_fields_show_only_latest_attempt_per_rule() -> None:
|
||||
"""重试成功后,页面不能继续展示同一规则的历史失败状态。"""
|
||||
global TRACE_ID
|
||||
TRACE_ID = f"trace-nl2sql-latest-attempt-{uuid4()}"
|
||||
task_id = ""
|
||||
try:
|
||||
task_id = await _seed_document("subscription")
|
||||
await _seed_latest_success_after_failure(task_id)
|
||||
_install_context(("operator",), ("offsite:read",))
|
||||
response = await _get_fields(task_id)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert len(data["queries"]) == 1
|
||||
assert data["queries"][0]["rule_code"] == "subscription_holding_ratio"
|
||||
assert data["queries"][0]["status"] == "success"
|
||||
assert data["queries"][0]["row_count"] == 1
|
||||
assert data["field_status"]["最新净值"] == "success"
|
||||
finally:
|
||||
await _cleanup(task_id)
|
||||
app.dependency_overrides.clear()
|
||||
TRACE_ID = ""
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_nl2sql_fields_mark_pending_before_verification() -> None:
|
||||
"""尚未触发核对时字段为空,必须标为 pending,不能伪装成查询失败。"""
|
||||
|
||||
@@ -105,17 +105,17 @@ def test_successful_notification_send_is_idempotent(
|
||||
_confirm_and_create_notice(client, task_id)
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "normal_return", "operator_id": "operator-001"},
|
||||
json={"notification_type": "normal_return", "operator_id": "1"},
|
||||
)
|
||||
notification_id = int(notice.json()["data"]["notification_id"])
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
json={"operator_id": "1", "operator_confirmed": True},
|
||||
)
|
||||
second = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
json={"operator_id": "1", "operator_confirmed": True},
|
||||
)
|
||||
recalculate = client.post(
|
||||
"/api/v1/offsite-fund/settlement-statistics/recalculate",
|
||||
@@ -175,12 +175,12 @@ def test_failed_notification_send_persists_failure_and_retry_count(
|
||||
_confirm_and_create_notice(client, task_id)
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
||||
json={"notification_type": "mail_return", "operator_id": "1"},
|
||||
)
|
||||
notification_id = int(notice.json()["data"]["notification_id"])
|
||||
sent = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
json={"operator_id": "1", "operator_confirmed": True},
|
||||
)
|
||||
|
||||
assert sent.json()["data"]["status"] == "发送失败"
|
||||
@@ -260,14 +260,14 @@ def test_mixed_mail_separates_normal_and_exception_returns(
|
||||
):
|
||||
confirmed = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": decision, "operator_id": "operator-001"},
|
||||
json={"decision": decision, "operator_id": "1"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={
|
||||
"notification_type": notification_type,
|
||||
"operator_id": "operator-001",
|
||||
"operator_id": "1",
|
||||
},
|
||||
)
|
||||
assert notice.status_code == 200
|
||||
@@ -275,7 +275,7 @@ def test_mixed_mail_separates_normal_and_exception_returns(
|
||||
notification_ids.append(notification_id)
|
||||
sent = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
json={"operator_id": "1", "operator_confirmed": True},
|
||||
)
|
||||
assert sent.json()["data"]["status"] == "发送成功"
|
||||
|
||||
@@ -296,7 +296,7 @@ def test_mixed_mail_separates_normal_and_exception_returns(
|
||||
def _confirm_and_create_notice(client: TestClient, task_id: str) -> None:
|
||||
confirmed = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
json={"decision": "确认正常", "operator_id": "1"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ def _inputs_payload() -> dict[str, object]:
|
||||
},
|
||||
"manager_info": {
|
||||
"manager_name": "张三",
|
||||
"management_company": "奶龙基金管理有限公司",
|
||||
"management_company": "南方基金管理有限公司",
|
||||
"registration_code": "P10000001",
|
||||
"employment_years": "10年",
|
||||
"investment_management_experience": "8年公募基金投资管理经验",
|
||||
|
||||
@@ -59,6 +59,8 @@ def test_normalize_amount_keeps_original_unit_semantics() -> None:
|
||||
assert normalize_amount_yuan("2.50", "万元") == Decimal("25000.00")
|
||||
assert normalize_amount_yuan("2.50", "元") == Decimal("2.50")
|
||||
assert normalize_amount_yuan("20,000.00万元", "万元") == Decimal("200000000.00")
|
||||
assert normalize_amount_yuan("人民币5,000.00元", "元") == Decimal("5000.00")
|
||||
assert normalize_amount_yuan("CNY 5,000.00", "元") == Decimal("5000.00")
|
||||
assert normalize_amount_yuan("2.50", "美元") is None
|
||||
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ def _settings(**updates: object) -> Settings:
|
||||
"offsite_smtp_username": "15273589815@163.com",
|
||||
"offsite_smtp_password": "test-auth-code",
|
||||
"offsite_smtp_sender": "15273589815@163.com",
|
||||
"offsite_smtp_enabled": False,
|
||||
"offsite_smtp_dry_run": True,
|
||||
}
|
||||
values.update(updates)
|
||||
return Settings(**values)
|
||||
|
||||
@@ -18,7 +18,7 @@ def _valid_inputs() -> dict[str, object]:
|
||||
},
|
||||
"manager_info": {
|
||||
"manager_name": "张三",
|
||||
"management_company": "奶龙基金管理有限公司",
|
||||
"management_company": "南方基金管理有限公司",
|
||||
"registration_code": "P10000001",
|
||||
},
|
||||
"team_info": {"team_description": "具备完整投研分工"},
|
||||
|
||||
@@ -93,15 +93,20 @@ async def test_authority_risk_level_replaces_caller_supplied_level() -> None:
|
||||
assert decision.risk_level_source == "fin_risk_assessment"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forged", [{"customer_risk_level": 5}, {"professional_investor": True},
|
||||
{"assessment_expires_at": "2099-01-01T00:00:00Z"}])
|
||||
@pytest.mark.parametrize(
|
||||
"forged",
|
||||
[
|
||||
{"customer_risk_level": 5},
|
||||
{"professional_investor": True},
|
||||
{"assessment_expires_at": "2099-01-01T00:00:00Z"},
|
||||
],
|
||||
)
|
||||
def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
query(**forged)
|
||||
|
||||
|
||||
async def test_insufficient_authority_level_is_denied() -> None:
|
||||
"""低两个等级及以上仍必须拒绝(第十四条第 2、3 款,矩阵里的"❌ 禁止")。"""
|
||||
decision = await service_with_row(authority_row(investor_type="C1")).evaluate(
|
||||
query(product_risk_level=3), context(), now=NOW
|
||||
)
|
||||
@@ -113,25 +118,36 @@ async def test_insufficient_authority_level_is_denied() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("investor_type", "product_level", "allowed", "reason_code"),
|
||||
[
|
||||
# 逐格抄自 knowledge/policy/个人投资者适当性管理指南.md 第十二条矩阵。
|
||||
# 这张表的价值在于:任何一格被改动,都必须是有意为之并在此处说明理由。
|
||||
("C1", 1, True, "SUITABLE"), ("C1", 2, True, "SUITABLE"),
|
||||
("C1", 3, False, "RISK_LEVEL_MISMATCH"), ("C1", 4, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C1", 1, True, "SUITABLE"),
|
||||
("C1", 2, True, "SUITABLE"),
|
||||
("C1", 3, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C1", 4, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C1", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C2", 1, True, "SUITABLE"), ("C2", 2, True, "SUITABLE"), ("C2", 3, True, "SUITABLE"),
|
||||
("C2", 4, False, "RISK_LEVEL_MISMATCH"), ("C2", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C3", 1, True, "SUITABLE"), ("C3", 2, True, "SUITABLE"), ("C3", 3, True, "SUITABLE"),
|
||||
("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), ("C3", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C4", 1, True, "SUITABLE"), ("C4", 2, True, "SUITABLE"), ("C4", 3, True, "SUITABLE"),
|
||||
("C4", 4, True, "SUITABLE"), ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"),
|
||||
("C5", 1, True, "SUITABLE"), ("C5", 2, True, "SUITABLE"), ("C5", 3, True, "SUITABLE"),
|
||||
("C5", 4, True, "SUITABLE"), ("C5", 5, True, "SUITABLE"),
|
||||
("C2", 1, True, "SUITABLE"),
|
||||
("C2", 2, True, "SUITABLE"),
|
||||
("C2", 3, True, "SUITABLE"),
|
||||
("C2", 4, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C2", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C3", 1, True, "SUITABLE"),
|
||||
("C3", 2, True, "SUITABLE"),
|
||||
("C3", 3, True, "SUITABLE"),
|
||||
("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"),
|
||||
("C3", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C4", 1, True, "SUITABLE"),
|
||||
("C4", 2, True, "SUITABLE"),
|
||||
("C4", 3, True, "SUITABLE"),
|
||||
("C4", 4, True, "SUITABLE"),
|
||||
("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"),
|
||||
("C5", 1, True, "SUITABLE"),
|
||||
("C5", 2, True, "SUITABLE"),
|
||||
("C5", 3, True, "SUITABLE"),
|
||||
("C5", 4, True, "SUITABLE"),
|
||||
("C5", 5, True, "SUITABLE"),
|
||||
],
|
||||
)
|
||||
async def test_full_matrix_matches_policy_document(
|
||||
investor_type: str, product_level: int, allowed: bool, reason_code: str
|
||||
) -> None:
|
||||
"""客服回答必须与知识库里的矩阵一致 —— 这是同一个 Agent 的两条出口。"""
|
||||
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
|
||||
query(product_risk_level=product_level), context(), now=NOW
|
||||
)
|
||||
@@ -140,7 +156,6 @@ async def test_full_matrix_matches_policy_document(
|
||||
|
||||
|
||||
async def test_disclosure_tier_always_requires_disclosure_and_recording() -> None:
|
||||
"""C3→R4、C4→R5 是第十五条豁免档:可买,但必须揭示、确认、录音。"""
|
||||
for investor_type, product_level in (("C3", 4), ("C4", 5)):
|
||||
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
|
||||
query(product_risk_level=product_level), context(), now=NOW
|
||||
@@ -269,10 +284,9 @@ async def test_tool_handler_uses_same_service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
added: list[Any] = []
|
||||
sink = added
|
||||
monkeypatch.setattr(
|
||||
"app.service.suitability_service.SessionFactory",
|
||||
lambda: FakeSession(authority_row(investor_type="C1"), sink),
|
||||
lambda: FakeSession(authority_row(investor_type="C1"), added),
|
||||
)
|
||||
result = await suitability_tool_handler(query(product_risk_level=5), context())
|
||||
assert result["allowed"] is False
|
||||
|
||||
Reference in New Issue
Block a user