feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -49,6 +49,27 @@ def test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> No
assert all(item.run_id != created.run_id for item in running_runs)
def test_agent_run_service_marks_stale_llm_wiki_run_failed_on_read() -> None:
with build_session() as db:
service = AgentRunService(db)
created = service.create_run(
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
status=AgentRunStatus.RUNNING.value,
route_json={
"job_type": "llm_wiki_sync",
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
"requested_document_ids": [],
},
)
fetched = service.get_run(created.run_id)
assert fetched is not None
assert fetched.status == AgentRunStatus.FAILED.value
assert fetched.error_message == "Knowledge index heartbeat timed out."
def test_agent_run_service_updates_existing_tool_call() -> None:
with build_session() as db:
service = AgentRunService(db)

View File

@@ -0,0 +1,74 @@
from decimal import Decimal
from app.algorithem import ApplicantExpenseProfileInput, evaluate_applicant_expense_profile
def test_applicant_profile_recommends_review_and_days_cap() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
applicant_claim_count_90d=6,
peer_claim_count_p75_90d=4,
applicant_amount_90d=Decimal("42800"),
available_peer_budget_90d=Decimal("150000"),
amount_percentile=Decimal("88"),
peer_amount_median_90d=Decimal("25000"),
adjusted_or_returned_count_180d=3,
approved_claim_count_180d=12,
requested_days=Decimal("5"),
peer_travel_days_p75=Decimal("3"),
business_buffer_days=Decimal("1"),
claim_amount=Decimal("8000"),
peer_daily_cost_baseline=Decimal("1400"),
tolerance_factor=Decimal("1.20"),
)
)
assert result.profile_score == 68
assert result.profile_level == "review"
assert result.recommendation_level == "review"
assert result.travel_days_ratio == Decimal("1.6667")
assert result.suggested_days == Decimal("4.0000")
assert result.suggested_amount_cap == Decimal("6720.00")
assert "applicant.amount_percentile.p85" in result.basis_codes
assert "travel.days_ratio.review" in result.basis_codes
def test_applicant_profile_handles_missing_baselines_without_false_risk() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
applicant_claim_count_90d=1,
applicant_amount_90d=Decimal("800"),
requested_days=Decimal("2"),
claim_amount=Decimal("800"),
)
)
assert result.profile_score == 0
assert result.profile_level == "normal"
assert result.recommendation_level == "normal"
assert result.frequency_ratio == Decimal("0")
assert result.daily_cost_ratio == Decimal("0")
assert result.suggested_days == Decimal("2.0000")
assert result.suggested_amount_cap is None
assert result.basis_codes == []
def test_entertainment_deviation_can_escalate_recommendation() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
entertainment_amount=Decimal("3000"),
attendee_count=3,
entertainment_standard_cap=Decimal("600"),
same_customer_frequency_90d=3,
applicant_entertainment_percentile=Decimal("96"),
)
)
assert result.entertainment_deviation_score == 100
assert result.current_claim_deviation_score == 100
assert result.profile_score == 15
assert result.profile_level == "normal"
assert result.recommendation_level == "escalation"
assert "entertainment.per_capita.escalation" in result.basis_codes
assert "entertainment.same_customer.frequency_review" in result.basis_codes
assert "entertainment.amount_percentile.p95" in result.basis_codes

View File

@@ -13,6 +13,10 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
def build_session_factory() -> sessionmaker[Session]:
@@ -109,6 +113,27 @@ def seed_budget_allocations(db: Session) -> None:
db.commit()
def seed_market_budget_monitor(db: Session) -> tuple[Role, OrganizationUnit]:
role = Role(role_code="budget_monitor", name="预算监控员")
department = OrganizationUnit(
id="dept-market",
unit_code="MARKET-DEPT",
name="市场部",
unit_type="department",
)
employee = Employee(
employee_no="E-BUDGET-MARKET-P8",
name="赵预算",
email="budget-monitor@example.com",
grade="P8",
organization_unit=department,
roles=[role],
)
db.add_all([role, department, employee])
db.flush()
return role, department
def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None:
client, session_factory = build_client()
with session_factory() as db:
@@ -281,3 +306,69 @@ def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None:
)
assert admin_response.status_code == 201
assert admin_response.json()["department_name"] == "销售部"
def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_budget_allocations(db)
budget_role, market_department = seed_market_budget_monitor(db)
p6_budget_monitor = Employee(
employee_no="E-BUDGET-MARKET-P6",
name="低级预算",
email="p6-budget-monitor@example.com",
grade="P6",
organization_unit=market_department,
roles=[budget_role],
)
db.add(p6_budget_monitor)
db.flush()
claim = ExpenseClaim(
claim_no="APP-BUDGET-ANALYSIS-001",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付预算申请",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
ordinary_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-role-codes": "employee",
},
)
monitor_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-monitor@example.com",
"x-auth-role-codes": "budget_monitor",
},
)
p6_monitor_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "p6-budget-monitor@example.com",
"x-auth-role-codes": "budget_monitor",
},
)
assert ordinary_response.status_code == 403
assert p6_monitor_response.status_code == 403
assert monitor_response.status_code == 200
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")

View File

@@ -16,10 +16,12 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService
@@ -108,6 +110,16 @@ def _seed_budget_allocation(
return allocation
def _seed_budget_monitor_role(db: Session) -> Role:
role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
if role is not None:
return role
role = Role(role_code="budget_monitor", name="预算监控员")
db.add(role)
db.flush()
return role
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充")
@@ -3320,32 +3332,55 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
)
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
current_user = CurrentUserContext(
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
manager_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-application-approve@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-APPROVE",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E8112",
name="李经理",
email="manager-application-approve@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="E8112-BUDGET",
name="赵预算",
email="budget-p8-application-approve@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E8113",
name="张三",
email="zhangsan-application-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([manager, employee])
db.add_all([department, manager, budget_manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-APPROVE",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
@@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
db.commit()
claim_id = claim.id
leader_approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="业务必要,同意申请。",
)
assert leader_approved is not None
assert leader_approved.status == "submitted"
assert leader_approved.approval_stage == "预算管理者审批"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "业务必要,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "预算管理者审批"
and flag.get("next_approver_name") == "赵预算"
and flag.get("next_approver_grade") == "P8"
for flag in leader_approved.risk_flags_json
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion="业务必要,同意申请",
budget_user,
opinion="预算额度可承接,同意。",
)
assert approved is not None
@@ -3400,14 +3458,15 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("leader_opinion") == "业务必要,同意申请。"
and flag.get("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "业务必要,同意申请"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_application_budget_approval"
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("generated_draft_claim_id") == generated_draft.id
@@ -3473,7 +3532,7 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
reason_codes=["application_budget_basis_missing"],
)
assert returned is not None
@@ -3493,11 +3552,8 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["return_stage"] == "直属领导审批"
assert return_event["return_stage_key"] == "direct_manager"
assert return_event["reason_codes"] == [
"application_business_need_unclear",
"application_budget_basis_missing",
]
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
assert return_event["risk_points"] == ["预算测算依据不足"]
assert return_event["next_status"] == "returned"
assert return_event["next_approval_stage"] == "待提交"
@@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-transfer@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
id="dept-budget-transfer",
unit_code="DELIVERY-BUDGET-TRANSFER",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="M-BUDGET-APP",
name="李经理",
email="manager-application-budget@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="P8-BUDGET-APP",
name="赵预算",
email="budget-p8-transfer@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-BUDGET-APP",
name="张三",
email="application-budget-owner-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([manager, employee])
db.add_all([department, manager, budget_manager, employee])
db.flush()
_seed_budget_allocation(
db,
@@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
service = ExpenseClaimService(db)
service.submit_claim(claim.id, owner)
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
reservation = db.query(BudgetReservation).one()
assert leader_approved is not None
assert leader_approved.approval_stage == "预算管理者审批"
assert reservation.source_type == "application"
assert reservation.source_id == claim.id
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
db.refresh(reservation)
assert approved is not None
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
@@ -3576,7 +3664,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
)
def test_direct_manager_approval_requires_leader_opinion() -> None:
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com",
name="李经理",
@@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="领导审核意见不能为空"):
ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "预算管理者审批"
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "同意"
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
owner = CurrentUserContext(
username="application-budget-analysis-owner@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E-BUDGET-ANALYSIS",
name="张三",
email="application-budget-analysis-owner@example.com",
)
db.add(employee)
db.flush()
_seed_budget_allocation(
db,
department_id="dept-budget-analysis",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-ANALYSIS",
employee_id=employee.id,
employee_name="张三",
department_id="dept-budget-analysis",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
ExpenseClaimService(db).submit_claim(claim.id, owner)
analysis = BudgetService(db).analyze_claim_budget(claim)
assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
assert analysis["metrics"]["after_usage_rate"] == "24.00"
assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
assert analysis["score"] >= 70
assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"])
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
current_user = CurrentUserContext(
username="finance-budget-approve@example.com",
@@ -4207,3 +4355,121 @@ def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
current_user = CurrentUserContext(
username="budget-p8-list@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
p8_without_budget_role = CurrentUserContext(
username="budget-p8-list@example.com",
name="budget manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
delivery_department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-LIST",
name="交付部",
unit_type="department",
)
market_department = OrganizationUnit(
unit_code="MARKET-BUDGET-LIST",
name="市场部",
unit_type="department",
)
budget_manager = Employee(
employee_no="E-P8-BUDGET-LIST",
name="赵预算",
email="budget-p8-list@example.com",
grade="P8",
organization_unit=delivery_department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-BUDGET-LIST-OWNER",
name="张三",
email="budget-list-owner@example.com",
organization_unit=delivery_department,
)
market_employee = Employee(
employee_no="E-BUDGET-LIST-MARKET",
name="王五",
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="APP-BUDGET-LIST-201",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="预算待审申请",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="APP-BUDGET-LIST-OTHER-DEPT",
employee_id=market_employee.id,
employee_name="王五",
department_id=market_department.id,
department_name="市场部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="其他部门预算待审申请",
location="上海",
amount=Decimal("13000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-BUDGET-LIST-202",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="transport",
reason="财务待审报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
assert [claim.claim_no for claim in claims_without_budget_role] == []

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import pytest
from app.services.knowledge_rag_runtime import (
KnowledgeRagError,
RuntimeModelConfig,
_LightRagRuntime,
)
def test_embedding_probe_error_includes_model_context(monkeypatch) -> None:
runtime = _LightRagRuntime.__new__(_LightRagRuntime)
config = RuntimeModelConfig(
slot="embedding",
provider="GLM",
model="Embedding-3",
endpoint="https://open.bigmodel.cn/api/paas/v4/",
api_key="token",
capability="embedding",
)
def fail_embeddings(*_args, **_kwargs):
raise KnowledgeRagError("token expired")
monkeypatch.setattr(runtime, "_request_embeddings", fail_embeddings)
with pytest.raises(KnowledgeRagError) as exc_info:
runtime._probe_embedding_dimension(config)
message = str(exc_info.value)
assert "slot=embedding" in message
assert "provider=GLM" in message
assert "model=Embedding-3" in message
assert "token expired" in message

View File

@@ -238,7 +238,7 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
def test_runtime_cache_uses_dedicated_instance_across_calling_threads(monkeypatch) -> None:
knowledge_rag_module.shutdown_knowledge_rag_runtime()
created_runtimes = []
@@ -270,8 +270,8 @@ def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
thread.start()
thread.join(timeout=5)
assert len(created_runtimes) == 2
assert worker_runtimes[0] is not main_runtime
assert len(created_runtimes) == 1
assert worker_runtimes[0] is main_runtime
knowledge_rag_module.shutdown_knowledge_rag_runtime()
assert all(runtime.finalized for runtime in created_runtimes)

View File

@@ -12,6 +12,7 @@ from app.db.base import Base
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED,
KNOWLEDGE_INGEST_STATUS_INGESTED,
KNOWLEDGE_INGEST_STATUS_SYNCING,
KnowledgeService,
)
@@ -88,3 +89,41 @@ def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
assert changed is True
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED
def test_reconcile_document_ingest_status_preserves_ingested_when_status_map_missing(
tmp_path,
monkeypatch,
) -> None:
service = KnowledgeService(storage_root=tmp_path)
uploaded = service.upload_document(
"报销制度",
"demo.txt",
b"hello",
CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
),
)
service.set_document_ingest_statuses(
[uploaded.id],
KNOWLEDGE_INGEST_STATUS_INGESTED,
agent_run_id="run_missing_status_map",
)
monkeypatch.setattr(
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
lambda self, _document_ids: {},
)
index = service._load_index()
changed = service._reconcile_document_ingest_statuses(
index,
document_ids=[uploaded.id],
preserve_syncing=False,
)
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
assert changed is False
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_INGESTED

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_INGESTED, KnowledgeService
from app.services.knowledge_sync import KnowledgeSyncDispatchService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_force_sync_queues_ingested_documents_and_creates_hermes_run(tmp_path, monkeypatch) -> None:
submitted: list[dict[str, object]] = []
user = CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
knowledge_service = KnowledgeService(storage_root=tmp_path, db=db)
uploaded = knowledge_service.upload_document("报销制度", "demo.txt", b"hello", user)
document_id = uploaded.id
knowledge_service.set_document_ingest_statuses(
[document_id],
KNOWLEDGE_INGEST_STATUS_INGESTED,
agent_run_id="run_previous",
)
monkeypatch.setattr(
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
lambda self, _document_ids: {},
)
monkeypatch.setattr(
"app.services.knowledge_sync.knowledge_index_task_manager.submit_sync",
lambda **kwargs: submitted.append(kwargs),
)
dispatch_service = KnowledgeSyncDispatchService(db)
dispatch_service.knowledge_service = knowledge_service
result = dispatch_service.queue_sync(
current_user=user,
folder=None,
document_ids=[document_id],
force=True,
changed_only=True,
)
assert result.agent_run_id.startswith("run_")
assert document_id in result.document_ids
assert submitted
assert submitted[0]["agent_run_id"] == result.agent_run_id

View File

@@ -14,6 +14,7 @@ from app.core import admin_secret
from app.core import secret_box
from app.core.secret_box import encrypt_secret
from app.db.base import Base
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
@@ -27,9 +28,11 @@ def build_session(db_file: Path) -> Session:
f"sqlite+pysqlite:///{db_file.as_posix()}",
connect_args={"check_same_thread": False},
)
SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
HermesTaskConfig.__table__.create(bind=engine)
HermesTaskExecutionLog.__table__.create(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
@@ -45,9 +48,12 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
initial_snapshot = service.get_settings_snapshot()
payload = initial_snapshot.model_dump()
service = SettingsService(db)
initial_snapshot = service.get_settings_snapshot()
assert initial_snapshot.llmForm.mainModel == "codex-mini-latest"
assert initial_snapshot.llmForm.mainEndpoint == "https://api.openai.com/v1"
assert initial_snapshot.llmForm.mainApiKey == ""
payload = initial_snapshot.model_dump()
payload["companyForm"]["companyName"] = "YGSOFT"
payload["companyForm"]["displayName"] = "云广软件"

View File

@@ -167,7 +167,9 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
assert "最终答复必须像助手在认真回答问题" in messages[0]["content"]
assert "最终答复必须使用 Markdown" in messages[0]["content"]
assert "## 结论" in messages[0]["content"]
assert "如果不能,必须明确说当前知识库没有找到直接依据" in messages[0]["content"]
assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
assert "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
@@ -435,6 +437,9 @@ def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
)
assert answer.startswith("张三,您好。")
assert "## 结论" in answer
assert "## 依据" in answer
assert "## 说明" in answer
assert "我先根据当前制度依据给出可以确认的部分" in answer
assert "已命中" not in answer
assert "答案整理阶段本轮没有及时返回" not in answer
@@ -477,8 +482,8 @@ def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch)
)
assert answer == "测试回答"
assert captured["timeout_seconds"] == 5
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
assert captured["timeout_seconds"] == 30
assert captured["slot_timeouts"] == {"main": 20, "backup": 30}
assert captured["max_attempts"] == 1
@@ -549,7 +554,7 @@ def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> N
assert "会计科目" in terms
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
def test_user_agent_knowledge_answer_uses_model_after_retrieval(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
@@ -560,11 +565,14 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
)
)
service = UserAgentService(db)
monkeypatch.setattr(
service,
"_generate_answer_with_model",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")),
)
captured: dict[str, object] = {}
def fake_generate_answer(payload, **kwargs):
captured["payload"] = payload
captured.update(kwargs)
return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。"
monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer)
response = service.respond(
UserAgentRequest(
@@ -593,10 +601,11 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
)
)
assert response.answer.startswith("张三,您好。")
assert "**结论**" in response.answer
assert captured["payload"].ontology.scenario == "knowledge"
assert "费用报销制度" in captured["fallback_answer"]
assert "## 依据" in captured["fallback_answer"]
assert response.answer.startswith("## 结论")
assert "30 日内提交报销申请" in response.answer
assert "## 依据" not in response.answer
assert "答案整理阶段本轮没有及时返回" not in response.answer
@@ -804,7 +813,8 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
assert "| 餐补 | 75 | 55 | 140 |" in answer
assert "餐补的标准为" in answer
assert "## 依据" not in answer
assert "## 结论" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None:
@@ -906,8 +916,8 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() ->
assert answer is not None
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
assert "**说明**" in answer
assert "## 依据" not in answer
assert "## 说明" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
@@ -952,12 +962,12 @@ def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
)
assert answer is not None
assert "**结论**" in answer
assert "## 结论" in answer
assert "登机牌、高速道路通行记录" in answer
assert "支付记录" in answer
assert "出差审批邮件、短信、微信等" in answer
assert "3" not in answer
assert "## 依据" not in answer
assert "## 依据" in answer
def test_user_agent_model_prompt_supports_contextual_personalization() -> None: