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:
caoxiaozhu
2026-05-13 15:31:04 +00:00
parent de51ed2e9f
commit 44b2838a12
4 changed files with 462 additions and 80 deletions

View File

@@ -193,15 +193,18 @@ class AgentConversationService:
if conversation is None:
return None
next_sequence = int(conversation.message_count or 0) + 1
normalized_message_json = dict(message_json or {})
normalized_message_json.setdefault("sequence", next_sequence)
message = AgentConversationMessage(
conversation_id=conversation_id,
run_id=run_id,
role=str(role or "user").strip() or "user",
content=normalized_content,
message_json=message_json or {},
message_json=normalized_message_json,
created_at=datetime.now(UTC),
)
conversation.message_count = int(conversation.message_count or 0) + 1
conversation.message_count = next_sequence
if role == "user" and not conversation.title:
conversation.title = normalized_content[:48]
conversation.updated_at = datetime.now(UTC)
@@ -220,15 +223,9 @@ class AgentConversationService:
normalized_id = str(conversation_id or "").strip()
if not normalized_id or limit <= 0:
return []
stmt = (
select(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == normalized_id)
.order_by(AgentConversationMessage.created_at.desc())
.limit(limit)
)
messages = list(self.db.scalars(stmt).all())
messages.reverse()
messages = self.list_messages(normalized_id)
if limit > 0:
messages = messages[-limit:]
return [
{
"role": item.role,
@@ -252,11 +249,13 @@ class AgentConversationService:
stmt = (
select(AgentConversationMessage)
.where(AgentConversationMessage.conversation_id == normalized_id)
.order_by(AgentConversationMessage.created_at.asc(), AgentConversationMessage.id.asc())
.order_by(AgentConversationMessage.created_at.asc())
)
messages = list(self.db.scalars(stmt).all())
messages.sort(key=self._message_sort_key)
if limit and limit > 0:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
return messages[:limit]
return messages
def update_state(
self,
@@ -396,6 +395,30 @@ class AgentConversationService:
"created_at": message.created_at,
}
@staticmethod
def _message_sort_key(message: AgentConversationMessage) -> tuple[int, datetime, str, int, str]:
message_json = dict(message.message_json or {})
sequence = AgentConversationService._coerce_message_sequence(message_json.get("sequence"))
created_at = message.created_at or datetime.min.replace(tzinfo=UTC)
run_id = str(message.run_id or "")
role_priority = 0 if str(message.role or "").strip() == "user" else 1
fallback_sequence = sequence if sequence is not None else 10**9
return (
fallback_sequence,
created_at,
run_id,
role_priority,
str(message.id or ""),
)
@staticmethod
def _coerce_message_sequence(value: Any) -> int | None:
try:
normalized = int(value)
except (TypeError, ValueError):
return None
return normalized if normalized > 0 else None
@staticmethod
def _is_empty_value(value: Any) -> bool:
if value is None:

View File

@@ -35,6 +35,38 @@ from app.models.financial_record import (
logger = get_logger("app.services.agent_foundation")
DEMO_EXPENSE_CLAIM_SIGNATURES = {
(
"EXP-202605-001",
"张三",
"华南客户拜访差旅报销",
"3280.00",
"submitted",
),
(
"EXP-202605-002",
"李四",
"客户路演餐费",
"860.00",
"approved",
),
(
"EXP-202605-003",
"王五",
"市场活动会务差旅",
"3280.00",
"review",
),
}
DEMO_RECEIVABLE_SIGNATURES = {
("AR-202605-001", "客户A", "50000.00", "partial"),
("AR-202605-002", "客户B", "78000.00", "overdue"),
}
DEMO_PAYABLE_SIGNATURES = {
("AP-202605-001", "供应商A", "33000.00", "scheduled"),
("AP-202605-002", "供应商B", "96000.00", "overdue"),
}
def prepare_agent_foundation() -> None:
settings = get_settings()
@@ -55,7 +87,7 @@ class AgentFoundationService:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._seed_agent_assets()
self._seed_financial_records()
self._sync_demo_financial_records()
self._seed_runs_and_logs()
self.db.commit()
except Exception:
@@ -63,6 +95,12 @@ class AgentFoundationService:
logger.exception("Failed to prepare agent foundation")
raise
def _sync_demo_financial_records(self) -> None:
if get_settings().seed_demo_financial_records:
self._seed_financial_records()
return
self._purge_demo_financial_records()
def _seed_agent_assets(self) -> None:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
if existing_codes:
@@ -568,6 +606,41 @@ class AgentFoundationService:
self.db.add_all([claim_1, claim_2, claim_3, *ar_records, *ap_records])
def _purge_demo_financial_records(self) -> None:
demo_claims = list(self.db.scalars(select(ExpenseClaim)).all())
for claim in demo_claims:
signature = (
str(claim.claim_no or "").strip(),
str(claim.employee_name or "").strip(),
str(claim.reason or "").strip(),
f"{Decimal(claim.amount or 0):.2f}",
str(claim.status or "").strip(),
)
if signature in DEMO_EXPENSE_CLAIM_SIGNATURES:
self.db.delete(claim)
demo_receivables = list(self.db.scalars(select(AccountsReceivableRecord)).all())
for record in demo_receivables:
signature = (
str(record.receivable_no or "").strip(),
str(record.customer_name or "").strip(),
f"{Decimal(record.amount_outstanding or 0):.2f}",
str(record.status or "").strip(),
)
if signature in DEMO_RECEIVABLE_SIGNATURES:
self.db.delete(record)
demo_payables = list(self.db.scalars(select(AccountsPayableRecord)).all())
for record in demo_payables:
signature = (
str(record.payable_no or "").strip(),
str(record.vendor_name or "").strip(),
f"{Decimal(record.amount_outstanding or 0):.2f}",
str(record.status or "").strip(),
)
if signature in DEMO_PAYABLE_SIGNATURES:
self.db.delete(record)
def _seed_runs_and_logs(self) -> None:
if self.db.scalar(select(AgentRun.id).limit(1)) is not None:
return

View File

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

View File

@@ -14,6 +14,9 @@ from app.schemas.agent_asset import AgentAssetListItem
from app.schemas.user_agent import (
UserAgentCitation,
UserAgentDraftPayload,
UserAgentExpenseQueryRecord,
UserAgentQueryPayload,
UserAgentQueryStatusGroup,
UserAgentReviewAction,
UserAgentReviewEditField,
UserAgentReviewClaimGroup,
@@ -94,6 +97,13 @@ EXPENSE_STATUS_LABELS = {
"paid": "已付款",
}
EXPENSE_STATUS_GROUP_LABELS = {
"draft": "草稿",
"in_progress": "审批中",
"completed": "审批完成",
"other": "其他状态",
}
SLOT_LABELS = {
"expense_type": "报销类型",
"customer_name": "客户名称",
@@ -132,6 +142,7 @@ class UserAgentService:
citations = self._build_rule_citations(payload)
suggested_actions = self._build_suggested_actions(payload)
risk_flags = self._resolve_risk_flags(payload)
query_payload = self._build_query_payload(payload)
draft_payload = (
self._build_draft_payload(payload)
if payload.ontology.intent == "draft"
@@ -153,6 +164,7 @@ class UserAgentService:
answer=review_answer or str(payload.tool_payload["message"]),
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
@@ -163,6 +175,7 @@ class UserAgentService:
answer=review_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
@@ -177,6 +190,7 @@ class UserAgentService:
answer=guided_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
@@ -203,6 +217,7 @@ class UserAgentService:
answer=answer or fallback_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
@@ -396,43 +411,58 @@ class UserAgentService:
subject = self._resolve_subject(payload)
if scenario == "expense":
record_count = int(data.get("record_count") or 0)
total_amount = float(data.get("total_amount") or 0)
query_payload = self._build_query_payload(payload)
scope_label = str(data.get("scope_label") or subject).strip() or subject
preview_records = data.get("records")
if record_count <= 0:
if query_payload is None:
return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。"
summary = f"查到{scope_label}{record_count} 笔,金额合计 {total_amount:.2f} 元。"
if not isinstance(preview_records, list) or not preview_records:
return f"{summary} 如需继续处理,可以查看明细或生成处理意见草稿。"
window_prefix = (
f"{query_payload.window_start_date}{query_payload.window_end_date}"
if query_payload.recent_window_applied
and query_payload.window_start_date
and query_payload.window_end_date
else (
f"{query_payload.window_days} 日内"
if query_payload.recent_window_applied and query_payload.window_days
else "当前条件下"
)
)
if query_payload.record_count <= 0:
if query_payload.older_record_count > 0 and query_payload.window_days:
return (
f"{window_prefix}没有查到{query_payload.scope_label}"
f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据,"
"请前往个人报销中心查看。"
)
return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。"
preview_text: list[str] = []
for item in preview_records[:3]:
if not isinstance(item, dict):
continue
claim_no = str(item.get("claim_no") or "未编号").strip() or "未编号"
occurred_at = str(item.get("occurred_at") or "").strip()
expense_type = EXPENSE_TYPE_LABELS.get(
str(item.get("expense_type") or "").strip(),
str(item.get("expense_type") or "报销").strip() or "报销",
group_lines = [
f"{item.label} {item.count}"
for item in query_payload.status_groups
if item.count > 0
]
answer_parts = [
f"我先为你列出{window_prefix}{query_payload.scope_label}"
f"{query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。"
]
if group_lines:
answer_parts.append(f"其中包括:{''.join(group_lines)}")
hint_parts: list[str] = []
if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count:
hint_parts.append(
f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。"
)
amount = float(item.get("amount") or 0)
status = EXPENSE_STATUS_LABELS.get(
str(item.get("status") or "").strip(),
str(item.get("status") or "处理中").strip() or "处理中",
)
date_prefix = f"{occurred_at}" if occurred_at else ""
preview_text.append(
f"{claim_no}{date_prefix}{expense_type}{amount:.2f} 元,{status}"
elif query_payload.records:
hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。")
if query_payload.older_record_count > 0 and query_payload.window_days:
hint_parts.append(
f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据,"
"请前往个人报销中心查看。"
)
if not preview_text:
return f"{summary} 如需继续处理,可以查看明细或生成处理意见草稿。"
has_more = bool(data.get("has_more")) or record_count > len(preview_records)
more_hint = " 当前先展示最近几笔,可继续查看明细。" if has_more else ""
return f"{summary} 其中包括:{''.join(preview_text)}{more_hint}".strip()
return " ".join(answer_parts + hint_parts).strip()
if scenario == "accounts_receivable":
record_count = int(data.get("record_count") or 0)
@@ -452,6 +482,81 @@ class UserAgentService:
return "已完成当前查询,但暂时没有更多结构化结果可展示。"
def _build_query_payload(
self,
payload: UserAgentRequest,
) -> UserAgentQueryPayload | None:
if payload.ontology.scenario != "expense" or payload.ontology.intent not in {"query", "compare"}:
return None
result_type = str(payload.tool_payload.get("result_type") or "").strip()
if result_type and result_type != "expense_claim_list":
return None
records: list[UserAgentExpenseQueryRecord] = []
for item in payload.tool_payload.get("records") or []:
if not isinstance(item, dict):
continue
amount = float(item.get("amount") or 0)
records.append(
UserAgentExpenseQueryRecord(
claim_id=str(item.get("claim_id") or "").strip(),
claim_no=str(item.get("claim_no") or "").strip() or "未编号",
employee_name=str(item.get("employee_name") or "").strip(),
expense_type=str(item.get("expense_type") or "").strip(),
expense_type_label=str(item.get("expense_type_label") or "").strip()
or EXPENSE_TYPE_LABELS.get(str(item.get("expense_type") or "").strip(), "报销"),
amount=round(amount, 2),
status=str(item.get("status") or "").strip(),
status_label=str(item.get("status_label") or "").strip()
or EXPENSE_STATUS_LABELS.get(str(item.get("status") or "").strip(), "处理中"),
status_group=str(item.get("status_group") or "").strip() or "other",
status_group_label=str(item.get("status_group_label") or "").strip()
or EXPENSE_STATUS_GROUP_LABELS.get(str(item.get("status_group") or "").strip(), "其他状态"),
approval_stage=str(item.get("approval_stage") or "").strip() or None,
document_date=str(item.get("document_date") or "").strip(),
occurred_at=str(item.get("occurred_at") or "").strip(),
reason=str(item.get("reason") or "").strip(),
location=str(item.get("location") or "").strip(),
)
)
status_groups: list[UserAgentQueryStatusGroup] = []
for item in payload.tool_payload.get("status_groups") or []:
if not isinstance(item, dict):
continue
status_groups.append(
UserAgentQueryStatusGroup(
key=str(item.get("key") or "").strip() or "other",
label=str(item.get("label") or "").strip() or "其他状态",
count=max(0, int(item.get("count") or 0)),
)
)
return UserAgentQueryPayload(
result_type="expense_claim_list",
scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单",
recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")),
window_days=(
int(payload.tool_payload["window_days"])
if payload.tool_payload.get("window_days") not in {None, ""}
else None
),
window_start_date=(
str(payload.tool_payload.get("window_start_date") or "").strip() or None
),
window_end_date=(
str(payload.tool_payload.get("window_end_date") or "").strip() or None
),
record_count=max(0, int(payload.tool_payload.get("record_count") or 0)),
preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))),
older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)),
has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")),
total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2),
status_groups=status_groups,
records=records,
)
def _build_explain_answer(
self,
payload: UserAgentRequest,