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:
@@ -193,15 +193,18 @@ class AgentConversationService:
|
|||||||
if conversation is None:
|
if conversation is None:
|
||||||
return 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(
|
message = AgentConversationMessage(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
role=str(role or "user").strip() or "user",
|
role=str(role or "user").strip() or "user",
|
||||||
content=normalized_content,
|
content=normalized_content,
|
||||||
message_json=message_json or {},
|
message_json=normalized_message_json,
|
||||||
created_at=datetime.now(UTC),
|
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:
|
if role == "user" and not conversation.title:
|
||||||
conversation.title = normalized_content[:48]
|
conversation.title = normalized_content[:48]
|
||||||
conversation.updated_at = datetime.now(UTC)
|
conversation.updated_at = datetime.now(UTC)
|
||||||
@@ -220,15 +223,9 @@ class AgentConversationService:
|
|||||||
normalized_id = str(conversation_id or "").strip()
|
normalized_id = str(conversation_id or "").strip()
|
||||||
if not normalized_id or limit <= 0:
|
if not normalized_id or limit <= 0:
|
||||||
return []
|
return []
|
||||||
|
messages = self.list_messages(normalized_id)
|
||||||
stmt = (
|
if limit > 0:
|
||||||
select(AgentConversationMessage)
|
messages = messages[-limit:]
|
||||||
.where(AgentConversationMessage.conversation_id == normalized_id)
|
|
||||||
.order_by(AgentConversationMessage.created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
messages = list(self.db.scalars(stmt).all())
|
|
||||||
messages.reverse()
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"role": item.role,
|
"role": item.role,
|
||||||
@@ -252,11 +249,13 @@ class AgentConversationService:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(AgentConversationMessage)
|
select(AgentConversationMessage)
|
||||||
.where(AgentConversationMessage.conversation_id == normalized_id)
|
.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:
|
if limit and limit > 0:
|
||||||
stmt = stmt.limit(limit)
|
return messages[:limit]
|
||||||
return list(self.db.scalars(stmt).all())
|
return messages
|
||||||
|
|
||||||
def update_state(
|
def update_state(
|
||||||
self,
|
self,
|
||||||
@@ -396,6 +395,30 @@ class AgentConversationService:
|
|||||||
"created_at": message.created_at,
|
"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
|
@staticmethod
|
||||||
def _is_empty_value(value: Any) -> bool:
|
def _is_empty_value(value: Any) -> bool:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -35,6 +35,38 @@ from app.models.financial_record import (
|
|||||||
|
|
||||||
logger = get_logger("app.services.agent_foundation")
|
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:
|
def prepare_agent_foundation() -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -55,7 +87,7 @@ class AgentFoundationService:
|
|||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=self.db.get_bind())
|
Base.metadata.create_all(bind=self.db.get_bind())
|
||||||
self._seed_agent_assets()
|
self._seed_agent_assets()
|
||||||
self._seed_financial_records()
|
self._sync_demo_financial_records()
|
||||||
self._seed_runs_and_logs()
|
self._seed_runs_and_logs()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -63,6 +95,12 @@ class AgentFoundationService:
|
|||||||
logger.exception("Failed to prepare agent foundation")
|
logger.exception("Failed to prepare agent foundation")
|
||||||
raise
|
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:
|
def _seed_agent_assets(self) -> None:
|
||||||
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||||
if existing_codes:
|
if existing_codes:
|
||||||
@@ -568,6 +606,41 @@ class AgentFoundationService:
|
|||||||
|
|
||||||
self.db.add_all([claim_1, claim_2, claim_3, *ar_records, *ap_records])
|
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:
|
def _seed_runs_and_logs(self) -> None:
|
||||||
if self.db.scalar(select(AgentRun.id).limit(1)) is not None:
|
if self.db.scalar(select(AgentRun.id).limit(1)) is not None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
@@ -60,8 +60,38 @@ class ExecutionOutcome:
|
|||||||
failed_tool_count: int
|
failed_tool_count: int
|
||||||
|
|
||||||
|
|
||||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"manager", "finance", "approver", "auditor", "executive"}
|
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
|
||||||
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
|
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:
|
class OrchestratorService:
|
||||||
@@ -850,47 +880,85 @@ class OrchestratorService:
|
|||||||
message: str,
|
message: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if ontology.scenario == "expense":
|
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(
|
conditions, scope_label, scoped_to_current_user = self._build_expense_query_scope(
|
||||||
ontology=ontology,
|
ontology=ontology,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
message=message,
|
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:
|
for condition in conditions:
|
||||||
count_stmt = count_stmt.where(condition)
|
count_stmt = count_stmt.where(condition)
|
||||||
amount_stmt = amount_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_count = int(self.db.scalar(count_stmt) or 0)
|
||||||
total_amount = float(self.db.scalar(amount_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())
|
preview_claims = list(self.db.scalars(preview_stmt).all())
|
||||||
|
status_groups = self._build_expense_status_groups(display_conditions)
|
||||||
return {
|
return {
|
||||||
"record_count": total_count,
|
"result_type": "expense_claim_list",
|
||||||
"total_amount": round(total_amount, 2),
|
"record_count": display_count,
|
||||||
|
"total_amount": round(display_amount, 2),
|
||||||
"scope_label": scope_label,
|
"scope_label": scope_label,
|
||||||
"scoped_to_current_user": scoped_to_current_user,
|
"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": [
|
"records": [
|
||||||
{
|
self._build_expense_query_record(claim)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
for claim in preview_claims
|
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":
|
if ontology.scenario == "accounts_receivable":
|
||||||
@@ -926,6 +994,122 @@ class OrchestratorService:
|
|||||||
"outstanding_amount": round(total_amount, 2),
|
"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(
|
def _build_expense_query_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1045,7 +1229,6 @@ class OrchestratorService:
|
|||||||
context_json: dict[str, Any],
|
context_json: dict[str, Any],
|
||||||
) -> tuple[list[Any], str]:
|
) -> tuple[list[Any], str]:
|
||||||
normalized_user_id = str(user_id or "").strip()
|
normalized_user_id = str(user_id or "").strip()
|
||||||
display_name = str(context_json.get("name") or "").strip()
|
|
||||||
employee = None
|
employee = None
|
||||||
if normalized_user_id:
|
if normalized_user_id:
|
||||||
employee = self.db.scalar(
|
employee = self.db.scalar(
|
||||||
@@ -1076,21 +1259,17 @@ class OrchestratorService:
|
|||||||
add_condition("employee_id", employee.id)
|
add_condition("employee_id", employee.id)
|
||||||
add_condition("employee_name", employee.name)
|
add_condition("employee_name", employee.name)
|
||||||
add_condition("employee_name", employee.email)
|
add_condition("employee_name", employee.email)
|
||||||
if not display_name:
|
else:
|
||||||
display_name = employee.name
|
add_condition("employee_id", normalized_user_id)
|
||||||
|
|
||||||
add_condition("employee_name", display_name)
|
|
||||||
add_condition("employee_name", normalized_user_id)
|
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:
|
if subject_name:
|
||||||
return conditions, "你的报销单"
|
return conditions, "你的报销单"
|
||||||
return conditions, "当前用户的报销单"
|
return conditions, "当前用户的报销单"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool:
|
def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool:
|
||||||
if bool(context_json.get("is_admin")):
|
|
||||||
return True
|
|
||||||
role_codes = {
|
role_codes = {
|
||||||
str(item).strip().lower()
|
str(item).strip().lower()
|
||||||
for item in context_json.get("role_codes", [])
|
for item in context_json.get("role_codes", [])
|
||||||
@@ -1147,6 +1326,8 @@ class OrchestratorService:
|
|||||||
"requires_confirmation": response.requires_confirmation,
|
"requires_confirmation": response.requires_confirmation,
|
||||||
"degraded": degraded,
|
"degraded": degraded,
|
||||||
}
|
}
|
||||||
|
if response.query_payload is not None:
|
||||||
|
result["query_payload"] = response.query_payload.model_dump()
|
||||||
if response.draft_payload is not None:
|
if response.draft_payload is not None:
|
||||||
result["draft_payload"] = response.draft_payload.model_dump()
|
result["draft_payload"] = response.draft_payload.model_dump()
|
||||||
if response.review_payload is not None:
|
if response.review_payload is not None:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from app.schemas.agent_asset import AgentAssetListItem
|
|||||||
from app.schemas.user_agent import (
|
from app.schemas.user_agent import (
|
||||||
UserAgentCitation,
|
UserAgentCitation,
|
||||||
UserAgentDraftPayload,
|
UserAgentDraftPayload,
|
||||||
|
UserAgentExpenseQueryRecord,
|
||||||
|
UserAgentQueryPayload,
|
||||||
|
UserAgentQueryStatusGroup,
|
||||||
UserAgentReviewAction,
|
UserAgentReviewAction,
|
||||||
UserAgentReviewEditField,
|
UserAgentReviewEditField,
|
||||||
UserAgentReviewClaimGroup,
|
UserAgentReviewClaimGroup,
|
||||||
@@ -94,6 +97,13 @@ EXPENSE_STATUS_LABELS = {
|
|||||||
"paid": "已付款",
|
"paid": "已付款",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EXPENSE_STATUS_GROUP_LABELS = {
|
||||||
|
"draft": "草稿",
|
||||||
|
"in_progress": "审批中",
|
||||||
|
"completed": "审批完成",
|
||||||
|
"other": "其他状态",
|
||||||
|
}
|
||||||
|
|
||||||
SLOT_LABELS = {
|
SLOT_LABELS = {
|
||||||
"expense_type": "报销类型",
|
"expense_type": "报销类型",
|
||||||
"customer_name": "客户名称",
|
"customer_name": "客户名称",
|
||||||
@@ -132,6 +142,7 @@ class UserAgentService:
|
|||||||
citations = self._build_rule_citations(payload)
|
citations = self._build_rule_citations(payload)
|
||||||
suggested_actions = self._build_suggested_actions(payload)
|
suggested_actions = self._build_suggested_actions(payload)
|
||||||
risk_flags = self._resolve_risk_flags(payload)
|
risk_flags = self._resolve_risk_flags(payload)
|
||||||
|
query_payload = self._build_query_payload(payload)
|
||||||
draft_payload = (
|
draft_payload = (
|
||||||
self._build_draft_payload(payload)
|
self._build_draft_payload(payload)
|
||||||
if payload.ontology.intent == "draft"
|
if payload.ontology.intent == "draft"
|
||||||
@@ -153,6 +164,7 @@ class UserAgentService:
|
|||||||
answer=review_answer or str(payload.tool_payload["message"]),
|
answer=review_answer or str(payload.tool_payload["message"]),
|
||||||
citations=citations,
|
citations=citations,
|
||||||
suggested_actions=suggested_actions,
|
suggested_actions=suggested_actions,
|
||||||
|
query_payload=query_payload,
|
||||||
review_payload=review_payload,
|
review_payload=review_payload,
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
requires_confirmation=payload.requires_confirmation,
|
requires_confirmation=payload.requires_confirmation,
|
||||||
@@ -163,6 +175,7 @@ class UserAgentService:
|
|||||||
answer=review_answer,
|
answer=review_answer,
|
||||||
citations=citations,
|
citations=citations,
|
||||||
suggested_actions=suggested_actions,
|
suggested_actions=suggested_actions,
|
||||||
|
query_payload=query_payload,
|
||||||
draft_payload=draft_payload,
|
draft_payload=draft_payload,
|
||||||
review_payload=review_payload,
|
review_payload=review_payload,
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
@@ -177,6 +190,7 @@ class UserAgentService:
|
|||||||
answer=guided_answer,
|
answer=guided_answer,
|
||||||
citations=citations,
|
citations=citations,
|
||||||
suggested_actions=suggested_actions,
|
suggested_actions=suggested_actions,
|
||||||
|
query_payload=query_payload,
|
||||||
draft_payload=draft_payload,
|
draft_payload=draft_payload,
|
||||||
review_payload=review_payload,
|
review_payload=review_payload,
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
@@ -203,6 +217,7 @@ class UserAgentService:
|
|||||||
answer=answer or fallback_answer,
|
answer=answer or fallback_answer,
|
||||||
citations=citations,
|
citations=citations,
|
||||||
suggested_actions=suggested_actions,
|
suggested_actions=suggested_actions,
|
||||||
|
query_payload=query_payload,
|
||||||
draft_payload=draft_payload,
|
draft_payload=draft_payload,
|
||||||
review_payload=review_payload,
|
review_payload=review_payload,
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
@@ -396,43 +411,58 @@ class UserAgentService:
|
|||||||
subject = self._resolve_subject(payload)
|
subject = self._resolve_subject(payload)
|
||||||
|
|
||||||
if scenario == "expense":
|
if scenario == "expense":
|
||||||
record_count = int(data.get("record_count") or 0)
|
query_payload = self._build_query_payload(payload)
|
||||||
total_amount = float(data.get("total_amount") or 0)
|
|
||||||
scope_label = str(data.get("scope_label") or subject).strip() or subject
|
scope_label = str(data.get("scope_label") or subject).strip() or subject
|
||||||
preview_records = data.get("records")
|
if query_payload is None:
|
||||||
if record_count <= 0:
|
|
||||||
return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。"
|
return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。"
|
||||||
|
|
||||||
summary = f"查到{scope_label}共 {record_count} 笔,金额合计 {total_amount:.2f} 元。"
|
window_prefix = (
|
||||||
if not isinstance(preview_records, list) or not preview_records:
|
f"{query_payload.window_start_date} 至 {query_payload.window_end_date}"
|
||||||
return f"{summary} 如需继续处理,可以查看明细或生成处理意见草稿。"
|
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] = []
|
group_lines = [
|
||||||
for item in preview_records[:3]:
|
f"{item.label} {item.count} 笔"
|
||||||
if not isinstance(item, dict):
|
for item in query_payload.status_groups
|
||||||
continue
|
if item.count > 0
|
||||||
claim_no = str(item.get("claim_no") or "未编号").strip() or "未编号"
|
]
|
||||||
occurred_at = str(item.get("occurred_at") or "").strip()
|
answer_parts = [
|
||||||
expense_type = EXPENSE_TYPE_LABELS.get(
|
f"我先为你列出{window_prefix}的{query_payload.scope_label},"
|
||||||
str(item.get("expense_type") or "").strip(),
|
f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。"
|
||||||
str(item.get("expense_type") or "报销").strip() or "报销",
|
]
|
||||||
|
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)
|
elif query_payload.records:
|
||||||
status = EXPENSE_STATUS_LABELS.get(
|
hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。")
|
||||||
str(item.get("status") or "").strip(),
|
|
||||||
str(item.get("status") or "处理中").strip() or "处理中",
|
if query_payload.older_record_count > 0 and query_payload.window_days:
|
||||||
)
|
hint_parts.append(
|
||||||
date_prefix = f"{occurred_at}," if occurred_at else ""
|
f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据,"
|
||||||
preview_text.append(
|
"请前往个人报销中心查看。"
|
||||||
f"{claim_no}({date_prefix}{expense_type},{amount:.2f} 元,{status})"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not preview_text:
|
return " ".join(answer_parts + hint_parts).strip()
|
||||||
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()
|
|
||||||
|
|
||||||
if scenario == "accounts_receivable":
|
if scenario == "accounts_receivable":
|
||||||
record_count = int(data.get("record_count") or 0)
|
record_count = int(data.get("record_count") or 0)
|
||||||
@@ -452,6 +482,81 @@ class UserAgentService:
|
|||||||
|
|
||||||
return "已完成当前查询,但暂时没有更多结构化结果可展示。"
|
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(
|
def _build_explain_answer(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user