Files
X-Financial/server/src/app/services/ontology_validation.py
caoxiaozhu 0264a4b5b4 refactor(server): user_agent/steward/ocr 等服务重构并适配关联任务
- user_agent 拆分 application/locations/knowledge/response/review 四个子模块,接入申请位置语义与关联草稿分支
- steward planner/runtime/slot/plan_builder 决策链路重构,travel_reimbursement_calculator/orchestrator_expense_query 适配
- ocr/document_preview/document_intelligence/receipt_folder 复用预览与资产缓存,expense_claim_draft_flow/application_handoff 适配
- pyproject.toml 新增依赖,paddleocr bootstrap 脚本与 server_start.sh 调整
- 更新差旅/交通/通信等财务规则表,同步 document_intelligence/ocr/receipt_folder/user_agent 等测试
2026-06-24 10:42:24 +08:00

293 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Any
from app.core.agent_enums import AgentPermissionLevel
from app.schemas.ontology import (
OntologyConstraint,
OntologyEntity,
OntologyFieldError,
OntologyMetric,
OntologyPermission,
OntologyTimeRange,
)
from app.services.ontology_rules import (
EXPENSE_REVIEW_ACTIONS,
MISSING_SLOT_LABELS,
OPERATE_KEYWORDS,
PRIVILEGED_ROLE_CODES,
)
class OntologyValidationMixin:
def _extract_risk_flags(self, compact_query: str, scenario: str) -> list[str]:
risk_flags: list[str] = []
def append(flag: str) -> None:
if flag not in risk_flags:
risk_flags.append(flag)
if "重复" in compact_query:
append("duplicate_expense")
if any(
keyword in compact_query
for keyword in ("发票异常", "票据异常", "验真失败", "附件缺失", "补件")
):
append("invoice_anomaly")
if any(keyword in compact_query for keyword in ("超标", "超预算", "超限")):
append("amount_over_limit")
if scenario == "budget" and any(
keyword in compact_query for keyword in ("预算不足", "超预算", "超支")
):
append("budget_over_limit")
if scenario == "budget" and any(
keyword in compact_query for keyword in ("预算预警", "触发预警", "接近预算")
):
append("budget_warning")
if scenario == "accounts_receivable" and any(
keyword in compact_query for keyword in ("逾期", "账龄", "欠款", "未回款")
):
append("ar_overdue")
if scenario == "accounts_payable" and any(
keyword in compact_query for keyword in ("逾期", "待付", "付款风险", "未付款")
):
append("ap_overdue")
return risk_flags
def _resolve_permission(
self,
compact_query: str,
context_json: dict,
intent: str,
) -> OntologyPermission:
role_codes = {
str(item).strip().lower()
for item in context_json.get("role_codes", [])
if str(item).strip()
}
is_admin = bool(context_json.get("is_admin"))
privileged = is_admin or bool(role_codes & PRIVILEGED_ROLE_CODES)
if intent in {"query", "explain", "compare", "risk_check"}:
return OntologyPermission(
level=AgentPermissionLevel.READ.value,
allowed=True,
reason="只读查询。",
)
if intent == "draft":
return OntologyPermission(
level=AgentPermissionLevel.DRAFT_WRITE.value,
allowed=True,
reason="允许生成草稿,但不会直接提交业务动作。",
)
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS) or "付款" in compact_query:
if privileged:
return OntologyPermission(
level=AgentPermissionLevel.APPROVAL_REQUIRED.value,
allowed=False,
reason="涉及付款、审批或上线动作,必须进入人工审批链。",
)
return OntologyPermission(
level=AgentPermissionLevel.FORBIDDEN.value,
allowed=False,
reason="当前账号缺少财务或审批权限,只能查看结果或生成草稿。",
)
return OntologyPermission(
level=AgentPermissionLevel.APPROVAL_REQUIRED.value,
allowed=False,
reason="操作类请求需要人工审批确认。",
)
def _build_field_errors(
self,
*,
scenario: str,
intent: str,
entities: list[OntologyEntity],
permission: OntologyPermission,
missing_slots: list[str],
ambiguity: list[str],
) -> list[OntologyFieldError]:
errors: list[OntologyFieldError] = []
if scenario == "unknown":
errors.append(
OntologyFieldError(
field="scenario",
code="scenario_unknown",
message="未识别出明确业务场景,请补充是报销、应收、应付还是制度问题。",
)
)
if intent == "compare" and len([item for item in entities if item.type != "amount"]) < 2:
errors.append(
OntologyFieldError(
field="entities",
code="compare_target_missing",
message="对比类问题请至少给出两个对象,或给出更明确的对比范围。",
)
)
if missing_slots:
errors.append(
OntologyFieldError(
field="missing_slots",
code="required_slot_missing",
message=(
"继续处理前还缺少关键信息:"
f"{''.join(self._display_slot_label(item) for item in missing_slots)}"
),
)
)
if ambiguity:
errors.append(
OntologyFieldError(
field="ambiguity",
code="ambiguity_detected",
message=f"当前问题存在歧义:{''.join(ambiguity)}",
)
)
if permission.level == AgentPermissionLevel.FORBIDDEN.value:
errors.append(
OntologyFieldError(
field="permission",
code="permission_forbidden",
message=permission.reason,
)
)
return errors
def _build_clarification(
self,
*,
scenario: str,
intent: str,
entities: list[OntologyEntity],
permission: OntologyPermission,
missing_slots: list[str],
ambiguity: list[str],
allow_incomplete_draft: bool,
model_clarification_required: bool,
model_clarification_question: str | None,
) -> tuple[bool, str | None]:
if permission.level == AgentPermissionLevel.FORBIDDEN.value:
return True, "当前动作超出权限范围。是否改为生成草稿或建议?"
if scenario == "knowledge" and intent in {"query", "explain"}:
return False, None
if model_clarification_required:
question = str(model_clarification_question or "").strip()
if question:
return True, question
if missing_slots:
return True, self._build_missing_slot_question(missing_slots)
if ambiguity:
return True, f"当前问题存在歧义,请进一步说明:{''.join(ambiguity)}"
if scenario == "unknown":
return True, "请说明这是报销、应收、应付,还是制度知识问题?"
if intent == "compare" and len([item for item in entities if item.type != "amount"]) < 2:
return True, "请补充需要对比的两个对象,例如两个客户、两个供应商或两个员工。"
if allow_incomplete_draft and scenario == "expense" and intent == "draft":
return False, None
if missing_slots:
return True, self._build_missing_slot_question(missing_slots)
if ambiguity:
return True, f"当前问题存在歧义,请进一步说明:{''.join(ambiguity)}"
return False, None
@staticmethod
def _allow_incomplete_draft(
context_json: dict[str, Any],
*,
scenario: str,
intent: str,
) -> bool:
if scenario != "expense" or intent != "draft":
return False
review_action = str(context_json.get("review_action") or "").strip()
return review_action in EXPENSE_REVIEW_ACTIONS
@staticmethod
def _display_slot_label(slot: str) -> str:
return MISSING_SLOT_LABELS.get(slot, slot)
def _build_missing_slot_question(self, missing_slots: list[str]) -> str:
labels = [self._display_slot_label(item) for item in missing_slots[:4]]
if not labels:
return "请补充更多上下文后再继续。"
return f"请补充{''.join(labels)},我会继续帮您解析和处理。"
@staticmethod
def _compute_confidence(
*,
scenario: str,
scenario_score: float,
intent_score: float,
entities: list[OntologyEntity],
time_range: OntologyTimeRange,
metrics: list[OntologyMetric],
constraints: list[OntologyConstraint],
risk_flags: list[str],
clarification_required: bool,
permission: OntologyPermission,
) -> float:
confidence = 0.18 + scenario_score + intent_score
confidence += min(0.16, len(entities) * 0.04)
if time_range.start_date:
confidence += 0.10
if metrics:
confidence += 0.06
if constraints:
confidence += 0.06
if risk_flags:
confidence += 0.08
if permission.level == AgentPermissionLevel.FORBIDDEN.value:
confidence = max(confidence, 0.86)
if scenario == "unknown":
confidence = min(confidence, 0.45)
if clarification_required and permission.level != AgentPermissionLevel.FORBIDDEN.value:
confidence = min(confidence, 0.58)
return round(min(confidence, 0.98), 2)
@staticmethod
def _build_result_summary(
scenario: str,
intent: str,
permission_level: str,
confidence: float,
) -> str:
return (
f"语义解析完成scenario={scenario}, intent={intent}, "
f"permission={permission_level}, confidence={confidence:.2f}"
)
@staticmethod
def _normalize_operator(value: str) -> str:
mapping = {
"超过": ">",
"大于": ">",
"高于": ">",
">": ">",
">=": ">=",
"不少于": ">=",
"不低于": ">=",
"小于": "<",
"低于": "<",
"少于": "<",
"<": "<",
"<=": "<=",
"至多": "<=",
"不超过": "<=",
"=": "=",
"": "=",
}
return mapping.get(value, value)
@staticmethod
def _normalize_amount(raw_value: str | None, unit: str | None) -> int | float:
numeric = float(raw_value or 0)
if unit in {"", "万元"}:
numeric *= 10000
return int(numeric) if numeric.is_integer() else round(numeric, 2)