refactor(backend): update service layers
- services/agent_conversations.py: update agent conversations service - services/agent_foundation.py: update agent foundation service - services/orchestrator.py: update orchestrator service - services/user_agent.py: update user agent service
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import (
|
||||
@@ -60,8 +60,38 @@ class ExecutionOutcome:
|
||||
failed_tool_count: int
|
||||
|
||||
|
||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"manager", "finance", "approver", "auditor", "executive"}
|
||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
|
||||
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
|
||||
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 20
|
||||
EXPENSE_STATUS_LABELS = {
|
||||
"draft": "草稿",
|
||||
"submitted": "已提交",
|
||||
"review": "审核中",
|
||||
"approved": "已通过",
|
||||
"rejected": "已驳回",
|
||||
"paid": "已付款",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_LABELS = {
|
||||
"draft": "草稿",
|
||||
"in_progress": "审批中",
|
||||
"completed": "审批完成",
|
||||
"other": "其他状态",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"travel": "差旅费",
|
||||
"hotel": "住宿费",
|
||||
"transport": "交通费",
|
||||
"meal": "餐费",
|
||||
"meeting": "会务费",
|
||||
"entertainment": "业务招待费",
|
||||
"office": "办公费",
|
||||
"training": "培训费",
|
||||
"communication": "通讯费",
|
||||
"welfare": "福利费",
|
||||
"other": "其他费用",
|
||||
}
|
||||
|
||||
|
||||
class OrchestratorService:
|
||||
@@ -850,47 +880,85 @@ class OrchestratorService:
|
||||
message: str,
|
||||
) -> dict[str, Any]:
|
||||
if ontology.scenario == "expense":
|
||||
count_stmt = select(func.count()).select_from(ExpenseClaim)
|
||||
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim)
|
||||
preview_stmt = (
|
||||
select(ExpenseClaim)
|
||||
.order_by(ExpenseClaim.occurred_at.desc(), ExpenseClaim.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
conditions, scope_label, scoped_to_current_user = self._build_expense_query_scope(
|
||||
ontology=ontology,
|
||||
user_id=user_id,
|
||||
context_json=context_json,
|
||||
message=message,
|
||||
)
|
||||
count_stmt = select(func.count()).select_from(ExpenseClaim)
|
||||
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim)
|
||||
for condition in conditions:
|
||||
count_stmt = count_stmt.where(condition)
|
||||
amount_stmt = amount_stmt.where(condition)
|
||||
preview_stmt = preview_stmt.where(condition)
|
||||
total_count = int(self.db.scalar(count_stmt) or 0)
|
||||
total_amount = float(self.db.scalar(amount_stmt) or 0)
|
||||
|
||||
recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology)
|
||||
display_count = total_count
|
||||
display_amount = total_amount
|
||||
older_record_count = 0
|
||||
display_conditions = list(conditions)
|
||||
window_start_date: str | None = None
|
||||
window_end_date: str | None = None
|
||||
|
||||
if recent_window_applied:
|
||||
reference_now = self._resolve_reference_now(context_json)
|
||||
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now)
|
||||
recent_condition = self._build_expense_recent_window_condition(
|
||||
recent_window_start,
|
||||
recent_window_end,
|
||||
)
|
||||
display_conditions.append(recent_condition)
|
||||
window_start_date = recent_window_start.date().isoformat()
|
||||
window_end_date = (recent_window_end - timedelta(microseconds=1)).date().isoformat()
|
||||
|
||||
recent_count_stmt = select(func.count()).select_from(ExpenseClaim).where(recent_condition)
|
||||
recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where(
|
||||
recent_condition
|
||||
)
|
||||
for condition in conditions:
|
||||
recent_count_stmt = recent_count_stmt.where(condition)
|
||||
recent_amount_stmt = recent_amount_stmt.where(condition)
|
||||
display_count = int(self.db.scalar(recent_count_stmt) or 0)
|
||||
display_amount = float(self.db.scalar(recent_amount_stmt) or 0)
|
||||
older_record_count = max(0, total_count - display_count)
|
||||
|
||||
preview_stmt = (
|
||||
select(ExpenseClaim)
|
||||
.order_by(
|
||||
func.coalesce(
|
||||
ExpenseClaim.submitted_at,
|
||||
ExpenseClaim.created_at,
|
||||
ExpenseClaim.occurred_at,
|
||||
).desc(),
|
||||
ExpenseClaim.occurred_at.desc(),
|
||||
)
|
||||
.limit(EXPENSE_QUERY_PREVIEW_LIMIT)
|
||||
)
|
||||
for condition in display_conditions:
|
||||
preview_stmt = preview_stmt.where(condition)
|
||||
preview_claims = list(self.db.scalars(preview_stmt).all())
|
||||
status_groups = self._build_expense_status_groups(display_conditions)
|
||||
return {
|
||||
"record_count": total_count,
|
||||
"total_amount": round(total_amount, 2),
|
||||
"result_type": "expense_claim_list",
|
||||
"record_count": display_count,
|
||||
"total_amount": round(display_amount, 2),
|
||||
"scope_label": scope_label,
|
||||
"scoped_to_current_user": scoped_to_current_user,
|
||||
"recent_window_applied": recent_window_applied,
|
||||
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
|
||||
"window_start_date": window_start_date,
|
||||
"window_end_date": window_end_date,
|
||||
"preview_count": len(preview_claims),
|
||||
"older_record_count": older_record_count,
|
||||
"records": [
|
||||
{
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"employee_name": claim.employee_name,
|
||||
"expense_type": claim.expense_type,
|
||||
"amount": round(float(claim.amount), 2),
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
|
||||
"reason": claim.reason,
|
||||
"location": claim.location,
|
||||
}
|
||||
self._build_expense_query_record(claim)
|
||||
for claim in preview_claims
|
||||
],
|
||||
"has_more": total_count > len(preview_claims),
|
||||
"status_groups": status_groups,
|
||||
"has_more_in_window": display_count > len(preview_claims),
|
||||
"total_matched_count": total_count,
|
||||
}
|
||||
|
||||
if ontology.scenario == "accounts_receivable":
|
||||
@@ -926,6 +994,122 @@ class OrchestratorService:
|
||||
"outstanding_amount": round(total_amount, 2),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _should_limit_expense_query_to_recent_window(
|
||||
ontology: OntologyParseResult,
|
||||
) -> bool:
|
||||
has_explicit_claim_no = any(
|
||||
item.type == "expense_claim"
|
||||
and str(item.normalized_value or item.value or "").strip()
|
||||
for item in ontology.entities
|
||||
)
|
||||
has_explicit_time_range = bool(
|
||||
ontology.time_range.start_date or ontology.time_range.end_date
|
||||
)
|
||||
return not has_explicit_claim_no and not has_explicit_time_range
|
||||
|
||||
@staticmethod
|
||||
def _resolve_reference_now(context_json: dict[str, Any]) -> datetime:
|
||||
raw_value = str(context_json.get("client_now_iso") or "").strip()
|
||||
if raw_value:
|
||||
normalized = raw_value.replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
except ValueError:
|
||||
pass
|
||||
return datetime.now(UTC)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_expense_recent_window_bounds(
|
||||
reference_now: datetime,
|
||||
) -> tuple[datetime, datetime]:
|
||||
normalized_now = reference_now.astimezone(UTC)
|
||||
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS)
|
||||
return window_start, window_end
|
||||
|
||||
@staticmethod
|
||||
def _build_expense_recent_window_condition(
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
) -> Any:
|
||||
document_datetime = func.coalesce(
|
||||
ExpenseClaim.submitted_at,
|
||||
ExpenseClaim.created_at,
|
||||
ExpenseClaim.occurred_at,
|
||||
)
|
||||
return and_(document_datetime >= window_start, document_datetime < window_end)
|
||||
|
||||
def _build_expense_status_groups(
|
||||
self,
|
||||
conditions: list[Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status)
|
||||
for condition in conditions:
|
||||
stmt = stmt.where(condition)
|
||||
|
||||
grouped_counts = {
|
||||
key: 0
|
||||
for key in EXPENSE_STATUS_GROUP_ORDER
|
||||
}
|
||||
for status, count in self.db.execute(stmt).all():
|
||||
group_key, _ = self._resolve_expense_status_group(str(status or "").strip())
|
||||
grouped_counts[group_key] = grouped_counts.get(group_key, 0) + int(count or 0)
|
||||
|
||||
return [
|
||||
{
|
||||
"key": key,
|
||||
"label": EXPENSE_STATUS_GROUP_LABELS[key],
|
||||
"count": grouped_counts.get(key, 0),
|
||||
}
|
||||
for key in EXPENSE_STATUS_GROUP_ORDER
|
||||
if grouped_counts.get(key, 0) > 0
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_expense_status_group(status: str) -> tuple[str, str]:
|
||||
normalized = str(status or "").strip().lower()
|
||||
if normalized == "draft":
|
||||
return "draft", EXPENSE_STATUS_GROUP_LABELS["draft"]
|
||||
if normalized in {"submitted", "review"}:
|
||||
return "in_progress", EXPENSE_STATUS_GROUP_LABELS["in_progress"]
|
||||
if normalized in {"approved", "paid"}:
|
||||
return "completed", EXPENSE_STATUS_GROUP_LABELS["completed"]
|
||||
return "other", EXPENSE_STATUS_GROUP_LABELS["other"]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_expense_query_document_datetime(
|
||||
claim: ExpenseClaim,
|
||||
) -> datetime | None:
|
||||
return claim.submitted_at or claim.created_at or claim.occurred_at
|
||||
|
||||
def _build_expense_query_record(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
) -> dict[str, Any]:
|
||||
status_group, status_group_label = self._resolve_expense_status_group(claim.status)
|
||||
document_datetime = self._resolve_expense_query_document_datetime(claim)
|
||||
return {
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"employee_name": claim.employee_name,
|
||||
"expense_type": claim.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
|
||||
"amount": round(float(claim.amount), 2),
|
||||
"status": claim.status,
|
||||
"status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"),
|
||||
"status_group": status_group,
|
||||
"status_group_label": status_group_label,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"document_date": document_datetime.date().isoformat() if document_datetime else "",
|
||||
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
|
||||
"reason": claim.reason,
|
||||
"location": claim.location,
|
||||
}
|
||||
|
||||
def _build_expense_query_scope(
|
||||
self,
|
||||
*,
|
||||
@@ -1045,7 +1229,6 @@ class OrchestratorService:
|
||||
context_json: dict[str, Any],
|
||||
) -> tuple[list[Any], str]:
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
display_name = str(context_json.get("name") or "").strip()
|
||||
employee = None
|
||||
if normalized_user_id:
|
||||
employee = self.db.scalar(
|
||||
@@ -1076,21 +1259,17 @@ class OrchestratorService:
|
||||
add_condition("employee_id", employee.id)
|
||||
add_condition("employee_name", employee.name)
|
||||
add_condition("employee_name", employee.email)
|
||||
if not display_name:
|
||||
display_name = employee.name
|
||||
else:
|
||||
add_condition("employee_id", normalized_user_id)
|
||||
add_condition("employee_name", normalized_user_id)
|
||||
|
||||
add_condition("employee_name", display_name)
|
||||
add_condition("employee_name", normalized_user_id)
|
||||
|
||||
subject_name = display_name or (employee.name if employee is not None else "") or normalized_user_id
|
||||
subject_name = (employee.name if employee is not None else "") or normalized_user_id
|
||||
if subject_name:
|
||||
return conditions, "你的报销单"
|
||||
return conditions, "当前用户的报销单"
|
||||
|
||||
@staticmethod
|
||||
def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool:
|
||||
if bool(context_json.get("is_admin")):
|
||||
return True
|
||||
role_codes = {
|
||||
str(item).strip().lower()
|
||||
for item in context_json.get("role_codes", [])
|
||||
@@ -1147,6 +1326,8 @@ class OrchestratorService:
|
||||
"requires_confirmation": response.requires_confirmation,
|
||||
"degraded": degraded,
|
||||
}
|
||||
if response.query_payload is not None:
|
||||
result["query_payload"] = response.query_payload.model_dump()
|
||||
if response.draft_payload is not None:
|
||||
result["draft_payload"] = response.draft_payload.model_dump()
|
||||
if response.review_payload is not None:
|
||||
|
||||
Reference in New Issue
Block a user