后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。
293 lines
10 KiB
Python
293 lines
10 KiB
Python
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)
|