2026-05-20 09:36:01 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, date, datetime
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
import pytest
|
2026-05-20 09:36:01 +08:00
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.base import Base
|
|
|
|
|
|
from app.models.employee import Employee
|
|
|
|
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
2026-05-25 13:35:39 +08:00
|
|
|
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
2026-05-20 09:36:01 +08:00
|
|
|
|
from app.schemas.orchestrator import OrchestratorRequest
|
2026-05-21 09:28:33 +08:00
|
|
|
|
from app.services.agent_conversations import AgentConversationService
|
2026-05-20 09:36:01 +08:00
|
|
|
|
from app.services.orchestrator import OrchestratorService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_session_factory() -> sessionmaker[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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.agent_foundation.AgentFoundationService.ensure_foundation_ready",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E9000",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-next@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E9001",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-next@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-next-step",
|
|
|
|
|
|
claim_no="EXP-202605-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="销售部",
|
|
|
|
|
|
expense_type="office",
|
|
|
|
|
|
reason="采购办公用品",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("128.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
items=[
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
item_date=date(2026, 5, 20),
|
|
|
|
|
|
item_type="office",
|
|
|
|
|
|
item_reason="采购办公用品",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("128.00"),
|
|
|
|
|
|
invoice_id="office-invoice.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee, claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="emp-next@example.com",
|
|
|
|
|
|
message="我已核对右侧识别结果,请进入下一步。",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"review_action": "next_step",
|
|
|
|
|
|
"draft_claim_id": claim.id,
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"name": "张三",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert response.status == "succeeded"
|
|
|
|
|
|
assert response.requires_confirmation is False
|
|
|
|
|
|
assert response.result["draft_payload"]["status"] == "submitted"
|
|
|
|
|
|
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
|
|
|
|
|
|
assert claim.status == "submitted"
|
|
|
|
|
|
assert claim.approval_stage == "直属领导审批"
|
|
|
|
|
|
assert claim.submitted_at is not None
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert response.conversation_id
|
|
|
|
|
|
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
|
2026-05-20 09:36:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E9011",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-blocked@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-next-step-blocked",
|
|
|
|
|
|
claim_no="EXP-202605-002",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="待补充",
|
|
|
|
|
|
expense_type="office",
|
|
|
|
|
|
reason="采购办公用品",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("128.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
items=[
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
item_date=date(2026, 5, 20),
|
|
|
|
|
|
item_type="office",
|
|
|
|
|
|
item_reason="采购办公用品",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("128.00"),
|
|
|
|
|
|
invoice_id="office-invoice.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([employee, claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="emp-blocked@example.com",
|
|
|
|
|
|
message="我已核对右侧识别结果,请进入下一步。",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"review_action": "next_step",
|
|
|
|
|
|
"draft_claim_id": claim.id,
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"name": "张三",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = response.result
|
|
|
|
|
|
review_payload = result["review_payload"]
|
|
|
|
|
|
actions = {
|
|
|
|
|
|
str(item.get("action_type") or "").strip()
|
|
|
|
|
|
for item in review_payload["confirmation_actions"]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert response.status == "succeeded"
|
|
|
|
|
|
assert result["draft_payload"]["status"] == "draft"
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert response.conversation_id
|
|
|
|
|
|
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
|
2026-05-20 09:36:01 +08:00
|
|
|
|
assert "AI预审暂未通过" in result["answer"]
|
|
|
|
|
|
assert "所属部门未完善" in result["answer"]
|
|
|
|
|
|
assert "next_step" not in actions
|
|
|
|
|
|
assert "save_draft" in actions
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
"所属部门未完善" in str(item.get("content") or "")
|
|
|
|
|
|
for item in review_payload["risk_briefs"]
|
|
|
|
|
|
)
|
2026-05-21 14:24:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_prompt() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = AgentConversationService(db)
|
|
|
|
|
|
conversation = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-review-type-lock",
|
|
|
|
|
|
user_id="emp-review-type@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "expense",
|
|
|
|
|
|
"draft_claim_id": "claim-old",
|
|
|
|
|
|
"attachment_names": ["old-train-ticket.pdf"],
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"business_location": "北京",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
fresh_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=conversation,
|
2026-05-21 16:09:47 +08:00
|
|
|
|
context_json={"draft_claim_id": "claim-old"},
|
2026-05-21 14:24:51 +08:00
|
|
|
|
message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销",
|
|
|
|
|
|
)
|
|
|
|
|
|
continued_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=conversation,
|
|
|
|
|
|
context_json={},
|
|
|
|
|
|
message="继续补充酒店发票",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert "draft_claim_id" not in fresh_context
|
|
|
|
|
|
assert "attachment_names" not in fresh_context
|
|
|
|
|
|
assert "review_form_values" not in fresh_context
|
|
|
|
|
|
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
|
|
|
|
|
|
assert continued_context["draft_claim_id"] == "claim-old"
|
|
|
|
|
|
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
2026-05-21 16:09:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
def test_conversation_hydration_preserves_incoming_application_time_context() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = AgentConversationService(db)
|
|
|
|
|
|
conversation = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-application-time-context",
|
|
|
|
|
|
user_id="emp-application-time@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "application",
|
|
|
|
|
|
"entry_source": "application",
|
|
|
|
|
|
"business_time_context": {
|
|
|
|
|
|
"mode": "single",
|
|
|
|
|
|
"start_date": "2026-05-01",
|
|
|
|
|
|
"end_date": "2026-05-01",
|
|
|
|
|
|
"display_value": "2026-05-01",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
stale_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=conversation,
|
|
|
|
|
|
context_json={"session_type": "application", "entry_source": "application"},
|
|
|
|
|
|
message="apply travel expense",
|
|
|
|
|
|
)
|
|
|
|
|
|
fresh_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=conversation,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "application",
|
|
|
|
|
|
"entry_source": "application",
|
|
|
|
|
|
"business_time_context": {
|
|
|
|
|
|
"mode": "single",
|
|
|
|
|
|
"start_date": "2026-05-25",
|
|
|
|
|
|
"end_date": "2026-05-25",
|
|
|
|
|
|
"display_value": "2026-05-25",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
message="apply travel expense",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert "business_time_context" not in stale_context
|
|
|
|
|
|
assert fresh_context["business_time_context"]["start_date"] == "2026-05-25"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = AgentConversationService(db)
|
|
|
|
|
|
old_conversation = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-old-claim-scope",
|
|
|
|
|
|
user_id="emp-scope@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "expense",
|
|
|
|
|
|
"draft_claim_id": "claim-old",
|
|
|
|
|
|
"attachment_names": ["old-hotel.pdf"],
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "住宿票",
|
|
|
|
|
|
"merchant_name": "旧酒店",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
service.append_message(
|
|
|
|
|
|
conversation_id=old_conversation.conversation_id,
|
|
|
|
|
|
role="user",
|
|
|
|
|
|
content="继续补充旧酒店发票",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
scoped_conversation = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id=old_conversation.conversation_id,
|
|
|
|
|
|
user_id="emp-scope@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "expense",
|
|
|
|
|
|
"draft_claim_id": "claim-current",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
conflict_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=old_conversation,
|
|
|
|
|
|
context_json={"draft_claim_id": "claim-current"},
|
|
|
|
|
|
message="继续补充当前单据的火车票",
|
|
|
|
|
|
)
|
|
|
|
|
|
scoped_context = service.hydrate_context_json(
|
|
|
|
|
|
conversation=scoped_conversation,
|
|
|
|
|
|
context_json={"draft_claim_id": "claim-current"},
|
|
|
|
|
|
message="继续补充当前单据的火车票",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(old_conversation)
|
|
|
|
|
|
assert scoped_conversation.conversation_id != old_conversation.conversation_id
|
|
|
|
|
|
assert scoped_conversation.draft_claim_id == "claim-current"
|
|
|
|
|
|
assert old_conversation.draft_claim_id == "claim-old"
|
|
|
|
|
|
assert conflict_context == {"draft_claim_id": "claim-current"}
|
|
|
|
|
|
assert scoped_context["draft_claim_id"] == "claim-current"
|
|
|
|
|
|
assert scoped_context["conversation_history"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
id="emp-history-query",
|
|
|
|
|
|
employee_no="E9020",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="history-query@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
beijing_claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-history-beijing",
|
|
|
|
|
|
claim_no="EXP-202506-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="去北京支持客户项目",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("321.45"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=2,
|
|
|
|
|
|
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="paid",
|
|
|
|
|
|
approval_stage="已入账",
|
|
|
|
|
|
)
|
|
|
|
|
|
shanghai_claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-history-shanghai",
|
|
|
|
|
|
claim_no="EXP-202507-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="去上海支持项目",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("888.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="paid",
|
|
|
|
|
|
approval_stage="已入账",
|
|
|
|
|
|
)
|
|
|
|
|
|
current_year_claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-history-beijing-current",
|
|
|
|
|
|
claim_no="EXP-202601-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="去北京支持年度项目",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("666.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="paid",
|
|
|
|
|
|
approval_stage="已入账",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="history-query@example.com",
|
|
|
|
|
|
message="我去年去北京报销的单据",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
|
|
|
|
|
"client_timezone_offset_minutes": -480,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
query_payload = response.result["query_payload"]
|
|
|
|
|
|
assert response.status == "succeeded"
|
|
|
|
|
|
assert response.trace_summary.scenario == "expense"
|
|
|
|
|
|
assert response.trace_summary.intent == "query"
|
|
|
|
|
|
assert query_payload["record_count"] == 1
|
|
|
|
|
|
assert query_payload["total_amount"] == 321.45
|
|
|
|
|
|
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
|
|
|
|
|
|
assert "321.45" in response.result["answer"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
id="emp-archive-query",
|
|
|
|
|
|
employee_no="E9021",
|
|
|
|
|
|
name="归档员工",
|
|
|
|
|
|
email="archive-query@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
claims = []
|
|
|
|
|
|
for index in range(6):
|
|
|
|
|
|
claims.append(
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
id=f"claim-archive-query-{index}",
|
|
|
|
|
|
claim_no=f"EXP-ARCHIVE-{index + 1:03d}",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="归档员工",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason=f"归档差旅 {index + 1}",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("100.00") + Decimal(index),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 2, index + 1, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 2, index + 2, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
approval_stage="归档入账",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
draft_claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-archive-query-draft",
|
|
|
|
|
|
claim_no="EXP-ARCHIVE-DRAFT",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="归档员工",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="未归档草稿",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("999.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 3, 1, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([employee, *claims, draft_claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="archive-query@example.com",
|
|
|
|
|
|
message="帮我查询一下我的归档的单据有哪些?",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
|
|
|
|
|
"client_timezone_offset_minutes": -480,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
query_payload = response.result["query_payload"]
|
|
|
|
|
|
assert response.status == "succeeded"
|
|
|
|
|
|
assert response.trace_summary.intent == "query"
|
|
|
|
|
|
assert query_payload["record_count"] == 6
|
|
|
|
|
|
assert query_payload["preview_count"] == 5
|
|
|
|
|
|
assert query_payload["preview_limit"] == 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 response.result["suggested_actions"] == []
|
|
|
|
|
|
assert "下面先列出最近 5 条记录" in response.result["answer"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E9030",
|
|
|
|
|
|
name="预览员工",
|
|
|
|
|
|
email="preview-orchestrator@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
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_claims = [
|
|
|
|
|
|
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
|
|
|
|
|
|
assert response.result.get("draft_payload") is None
|
2026-05-21 23:52:34 +08:00
|
|
|
|
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert user_claims == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = AgentConversationService(db)
|
|
|
|
|
|
conversation = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-scene-choice",
|
|
|
|
|
|
user_id="emp-scene-choice@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "expense",
|
|
|
|
|
|
"draft_claim_id": "claim-old",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"business_location": "北京",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="emp-scene-choice@example.com",
|
|
|
|
|
|
conversation_id=conversation.conversation_id,
|
|
|
|
|
|
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 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]] == ["差旅费", "交通费", "住宿费"]
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
message = (
|
|
|
|
|
|
"发生时间:2026-05-25\n"
|
|
|
|
|
|
"地点:上海\n"
|
|
|
|
|
|
"事由:支持上海国网服务器部署\n"
|
|
|
|
|
|
"天数:3天"
|
|
|
|
|
|
)
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
response = OrchestratorService(db).run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-session@example.com",
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "application",
|
|
|
|
|
|
"entry_source": "application",
|
|
|
|
|
|
"name": "申请员工",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = response.result
|
|
|
|
|
|
assert response.status == "blocked"
|
|
|
|
|
|
assert response.trace_summary.scenario == "expense"
|
|
|
|
|
|
assert "费用申请" in result["answer"]
|
|
|
|
|
|
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
|
|
|
|
|
assert "请先在下面选择报销场景" not in result["answer"]
|
|
|
|
|
|
assert result.get("review_payload") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
initial_message = (
|
|
|
|
|
|
"发生时间:2026-05-25\n"
|
|
|
|
|
|
"地点:上海\n"
|
|
|
|
|
|
"事由:支持上海国网服务器部署\n"
|
|
|
|
|
|
"天数:3天"
|
|
|
|
|
|
)
|
|
|
|
|
|
context_json = {
|
|
|
|
|
|
"session_type": "application",
|
|
|
|
|
|
"entry_source": "application",
|
|
|
|
|
|
"name": "申请员工",
|
|
|
|
|
|
"manager_name": "陈硕",
|
|
|
|
|
|
}
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = OrchestratorService(db)
|
|
|
|
|
|
|
|
|
|
|
|
first = service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-flow@example.com",
|
|
|
|
|
|
message=initial_message,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
second = service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-flow@example.com",
|
|
|
|
|
|
conversation_id=first.conversation_id,
|
|
|
|
|
|
message="飞机",
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
third = service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-flow@example.com",
|
|
|
|
|
|
conversation_id=first.conversation_id,
|
|
|
|
|
|
message="预计总费用:12000元",
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
fourth = service.run(
|
|
|
|
|
|
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 first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
|
|
|
|
|
|
|
|
|
|
|
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
|
|
|
|
|
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
|
|
|
|
|
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
|
|
|
|
|
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
|
|
|
|
|
|
|
|
|
|
|
assert "这是模拟的费用申请结果" in third.result["answer"]
|
|
|
|
|
|
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
|
|
|
|
|
assert "请核对上述信息无误" in third.result["answer"]
|
|
|
|
|
|
assert "[确认](#application-submit)" in third.result["answer"]
|
|
|
|
|
|
assert third.status == "blocked"
|
|
|
|
|
|
assert third.result["requires_confirmation"] is True
|
|
|
|
|
|
assert third.result["suggested_actions"] == []
|
|
|
|
|
|
|
|
|
|
|
|
assert fourth.status == "succeeded"
|
|
|
|
|
|
assert fourth.result["clarification_required"] is False
|
|
|
|
|
|
assert fourth.result["missing_slots"] == []
|
|
|
|
|
|
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"]
|
|
|
|
|
|
assert "当前状态:陈硕审核中" in fourth.result["answer"]
|
|
|
|
|
|
assert fourth.result["suggested_actions"] == []
|
|
|
|
|
|
application_claims = [
|
|
|
|
|
|
claim
|
|
|
|
|
|
for claim in db.query(ExpenseClaim).all()
|
|
|
|
|
|
if claim.claim_no.startswith("APP-20260525-")
|
|
|
|
|
|
]
|
|
|
|
|
|
assert len(application_claims) == 1
|
|
|
|
|
|
assert application_claims[0].status == "submitted"
|
|
|
|
|
|
assert application_claims[0].approval_stage == "直属领导审批"
|
|
|
|
|
|
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
|
|
|
|
|
lambda *_args, **_kwargs: None,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
initial_message = (
|
|
|
|
|
|
"发生时间:2026-05-25\n"
|
|
|
|
|
|
"地点:上海\n"
|
|
|
|
|
|
"事由:支持上海国网服务器部署\n"
|
|
|
|
|
|
"天数:3天"
|
|
|
|
|
|
)
|
|
|
|
|
|
context_json = {
|
|
|
|
|
|
"session_type": "application",
|
|
|
|
|
|
"entry_source": "application",
|
|
|
|
|
|
"name": "申请员工",
|
|
|
|
|
|
"manager_name": "陈硕",
|
|
|
|
|
|
}
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = OrchestratorService(db)
|
|
|
|
|
|
|
|
|
|
|
|
first = service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-approval-required@example.com",
|
|
|
|
|
|
message=initial_message,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-approval-required@example.com",
|
|
|
|
|
|
conversation_id=first.conversation_id,
|
|
|
|
|
|
message="飞机",
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
preview = service.run(
|
|
|
|
|
|
OrchestratorRequest(
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
user_id="application-approval-required@example.com",
|
|
|
|
|
|
conversation_id=first.conversation_id,
|
|
|
|
|
|
message="预计总费用:12000元",
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
|
|
|
|
|
return OntologyParseResult(
|
|
|
|
|
|
scenario="expense",
|
|
|
|
|
|
intent="operate",
|
|
|
|
|
|
entities=[],
|
|
|
|
|
|
permission=OntologyPermission(
|
|
|
|
|
|
level="approval_required",
|
|
|
|
|
|
allowed=False,
|
|
|
|
|
|
reason="操作类请求需要人工审批确认。",
|
|
|
|
|
|
),
|
|
|
|
|
|
confidence=0.95,
|
|
|
|
|
|
missing_slots=[],
|
|
|
|
|
|
ambiguity=[],
|
|
|
|
|
|
clarification_required=False,
|
|
|
|
|
|
clarification_question=None,
|
|
|
|
|
|
run_id=run_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
|
|
|
|
|
approval_required_parse_for_run,
|
|
|
|
|
|
)
|
|
|
|
|
|
submitted = service.run(
|
|
|
|
|
|
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"
|
|
|
|
|
|
assert submitted.status == "succeeded"
|
|
|
|
|
|
assert submitted.requires_confirmation is False
|
|
|
|
|
|
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
|
|
|
|
|
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
|
|
|
|
|
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
|
|
|
|
|
assert submitted.result["draft_payload"]["status"] == "submitted"
|