refactor(backend): update and add service layers
- services/ontology.py: update ontology service - services/orchestrator.py: update orchestrator service - services/user_agent.py: update user agent service - services/settings.py: update settings service - services/expense_claims.py: update expense claims service - services/agent_conversations.py: add new agent conversations service
This commit is contained in:
@@ -120,6 +120,24 @@ EXPLAIN_KEYWORDS = ("为什么", "依据", "原因", "怎么处理", "是否可
|
||||
COMPARE_KEYWORDS = ("对比", "比较", "相比", "差异", "变化")
|
||||
RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期", "验真", "巡检")
|
||||
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
|
||||
DRAFT_FOLLOW_UP_KEYWORDS = (
|
||||
"继续",
|
||||
"补充",
|
||||
"补一下",
|
||||
"修改",
|
||||
"改成",
|
||||
"改为",
|
||||
"换成",
|
||||
"更新",
|
||||
"确认",
|
||||
"提交",
|
||||
"保存",
|
||||
"客户是",
|
||||
"地点是",
|
||||
"金额是",
|
||||
"日期是",
|
||||
"时间是",
|
||||
)
|
||||
OPERATE_KEYWORDS = (
|
||||
"直接付款",
|
||||
"帮我付款",
|
||||
@@ -200,6 +218,7 @@ STATUS_KEYWORDS = {
|
||||
}
|
||||
|
||||
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
||||
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -289,12 +308,17 @@ class SemanticOntologyService:
|
||||
raise ValueError("query 不能为空。")
|
||||
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
context_json = payload.context_json or {}
|
||||
reference = self._load_reference_catalog()
|
||||
compact_query = self._compact(query)
|
||||
|
||||
entities = self._extract_entities(query, compact_query, reference)
|
||||
rule_scenario, scenario_score = self._detect_scenario(compact_query)
|
||||
time_range, _time_score = self._extract_time_range(query, compact_query)
|
||||
context_scenario = self._resolve_context_scenario(context_json)
|
||||
if rule_scenario == "unknown" and context_scenario is not None:
|
||||
rule_scenario = context_scenario
|
||||
scenario_score = max(scenario_score, 0.14)
|
||||
if rule_scenario == "unknown":
|
||||
inferred_scenario = self._infer_scenario_from_entities(entities)
|
||||
if inferred_scenario is not None:
|
||||
@@ -316,6 +340,17 @@ class SemanticOntologyService:
|
||||
entities=entities,
|
||||
time_range=time_range,
|
||||
)
|
||||
if self._should_inherit_expense_draft(
|
||||
compact_query,
|
||||
scenario=rule_scenario,
|
||||
entities=entities,
|
||||
time_range=time_range,
|
||||
context_json=context_json,
|
||||
):
|
||||
rule_scenario = "expense"
|
||||
rule_intent = "draft"
|
||||
scenario_score = max(scenario_score, 0.18)
|
||||
intent_score = max(intent_score, 0.18)
|
||||
metrics = self._extract_metrics(compact_query)
|
||||
constraints = self._extract_constraints(compact_query, entities)
|
||||
model_parse = self._parse_with_model(
|
||||
@@ -353,7 +388,7 @@ class SemanticOntologyService:
|
||||
intent=intent,
|
||||
entities=entities,
|
||||
time_range=time_range,
|
||||
context_json=payload.context_json or {},
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
ambiguity = self._normalize_short_text_list(
|
||||
@@ -362,7 +397,7 @@ class SemanticOntologyService:
|
||||
risk_flags = self._extract_risk_flags(compact_query, scenario)
|
||||
permission = self._resolve_permission(
|
||||
compact_query,
|
||||
payload.context_json or {},
|
||||
context_json,
|
||||
intent,
|
||||
)
|
||||
|
||||
@@ -524,6 +559,13 @@ class SemanticOntologyService:
|
||||
def _compact(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text).lower()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_context_scenario(context_json: dict[str, Any]) -> str | None:
|
||||
value = str(context_json.get("conversation_scenario") or "").strip()
|
||||
if value in CONTEXTUAL_SCENARIOS:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
||||
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
||||
for scenario, keywords in SCENARIO_KEYWORDS.items():
|
||||
@@ -581,6 +623,68 @@ class SemanticOntologyService:
|
||||
return "draft", 0.22
|
||||
return "query", 0.10
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_follow_up_message(compact_query: str) -> bool:
|
||||
if not compact_query:
|
||||
return False
|
||||
if any(keyword in compact_query for keyword in DRAFT_FOLLOW_UP_KEYWORDS):
|
||||
return True
|
||||
if compact_query.startswith(("那", "这", "它", "这个", "那个")):
|
||||
return True
|
||||
|
||||
has_domain_keyword = any(
|
||||
keyword in compact_query
|
||||
for keyword, _weight in (
|
||||
*SCENARIO_KEYWORDS["expense"],
|
||||
*SCENARIO_KEYWORDS["accounts_receivable"],
|
||||
*SCENARIO_KEYWORDS["accounts_payable"],
|
||||
*SCENARIO_KEYWORDS["knowledge"],
|
||||
)
|
||||
)
|
||||
return len(compact_query) <= 12 and not has_domain_keyword
|
||||
|
||||
def _should_inherit_expense_draft(
|
||||
self,
|
||||
compact_query: str,
|
||||
*,
|
||||
scenario: str,
|
||||
entities: list[OntologyEntity],
|
||||
time_range: OntologyTimeRange,
|
||||
context_json: dict[str, Any],
|
||||
) -> bool:
|
||||
context_scenario = self._resolve_context_scenario(context_json)
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
if context_scenario != "expense" and not draft_claim_id:
|
||||
return False
|
||||
|
||||
if any(keyword in compact_query for keyword in DRAFT_FOLLOW_UP_KEYWORDS):
|
||||
return True
|
||||
if self._looks_like_expense_narrative(
|
||||
compact_query,
|
||||
scenario="expense",
|
||||
entities=entities,
|
||||
time_range=time_range,
|
||||
):
|
||||
return True
|
||||
if self._looks_like_follow_up_message(compact_query):
|
||||
return True
|
||||
|
||||
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
||||
return False
|
||||
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS + RISK_KEYWORDS):
|
||||
return False
|
||||
if any(keyword in compact_query for keyword in QUERY_KEYWORDS):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
draft_claim_id
|
||||
and any(
|
||||
item.type
|
||||
in {"amount", "customer", "employee", "expense_type", "project", "invoice"}
|
||||
for item in entities
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_generic_expense_prompt(compact_query: str) -> bool:
|
||||
return compact_query in GENERIC_EXPENSE_PROMPTS
|
||||
@@ -670,6 +774,11 @@ class SemanticOntologyService:
|
||||
"ocr_documents": payload.context_json.get("ocr_documents", []),
|
||||
"request_context": payload.context_json.get("request_context"),
|
||||
"role_codes": payload.context_json.get("role_codes", []),
|
||||
"conversation_id": payload.context_json.get("conversation_id"),
|
||||
"conversation_scenario": payload.context_json.get("conversation_scenario"),
|
||||
"conversation_intent": payload.context_json.get("conversation_intent"),
|
||||
"draft_claim_id": payload.context_json.get("draft_claim_id"),
|
||||
"conversation_history": payload.context_json.get("conversation_history", []),
|
||||
},
|
||||
"rule_candidates": {
|
||||
"scenario": fallback_scenario,
|
||||
@@ -690,6 +799,8 @@ class SemanticOntologyService:
|
||||
"意图 intent 只能是:query, explain, compare, risk_check, draft, operate。"
|
||||
"如果用户是在描述一笔待处理费用、待报销事项、上传票据或希望整理报销,"
|
||||
"即使没有明确说“生成草稿”,也优先使用 expense + draft。"
|
||||
"如果提供了 conversation_history,必须把最近轮次作为当前追问的上下文,"
|
||||
"正确理解“这个”“那笔”“改成 800”“继续补充”这类省略表达。"
|
||||
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
|
||||
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
|
||||
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
|
||||
|
||||
Reference in New Issue
Block a user