feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -16,11 +16,14 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
FIXTURE_TENANT_ID = "default"
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
@@ -29,7 +32,47 @@ def build_session_factory() -> sessionmaker[Session]:
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
with factory() as db:
|
||||
db.add(
|
||||
Tenant(
|
||||
tenant_id=FIXTURE_TENANT_ID,
|
||||
tenant_code="orchestrator-review-fixture",
|
||||
name="Orchestrator 回归测试租户",
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return factory
|
||||
|
||||
|
||||
def run_for_fixture_tenant(
|
||||
service: OrchestratorService,
|
||||
payload: OrchestratorRequest,
|
||||
):
|
||||
"""显式使用已注册 fixture 租户,不提供生产默认租户回退。"""
|
||||
|
||||
return service.run(payload, trusted_tenant_id=FIXTURE_TENANT_ID)
|
||||
|
||||
|
||||
def seed_application_employee(db: Session, *, email: str) -> None:
|
||||
"""为申请提交回归提供可唯一解析的真实直属领导关系。"""
|
||||
|
||||
manager = Employee(
|
||||
tenant_id=FIXTURE_TENANT_ID,
|
||||
employee_no="E-APP-MANAGER",
|
||||
name="陈硕",
|
||||
email="application-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
tenant_id=FIXTURE_TENANT_ID,
|
||||
employee_no="E-APP-EMPLOYEE",
|
||||
name="申请员工",
|
||||
email=email,
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -92,7 +135,10 @@ def test_schedule_digital_employee_task_runs_real_service(
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.ontology.SemanticOntologyService.parse_for_run", parse_for_run)
|
||||
monkeypatch.setattr(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
parse_for_run,
|
||||
)
|
||||
monkeypatch.setattr(method_path, lambda self, **kwargs: dict(summary))
|
||||
|
||||
session_factory = build_session_factory()
|
||||
@@ -114,8 +160,9 @@ def test_schedule_digital_employee_task_runs_real_service(
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(source="schedule", task_id=task.id, message=task.name)
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(source="schedule", task_id=task.id, message=task.name),
|
||||
)
|
||||
|
||||
run = db.query(AgentRun).filter_by(run_id=response.run_id).one()
|
||||
@@ -176,7 +223,8 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="emp-next@example.com",
|
||||
@@ -187,7 +235,7 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
"attachment_count": 1,
|
||||
"name": "张三",
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
@@ -246,7 +294,8 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
db.add_all([employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="emp-blocked@example.com",
|
||||
@@ -257,7 +306,7 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||
"attachment_count": 1,
|
||||
"name": "张三",
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = response.result
|
||||
@@ -490,7 +539,8 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
|
||||
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="history-query@example.com",
|
||||
@@ -499,7 +549,7 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
|
||||
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||
"client_timezone_offset_minutes": -480,
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
query_payload = response.result["query_payload"]
|
||||
@@ -570,7 +620,8 @@ def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
|
||||
db.add_all([employee, *claims, draft_claim])
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="archive-query@example.com",
|
||||
@@ -579,7 +630,7 @@ def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
|
||||
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||
"client_timezone_offset_minutes": -480,
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
query_payload = response.result["query_payload"]
|
||||
@@ -588,9 +639,11 @@ def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
|
||||
assert query_payload["record_count"] == 6
|
||||
assert query_payload["preview_count"] == 5
|
||||
assert query_payload["preview_limit"] == 5
|
||||
assert query_payload["title"] == "最近 5 条你的归档报销单"
|
||||
assert query_payload["title"] == "最近 5 条您的归档报销单"
|
||||
assert all(record["status"] == "approved" for record in query_payload["records"])
|
||||
assert "EXP-ARCHIVE-DRAFT" not in [record["claim_no"] for record in query_payload["records"]]
|
||||
assert "EXP-ARCHIVE-DRAFT" not in [
|
||||
record["claim_no"] for record in query_payload["records"]
|
||||
]
|
||||
assert response.result["suggested_actions"] == []
|
||||
assert "下面先列出最近 5 条记录" in response.result["answer"]
|
||||
|
||||
@@ -612,22 +665,23 @@ def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="preview-orchestrator@example.com",
|
||||
message="业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报",
|
||||
context_json={
|
||||
"name": "预览员工",
|
||||
"user_input_text": "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报",
|
||||
"user_input_text": (
|
||||
"业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
||||
),
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
user_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.employee_name == "预览员工"
|
||||
claim for claim in db.query(ExpenseClaim).all() if claim.employee_name == "预览员工"
|
||||
]
|
||||
assert response.status == "succeeded"
|
||||
assert response.result.get("review_payload") is not None
|
||||
@@ -660,25 +714,32 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex
|
||||
},
|
||||
)
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="emp-scene-choice@example.com",
|
||||
conversation_id=conversation.conversation_id,
|
||||
message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销",
|
||||
message=(
|
||||
"业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销"
|
||||
),
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": "claim-old",
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = response.result
|
||||
assert response.status == "succeeded"
|
||||
assert response.status == "blocked"
|
||||
assert result.get("review_payload") is None
|
||||
assert result.get("draft_payload") is None
|
||||
assert "请先在下面选择报销场景" in result["answer"]
|
||||
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||
assert [item["label"] for item in result["suggested_actions"][:3]] == [
|
||||
"差旅费",
|
||||
"交通费",
|
||||
"住宿费",
|
||||
]
|
||||
|
||||
|
||||
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
||||
@@ -689,14 +750,10 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
message = "发生时间:2026-05-25\n地点:上海\n事由:支持上海国网服务器部署\n天数:3天"
|
||||
with session_factory() as db:
|
||||
response = OrchestratorService(db).run(
|
||||
response = run_for_fixture_tenant(
|
||||
OrchestratorService(db),
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-session@example.com",
|
||||
@@ -706,7 +763,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = response.result
|
||||
@@ -727,51 +784,53 @@ def test_orchestrator_application_session_guides_transport_estimate_and_submit(
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
initial_message = "发生时间:2026-05-25\n地点:上海\n事由:支持上海国网服务器部署\n天数:3天"
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"department_name": "交付部",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
seed_application_employee(db, email="application-flow@example.com")
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
first = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
second = service.run(
|
||||
second = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
third = service.run(
|
||||
third = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == [
|
||||
"一次性补充申请信息"
|
||||
]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:"
|
||||
|
||||
assert "这是费用申请核对结果" in second.result["answer"]
|
||||
@@ -793,11 +852,7 @@ def test_orchestrator_application_session_guides_transport_estimate_and_submit(
|
||||
assert "申请单据已生成,并已进入审批流程" in third.result["answer"]
|
||||
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in third.result["answer"]
|
||||
assert third.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.claim_no.startswith("AP-")
|
||||
]
|
||||
application_claims = db.query(ExpenseClaim).all()
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
assert application_claims[0].approval_stage == "直属领导审批"
|
||||
@@ -812,37 +867,36 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
initial_message = "发生时间:2026-05-25\n地点:上海\n事由:支持上海国网服务器部署\n天数:3天"
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"department_name": "交付部",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
seed_application_employee(db, email="application-approval-required@example.com")
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
first = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
preview = service.run(
|
||||
preview = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
||||
@@ -867,14 +921,15 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
approval_required_parse_for_run,
|
||||
)
|
||||
submitted = service.run(
|
||||
submitted = run_for_fixture_tenant(
|
||||
service,
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert preview.status == "blocked"
|
||||
@@ -946,10 +1001,7 @@ def test_authenticated_orchestrator_application_draft_consumes_server_preview_de
|
||||
source="user_message",
|
||||
user_id="forged@example.com",
|
||||
message=(
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
"发生时间:2026-05-25\n地点:上海\n事由:支持上海国网服务器部署\n天数:3天"
|
||||
),
|
||||
context_json=context_json,
|
||||
),
|
||||
@@ -1010,9 +1062,7 @@ def test_authenticated_orchestrator_application_draft_consumes_server_preview_de
|
||||
)
|
||||
assert learning_decision is not None
|
||||
feedback = db.scalar(
|
||||
select(AIDecisionFeedback).where(
|
||||
AIDecisionFeedback.decision_id == learning_decision.id
|
||||
)
|
||||
select(AIDecisionFeedback).where(AIDecisionFeedback.decision_id == learning_decision.id)
|
||||
)
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "accepted"
|
||||
|
||||
Reference in New Issue
Block a user