feat(ai): add tenant-safe hierarchical expense learning

This commit is contained in:
caoxiaozhu
2026-07-16 14:30:41 +08:00
parent 6bdf65bc24
commit ee88a36baf
65 changed files with 6909 additions and 232 deletions

View File

@@ -26,6 +26,7 @@ class CurrentUserContext:
is_admin: bool
tenant_id: str = "default"
department_name: str = ""
department_id: str = ""
cost_center: str = ""
position: str = ""
grade: str = ""
@@ -74,6 +75,7 @@ def _authenticate_bearer_user(db: Session, authorization: str | None) -> Current
is_admin=user.is_admin,
tenant_id=user.tenant_id,
department_name=user.department,
department_id=user.department_id or "",
cost_center=user.cost_center,
position=user.position,
grade=user.grade,

View File

@@ -141,6 +141,7 @@ def regenerate_risk_rule(
AgentAssetRiskRuleRegenerationService(db).regenerate(
asset_id,
payload,
tenant_id=current_user.tenant_id,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)

View File

@@ -83,6 +83,7 @@ def _complete_risk_rule_generation_task(
payload: dict,
actor: str,
request_id: str | None,
tenant_id: str,
) -> None:
db = get_session_factory()()
try:
@@ -90,6 +91,7 @@ def _complete_risk_rule_generation_task(
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
asset_id,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
@@ -334,6 +336,7 @@ def generate_agent_asset_risk_rule(
actor = (x_actor or current_user.name or "system").strip() or "system"
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
payload,
tenant_id=current_user.tenant_id,
actor=actor,
request_id=x_request_id,
)
@@ -343,6 +346,7 @@ def generate_agent_asset_risk_rule(
payload.model_dump(mode="json"),
actor,
x_request_id,
current_user.tenant_id,
)
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
@@ -941,9 +945,10 @@ def create_golden_case(
_: RuleEditorUser,
db: DbSession,
) -> GoldenCaseRead:
from app.models.golden_case import GoldenCase
from sqlalchemy import select
from app.models.golden_case import GoldenCase
existing = db.scalar(select(GoldenCase).where(GoldenCase.case_key == body.case_key))
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="case_key 已存在")
@@ -975,9 +980,10 @@ def list_golden_cases(
_: CurrentUser,
db: DbSession,
) -> list[GoldenCaseRead]:
from app.models.golden_case import GoldenCase
from sqlalchemy import select
from app.models.golden_case import GoldenCase
cases = db.scalars(
select(GoldenCase).where(GoldenCase.rule_code == rule_code).order_by(GoldenCase.created_at)
).all()
@@ -1013,7 +1019,6 @@ def run_golden_eval(
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
raise ValueError("manifest 缺少 rule_code。")
version = body.version or asset.working_version or ""
report = RiskRuleGoldenEvaluator().evaluate_for_rule(db, manifest, rule_code)
return GoldenEvalRead(**report.to_dict())
except Exception as exc:

View File

@@ -5,16 +5,34 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.expense_application_memory import (
ExpenseApplicationMemoryListRead,
ExpenseApplicationMemoryRead,
ExpenseApplicationMemoryRevokedRead,
ExpenseApplicationOrganizationMemoryCreate,
ExpenseApplicationOrganizationMemoryRevoke,
ExpenseApplicationOrganizationMemoryUpdate,
)
from app.services.expense_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_memory_admin import (
ExpenseApplicationOrganizationMemoryService,
OrganizationMemoryConflictError,
OrganizationMemoryNotFoundError,
)
router = APIRouter(prefix="/expense-application-memories")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdminUser = Annotated[
CurrentUserContext,
Depends(require_platform_admin_user),
]
@router.get(
@@ -29,6 +47,105 @@ def list_my_expense_application_memories(
return ExpenseApplicationMemoryService(db).list_current_user_memories(current_user)
@router.get(
"/organization",
response_model=ExpenseApplicationMemoryListRead,
summary="读取当前租户的企业与部门费用记忆",
)
def list_organization_expense_application_memories(
db: DbSession,
current_user: PlatformAdminUser,
) -> ExpenseApplicationMemoryListRead:
return ExpenseApplicationOrganizationMemoryService(db).list_organization_memories(
current_user
)
@router.post(
"/organization",
response_model=ExpenseApplicationMemoryRead,
status_code=status.HTTP_201_CREATED,
summary="创建企业或部门费用记忆",
)
def create_organization_expense_application_memory(
payload: ExpenseApplicationOrganizationMemoryCreate,
db: DbSession,
current_user: PlatformAdminUser,
) -> ExpenseApplicationMemoryRead:
try:
return ExpenseApplicationOrganizationMemoryService(
db
).create_organization_memory(payload, current_user)
except OrganizationMemoryConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(error),
) from error
@router.put(
"/organization/{memory_id}",
response_model=ExpenseApplicationMemoryRead,
summary="换代更新企业或部门费用记忆",
)
def update_organization_expense_application_memory(
memory_id: str,
payload: ExpenseApplicationOrganizationMemoryUpdate,
db: DbSession,
current_user: PlatformAdminUser,
) -> ExpenseApplicationMemoryRead:
try:
return ExpenseApplicationOrganizationMemoryService(
db
).update_organization_memory(memory_id, payload, current_user)
except OrganizationMemoryNotFoundError as error:
raise _organization_memory_not_found() from error
except OrganizationMemoryConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(error),
) from error
@router.post(
"/organization/{memory_id}/revoke",
response_model=ExpenseApplicationMemoryRevokedRead,
summary="撤销企业或部门费用记忆",
)
def revoke_organization_expense_application_memory(
memory_id: str,
payload: ExpenseApplicationOrganizationMemoryRevoke,
db: DbSession,
current_user: PlatformAdminUser,
) -> ExpenseApplicationMemoryRevokedRead:
try:
return ExpenseApplicationOrganizationMemoryService(
db
).revoke_organization_memory(memory_id, payload, current_user)
except OrganizationMemoryNotFoundError as error:
raise _organization_memory_not_found() from error
except OrganizationMemoryConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(error),
) from error
@router.delete(
"/{memory_id}",
response_model=ExpenseApplicationMemoryRevokedRead,
@@ -49,3 +166,10 @@ def revoke_my_expense_application_memory(
detail="未找到可撤销的个人费用申请记忆。",
)
return result
def _organization_memory_not_found() -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="未找到当前租户内可管理的组织费用记忆。",
)

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.risk_observation import (
RiskObservationDashboardRead,
@@ -16,8 +16,9 @@ from app.schemas.risk_observation import (
)
from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations", dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/risk-observations")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
@@ -28,6 +29,7 @@ DbSession = Annotated[Session, Depends(get_db)]
)
def list_risk_observations(
db: DbSession,
current_user: CurrentUser,
claim_id: Annotated[str | None, Query(max_length=80)] = None,
run_id: Annotated[str | None, Query(max_length=80)] = None,
execution_log_id: Annotated[str | None, Query(max_length=80)] = None,
@@ -42,6 +44,7 @@ def list_risk_observations(
offset: Annotated[int, Query(ge=0)] = 0,
) -> RiskObservationListRead:
items, total = RiskObservationService(db).list_observations(
tenant_id=current_user.tenant_id,
claim_id=claim_id,
run_id=run_id,
execution_log_id=execution_log_id,
@@ -63,10 +66,12 @@ def list_risk_observations(
)
def summarize_risk_observations(
db: DbSession,
current_user: CurrentUser,
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
) -> RiskObservationDashboardRead:
return RiskObservationService(db).summarize_dashboard(
tenant_id=current_user.tenant_id,
window_days=window_days,
limit=limit,
)
@@ -78,8 +83,15 @@ def summarize_risk_observations(
summary="查询单据风险观察",
description="按报销单 ID 返回该单据关联的风险观察,供单据详情证据链使用。",
)
def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObservationRead]:
return RiskObservationService(db).list_claim_observations(claim_id)
def list_claim_risk_observations(
claim_id: str,
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
return RiskObservationService(db).list_claim_observations(
claim_id,
tenant_id=current_user.tenant_id,
)
@router.get(
@@ -91,8 +103,12 @@ def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObser
def list_execution_log_risk_observations(
execution_log_id: str,
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
return RiskObservationService(db).list_execution_log_observations(execution_log_id)
return RiskObservationService(db).list_execution_log_observations(
execution_log_id,
tenant_id=current_user.tenant_id,
)
@router.get(
@@ -110,8 +126,12 @@ def list_execution_log_risk_observations(
def get_risk_observation(
observation_key_or_id: str,
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationRead:
observation = RiskObservationService(db).get_observation(observation_key_or_id)
observation = RiskObservationService(db).get_observation(
observation_key_or_id,
tenant_id=current_user.tenant_id,
)
if observation is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -136,9 +156,15 @@ def create_risk_observation_feedback(
observation_key_or_id: str,
payload: RiskObservationFeedbackCreate,
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationFeedbackRead:
try:
return RiskObservationService(db).create_feedback(observation_key_or_id, payload)
return RiskObservationService(db).create_feedback(
observation_key_or_id,
payload,
tenant_id=current_user.tenant_id,
actor=current_user.name or current_user.username,
)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -79,10 +79,67 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes",
}
),
"20260716_0007": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"workflow_outcomes",
}
),
"20260716_0008": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"few_shot_samples",
"workflow_outcomes",
}
),
"20260716_0009": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"few_shot_samples",
"workflow_outcomes",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
# 它们作为完整或部分旧资产存在,由 0008 统一收编;其他未来表仍严格拒绝。
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES = frozenset(
{"risk_observations", "risk_observation_feedback", "few_shot_samples"}
)
class MigrationPreflightError(RuntimeError):
"""Raised when the database schema cannot be safely advanced by Alembic."""
@@ -103,10 +160,11 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
owned_tables = table_names & MIGRATION_OWNED_TABLES
if "alembic_version" not in table_names:
if owned_tables:
unsafe_owned_tables = owned_tables - LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
if unsafe_owned_tables:
raise MigrationPreflightError(
"unversioned database contains migration-owned tables "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
f"({_format_tables(unsafe_owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
@@ -117,10 +175,11 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
).scalars()
)
if not revisions:
if owned_tables:
unsafe_owned_tables = owned_tables - LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
if unsafe_owned_tables:
raise MigrationPreflightError(
"alembic_version has no recorded revision but migration-owned tables exist "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
f"({_format_tables(unsafe_owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
@@ -137,9 +196,14 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
f"unknown Alembic revision {revision!r}; refusing to run migrations"
)
if owned_tables != expected_tables:
missing_tables = expected_tables - owned_tables
unexpected_tables = owned_tables - expected_tables
adoptable_tables = (
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
if revision not in {"20260716_0008", "20260716_0009"}
else frozenset()
)
missing_tables = expected_tables - owned_tables
unexpected_tables = owned_tables - expected_tables - adoptable_tables
if missing_tables or unexpected_tables:
raise MigrationPreflightError(
f"migration-owned table set does not match revision {revision}: "
f"missing={_format_tables(missing_tables)}; "

View File

@@ -16,6 +16,9 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"business_events",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"few_shot_samples",
"workflow_outcomes",
}
)

View File

@@ -15,6 +15,7 @@ from sqlalchemy import (
String,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
@@ -34,7 +35,7 @@ def _candidate_expires_at() -> datetime:
class MemoryEntry(Base):
"""受证据约束、可撤销的个人费用申请记忆。"""
"""受证据约束、可审计且可撤销的分层费用申请记忆。"""
__tablename__ = "memory_entries"
__table_args__ = (
@@ -59,9 +60,33 @@ class MemoryEntry(Base):
name="fk_memory_entries_tenant_superseded_by",
),
CheckConstraint(
"scope_type = 'user'",
"scope_type IN ('user', 'department', 'enterprise')",
name="ck_memory_entries_scope_type",
),
CheckConstraint(
"origin_type IN ('learned', 'admin_managed')",
name="ck_memory_entries_origin_type",
),
CheckConstraint(
"(scope_type = 'user' AND origin_type = 'learned') OR "
"(scope_type IN ('department', 'enterprise') "
"AND origin_type = 'admin_managed')",
name="ck_memory_entries_scope_origin",
),
CheckConstraint(
"scope_type != 'enterprise' OR scope_id = tenant_id",
name="ck_memory_entries_enterprise_scope",
),
CheckConstraint(
"(origin_type = 'learned' AND managed_by IS NULL "
"AND managed_at IS NULL AND management_reason IS NULL) OR "
"(origin_type = 'admin_managed' AND managed_by IS NOT NULL "
"AND length(trim(managed_by)) > 0 AND managed_at IS NOT NULL "
"AND management_reason IS NOT NULL "
"AND length(trim(management_reason)) > 0 "
"AND policy_version IS NOT NULL AND length(trim(policy_version)) > 0)",
name="ck_memory_entries_management_audit",
),
CheckConstraint(
"scene = 'travel_application'",
name="ck_memory_entries_scene",
@@ -120,6 +145,17 @@ class MemoryEntry(Base):
"superseded_by_id IS NULL OR superseded_by_id != id",
name="ck_memory_entries_not_self_superseded",
),
CheckConstraint(
"(management_request_id IS NULL AND management_payload_fingerprint IS NULL) OR "
"(management_request_id IS NOT NULL "
"AND management_payload_fingerprint IS NOT NULL)",
name="ck_memory_entries_management_idempotency_pair",
),
CheckConstraint(
"(revoke_request_id IS NULL AND revoke_payload_fingerprint IS NULL) OR "
"(revoke_request_id IS NOT NULL AND revoke_payload_fingerprint IS NOT NULL)",
name="ck_memory_entries_revoke_idempotency_pair",
),
Index(
"ix_memory_entries_scope_lookup",
"tenant_id",
@@ -136,6 +172,31 @@ class MemoryEntry(Base):
"candidate_expires_at",
"active_expires_at",
),
Index(
"uq_memory_entries_management_request",
"tenant_id",
"management_request_id",
unique=True,
),
Index(
"uq_memory_entries_revoke_request",
"tenant_id",
"revoke_request_id",
unique=True,
),
Index(
"uq_memory_entries_active_scope",
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
unique=True,
postgresql_where=text(
"status = 'active' "
"AND scope_type IN ('department', 'enterprise')"
),
).ddl_if(dialect="postgresql"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
@@ -147,6 +208,16 @@ class MemoryEntry(Base):
server_default="user",
)
scope_id: Mapped[str] = mapped_column(String(120), nullable=False)
origin_type: Mapped[str] = mapped_column(
String(24),
nullable=False,
default="learned",
server_default="learned",
)
managed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
managed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
management_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
policy_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
scene: Mapped[str] = mapped_column(
String(50),
nullable=False,
@@ -212,6 +283,16 @@ class MemoryEntry(Base):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
superseded_by_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
management_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
management_payload_fingerprint: Mapped[str | None] = mapped_column(
String(80),
nullable=True,
)
revoke_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
revoke_payload_fingerprint: Mapped[str | None] = mapped_column(
String(80),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
@@ -20,12 +20,26 @@ class FewShotSample(Base):
__tablename__ = "few_shot_samples"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"sample_key",
name="uq_few_shot_samples_tenant_key",
),
Index(
"ix_few_shot_samples_tenant_rule_lookup",
"tenant_id",
"scene",
"policy_ref",
"rule_version",
"status",
),
Index("ix_few_shot_samples_scene_label", "scene", "label"),
Index("ix_few_shot_samples_domain_risk_type", "domain", "risk_type"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
sample_key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
sample_key: Mapped[str] = mapped_column(String(160), index=True)
source_observation_id: Mapped[str | None] = mapped_column(
ForeignKey("risk_observations.id"),
nullable=True,
@@ -33,6 +47,8 @@ class FewShotSample(Base):
)
scene: Mapped[str] = mapped_column(String(50), default="risk_rule_generation", index=True)
policy_ref: Mapped[str] = mapped_column(String(160), default="", index=True)
rule_version: Mapped[str] = mapped_column(String(80), default="", index=True)
domain: Mapped[str] = mapped_column(String(50), default="", index=True)
risk_type: Mapped[str] = mapped_column(String(80), default="", index=True)
risk_level: Mapped[str] = mapped_column(String(20), default="")
@@ -45,7 +61,9 @@ class FewShotSample(Base):
vector_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=func.now(),

View File

@@ -4,7 +4,17 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -14,18 +24,25 @@ from app.db.base_class import Base
class RiskObservation(Base):
__tablename__ = "risk_observations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"observation_key",
name="uq_risk_observations_tenant_key",
),
Index("ix_risk_observations_tenant_status", "tenant_id", "status", "created_at"),
Index("ix_risk_observations_subject", "subject_type", "subject_key"),
Index("ix_risk_observations_signal_level", "risk_signal", "risk_level"),
Index("ix_risk_observations_status_created", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
observation_key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
observation_key: Mapped[str] = mapped_column(String(160), index=True)
subject_type: Mapped[str] = mapped_column(String(50), index=True)
subject_key: Mapped[str] = mapped_column(String(160), index=True)
subject_label: Mapped[str] = mapped_column(String(160), default="")
claim_id: Mapped[str | None] = mapped_column(
ForeignKey("expense_claims.id"),
String(36),
nullable=True,
index=True,
)
@@ -66,7 +83,12 @@ class RiskObservation(Base):
onupdate=func.now(),
)
claim = relationship("ExpenseClaim", foreign_keys=[claim_id])
claim = relationship(
"ExpenseClaim",
primaryjoin="foreign(RiskObservation.claim_id) == ExpenseClaim.id",
foreign_keys=[claim_id],
viewonly=True,
)
feedback_items = relationship(
"RiskObservationFeedback",
back_populates="observation",

View File

@@ -1,10 +1,18 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class ExpenseApplicationMemoryConflict(BaseModel):
scope_type: Literal["user", "department", "enterprise"]
scope_label: str
priority: int
reason: Literal["same_priority_conflict", "lower_priority_overridden"]
class ExpenseApplicationMemoryApplication(BaseModel):
memory_id: str
field_key: str = "transport_mode"
@@ -15,7 +23,14 @@ class ExpenseApplicationMemoryApplication(BaseModel):
evidence_count: int = 0
approved_evidence_count: int = 0
confidence: float = 0.0
effective_confidence: float = 0.0
expires_at: datetime | None = None
scope_type: Literal["user", "department", "enterprise"] = "user"
scope_id: str = ""
scope_label: str = "个人偏好"
priority: int = 100
conflicts: list[ExpenseApplicationMemoryConflict] = Field(default_factory=list)
can_revoke: bool = True
message: str = "已按可信历史记忆预填常用出行方式,可继续修改。"
@@ -38,6 +53,12 @@ class ExpenseApplicationMemoryRead(BaseModel):
field_key: str
value: str = ""
status: str
scope_type: Literal["user", "department", "enterprise"] = "user"
scope_id: str = ""
scope_label: str = "个人偏好"
source: str = "verified_user_history"
origin_type: Literal["learned", "admin_managed"] = "learned"
generation: int = 1
evidence_count: int = 0
approved_evidence_count: int = 0
confidence: float = 0.0
@@ -50,6 +71,11 @@ class ExpenseApplicationMemoryRead(BaseModel):
suppressed_at: datetime | None = None
revoked_at: datetime | None = None
revoked_reason: str = ""
managed_by: str = ""
managed_at: datetime | None = None
management_reason: str = ""
superseded_by_id: str = ""
can_revoke: bool = True
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -62,3 +88,26 @@ class ExpenseApplicationMemoryRevokedRead(BaseModel):
memory_id: str
status: str = "revoked"
revoked_at: datetime
class ExpenseApplicationOrganizationMemoryCreate(BaseModel):
scope_type: Literal["department", "enterprise"]
scope_id: str | None = Field(default=None, max_length=120)
value: Literal["飞机", "火车", "轮船"]
expires_in_days: int = Field(default=180, ge=30, le=365)
reason: str = Field(min_length=1, max_length=255)
request_id: str = Field(min_length=8, max_length=120)
class ExpenseApplicationOrganizationMemoryUpdate(BaseModel):
value: Literal["飞机", "火车", "轮船"] | None = None
expires_in_days: int | None = Field(default=None, ge=30, le=365)
expected_generation: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=255)
request_id: str = Field(min_length=8, max_length=120)
class ExpenseApplicationOrganizationMemoryRevoke(BaseModel):
expected_generation: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=255)
request_id: str = Field(min_length=8, max_length=120)

View File

@@ -161,6 +161,19 @@ class ExpenseClaimPreReviewFindingRead(BaseModel):
remediation: ExpenseClaimPreReviewRemediationRead
class ExpenseClaimHistoricalCaseEvidenceRead(BaseModel):
label: Literal["confirmed", "false_positive"]
label_text: str
advisory_only: Literal[True] = True
score: float = 0.0
scene_code: str = ""
policy_ref: str = ""
rule_version: str = ""
version_status: Literal["matched", "stale"] = "matched"
stale: bool = False
summary: str
class ExpenseClaimPreReviewRead(BaseModel):
review_id: str
input_fingerprint: str
@@ -173,6 +186,9 @@ class ExpenseClaimPreReviewRead(BaseModel):
blocking_count: int = 0
message: str
findings: list[ExpenseClaimPreReviewFindingRead] = Field(default_factory=list)
historical_case_evidence: list[ExpenseClaimHistoricalCaseEvidenceRead] = Field(
default_factory=list
)
class ExpenseClaimSubmitPayload(BaseModel):

View File

@@ -44,6 +44,7 @@ class RiskObservationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
observation_key: str
subject_type: str
subject_key: str
@@ -100,7 +101,11 @@ class RiskObservationListRead(BaseModel):
class RiskObservationFeedbackCreate(BaseModel):
feedback_type: RiskObservationFeedbackType
action: str | None = Field(default=None, max_length=50)
actor: str | None = Field(default=None, max_length=100)
actor: str | None = Field(
default=None,
max_length=100,
description="兼容字段;服务端始终以当前认证用户覆盖该值。",
)
comment: str | None = Field(default=None, max_length=1000)
payload_json: dict[str, Any] = Field(default_factory=dict)

View File

@@ -15,6 +15,7 @@ from app.schemas.agent_asset import (
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
from app.services.risk_rule_generation import (
BUSINESS_DOMAIN_LABELS,
EXPENSE_BUSINESS_STAGE_LABELS,
@@ -22,7 +23,6 @@ from app.services.risk_rule_generation import (
RiskRuleGenerationService,
)
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
from app.services.runtime_chat import RuntimeChatService
@@ -52,6 +52,7 @@ class AgentAssetRiskRuleRegenerationService:
asset_id: str,
body: AgentAssetRiskRuleRegenerateRequest,
*,
tenant_id: str | None = None,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
@@ -60,12 +61,14 @@ class AgentAssetRiskRuleRegenerationService:
return self._regenerate_revision_draft(
asset,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
return self._regenerate_unpublished_draft(
asset,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
@@ -75,6 +78,7 @@ class AgentAssetRiskRuleRegenerationService:
asset: AgentAsset,
body: AgentAssetRiskRuleRegenerateRequest,
*,
tenant_id: str | None,
actor: str,
request_id: str | None,
) -> AgentAsset:
@@ -84,7 +88,12 @@ class AgentAssetRiskRuleRegenerationService:
before = self._snapshot(asset)
config = dict(asset.config_json or {})
request = self._build_generation_request(asset, config, body.model_dump(exclude_unset=True))
payload, risk_score = self._compile_payload(request, actor=actor, created_at=asset.created_at)
payload, risk_score = self._compile_payload(
request,
tenant_id=tenant_id,
actor=actor,
created_at=asset.created_at,
)
rule_code = self._stable_rule_code(asset, payload)
payload["rule_code"] = rule_code
file_name = f"{rule_code}.json"
@@ -104,6 +113,7 @@ class AgentAssetRiskRuleRegenerationService:
actor=actor,
)
config.update(self._config_from_payload(payload, risk_score=risk_score, request=request))
config["tenant_id"] = str(tenant_id or "").strip()
config.update(
{
"generation_status": "completed",
@@ -138,6 +148,7 @@ class AgentAssetRiskRuleRegenerationService:
asset: AgentAsset,
body: AgentAssetRiskRuleRegenerateRequest,
*,
tenant_id: str | None,
actor: str,
request_id: str | None,
) -> AgentAsset:
@@ -151,7 +162,12 @@ class AgentAssetRiskRuleRegenerationService:
body.model_dump(exclude_unset=True),
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
)
payload, risk_score = self._compile_payload(request, actor=actor, created_at=datetime.now(UTC))
payload, risk_score = self._compile_payload(
request,
tenant_id=tenant_id,
actor=actor,
created_at=datetime.now(UTC),
)
payload["rule_code"] = str(asset.code or payload["rule_code"]).strip()
payload["enabled"] = False
payload.setdefault("metadata", {})["revision_version"] = revision_version
@@ -184,6 +200,7 @@ class AgentAssetRiskRuleRegenerationService:
}
)
config["revision_draft"] = revision
config["tenant_id"] = str(tenant_id or "").strip()
config["last_operation"] = {
"action": "regenerate_revision",
"actor": actor,
@@ -216,6 +233,7 @@ class AgentAssetRiskRuleRegenerationService:
self,
request: dict[str, Any],
*,
tenant_id: str | None,
actor: str,
created_at: datetime | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
@@ -230,6 +248,7 @@ class AgentAssetRiskRuleRegenerationService:
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
fields = self.generator._resolve_fields(natural_language, domain=domain)
draft = self.generator._compile_with_model(
tenant_id=tenant_id,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,

View File

@@ -53,6 +53,7 @@ class AuthenticatedUser:
avatar: str
is_admin: bool = False
employee_id: str | None = None
department_id: str | None = None
tenant_id: str = "default"
@@ -116,7 +117,7 @@ class AuthService:
}
if auth_session.username.strip().casefold() not in allowed_identifiers:
return None
return self._build_admin_user(record)
return self._restore_session_scope(self._build_admin_user(record), auth_session)
if auth_session.principal_type != "employee":
return None
@@ -133,7 +134,17 @@ class AuthService:
employee = self.db.execute(stmt).scalars().first()
if employee is None or employee.employment_status == "停用":
return None
return self._build_employee_user(employee)
return self._restore_session_scope(self._build_employee_user(employee), auth_session)
@staticmethod
def _restore_session_scope(
user: AuthenticatedUser,
auth_session: AuthSession,
) -> AuthenticatedUser:
"""会话恢复时以签发并认证过的会话租户为准,禁止回落到默认租户。"""
user.tenant_id = str(auth_session.tenant_id or "default").strip() or "default"
return user
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
normalized = identifier.strip()
@@ -249,6 +260,7 @@ class AuthService:
avatar=(employee.name or "?")[:1].upper(),
is_admin=False,
employee_id=employee.id,
department_id=employee.organization_unit_id,
)
@staticmethod

View File

@@ -24,6 +24,12 @@ from app.schemas.expense_application_memory import (
from app.services.expense_application_memory_evidence import (
ExpenseApplicationMemoryEvidenceValidator,
)
from app.services.expense_application_memory_resolution import (
ExpenseApplicationMemoryResolution,
ExpenseApplicationMemoryResolver,
memory_scope_label,
memory_source,
)
from app.services.expense_application_snapshot import hmac_fingerprint
from app.services.expense_cases import ExpenseCaseService
@@ -191,16 +197,16 @@ class ExpenseApplicationMemoryService:
try:
with self.db.begin_nested():
entry = self._resolve_active_entry(current_user)
if entry is None:
resolution = self._resolve_active_entry(current_user)
if resolution is None:
return []
value = self._entry_value(entry)
if not value:
return []
facts[MEMORY_FIELD_KEY] = value
return [self._build_application(entry, value)]
application = resolution.to_application(current_user)
if resolution.winner is None:
return [application]
facts[MEMORY_FIELD_KEY] = resolution.value
return [application]
except Exception:
logger.warning("个人出行方式记忆读取失败,本轮预览不应用记忆。", exc_info=True)
logger.warning("出行方式分层记忆读取失败,本轮预览不应用记忆。", exc_info=True)
return []
def learning_receipts_for_preview_decision(
@@ -255,7 +261,7 @@ class ExpenseApplicationMemoryService:
self._refresh_entry_metrics(entry, now=now, allow_activation=False)
self.db.commit()
return ExpenseApplicationMemoryListRead(
items=[self._serialize_entry(entry) for entry in entries]
items=[self._serialize_entry(entry, current_user) for entry in entries]
)
def revoke_current_user_memory(
@@ -293,38 +299,12 @@ class ExpenseApplicationMemoryService:
def _resolve_active_entry(
self,
current_user: CurrentUserContext,
) -> MemoryEntry | None:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._scope_id(current_user)
now = datetime.now(UTC)
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status.in_(["candidate", "active"]),
)
.order_by(MemoryEntry.last_evidence_at.desc(), MemoryEntry.generation.desc())
.with_for_update()
).all()
) -> ExpenseApplicationMemoryResolution | None:
return ExpenseApplicationMemoryResolver(self.db).resolve(
current_user,
user_scope_id=self._scope_id(current_user),
refresh_user_entry=self._refresh_entry_metrics,
)
active_entry = next((entry for entry in entries if entry.status == "active"), None)
if active_entry is not None:
self._refresh_entry_metrics(active_entry, now=now)
if active_entry.status == "active":
return active_entry
for entry in entries:
if entry.status != "candidate":
continue
self._refresh_entry_metrics(entry, now=now)
if entry.status == "active":
return entry
return None
def _refresh_entry_metrics(
self,
@@ -522,6 +502,7 @@ class ExpenseApplicationMemoryService:
scope_id=scope_id,
scene=MEMORY_SCENE,
field_key=MEMORY_FIELD_KEY,
origin_type="learned",
generation=generation,
value_json={"value": value},
value_fingerprint=value_fingerprint,
@@ -531,6 +512,7 @@ class ExpenseApplicationMemoryService:
confidence=Decimal("0"),
candidate_expires_at=now + MEMORY_CANDIDATE_TTL,
last_evidence_at=now,
policy_version=MEMORY_POLICY_VERSION,
)
def _approved_case_ids(
@@ -623,20 +605,6 @@ class ExpenseApplicationMemoryService:
normalized = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
return normalized <= now
@staticmethod
def _build_application(
entry: MemoryEntry,
value: str,
) -> ExpenseApplicationMemoryApplication:
return ExpenseApplicationMemoryApplication(
memory_id=entry.id,
value=value,
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
expires_at=entry.active_expires_at,
)
@staticmethod
def _build_receipt(
entry: MemoryEntry,
@@ -666,7 +634,11 @@ class ExpenseApplicationMemoryService:
)
@staticmethod
def _serialize_entry(entry: MemoryEntry) -> ExpenseApplicationMemoryRead:
def _serialize_entry(
entry: MemoryEntry,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryRead:
scope_type = str(entry.scope_type or "user")
value = (
""
if entry.status == "revoked"
@@ -678,11 +650,20 @@ class ExpenseApplicationMemoryService:
field_key=entry.field_key,
value=value,
status=entry.status,
scope_type=scope_type,
scope_id=str(entry.scope_id or ""),
scope_label=memory_scope_label(entry, current_user),
source=memory_source(scope_type),
origin_type=str(getattr(entry, "origin_type", "learned") or "learned"),
generation=int(entry.generation or 1),
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
activation_threshold=MEMORY_ACTIVATION_THRESHOLD,
policy_version=MEMORY_POLICY_VERSION,
policy_version=str(
getattr(entry, "policy_version", MEMORY_POLICY_VERSION)
or MEMORY_POLICY_VERSION
),
valid_from=entry.activated_at or entry.created_at,
expires_at=(
entry.active_expires_at
@@ -694,6 +675,11 @@ class ExpenseApplicationMemoryService:
suppressed_at=entry.suppressed_at,
revoked_at=entry.revoked_at,
revoked_reason=str(entry.revoked_reason or ""),
managed_by=str(getattr(entry, "managed_by", "") or ""),
managed_at=getattr(entry, "managed_at", None),
management_reason=str(getattr(entry, "management_reason", "") or ""),
superseded_by_id=str(entry.superseded_by_id or ""),
can_revoke=scope_type == "user",
created_at=entry.created_at,
updated_at=entry.updated_at,
)

View File

@@ -0,0 +1,761 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_memory import MemoryEntry
from app.models.organization import OrganizationUnit
from app.schemas.expense_application_memory import (
ExpenseApplicationMemoryListRead,
ExpenseApplicationMemoryRead,
ExpenseApplicationMemoryRevokedRead,
ExpenseApplicationOrganizationMemoryCreate,
ExpenseApplicationOrganizationMemoryRevoke,
ExpenseApplicationOrganizationMemoryUpdate,
)
from app.services.expense_application_memory_resolution import memory_source
from app.services.expense_application_snapshot import hmac_fingerprint
from app.services.expense_cases import ExpenseCaseService
from app.services.organization_memory_locks import (
organization_memory_operation_locks,
organization_memory_request_lock_key,
organization_memory_scope_lock_key,
)
MEMORY_SCENE = "travel_application"
MEMORY_FIELD_KEY = "transport_mode"
ORGANIZATION_SCOPE_TYPES = {"department", "enterprise"}
ORGANIZATION_MEMORY_POLICY_VERSION = "expense_application_transport_org_memory.v1"
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
class OrganizationMemoryNotFoundError(LookupError):
pass
class OrganizationMemoryConflictError(ValueError):
pass
class ExpenseApplicationOrganizationMemoryService:
"""由平台管理员显式维护企业/部门出行方式记忆。"""
def __init__(self, db: Session) -> None:
self.db = db
def list_organization_memories(
self,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryListRead:
self._require_admin(current_user)
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
now = datetime.now(UTC)
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type.in_(ORGANIZATION_SCOPE_TYPES),
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
)
.order_by(
MemoryEntry.scope_type.asc(),
MemoryEntry.scope_id.asc(),
MemoryEntry.generation.desc(),
)
).all()
)
for entry in entries:
if entry.status == "active" and self._is_expired(entry, now):
entry.status = "expired"
entry.expired_at = now
department_names = self._department_names(entries)
self.db.commit()
return ExpenseApplicationMemoryListRead(
items=[self._serialize_entry(entry, department_names) for entry in entries]
)
def create_organization_memory(
self,
payload: ExpenseApplicationOrganizationMemoryCreate,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryRead:
self._require_admin(current_user)
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._validate_scope(
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
request_id = self._normalize_request_id(payload.request_id)
reason = self._normalize_reason(payload.reason)
request_fingerprint = self._request_fingerprint(
operation="create",
target=f"{payload.scope_type}:{scope_id}",
payload={
"scope_type": payload.scope_type,
"scope_id": scope_id,
"value": payload.value,
"expires_in_days": payload.expires_in_days,
"reason": reason,
},
)
now = datetime.now(UTC)
entry_id = self._request_entry_id(
tenant_id=tenant_id,
operation="create",
target=f"{payload.scope_type}:{scope_id}",
request_id=request_id,
)
expires_at = now + timedelta(days=payload.expires_in_days)
try:
with organization_memory_operation_locks(
self.db,
self._scope_lock_key(
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
),
self._request_lock_key(tenant_id, request_id),
):
replay = self._management_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is not None:
result = self._serialize_entry(
replay,
self._department_names([replay]),
)
self.db.commit()
return result
active = self._get_scope_active_entry(
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
)
if active is not None:
if self._is_expired(active, now):
active.status = "expired"
active.expired_at = now
self.db.flush()
else:
raise OrganizationMemoryConflictError(
"该组织范围已有生效记忆,请使用更新操作换代。"
)
entry = self._new_active_generation(
entry_id=entry_id,
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
value=payload.value,
expires_at=expires_at,
reason=reason,
request_id=request_id,
request_fingerprint=request_fingerprint,
expected_active_ids=set(),
current_user=current_user,
now=now,
)
self.db.commit()
except OrganizationMemoryConflictError:
self.db.rollback()
raise
except IntegrityError as error:
self.db.rollback()
replay = self._management_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is None:
raise OrganizationMemoryConflictError(
"该组织记忆已被其他管理员创建,请刷新后重试。"
) from error
entry = replay
self.db.refresh(entry)
return self._serialize_entry(entry, self._department_names([entry]))
def update_organization_memory(
self,
memory_id: str,
payload: ExpenseApplicationOrganizationMemoryUpdate,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryRead:
self._require_admin(current_user)
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
previous_snapshot = self._get_entry(memory_id, tenant_id, for_update=False)
request_id = self._normalize_request_id(payload.request_id)
reason = self._normalize_reason(payload.reason)
request_fingerprint = self._request_fingerprint(
operation="update",
target=previous_snapshot.id,
payload={
"value": payload.value,
"expires_in_days": payload.expires_in_days,
"expected_generation": payload.expected_generation,
"reason": reason,
},
)
entry_id = self._request_entry_id(
tenant_id=tenant_id,
operation="update",
target=previous_snapshot.id,
request_id=request_id,
)
now = datetime.now(UTC)
try:
with organization_memory_operation_locks(
self.db,
self._scope_lock_key_for_entry(tenant_id, previous_snapshot),
self._request_lock_key(tenant_id, request_id),
):
replay = self._management_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is not None:
result = self._serialize_entry(
replay,
self._department_names([replay]),
)
self.db.commit()
return result
previous = self._get_entry(memory_id, tenant_id)
self._require_active(previous)
self._validate_expected_generation(previous, payload.expected_generation)
if self._is_expired(previous, now):
previous.status = "expired"
previous.expired_at = now
self.db.commit()
raise OrganizationMemoryConflictError(
"组织记忆已过期,不能继续更新。"
)
expires_at = (
now + timedelta(days=payload.expires_in_days)
if payload.expires_in_days is not None
else self._aware(previous.active_expires_at)
)
entry = self._new_active_generation(
entry_id=entry_id,
tenant_id=tenant_id,
scope_type=str(previous.scope_type),
scope_id=str(previous.scope_id),
value=payload.value or self._entry_value(previous),
expires_at=expires_at,
reason=reason,
request_id=request_id,
request_fingerprint=request_fingerprint,
expected_active_ids={previous.id},
current_user=current_user,
now=now,
)
self.db.commit()
except OrganizationMemoryConflictError:
self.db.rollback()
raise
except IntegrityError as error:
self.db.rollback()
replay = self._management_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is None:
raise OrganizationMemoryConflictError(
"组织记忆已被其他管理员更新,请刷新后重试。"
) from error
entry = replay
self.db.refresh(entry)
return self._serialize_entry(entry, self._department_names([entry]))
def revoke_organization_memory(
self,
memory_id: str,
payload: ExpenseApplicationOrganizationMemoryRevoke,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryRevokedRead:
self._require_admin(current_user)
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
entry_snapshot = self._get_entry(memory_id, tenant_id, for_update=False)
request_id = self._normalize_request_id(payload.request_id)
reason = self._normalize_reason(payload.reason)
request_fingerprint = self._request_fingerprint(
operation="revoke",
target=entry_snapshot.id,
payload={
"expected_generation": payload.expected_generation,
"reason": reason,
},
)
now = datetime.now(UTC)
try:
with organization_memory_operation_locks(
self.db,
self._scope_lock_key_for_entry(tenant_id, entry_snapshot),
self._request_lock_key(tenant_id, request_id),
):
replay = self._revoke_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is not None:
result = self._revoked_response(replay)
self.db.commit()
return result
entry = self._get_entry(memory_id, tenant_id)
self._require_active(entry)
self._validate_expected_generation(entry, payload.expected_generation)
if self._is_expired(entry, now):
entry.status = "expired"
entry.expired_at = now
self.db.commit()
raise OrganizationMemoryConflictError(
"组织记忆已过期,不能继续撤销。"
)
entry.status = "revoked"
entry.revoked_at = now
entry.revoked_reason = reason
entry.managed_by = self._actor_id(current_user)
entry.managed_at = now
entry.management_reason = reason
entry.revoke_request_id = request_id
entry.revoke_payload_fingerprint = request_fingerprint
self.db.commit()
except OrganizationMemoryConflictError:
self.db.rollback()
raise
except IntegrityError as error:
self.db.rollback()
replay = self._revoke_request_replay(
tenant_id=tenant_id,
request_id=request_id,
request_fingerprint=request_fingerprint,
)
if replay is None:
raise OrganizationMemoryConflictError(
"组织记忆已被其他管理员撤销,请刷新后重试。"
) from error
entry = replay
return self._revoked_response(entry)
def _new_active_generation(
self,
*,
entry_id: str,
tenant_id: str,
scope_type: str,
scope_id: str,
value: str,
expires_at: datetime,
reason: str,
request_id: str,
request_fingerprint: str,
expected_active_ids: set[str],
current_user: CurrentUserContext,
now: datetime,
) -> MemoryEntry:
if value not in SUPPORTED_TRANSPORT_VALUES:
raise ValueError("组织记忆只允许飞机、火车或轮船三种低敏枚举值。")
existing = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == scope_type,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status == "active",
)
.with_for_update()
).all()
)
actual_active_ids = {entry.id for entry in existing}
if actual_active_ids != expected_active_ids:
raise OrganizationMemoryConflictError(
"组织记忆生效版本已变化,请刷新后重试。"
)
generation = int(
self.db.scalar(
select(func.coalesce(func.max(MemoryEntry.generation), 0)).where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == scope_type,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
)
)
or 0
) + 1
for old_entry in existing:
old_entry.status = "suppressed"
old_entry.suppressed_at = now
if existing:
self.db.flush()
entry = MemoryEntry(
id=entry_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
scene=MEMORY_SCENE,
field_key=MEMORY_FIELD_KEY,
origin_type="admin_managed",
generation=generation,
value_json={"value": value},
value_fingerprint=hmac_fingerprint(
{"field_key": MEMORY_FIELD_KEY, "value": value}
),
status="active",
evidence_count=0,
approved_evidence_count=0,
confidence=Decimal("1.0000"),
last_evidence_at=now,
candidate_expires_at=expires_at,
activated_at=now,
active_expires_at=expires_at,
managed_by=self._actor_id(current_user),
managed_at=now,
management_reason=reason,
policy_version=ORGANIZATION_MEMORY_POLICY_VERSION,
management_request_id=request_id,
management_payload_fingerprint=request_fingerprint,
)
self.db.add(entry)
self.db.flush()
for old_entry in existing:
old_entry.superseded_by_id = entry.id
self.db.flush()
return entry
@staticmethod
def _scope_lock_key(
*,
tenant_id: str,
scope_type: str,
scope_id: str,
) -> str:
return organization_memory_scope_lock_key(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
scene=MEMORY_SCENE,
field_key=MEMORY_FIELD_KEY,
)
@classmethod
def _scope_lock_key_for_entry(
cls,
tenant_id: str,
entry: MemoryEntry,
) -> str:
return cls._scope_lock_key(
tenant_id=tenant_id,
scope_type=str(entry.scope_type),
scope_id=str(entry.scope_id),
)
@staticmethod
def _request_lock_key(tenant_id: str, request_id: str) -> str:
return organization_memory_request_lock_key(tenant_id, request_id)
def _management_request_replay(
self,
*,
tenant_id: str,
request_id: str,
request_fingerprint: str,
) -> MemoryEntry | None:
if self._get_revoke_request_entry(tenant_id, request_id) is not None:
raise OrganizationMemoryConflictError(
"该幂等请求标识已用于其他组织记忆操作。"
)
entry = self.db.scalar(
self._organization_entry_query().where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.management_request_id == request_id,
)
)
if entry is None:
return None
if entry.management_payload_fingerprint != request_fingerprint:
raise OrganizationMemoryConflictError(
"同一幂等请求标识对应的请求内容不一致。"
)
return entry
def _revoke_request_replay(
self,
*,
tenant_id: str,
request_id: str,
request_fingerprint: str,
) -> MemoryEntry | None:
management_entry = self.db.scalar(
self._organization_entry_query().where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.management_request_id == request_id,
)
)
if management_entry is not None:
raise OrganizationMemoryConflictError(
"该幂等请求标识已用于其他组织记忆操作。"
)
entry = self._get_revoke_request_entry(tenant_id, request_id)
if entry is None:
return None
if entry.revoke_payload_fingerprint != request_fingerprint:
raise OrganizationMemoryConflictError(
"同一幂等请求标识对应的请求内容不一致。"
)
if entry.status != "revoked" or entry.revoked_at is None:
raise OrganizationMemoryConflictError("撤销操作审计状态不完整,请人工复核。")
return entry
def _get_revoke_request_entry(
self,
tenant_id: str,
request_id: str,
) -> MemoryEntry | None:
return self.db.scalar(
self._organization_entry_query().where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.revoke_request_id == request_id,
)
)
@staticmethod
def _request_fingerprint(
*,
operation: str,
target: str,
payload: dict[str, object],
) -> str:
return hmac_fingerprint(
{
"protocol": "organization_memory_mutation.v1",
"operation": operation,
"target": target,
"payload": payload,
}
)
@staticmethod
def _normalize_request_id(value: str) -> str:
normalized = str(value or "").strip()
if len(normalized) < 8:
raise ValueError("组织记忆管理操作缺少有效的幂等请求标识。")
return normalized[:120]
@staticmethod
def _revoked_response(entry: MemoryEntry) -> ExpenseApplicationMemoryRevokedRead:
if entry.revoked_at is None:
raise OrganizationMemoryConflictError("撤销操作缺少审计时间,请人工复核。")
return ExpenseApplicationMemoryRevokedRead(
memory_id=entry.id,
revoked_at=entry.revoked_at,
)
@staticmethod
def _organization_entry_query():
return select(MemoryEntry).where(
MemoryEntry.scope_type.in_(ORGANIZATION_SCOPE_TYPES),
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
)
def _get_entry(
self,
memory_id: str,
tenant_id: str,
*,
for_update: bool = True,
) -> MemoryEntry:
statement = self._organization_entry_query().where(
MemoryEntry.id == str(memory_id or "").strip(),
MemoryEntry.tenant_id == tenant_id,
)
if for_update:
statement = statement.with_for_update()
entry = self.db.scalar(statement)
if entry is None:
raise OrganizationMemoryNotFoundError("未找到当前租户内可管理的组织记忆。")
return entry
def _get_scope_active_entry(
self,
*,
tenant_id: str,
scope_type: str,
scope_id: str,
) -> MemoryEntry | None:
return self.db.scalar(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == scope_type,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status == "active",
)
.with_for_update()
)
@staticmethod
def _require_active(entry: MemoryEntry) -> None:
if entry.status != "active":
raise OrganizationMemoryConflictError(
"该组织记忆已被换代、撤销或失效,请刷新后重试。"
)
@staticmethod
def _request_entry_id(
*,
tenant_id: str,
operation: str,
target: str,
request_id: str,
) -> str:
normalized_request_id = str(request_id or "").strip()
if not normalized_request_id:
raise ValueError("组织记忆管理操作缺少幂等请求标识。")
material = "|".join(
(tenant_id, operation, target, normalized_request_id)
)
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"x-financial:memory:{material}"))
def _validate_scope(
self,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> str:
normalized_scope_id = str(scope_id or "").strip()
if scope_type == "enterprise":
if normalized_scope_id and normalized_scope_id != tenant_id:
raise ValueError("企业记忆的 scope_id 必须等于当前租户 ID。")
return tenant_id
if scope_type != "department" or not normalized_scope_id:
raise ValueError("部门记忆必须提供稳定的 OrganizationUnit.id。")
department = self.db.get(OrganizationUnit, normalized_scope_id)
if department is None or str(department.unit_type or "") != "department":
raise ValueError("部门记忆只能绑定已存在的 department 类型组织单元。")
return normalized_scope_id
@staticmethod
def _require_admin(current_user: CurrentUserContext) -> None:
if not current_user.is_admin:
raise PermissionError("只有平台管理员可以维护企业或部门记忆。")
@staticmethod
def _validate_expected_generation(entry: MemoryEntry, expected: int) -> None:
if int(entry.generation or 0) != expected:
raise OrganizationMemoryConflictError(
"组织记忆已被其他管理员更新,请刷新后重试。"
)
def _department_names(self, entries: list[MemoryEntry]) -> dict[str, str]:
department_ids = {
str(entry.scope_id)
for entry in entries
if entry.scope_type == "department" and str(entry.scope_id or "")
}
if not department_ids:
return {}
return {
unit.id: str(unit.name or "").strip()
for unit in self.db.scalars(
select(OrganizationUnit).where(OrganizationUnit.id.in_(department_ids))
).all()
}
@staticmethod
def _serialize_entry(
entry: MemoryEntry,
department_names: dict[str, str],
) -> ExpenseApplicationMemoryRead:
scope_type = str(entry.scope_type)
if scope_type == "enterprise":
scope_label = "企业统一规则"
else:
name = department_names.get(str(entry.scope_id), "")
scope_label = f"部门规则({name}" if name else "部门规则"
return ExpenseApplicationMemoryRead(
id=entry.id,
scene=entry.scene,
field_key=entry.field_key,
value=ExpenseApplicationOrganizationMemoryService._entry_value(entry),
status=entry.status,
scope_type=scope_type,
scope_id=str(entry.scope_id),
scope_label=scope_label,
source=memory_source(scope_type),
origin_type=str(getattr(entry, "origin_type", "admin_managed")),
generation=int(entry.generation or 1),
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
activation_threshold=0,
policy_version=str(
getattr(entry, "policy_version", ORGANIZATION_MEMORY_POLICY_VERSION)
),
valid_from=entry.activated_at or entry.created_at,
expires_at=entry.active_expires_at,
last_evidence_at=entry.last_evidence_at,
activated_at=entry.activated_at,
suppressed_at=entry.suppressed_at,
revoked_at=entry.revoked_at,
revoked_reason=str(entry.revoked_reason or ""),
managed_by=str(getattr(entry, "managed_by", "") or ""),
managed_at=getattr(entry, "managed_at", None),
management_reason=str(getattr(entry, "management_reason", "") or ""),
superseded_by_id=str(entry.superseded_by_id or ""),
can_revoke=entry.status == "active",
created_at=entry.created_at,
updated_at=entry.updated_at,
)
@staticmethod
def _entry_value(entry: MemoryEntry) -> str:
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
return str(value_json.get("value") or "").strip()
@staticmethod
def _actor_id(current_user: CurrentUserContext) -> str:
value = str(current_user.employee_id or current_user.username or "").strip()
if not value:
raise ValueError("当前管理员缺少稳定的操作人标识。")
return value[:120]
@staticmethod
def _normalize_reason(value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("组织记忆管理操作必须填写原因。")
return normalized[:255]
@staticmethod
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
return (
entry.active_expires_at is not None
and ExpenseApplicationOrganizationMemoryService._aware(
entry.active_expires_at
)
<= now
)
@staticmethod
def _aware(value: datetime | None) -> datetime:
if value is None:
raise ValueError("组织记忆缺少有效期。")
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)

View File

@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_memory import MemoryEntry
from app.schemas.expense_application_memory import (
ExpenseApplicationMemoryApplication,
ExpenseApplicationMemoryConflict,
)
from app.services.expense_cases import ExpenseCaseService
MEMORY_SCENE = "travel_application"
MEMORY_FIELD_KEY = "transport_mode"
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
SCOPE_PRIORITIES = {"enterprise": 300, "department": 200, "user": 100}
RefreshUserEntry = Callable[..., None]
@dataclass(frozen=True, slots=True)
class ExpenseApplicationMemoryResolution:
winner: MemoryEntry | None
value: str
conflicts: tuple[ExpenseApplicationMemoryConflict, ...]
effective_confidence: float
status: str = "applied"
def to_application(
self,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryApplication:
if self.winner is None:
first = self.conflicts[0]
return ExpenseApplicationMemoryApplication(
memory_id="",
value="",
source="memory_conflict",
status="conflict",
confidence=0.0,
effective_confidence=0.0,
scope_type=first.scope_type,
scope_id="",
scope_label=first.scope_label,
priority=first.priority,
conflicts=list(self.conflicts),
can_revoke=False,
message="发现同一层级存在相互冲突的有效记忆,本次未自动填充,请联系管理员处理。",
)
entry = self.winner
scope_type = _scope_type(entry)
return ExpenseApplicationMemoryApplication(
memory_id=entry.id,
value=self.value,
source=memory_source(scope_type),
status=self.status,
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
effective_confidence=self.effective_confidence,
expires_at=entry.active_expires_at,
scope_type=scope_type,
scope_id=str(entry.scope_id or ""),
scope_label=memory_scope_label(entry, current_user),
priority=SCOPE_PRIORITIES[scope_type],
conflicts=list(self.conflicts),
can_revoke=scope_type == "user",
message=_application_message(scope_type),
)
class ExpenseApplicationMemoryResolver:
"""解析当前用户可见的企业、部门、个人记忆,并保守处理冲突。"""
def __init__(self, db: Session) -> None:
self.db = db
def resolve(
self,
current_user: CurrentUserContext,
*,
user_scope_id: str,
refresh_user_entry: RefreshUserEntry,
) -> ExpenseApplicationMemoryResolution | None:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
relevant_scopes = [("enterprise", tenant_id)]
department_id = str(getattr(current_user, "department_id", "") or "").strip()
if department_id:
relevant_scopes.append(("department", department_id))
relevant_scopes.append(("user", user_scope_id))
now = datetime.now(UTC)
entries: list[MemoryEntry] = []
for scope_type, scope_id in relevant_scopes:
entries.extend(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == scope_type,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status.in_(["candidate", "active"]),
)
.order_by(
MemoryEntry.generation.desc(),
MemoryEntry.last_evidence_at.desc(),
)
.with_for_update()
).all()
)
user_entries = [entry for entry in entries if _scope_type(entry) == "user"]
active_user_found = False
for entry in user_entries:
if entry.status != "active":
continue
refresh_user_entry(entry, now=now)
active_user_found = active_user_found or entry.status == "active"
for entry in user_entries:
if entry.status != "candidate":
continue
refresh_user_entry(
entry,
now=now,
allow_activation=not active_user_found,
)
active_user_found = active_user_found or entry.status == "active"
eligible: list[MemoryEntry] = []
for entry in entries:
if (
_scope_type(entry) != "user"
and entry.status == "active"
and _is_expired(entry, now)
):
entry.status = "expired"
entry.expired_at = now
if entry.status != "active" or _is_expired(entry, now):
continue
if _entry_value(entry):
eligible.append(entry)
if not eligible:
return None
highest_priority = max(SCOPE_PRIORITIES[_scope_type(entry)] for entry in eligible)
highest = [
entry
for entry in eligible
if SCOPE_PRIORITIES[_scope_type(entry)] == highest_priority
]
top_values = {_entry_value(entry) for entry in highest}
if len(top_values) > 1:
conflicts = tuple(
_conflict(entry, current_user, reason="same_priority_conflict")
for entry in highest
)
return ExpenseApplicationMemoryResolution(
winner=None,
value="",
conflicts=conflicts,
effective_confidence=0.0,
status="conflict",
)
winner = highest[0]
value = _entry_value(winner)
conflicts = tuple(
_conflict(entry, current_user, reason="lower_priority_overridden")
for entry in eligible
if SCOPE_PRIORITIES[_scope_type(entry)] < highest_priority
and _entry_value(entry) != value
)
return ExpenseApplicationMemoryResolution(
winner=winner,
value=value,
conflicts=conflicts,
effective_confidence=_effective_confidence(winner, now),
)
def _entry_value(entry: MemoryEntry) -> str:
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
value = str(value_json.get("value") or "").strip()
return value if value in SUPPORTED_TRANSPORT_VALUES else ""
def _scope_type(entry: MemoryEntry) -> str:
value = str(entry.scope_type or "user")
return value if value in SCOPE_PRIORITIES else "user"
def memory_source(scope_type: str) -> str:
return {
"enterprise": "enterprise_policy_memory",
"department": "department_policy_memory",
"user": "verified_user_history",
}[scope_type]
def memory_scope_label(entry: MemoryEntry, current_user: CurrentUserContext) -> str:
scope_type = _scope_type(entry)
if scope_type == "enterprise":
return "企业统一规则"
if scope_type == "department":
department_name = str(current_user.department_name or "").strip()
return f"部门规则({department_name}" if department_name else "部门规则"
return "个人偏好"
def _application_message(scope_type: str) -> str:
return {
"enterprise": "已按企业统一规则预填出行方式,可在规则允许范围内调整。",
"department": "已按当前部门规则预填出行方式,可继续修改。",
"user": "已按可信历史记忆预填常用出行方式,可继续修改。",
}[scope_type]
def _conflict(
entry: MemoryEntry,
current_user: CurrentUserContext,
*,
reason: str,
) -> ExpenseApplicationMemoryConflict:
scope_type = _scope_type(entry)
return ExpenseApplicationMemoryConflict(
scope_type=scope_type,
scope_label=memory_scope_label(entry, current_user),
priority=SCOPE_PRIORITIES[scope_type],
reason=reason,
)
def _effective_confidence(entry: MemoryEntry, now: datetime) -> float:
base = max(0.0, min(1.0, float(entry.confidence or 0)))
if base == 0.0 and str(getattr(entry, "origin_type", "learned")) == "admin_managed":
base = 1.0
started_at = entry.activated_at or entry.created_at
expires_at = entry.active_expires_at
if started_at is None or expires_at is None:
return round(base, 4)
started = _aware(started_at)
expires = _aware(expires_at)
total_seconds = max(1.0, (expires - started).total_seconds())
remaining_ratio = max(0.0, min(1.0, (expires - now).total_seconds() / total_seconds))
# 有效期内仅降低解释性置信度,不会因衰减而提前停止应用。
return round(base * max(0.5, remaining_ratio), 4)
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
expires_at = entry.active_expires_at if entry.status == "active" else entry.candidate_expires_at
return expires_at is not None and _aware(expires_at) <= now
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.core.secret_box import decrypt_secret
from app.models.financial_record import ExpenseClaim
from app.models.system_model_setting import SystemModelSetting
from app.services.embedding_provider import EmbeddingProvider
from app.services.few_shot_retrieval import FewShotRetriever
from app.services.few_shot_store import FewShotStore
from app.services.knowledge_rag_runtime import RuntimeModelConfig
logger = get_logger("app.services.expense_claim_historical_evidence")
_SCENE_BY_BUSINESS_STAGE = {
"expense_application": "expense_application",
"reimbursement": "expense_reimbursement",
}
_LABEL_TEXT = {
"confirmed": "历史已确认,仅供复核",
"false_positive": "历史误报,仅供复核",
}
_SUMMARY_BY_LABEL = {
"confirmed": "历史相似案例经人工复核确认风险成立。",
"false_positive": "历史相似案例经人工复核判定为误报。",
}
_MAX_RULE_CONTEXTS = 3
_MAX_EVIDENCE = 3
class ExpenseClaimHistoricalEvidenceService:
"""为费用预审补充历史案例证据,但不参与确定性决策。"""
def __init__(self, db: Session) -> None:
self.db = db
def retrieve(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
business_stage: str,
findings: list[dict[str, Any]],
) -> list[dict[str, Any]]:
tenant = _text(tenant_id)
scene_code = _SCENE_BY_BUSINESS_STAGE.get(_text(business_stage), "")
if not tenant or not scene_code:
return []
try:
retriever = self._build_retriever()
if retriever is None:
return []
query = self._build_query(claim, findings=findings)
evidence: list[dict[str, Any]] = []
emitted_sample_ids: set[str] = set()
for policy_ref, rule_version in self._rule_contexts(findings):
hits = retriever.retrieve_for_expense_case(
tenant_id=tenant,
scene=scene_code,
policy_ref=policy_ref,
rule_version=rule_version,
query=query,
top_k=_MAX_EVIDENCE,
)
for hit in hits:
sample_id = _text(hit.get("sample_id"))
if not sample_id or sample_id in emitted_sample_ids:
continue
public_item = self._to_public_evidence(hit, scene_code=scene_code)
if not public_item:
continue
evidence.append(public_item)
emitted_sample_ids.add(sample_id)
if len(evidence) >= _MAX_EVIDENCE:
return evidence
return evidence
except Exception:
# 历史案例只作复核参考。检索、向量库或配置异常不得阻断预审。
logger.warning(
"费用预审历史案例检索失败 tenant_id=%s claim_id=%s",
tenant,
_text(claim.id),
exc_info=True,
)
return []
def _build_retriever(self) -> FewShotRetriever | None:
"""只读加载 embedding 配置,禁止在费用事务中触发配置初始化提交。"""
model_row = self.db.get(SystemModelSetting, "embedding")
if model_row is None or not model_row.enabled:
return None
encrypted_api_key = _text(model_row.api_key_encrypted)
try:
api_key = decrypt_secret(encrypted_api_key) if encrypted_api_key else ""
except ValueError:
logger.warning("embedding 配置密钥无法解密,历史案例检索已跳过")
return None
provider = EmbeddingProvider(
RuntimeModelConfig(
slot="embedding",
provider=_text(model_row.provider),
model=_text(model_row.model_name),
endpoint=_text(model_row.endpoint),
api_key=api_key,
capability=_text(model_row.capability) or "embedding",
)
)
return FewShotRetriever(FewShotStore(provider), self.db)
@staticmethod
def _rule_contexts(
findings: list[dict[str, Any]],
) -> list[tuple[str, str]]:
contexts: list[tuple[str, str]] = []
for finding in findings:
context = (
_text(finding.get("rule_code")),
_text(finding.get("rule_version")),
)
if context != ("", "") and context not in contexts:
contexts.append(context)
if len(contexts) >= _MAX_RULE_CONTEXTS:
break
# 无规则命中时仍按租户和业务场景检索,但显式传递空的规则标识。
return contexts or [("", "")]
@staticmethod
def _build_query(
claim: ExpenseClaim,
*,
findings: list[dict[str, Any]],
) -> str:
parts = [
_text(claim.expense_type),
_text(claim.reason),
_text(claim.location),
*[
_text(finding.get("message"))
for finding in findings
if _text(finding.get("message"))
],
]
return "\n".join(part for part in parts if part).strip()
@staticmethod
def _to_public_evidence(
hit: dict[str, Any],
*,
scene_code: str,
) -> dict[str, Any]:
label = _text(hit.get("label")).lower()
label_text = _LABEL_TEXT.get(label)
summary = _SUMMARY_BY_LABEL.get(label)
if not label_text or not summary:
return {}
return {
"label": label,
"label_text": label_text,
"advisory_only": True,
"score": round(float(hit.get("score") or 0.0), 4),
"scene_code": _text(hit.get("scene")) or scene_code,
"policy_ref": _text(hit.get("policy_ref")),
"rule_version": _text(hit.get("rule_version")),
"version_status": (
"stale" if bool(hit.get("stale")) else "matched"
),
"stale": bool(hit.get("stale")),
"summary": summary,
}
def _text(value: Any) -> str:
return str(value or "").strip()
def build_user_agent_historical_evidence_notice(claim: ExpenseClaim) -> str:
"""生成脱敏的 User Agent 提示,不读取或回显历史案例原文。"""
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
return ""
labels: list[str] = []
for flag in flags:
if (
not isinstance(flag, dict)
or _text(flag.get("source")) != "ai_pre_review"
):
continue
for item in list(flag.get("historical_case_evidence") or []):
if not isinstance(item, dict):
continue
label_text = _LABEL_TEXT.get(_text(item.get("label")).lower(), "")
if label_text and label_text not in labels:
labels.append(label_text)
return "历史案例参考:" + "".join(labels) if labels else ""

View File

@@ -6,6 +6,9 @@ from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_historical_evidence import (
ExpenseClaimHistoricalEvidenceService,
)
from app.services.expense_claim_pre_review_decision import build_pre_review_decision
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import (
@@ -45,6 +48,7 @@ class ExpenseClaimPreReviewMixin:
claim,
is_application_claim=is_application_claim,
reviewed_at=datetime.now(UTC),
tenant_id=current_user.tenant_id,
)
if pre_review_flag is None:
raise RuntimeError("无法生成费用预审结果。")
@@ -83,6 +87,7 @@ class ExpenseClaimPreReviewMixin:
*,
decision_payload: dict[str, Any],
business_stage: str,
historical_case_evidence: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
decision = str(decision_payload.get("decision") or "ready_with_review")
passed = decision != "needs_fix"
@@ -101,6 +106,7 @@ class ExpenseClaimPreReviewMixin:
"passed": passed,
"blocking_risk_count": blocking_count,
**decision_payload,
"historical_case_evidence": list(historical_case_evidence or []),
"next_action": "next_step" if passed else "risk_explanation_required",
"created_at": str(decision_payload.get("reviewed_at") or ""),
},
@@ -128,12 +134,14 @@ class ExpenseClaimPreReviewMixin:
*,
is_application_claim: bool | None = None,
reviewed_at: datetime | None = None,
tenant_id: str = "",
) -> dict[str, Any] | None:
"""业务变更事务内刷新预审快照,不提交、不单独写事件。"""
return self._refresh_claim_pre_review_flags(
claim,
is_application_claim=is_application_claim,
reviewed_at=reviewed_at,
tenant_id=tenant_id,
)
def _refresh_claim_pre_review_flags(
@@ -142,6 +150,7 @@ class ExpenseClaimPreReviewMixin:
*,
is_application_claim: bool | None = None,
reviewed_at: datetime | None = None,
tenant_id: str = "",
) -> dict[str, Any] | None:
if claim is None:
return None
@@ -187,9 +196,18 @@ class ExpenseClaimPreReviewMixin:
platform_rule_set_fingerprint=platform_rule_set_fingerprint,
reviewed_at=reviewed_at,
)
historical_case_evidence = ExpenseClaimHistoricalEvidenceService(
self.db
).retrieve(
claim,
tenant_id=tenant_id,
business_stage=business_stage,
findings=list(decision_payload.get("findings") or []),
)
pre_review_flag = self._build_ai_pre_review_flag(
decision_payload=decision_payload,
business_stage=business_stage,
historical_case_evidence=historical_case_evidence,
)
claim.risk_flags_json = self._replace_ai_pre_review_flag(
review_flags,

View File

@@ -210,6 +210,48 @@ def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | N
for item in list(flag.get("findings") or [])
if isinstance(item, dict)
],
"historical_case_evidence": _public_historical_evidence(flag),
}
def _public_historical_evidence(flag: dict[str, Any]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in list(flag.get("historical_case_evidence") or []):
if not isinstance(item, dict):
continue
public_item = _historical_evidence_public_payload(item)
if public_item is not None:
result.append(public_item)
return result
def _historical_evidence_public_payload(
item: dict[str, Any],
) -> dict[str, Any] | None:
label = _text(item.get("label")).lower()
if label not in {"confirmed", "false_positive"}:
return None
return {
"label": label,
"label_text": (
"历史已确认,仅供复核"
if label == "confirmed"
else "历史误报,仅供复核"
),
"advisory_only": True,
"score": round(float(item.get("score") or 0.0), 4),
"scene_code": _text(item.get("scene_code")),
"policy_ref": _text(item.get("policy_ref")),
"rule_version": _text(item.get("rule_version")),
"version_status": (
"stale" if bool(item.get("stale")) else "matched"
),
"stale": bool(item.get("stale")),
"summary": (
"历史相似案例经人工复核确认风险成立。"
if label == "confirmed"
else "历史相似案例经人工复核判定为误报。"
),
}

View File

@@ -260,6 +260,7 @@ class ExpenseClaimItemActionMixin:
pre_review_flag = self.refresh_claim_pre_review_state(
claim,
is_application_claim=is_application_claim,
tenant_id=current_user.tenant_id,
)
if pre_review_flag is None:
raise RuntimeError("无法生成提交前预审结果。")

View File

@@ -31,6 +31,16 @@ LABEL_CONCLUSION_FALLBACK = {
"false_positive": "经人工复核判定为误报,相似情形不应触发该风险规则。",
}
CONTROL_STAGE_SCENES = {
"application": "expense_application",
"expense_application": "expense_application",
"pre_application": "expense_application",
"pre_reimbursement": "expense_reimbursement",
"reimbursement": "expense_reimbursement",
"claim": "expense_reimbursement",
"post_payment": "expense_post_payment",
}
class FewShotIngestionService:
"""把已确认的风险观测沉淀为 few-shot 样本。"""
@@ -48,22 +58,34 @@ class FewShotIngestionService:
label = observation.feedback_status
if label not in CONFIRMED_LABELS:
return None
tenant_id = str(observation.tenant_id or "").strip()
if not tenant_id:
logger.warning("few-shot ingestion 缺少 tenant_id observation_id=%s", observation.id)
return None
sample_key = f"obs:{observation.id}"
sample = self.db.scalar(
select(FewShotSample).where(FewShotSample.sample_key == sample_key)
select(FewShotSample).where(
FewShotSample.tenant_id == tenant_id,
FewShotSample.sample_key == sample_key,
)
)
domain = self._extract_domain(observation)
scene = self._extract_scene(observation)
policy_ref, rule_version = self._extract_rule_identity(observation)
case_text = self._build_case_text(observation)
conclusion_text = self._build_conclusion_text(observation, feedback, label)
payload = self._build_payload(observation, feedback, label)
if sample is None:
sample = FewShotSample(
tenant_id=tenant_id,
sample_key=sample_key,
source_observation_id=observation.id,
scene="risk_rule_generation",
scene=scene,
policy_ref=policy_ref,
rule_version=rule_version,
domain=domain,
risk_type=observation.risk_type or "",
risk_level=observation.risk_level or "",
@@ -75,7 +97,11 @@ class FewShotIngestionService:
)
self.db.add(sample)
else:
sample.tenant_id = tenant_id
sample.label = label
sample.scene = scene
sample.policy_ref = policy_ref
sample.rule_version = rule_version
sample.domain = domain
sample.risk_type = observation.risk_type or ""
sample.risk_level = observation.risk_level or ""
@@ -83,7 +109,6 @@ class FewShotIngestionService:
sample.conclusion_text = conclusion_text
sample.payload_json = payload
sample.status = "active"
sample.vector_id = sample.vector_id
try:
self.db.commit()
self.db.refresh(sample)
@@ -101,11 +126,17 @@ class FewShotIngestionService:
logger.warning("few-shot vector_id 回写失败 sample_id=%s", sample.id)
return sample
def retract_observation(self, observation_id: str) -> bool:
def retract_observation(self, observation_id: str, *, tenant_id: str) -> bool:
"""观测被撤销时删掉对应样本及其向量。"""
tenant = str(tenant_id or "").strip()
if not tenant:
raise ValueError("tenant_id is required")
sample = self.db.scalar(
select(FewShotSample).where(FewShotSample.source_observation_id == observation_id)
select(FewShotSample).where(
FewShotSample.tenant_id == tenant,
FewShotSample.source_observation_id == observation_id,
)
)
if sample is None:
return False
@@ -128,6 +159,34 @@ class FewShotIngestionService:
ontology = observation.ontology_json or {}
return str(ontology.get("domain") or "")
def _extract_scene(self, observation: RiskObservation) -> str:
stage = str(observation.control_stage or "").strip().lower()
if stage in CONTROL_STAGE_SCENES:
return CONTROL_STAGE_SCENES[stage]
return stage or "risk_rule_generation"
def _extract_rule_identity(self, observation: RiskObservation) -> tuple[str, str]:
trace = observation.decision_trace_json or {}
policy_ref = _text(
trace.get("policy_ref") or trace.get("rule_code") or trace.get("policy_code")
)
if not policy_ref:
for value in observation.policy_refs_json or []:
if isinstance(value, dict):
policy_ref = _text(
value.get("policy_ref") or value.get("rule_code") or value.get("code")
)
else:
policy_ref = _text(value)
if policy_ref:
break
rule_version = _text(
trace.get("rule_version")
or trace.get("policy_version")
or observation.algorithm_version
)
return policy_ref, rule_version
def _build_case_text(self, observation: RiskObservation) -> str:
parts = [
observation.title or "",
@@ -162,6 +221,8 @@ class FewShotIngestionService:
label: str,
) -> dict[str, Any]:
return {
"tenant_id": observation.tenant_id,
"scene": self._extract_scene(observation),
"label": label,
"risk_type": observation.risk_type,
"risk_signal": observation.risk_signal,
@@ -171,7 +232,13 @@ class FewShotIngestionService:
"feedback_actor": feedback.actor or "",
"ontology": observation.ontology_json or {},
"policy_refs": observation.policy_refs_json or [],
"policy_ref": self._extract_rule_identity(observation)[0],
"rule_version": self._extract_rule_identity(observation)[1],
"evidence": observation.evidence_json or [],
"subject_label": observation.subject_label or "",
"claim_no": observation.claim_no or "",
}
def _text(value: Any) -> str:
return str(value or "").strip()

View File

@@ -19,9 +19,11 @@ from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.few_shot_sample import FewShotSample
from app.services.embedding_provider import EmbeddingProvider
from app.services.few_shot_store import FewShotStore
@@ -38,17 +40,19 @@ MAX_HISTORICAL_SAMPLES = 3
class FewShotRetriever:
"""按 case 特征检索已确认样本,返回 prompt 可直接消费的结构。"""
def __init__(self, store: FewShotStore) -> None:
def __init__(self, store: FewShotStore, session: Session | None = None) -> None:
self._store = store
self._session = session
@classmethod
def from_session(cls, session: Session) -> "FewShotRetriever":
def from_session(cls, session: Session) -> FewShotRetriever:
provider = EmbeddingProvider.from_settings(session)
return cls(FewShotStore(provider))
return cls(FewShotStore(provider), session)
def retrieve_for_risk_rule_generation(
self,
*,
tenant_id: str,
domain: str = "",
risk_type: str = "",
natural_language: str,
@@ -65,12 +69,125 @@ class FewShotRetriever:
return []
hits = self._store.search(
case_text,
tenant_id=tenant_id,
scene="risk_rule_generation",
labels=["confirmed", "false_positive"],
top_k=top_k,
)
return self._hits_to_injection_blocks(hits)
def retrieve_for_expense_case(
self,
*,
tenant_id: str,
scene: str,
policy_ref: str,
rule_version: str,
query: str,
top_k: int = MAX_HISTORICAL_SAMPLES,
) -> list[dict[str, Any]]:
"""返回仅作建议的历史案例,并以关系库状态做二次授权校验。"""
tenant = str(tenant_id or "").strip()
normalized_scene = str(scene or "").strip()
normalized_policy = str(policy_ref or "").strip()
normalized_version = str(rule_version or "").strip()
if not tenant:
raise ValueError("tenant_id is required for historical case retrieval")
if not query or not normalized_scene or self._session is None:
return []
hits = self._store.search(
query,
tenant_id=tenant,
scene=normalized_scene,
policy_ref=normalized_policy or None,
rule_version=normalized_version or None,
labels=["confirmed", "false_positive"],
top_k=top_k,
)
# 精确版本不足时补检同规则旧版本,但输出会显式标记为 stale绝不自动执行。
if normalized_version and len(hits) < top_k:
fallback_hits = self._store.search(
query,
tenant_id=tenant,
scene=normalized_scene,
policy_ref=normalized_policy or None,
labels=["confirmed", "false_positive"],
top_k=top_k * 2,
)
seen = {str(item.get("sample_id") or "") for item in hits}
hits.extend(
item for item in fallback_hits if str(item.get("sample_id") or "") not in seen
)
return self._validated_expense_case_hits(
hits[: top_k * 2],
tenant_id=tenant,
scene=normalized_scene,
policy_ref=normalized_policy,
rule_version=normalized_version,
top_k=top_k,
)
def _validated_expense_case_hits(
self,
hits: list[dict[str, Any]],
*,
tenant_id: str,
scene: str,
policy_ref: str,
rule_version: str,
top_k: int,
) -> list[dict[str, Any]]:
sample_ids = [str(hit.get("sample_id") or "") for hit in hits]
sample_ids = [sample_id for sample_id in sample_ids if sample_id]
if not sample_ids or self._session is None:
return []
conditions = [
FewShotSample.id.in_(sample_ids),
FewShotSample.tenant_id == tenant_id,
FewShotSample.scene == scene,
FewShotSample.status == "active",
]
if policy_ref:
conditions.append(FewShotSample.policy_ref == policy_ref)
samples = {
item.id: item
for item in self._session.scalars(select(FewShotSample).where(*conditions)).all()
}
result: list[dict[str, Any]] = []
emitted_sample_ids: set[str] = set()
for hit in hits:
sample_id = str(hit.get("sample_id") or "")
sample = samples.get(sample_id)
if sample is None or sample_id in emitted_sample_ids:
continue
version_matches = not rule_version or sample.rule_version == rule_version
result.append(
{
"source": "historical_case",
"advisory_only": True,
"sample_id": sample.id,
"label": sample.label,
"score": round(float(hit.get("score") or 0.0), 4),
"scene": sample.scene,
"policy_ref": sample.policy_ref,
"rule_version": sample.rule_version,
"version_status": "matched" if version_matches else "stale",
"stale": not version_matches,
"conclusion": sample.conclusion_text[:SINGLE_SAMPLE_MAX_CHARS],
"evidence": {
"risk_type": sample.risk_type,
"risk_level": sample.risk_level,
"payload": sample.payload_json or {},
},
}
)
emitted_sample_ids.add(sample_id)
if len(result) >= top_k:
break
return result
def _build_case_text(
self,
*,

View File

@@ -20,6 +20,17 @@ from app.services.knowledge_rag import _resolve_default_qdrant_url
logger = get_logger("app.services.few_shot_store")
FEW_SHOT_COLLECTION = "few_shot_samples"
FEW_SHOT_VECTOR_NAMESPACE = uuid.UUID("0ecb5868-cf75-47c1-b3bf-81e4a6ce1df3")
def stable_vector_id(*, tenant_id: str, sample_id: str) -> str:
"""同一租户样本始终映射到同一个 Qdrant point。"""
tenant = str(tenant_id or "").strip()
sample = str(sample_id or "").strip()
if not tenant or not sample:
raise ValueError("tenant_id and sample_id are required")
return str(uuid.uuid5(FEW_SHOT_VECTOR_NAMESPACE, f"{tenant}:{sample}"))
def _resolve_qdrant_config() -> tuple[str, str]:
@@ -73,26 +84,29 @@ class FewShotStore:
try:
client.get_collection(FEW_SHOT_COLLECTION)
self._ensured = True
return True
except UnexpectedResponse as exc:
if exc.status_code != 404:
raise
# collection 不存在则创建
dim = self._embedding_provider.dimension()
dim = self._embedding_provider.dimension()
from qdrant_client.http.models import Distance, VectorParams
client.create_collection(
collection_name=FEW_SHOT_COLLECTION,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
logger.info("few-shot collection 创建成功 dim=%s", dim)
# 老 collection 也要补齐过滤索引,不能只在首次建表时创建。
from qdrant_client.http.models import (
Distance,
VectorParams,
PayloadSchemaType,
)
client.create_collection(
collection_name=FEW_SHOT_COLLECTION,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
for field, field_type in [
("sample_id", PayloadSchemaType.KEYWORD),
("tenant_id", PayloadSchemaType.KEYWORD),
("scene", PayloadSchemaType.KEYWORD),
("policy_ref", PayloadSchemaType.KEYWORD),
("rule_version", PayloadSchemaType.KEYWORD),
("label", PayloadSchemaType.KEYWORD),
("domain", PayloadSchemaType.KEYWORD),
("risk_type", PayloadSchemaType.KEYWORD),
@@ -107,7 +121,6 @@ class FewShotStore:
except Exception:
logger.debug("payload index 创建跳过 field=%s", field, exc_info=True)
self._ensured = True
logger.info("few-shot collection 创建成功 dim=%s", dim)
return True
except Exception:
logger.warning("few-shot collection 初始化失败,本轮操作跳过", exc_info=True)
@@ -116,18 +129,31 @@ class FewShotStore:
def upsert(self, sample: Any) -> str | None:
"""把一条样本向量化并写入 Qdrant返回 vector_id失败返回 None。"""
tenant_id = str(getattr(sample, "tenant_id", "") or "").strip()
sample_id = str(getattr(sample, "id", "") or "").strip()
if not tenant_id or not sample_id:
logger.warning("few-shot upsert 缺少 tenant_id/sample_id已拒绝")
return None
if not self._ensure_collection():
return None
client = self._client
try:
vector = self._embedding_provider.embed([sample.case_text])[0]
except Exception:
logger.warning("few-shot embedding 失败 sample_key=%s", getattr(sample, "sample_key", ""), exc_info=True)
logger.warning(
"few-shot embedding 失败 sample_key=%s",
getattr(sample, "sample_key", ""),
exc_info=True,
)
return None
vector_id = uuid.uuid4().hex
vector_id = stable_vector_id(tenant_id=tenant_id, sample_id=sample_id)
previous_vector_id = str(getattr(sample, "vector_id", "") or "").strip()
payload = {
"sample_id": sample.id,
"sample_id": sample_id,
"tenant_id": tenant_id,
"scene": sample.scene,
"policy_ref": getattr(sample, "policy_ref", "") or "",
"rule_version": getattr(sample, "rule_version", "") or "",
"label": sample.label,
"domain": sample.domain,
"risk_type": sample.risk_type,
@@ -137,25 +163,51 @@ class FewShotStore:
"payload_json": sample.payload_json,
}
try:
points = [{"id": vector_id, "vector": vector, "payload": payload}]
if previous_vector_id and previous_vector_id != vector_id:
# 先用最新判定覆盖旧 point避免后续删除短暂失败时暴露陈旧标签。
points.append({"id": previous_vector_id, "vector": vector, "payload": payload})
client.upsert(
collection_name=FEW_SHOT_COLLECTION,
points=[{"id": vector_id, "vector": vector, "payload": payload}],
points=points,
)
if previous_vector_id and previous_vector_id != vector_id:
try:
client.delete(
collection_name=FEW_SHOT_COLLECTION,
points_selector=[previous_vector_id],
)
except Exception:
logger.warning(
"few-shot 旧向量清理失败,已保留同内容副本 vector_id=%s",
previous_vector_id,
exc_info=True,
)
return vector_id
except Exception:
logger.warning("few-shot upsert 失败 sample_key=%s", getattr(sample, "sample_key", ""), exc_info=True)
logger.warning(
"few-shot upsert 失败 sample_key=%s",
getattr(sample, "sample_key", ""),
exc_info=True,
)
return None
def search(
self,
case_text: str,
*,
tenant_id: str,
scene: str | None = None,
policy_ref: str | None = None,
rule_version: str | None = None,
labels: list[str] | None = None,
top_k: int = 3,
) -> list[dict[str, Any]]:
"""按 case_text 检索相似样本,可按 scene/label 过滤。失败返回空列表。"""
tenant = str(tenant_id or "").strip()
if not tenant:
raise ValueError("tenant_id is required for few-shot search")
if not case_text or not self._ensure_collection():
return []
client = self._client
@@ -164,9 +216,16 @@ class FewShotStore:
except Exception:
logger.warning("few-shot 检索 embedding 失败", exc_info=True)
return []
must: list[dict[str, Any]] = [{"key": "status", "match": {"value": "active"}}]
must: list[dict[str, Any]] = [
{"key": "tenant_id", "match": {"value": tenant}},
{"key": "status", "match": {"value": "active"}},
]
if scene:
must.append({"key": "scene", "match": {"value": scene}})
if policy_ref:
must.append({"key": "policy_ref", "match": {"value": policy_ref}})
if rule_version:
must.append({"key": "rule_version", "match": {"value": rule_version}})
if labels:
must.append({"key": "label", "match": {"any": labels}})
try:
@@ -188,6 +247,10 @@ class FewShotStore:
hits.append(
{
"sample_id": payload.get("sample_id"),
"tenant_id": payload.get("tenant_id"),
"scene": payload.get("scene"),
"policy_ref": payload.get("policy_ref") or "",
"rule_version": payload.get("rule_version") or "",
"score": float(getattr(point, "score", 0.0)),
"label": payload.get("label"),
"domain": payload.get("domain"),

View File

@@ -14,6 +14,7 @@ from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim
from app.models.hermes_report import HermesRiskReport
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.hermes_risk_scanner")
@@ -38,43 +39,60 @@ class HermesRiskScannerService:
logger.info(f"Fetched {len(claims)} claims to analyze.")
observation_service = RiskObservationService(self.db)
result = evaluate_financial_risk_graph(
RiskGraphEvaluationContext(
claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in claims],
target_claim_ids={claim.id for claim in claims},
history_stats=observation_service.build_history_stats(
expense_types={str(claim.expense_type or "") for claim in claims},
),
)
)
claims_by_id = {claim.id: claim for claim in claims}
for observation in result.observations:
claim = claims_by_id.get(observation.claim_id)
if claim is None:
continue
observation_service.upsert_observation(
observation,
run_id=run_id,
execution_log_id=log_id,
)
claim.hermes_risk_flag = True
claim.risk_flags_json = self._append_algorithm_flag(claim, observation.as_dict())
if log_id:
self.db.add(
HermesRiskReport(
claim_id=observation.claim_id,
execution_log_id=log_id,
risk_level=observation.risk_level,
risk_type=observation.risk_signal,
risk_description=observation.description,
related_claim_ids=[
observation.claim_id,
*observation.similar_case_claim_ids,
],
)
observation_count = 0
graph_node_count = 0
graph_edge_count = 0
for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items():
result = evaluate_financial_risk_graph(
RiskGraphEvaluationContext(
claims=[
RiskGraphClaimSnapshot.from_orm(claim)
for claim in tenant_claims
],
target_claim_ids={claim.id for claim in tenant_claims},
history_stats=observation_service.build_history_stats(
tenant_id=tenant_id,
expense_types={
str(claim.expense_type or "") for claim in tenant_claims
},
),
)
)
claims_by_id = {claim.id: claim for claim in tenant_claims}
observation_count += len(result.observations)
graph_node_count += len(result.nodes)
graph_edge_count += len(result.edges)
for observation in result.observations:
claim = claims_by_id.get(observation.claim_id)
if claim is None:
continue
observation_service.upsert_observation(
observation,
tenant_id=tenant_id,
run_id=run_id,
execution_log_id=log_id,
)
claim.hermes_risk_flag = True
claim.risk_flags_json = self._append_algorithm_flag(
claim,
observation.as_dict(),
)
if log_id:
self.db.add(
HermesRiskReport(
claim_id=observation.claim_id,
execution_log_id=log_id,
risk_level=observation.risk_level,
risk_type=observation.risk_signal,
risk_description=observation.description,
related_claim_ids=[
observation.claim_id,
*observation.similar_case_claim_ids,
],
)
)
now = datetime.now(timezone.utc)
for claim in claims:
@@ -83,15 +101,28 @@ class HermesRiskScannerService:
self.db.commit()
logger.info(
"Hermes risk graph scan completed. Found %s observations.",
len(result.observations),
observation_count,
)
return {
"scanned_claim_count": len(claims),
"risk_observation_count": len(result.observations),
"graph_node_count": len(result.nodes),
"graph_edge_count": len(result.edges),
"risk_observation_count": observation_count,
"graph_node_count": graph_node_count,
"graph_edge_count": graph_edge_count,
}
def _group_claims_by_tenant(
self,
claims: list[ExpenseClaim],
) -> dict[str, list[ExpenseClaim]]:
grouped: dict[str, list[ExpenseClaim]] = {}
for claim in claims:
tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
self.db,
claim.id,
)
grouped.setdefault(tenant_id, []).append(claim)
return grouped
def _fetch_unscanned_claims(self) -> list[ExpenseClaim]:
stmt = (
select(ExpenseClaim)

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from hashlib import sha256
from threading import Lock, RLock
from sqlalchemy import func, select
from sqlalchemy.orm import Session
_FALLBACK_LOCKS_GUARD = Lock()
_FALLBACK_LOCKS: dict[str, RLock] = {}
@contextmanager
def organization_memory_operation_locks(
db: Session,
*lock_keys: str,
) -> Iterator[None]:
"""串行化组织记忆作用域和幂等键,锁的生命周期覆盖当前事务。"""
normalized_keys = sorted({str(key) for key in lock_keys if str(key)})
bind = db.get_bind()
if bind.dialect.name == "postgresql":
for lock_key in normalized_keys:
db.execute(
select(
func.pg_advisory_xact_lock(
organization_memory_advisory_lock_id(lock_key)
)
)
)
yield
return
# SQLite 等方言没有事务级 advisory lock。进程内锁配合数据库唯一索引
# 提供安全退化,保证测试与单进程部署不会出现空集合竞态。
fallback_locks = [_fallback_lock(lock_key) for lock_key in normalized_keys]
for fallback_lock in fallback_locks:
fallback_lock.acquire()
try:
yield
finally:
for fallback_lock in reversed(fallback_locks):
fallback_lock.release()
def organization_memory_scope_lock_key(
*,
tenant_id: str,
scope_type: str,
scope_id: str,
scene: str,
field_key: str,
) -> str:
return "|".join(
(
"organization-memory-scope",
tenant_id,
scope_type,
scope_id,
scene,
field_key,
)
)
def organization_memory_request_lock_key(tenant_id: str, request_id: str) -> str:
return f"organization-memory-request|{tenant_id}|{request_id}"
def _fallback_lock(lock_key: str) -> RLock:
with _FALLBACK_LOCKS_GUARD:
return _FALLBACK_LOCKS.setdefault(lock_key, RLock())
def organization_memory_advisory_lock_id(lock_key: str) -> int:
return int.from_bytes(
sha256(lock_key.encode("utf-8")).digest()[:8],
byteorder="big",
signed=True,
)

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, joinedload
from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft
from app.core.logging import get_logger
from app.db.base import Base
from app.models.expense_case import ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.schemas.risk_observation import (
@@ -34,6 +35,7 @@ FEEDBACK_STATUS_MAP = {
"ignore": ("ignored", "ignored"),
"resolve": ("resolved", "resolved"),
}
DEFAULT_TENANT_ID = "default"
class RiskObservationService:
@@ -61,6 +63,7 @@ class RiskObservationService:
self,
observation: RiskObservationDraft | dict[str, Any],
*,
tenant_id: str | None = None,
run_id: str | None = None,
execution_log_id: str | None = None,
) -> RiskObservation:
@@ -73,12 +76,22 @@ class RiskObservationService:
observation_key = str(payload.get("observation_key") or "").strip()
if not observation_key:
raise ValueError("Risk observation requires observation_key.")
normalized_tenant_id = self._resolve_tenant_id(
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")),
claim_id=_optional_text(payload.get("claim_id")),
)
item = self.db.scalar(
select(RiskObservation).where(RiskObservation.observation_key == observation_key)
select(RiskObservation).where(
RiskObservation.tenant_id == normalized_tenant_id,
RiskObservation.observation_key == observation_key,
)
)
if item is None:
item = RiskObservation(observation_key=observation_key)
item = RiskObservation(
tenant_id=normalized_tenant_id,
observation_key=observation_key,
)
self.db.add(item)
item.subject_type = _text(payload.get("subject_type"))
@@ -118,9 +131,14 @@ class RiskObservationService:
claim: ExpenseClaim,
flags: list[dict[str, Any]],
*,
tenant_id: str | None = None,
run_id: str | None = None,
execution_log_id: str | None = None,
) -> list[RiskObservation]:
normalized_tenant_id = self._resolve_tenant_id(
tenant_id=tenant_id,
claim_id=claim.id,
)
observations: list[RiskObservation] = []
for flag in flags:
if not isinstance(flag, dict):
@@ -187,6 +205,7 @@ class RiskObservationService:
"action": _text(flag.get("action")),
},
},
tenant_id=normalized_tenant_id,
run_id=run_id,
execution_log_id=execution_log_id,
)
@@ -196,6 +215,7 @@ class RiskObservationService:
def build_history_stats(
self,
*,
tenant_id: str | None = None,
risk_signals: set[str] | None = None,
expense_types: set[str] | None = None,
limit: int = 2000,
@@ -204,6 +224,7 @@ class RiskObservationService:
stmt = (
select(RiskObservation, ExpenseClaim.expense_type)
.outerjoin(ExpenseClaim, RiskObservation.claim_id == ExpenseClaim.id)
.where(RiskObservation.tenant_id == _normalize_tenant_id(tenant_id))
.order_by(RiskObservation.created_at.desc())
.limit(limit)
)
@@ -238,6 +259,7 @@ class RiskObservationService:
def list_observations(
self,
*,
tenant_id: str | None = None,
claim_id: str | None = None,
run_id: str | None = None,
execution_log_id: str | None = None,
@@ -249,7 +271,7 @@ class RiskObservationService:
offset: int = 0,
) -> tuple[list[RiskObservation], int]:
self.ensure_storage_ready()
conditions = []
conditions = [RiskObservation.tenant_id == _normalize_tenant_id(tenant_id)]
if claim_id:
conditions.append(RiskObservation.claim_id == claim_id)
if run_id:
@@ -270,31 +292,52 @@ class RiskObservationService:
RiskObservation.risk_score.desc(),
RiskObservation.created_at.desc(),
)
if conditions:
count_stmt = count_stmt.where(*conditions)
stmt = stmt.where(*conditions)
count_stmt = count_stmt.where(*conditions)
stmt = stmt.where(*conditions)
total = int(self.db.scalar(count_stmt) or 0)
items = list(self.db.scalars(stmt.offset(offset).limit(limit)).all())
return items, total
def get_observation(self, observation_key_or_id: str) -> RiskObservation | None:
def get_observation(
self,
observation_key_or_id: str,
*,
tenant_id: str | None = None,
) -> RiskObservation | None:
self.ensure_storage_ready()
value = str(observation_key_or_id or "").strip()
if not value:
return None
return self.db.scalar(
select(RiskObservation).where(
(RiskObservation.observation_key == value) | (RiskObservation.id == value)
RiskObservation.tenant_id == _normalize_tenant_id(tenant_id),
(RiskObservation.observation_key == value) | (RiskObservation.id == value),
)
)
def list_claim_observations(self, claim_id: str) -> list[RiskObservation]:
items, _ = self.list_observations(claim_id=claim_id, limit=100, offset=0)
def list_claim_observations(
self,
claim_id: str,
*,
tenant_id: str | None = None,
) -> list[RiskObservation]:
items, _ = self.list_observations(
tenant_id=tenant_id,
claim_id=claim_id,
limit=100,
offset=0,
)
return items
def list_execution_log_observations(self, execution_log_id: str) -> list[RiskObservation]:
def list_execution_log_observations(
self,
execution_log_id: str,
*,
tenant_id: str | None = None,
) -> list[RiskObservation]:
items, _ = self.list_observations(
tenant_id=tenant_id,
execution_log_id=execution_log_id,
limit=200,
offset=0,
@@ -305,9 +348,15 @@ class RiskObservationService:
self,
observation_key_or_id: str,
payload: RiskObservationFeedbackCreate,
*,
tenant_id: str | None = None,
actor: str | None = None,
) -> RiskObservationFeedback:
self.ensure_storage_ready()
observation = self.get_observation(observation_key_or_id)
observation = self.get_observation(
observation_key_or_id,
tenant_id=tenant_id,
)
if observation is None:
raise LookupError("Risk observation not found.")
@@ -315,7 +364,7 @@ class RiskObservationService:
observation_id=observation.id,
feedback_type=payload.feedback_type,
action=payload.action or "",
actor=payload.actor or "",
actor=_text(actor) or "system",
comment=payload.comment,
payload_json=payload.payload_json,
)
@@ -336,7 +385,8 @@ class RiskObservationService:
) -> None:
"""人工确认/误报后把样本沉淀进 few-shot 池,任何失败都不影响主流程。"""
if os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true").strip().lower() in {"0", "false", "no"}:
few_shot_enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
if few_shot_enabled.strip().lower() in {"0", "false", "no"}:
return
if observation.feedback_status not in {"confirmed", "false_positive"}:
return
@@ -350,15 +400,20 @@ class RiskObservationService:
def summarize_dashboard(
self,
*,
tenant_id: str | None = None,
window_days: int = 30,
limit: int = 500,
) -> RiskObservationDashboardRead:
self.ensure_storage_ready()
normalized_tenant_id = _normalize_tenant_id(tenant_id)
since = datetime.now(UTC) - timedelta(days=window_days)
stmt = (
select(RiskObservation)
.options(joinedload(RiskObservation.claim))
.where(RiskObservation.created_at >= since)
.where(
RiskObservation.tenant_id == normalized_tenant_id,
RiskObservation.created_at >= since,
)
.order_by(RiskObservation.created_at.desc())
.limit(limit)
)
@@ -371,7 +426,14 @@ class RiskObservationService:
self.db.scalar(
select(func.count())
.select_from(RiskObservationFeedback)
.where(RiskObservationFeedback.created_at >= since)
.join(
RiskObservation,
RiskObservation.id == RiskObservationFeedback.observation_id,
)
.where(
RiskObservation.tenant_id == normalized_tenant_id,
RiskObservationFeedback.created_at >= since,
)
)
or 0
)
@@ -435,6 +497,28 @@ class RiskObservationService:
][:10],
)
def _resolve_tenant_id(
self,
*,
tenant_id: str | None,
claim_id: str | None,
) -> str:
explicit_tenant_id = str(tenant_id or "").strip()
normalized_claim_id = str(claim_id or "").strip()
linked_tenant_id = self.db.scalar(
select(ExpenseCaseLink.tenant_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == normalized_claim_id,
)
) if normalized_claim_id else None
claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
if explicit_tenant_id:
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)
if linked_tenant_id and claim_tenant_id != normalized_tenant_id:
raise PermissionError("Risk observation tenant does not match claim tenant.")
return normalized_tenant_id
return claim_tenant_id
def _count_by(items: list[RiskObservation], field: str) -> dict[str, int]:
counts: dict[str, int] = {}
@@ -537,7 +621,8 @@ def _supplier_names(item: RiskObservation) -> list[str]:
names.append(text.split(":", 1)[1] or text)
for evidence in item.evidence_json or []:
if isinstance(evidence, dict):
metadata = evidence.get("metadata") if isinstance(evidence.get("metadata"), dict) else {}
metadata_value = evidence.get("metadata")
metadata = metadata_value if isinstance(metadata_value, dict) else {}
for key in ("supplier_name", "vendor_name", "merchant_name", "supplier", "vendor"):
name = _text(evidence.get(key)) or _text(metadata.get(key))
if name:
@@ -603,6 +688,10 @@ def _text(value: Any) -> str:
return str(value or "").strip()
def _normalize_tenant_id(value: Any) -> str:
return _text(value) or DEFAULT_TENANT_ID
def _canonical_key(value: Any) -> str:
return "_".join(_text(value).lower().split())

View File

@@ -14,7 +14,10 @@ from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.expense_claim_risk_stage import infer_risk_domain
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_generation_ontology import (
BUSINESS_DOMAIN_LABELS,
DOMAIN_FIELD_PREFIXES,
@@ -26,16 +29,13 @@ from app.services.risk_rule_generation_ontology import (
RiskRuleField,
)
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
from app.services.risk_rule_generation_semantics import (
CITY_CONSISTENCY_SEMANTIC_TYPE,
CITY_CONSISTENCY_SEMANTIC_TYPES,
build_city_consistency_draft,
build_city_consistency_params,
)
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
from app.services.runtime_chat import RuntimeChatService
@@ -57,6 +57,7 @@ class RiskRuleGenerationService:
self,
body: AgentAssetRiskRuleGenerateRequest,
*,
tenant_id: str | None = None,
actor: str,
request_id: str | None = None,
) -> str:
@@ -81,6 +82,7 @@ class RiskRuleGenerationService:
created_at = datetime.now(UTC)
fields = self._resolve_fields(natural_language, domain=domain)
draft = self._compile_with_model(
tenant_id=tenant_id,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
@@ -174,6 +176,7 @@ class RiskRuleGenerationService:
"ontology_signal": payload.get("ontology_signal"),
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"tenant_id": str(tenant_id or "").strip(),
"source_ref": "自然语言风险规则",
"last_operation": {
"action": "create",
@@ -217,6 +220,7 @@ class RiskRuleGenerationService:
def _compile_with_model(
self,
*,
tenant_id: str | None,
natural_language: str,
domain: str,
business_stage: str,
@@ -235,6 +239,7 @@ class RiskRuleGenerationService:
for item in fields
]
few_shot_samples = self._retrieve_few_shot_samples(
tenant_id=tenant_id,
domain=domain,
natural_language=natural_language,
)
@@ -271,6 +276,7 @@ class RiskRuleGenerationService:
def _retrieve_few_shot_samples(
self,
*,
tenant_id: str | None,
domain: str,
natural_language: str,
) -> list[dict[str, Any]]:
@@ -280,11 +286,15 @@ class RiskRuleGenerationService:
if os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true").strip().lower() in {"0", "false", "no"}:
return []
normalized_tenant_id = str(tenant_id or "").strip()
if not normalized_tenant_id:
return []
try:
from app.services.few_shot_retrieval import FewShotRetriever
retriever = FewShotRetriever.from_session(self.db)
return retriever.retrieve_for_risk_rule_generation(
tenant_id=normalized_tenant_id,
domain=domain,
natural_language=natural_language,
)

View File

@@ -43,6 +43,7 @@ class RiskRuleGenerationJobService:
self,
body: AgentAssetRiskRuleGenerateRequest,
*,
tenant_id: str | None = None,
actor: str,
request_id: str | None = None,
) -> str:
@@ -96,6 +97,7 @@ class RiskRuleGenerationJobService:
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"generated_by": "natural_language",
"tenant_id": str(tenant_id or "").strip(),
"generation_status": AgentAssetStatus.GENERATING.value,
"generation_started_at": created_at.isoformat(),
"generation_request": self._dump_generation_request(body),
@@ -130,6 +132,7 @@ class RiskRuleGenerationJobService:
asset_id: str,
body: AgentAssetRiskRuleGenerateRequest,
*,
tenant_id: str | None = None,
actor: str,
request_id: str | None = None,
) -> None:
@@ -137,7 +140,13 @@ class RiskRuleGenerationJobService:
asset = self.db.get(AgentAsset, asset_id)
if asset is None or asset.status != AgentAssetStatus.GENERATING.value:
return
self._complete_rule_asset(asset, body, actor=actor, request_id=request_id)
self._complete_rule_asset(
asset,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
except Exception as exc: # noqa: BLE001 - 后台任务必须把失败写回资产状态
self.mark_generation_failed(
asset_id,
@@ -190,6 +199,7 @@ class RiskRuleGenerationJobService:
asset: AgentAsset,
body: AgentAssetRiskRuleGenerateRequest,
*,
tenant_id: str | None,
actor: str,
request_id: str | None,
) -> None:
@@ -205,6 +215,7 @@ class RiskRuleGenerationJobService:
fields = self.generator._resolve_fields(natural_language, domain=domain)
draft = self.generator._compile_with_model(
tenant_id=tenant_id,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
@@ -282,6 +293,7 @@ class RiskRuleGenerationJobService:
"ontology_signal": payload.get("ontology_signal"),
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"tenant_id": str(tenant_id or "").strip(),
"source_ref": "自然语言风险规则",
"generation_status": "completed",
"generation_completed_at": datetime.now(UTC).isoformat(),

View File

@@ -31,7 +31,11 @@ from app.services.expense_application_draft_events import (
ExpenseApplicationDraftEventService,
)
from app.services.expense_application_learning import ExpenseApplicationLearningService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_historical_evidence import (
build_user_agent_historical_evidence_notice,
)
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_application_dates import (
@@ -884,6 +888,12 @@ class UserAgentApplicationPersistenceMixin:
if existing is not None:
return existing
raise
# 非默认租户的费用单必须先建立 tenant-scoped Case Link后续
# submit_claim() 的访问策略才能在同一事务内重新查询到刚创建的记录。
ExpenseCaseService(self.db).ensure_case_for_claim(
claim,
tenant_id=current_user.tenant_id,
)
if not submit:
_, draft_event = draft_event_service.record(
payload,
@@ -1349,6 +1359,9 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
facts["application_no"] = application_claim.claim_no
facts["application_claim_id"] = application_claim.id
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
facts["historical_case_evidence_notice"] = (
build_user_agent_historical_evidence_notice(application_claim)
)
return UserAgentResponse(
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
citations=[],
@@ -1417,6 +1430,9 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
if step == "submitted":
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
historical_notice = str(
facts.get("historical_case_evidence_notice") or ""
).strip()
submitted_title = (
"申请单据已修改并重新提交,已进入审批流程。"
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
@@ -1427,6 +1443,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
submitted_title,
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
f"申请单号:{application_no}",
*([historical_notice] if historical_notice else []),
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
]
)