feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -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"