from __future__ import annotations import json from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.core.agent_enums import AgentAssetDomain from app.db.base import Base from app.models.agent_asset import AgentAsset from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.risk_rule_generation import RiskRuleGenerationService from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload class SemanticPlanEnvelopeRuntimeChatService: def complete(self, *args, **kwargs) -> str: return json.dumps( { "semantic_plan": { "rule_intent": "费用申请金额不得超过可用预算", "judgment_steps": [ "读取申请金额", "读取可用预算余额", "比较申请金额是否大于可用预算", ], }, "dsl": { "name": "预算余额超额校验", "description": "申请金额超过当前可用预算时提示风险。", "template_key": "composite_rule_v1", "semantic_type": "budget_available_balance_check", "field_keys": ["claim.amount", "budget.remaining_amount"], "condition_summary": "claim.amount > budget.remaining_amount", "conditions": [ { "id": "amount_exceeds_budget", "operator": "numeric_compare", "left_fields": ["claim.amount"], "right_fields": ["budget.remaining_amount"], "compare": "gt", } ], "hit_logic": {"all": ["amount_exceeds_budget"]}, "message_template": "申请金额超过当前可用预算余额。", "keywords": [], }, }, ensure_ascii=False, ) class SemanticPlanOnlyRuntimeChatService: def complete(self, *args, **kwargs) -> str: return json.dumps( { "semantic_plan": { "rule_intent": "费用申请金额超过可用预算余额时提示风险", "required_fields": [ {"field": "claim.amount", "label": "申请金额"}, {"field": "budget.remaining_amount", "label": "可用预算余额"}, ], "judgment_steps": [ "读取申请金额 claim.amount", "读取可用预算余额 budget.remaining_amount", "若申请金额超过可用预算余额则命中预算风险", ], "risk_action": {"message": "要求补充预算审批说明"}, } }, ensure_ascii=False, ) def build_session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) return sessionmaker(bind=engine, autoflush=False, autocommit=False)() def test_prompt_requires_semantic_plan_then_dsl() -> None: messages = build_risk_rule_compiler_messages( domain="expense", domain_label="报销", business_stage="expense_application", business_stage_label="费用申请", expense_category="travel", expense_category_label="差旅费", natural_language="申请金额超过预算余额时提示风险。", available_fields=[{"key": "claim.amount", "label": "申请金额", "type": "number", "source": "claim"}], ) request_payload = json.loads(messages[1]["content"]) required_shape = request_payload["required_json_shape"] assert "semantic_plan" in required_shape assert "dsl" in required_shape assert "semantic_plan 和 dsl" in messages[0]["content"] def test_semantic_plan_envelope_is_unwrapped_and_persisted(tmp_path) -> None: with build_session() as db: manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") service = RiskRuleGenerationService( db, rule_library_manager=manager, runtime_chat_service=SemanticPlanEnvelopeRuntimeChatService(), ) asset_id = service.generate_rule_asset( AgentAssetRiskRuleGenerateRequest( business_domain=AgentAssetDomain.EXPENSE, business_stage="expense_application", expense_category="travel", rule_title="预算余额超额校验", natural_language="费用申请时,如果申请金额超过当前可用预算余额,则提示预算风险。", ), actor="pytest", ) asset = db.get(AgentAsset, asset_id) assert asset is not None payload = manager.read_rule_library_json( library=RISK_RULES_LIBRARY, file_name=asset.config_json["rule_document"]["file_name"], ) assert payload["template_key"] == "composite_rule_v1" assert payload["params"]["conditions"][0]["operator"] == "numeric_compare" assert payload["metadata"]["model_semantic_plan"]["rule_intent"] == "费用申请金额不得超过可用预算" assert payload["semantic_plan"]["judgment_steps"] def test_semantic_plan_only_response_can_generate_standard_dsl(tmp_path) -> None: with build_session() as db: manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") service = RiskRuleGenerationService( db, rule_library_manager=manager, runtime_chat_service=SemanticPlanOnlyRuntimeChatService(), ) asset_id = service.generate_rule_asset( AgentAssetRiskRuleGenerateRequest( business_domain=AgentAssetDomain.EXPENSE, business_stage="expense_application", expense_category="travel", rule_title="预算余额语义计划校验", natural_language="费用申请金额超过可用预算余额时提示风险,并要求补充审批说明。", ), actor="pytest", ) asset = db.get(AgentAsset, asset_id) assert asset is not None payload = manager.read_rule_library_json( library=RISK_RULES_LIBRARY, file_name=asset.config_json["rule_document"]["file_name"], ) assert payload["params"]["conditions"][0]["operator"] == "numeric_compare" assert payload["params"]["conditions"][0]["left_fields"] == ["claim.amount"] assert payload["params"]["conditions"][0]["right_fields"] == ["budget.remaining_amount"] assert payload["metadata"]["model_semantic_plan"]["required_fields"] def test_unwrap_semantic_plan_payload_keeps_legacy_payload_compatible() -> None: legacy = {"template_key": "field_required_v1", "field_keys": ["claim.reason"]} assert unwrap_semantic_plan_payload(legacy) == legacy wrapped = unwrap_semantic_plan_payload( { "semantic_plan": {"rule_intent": "预算校验"}, "dsl": {"template_key": "composite_rule_v1", "field_keys": ["claim.amount"]}, } ) assert wrapped["template_key"] == "composite_rule_v1" assert wrapped["model_semantic_plan"]["rule_intent"] == "预算校验" plan_only = unwrap_semantic_plan_payload( { "semantic_plan": { "rule_intent": "预算校验", "required_fields": [{"field": "claim.amount"}], "judgment_steps": ["申请金额超过预算余额"], } } ) assert plan_only["template_key"] == "composite_rule_v1" assert plan_only["field_keys"] == ["claim.amount"]