From 2574bc81d1bdf4cc8f62ecc69a8cfe0474755957 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 19 May 2026 17:24:13 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=92=8C=E5=B7=AE=E6=97=85=E6=8A=A5?= =?UTF-8?q?=E9=94=80=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/app/services/user_agent.py | 7562 ++++++------ .../travel-reimbursement-create-view.css | 8751 +++++++------ .../components/business/PersonalWorkbench.vue | 16 +- web/src/views/PersonalWorkbenchView.vue | 10 +- .../views/TravelReimbursementCreateView.vue | 2458 ++-- .../scripts/TravelReimbursementCreateView.js | 10214 ++++++++-------- 6 files changed, 14989 insertions(+), 14022 deletions(-) diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 357fad0..0b5c0e5 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -1,3781 +1,3781 @@ -from __future__ import annotations - -import json -import re -from datetime import UTC, datetime, timedelta -from decimal import Decimal, InvalidOperation -from typing import Any - -from sqlalchemy import or_, select -from sqlalchemy.orm import Session, selectinload - -from app.core.agent_enums import AgentAssetStatus, AgentAssetType -from app.models.employee import Employee -from app.models.financial_record import ExpenseClaim -from app.schemas.agent_asset import AgentAssetListItem -from app.schemas.user_agent import ( - UserAgentCitation, - UserAgentDraftPayload, - UserAgentExpenseQueryRecord, - UserAgentQueryPayload, - UserAgentQueryStatusGroup, - UserAgentReviewAction, - UserAgentReviewEditField, - UserAgentReviewClaimGroup, - UserAgentReviewDocumentCard, - UserAgentReviewDocumentField, - UserAgentReviewPayload, - UserAgentReviewRiskBrief, - UserAgentReviewSlotCard, - UserAgentRequest, - UserAgentResponse, - UserAgentSuggestedAction, -) -from app.services.agent_assets import AgentAssetService -from app.services.agent_foundation import AgentFoundationService -from app.services.expense_claims import ExpenseClaimService -from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check -from app.services.runtime_chat import RuntimeChatService - -SCENARIO_LABELS = { - "expense": "报销", - "accounts_receivable": "应收", - "accounts_payable": "应付", - "knowledge": "知识", - "unknown": "通用", -} - -RISK_REASON_MAP = { - "duplicate_expense": "检测到同员工、同金额或近似单据存在重复提交迹象。", - "location_mismatch": "申报出差地点与票据识别地点可能不一致,需要核对行程或补充说明。", - "amount_over_limit": "金额超过当前制度或预算阈值,需要补充例外说明。", - "invoice_anomaly": "票据或附件完整性不满足当前规则要求,需要补件或人工复核。", - "ar_overdue": "应收账款已出现逾期,存在回款延迟风险。", - "ap_overdue": "应付付款已出现逾期,可能影响供应商履约或合作关系。", -} - -GENERIC_EXPENSE_PROMPTS = { - "报销", - "我要报销", - "我想报销", - "帮我报销", - "我要申请报销", - "发起报销", - "提交报销", -} - -EXPLICIT_DRAFT_KEYWORDS = ("生成", "草稿", "起草", "创建", "发起", "准备") - -EXPENSE_TYPE_LABELS = { - "travel": "差旅费", - "hotel": "住宿费", - "transport": "交通费", - "meal": "餐费", - "meeting": "会务费", - "entertainment": "业务招待费", - "office": "办公费", - "training": "培训费", - "communication": "通讯费", - "welfare": "福利费", - "other": "其他费用", -} - -GROUP_SCENE_LABELS = { - "travel": "差旅费", - "entertainment": "业务招待费", - "meal": "伙食费", - "transport": "交通费", - "hotel": "住宿费", - "office": "办公费", - "training": "培训费", - "communication": "通讯费", - "welfare": "福利费", - "other": "其他费用", -} - -KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3 -KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5 -KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS - -KNOWLEDGE_DIRECT_ANSWER_HINTS = ( - "是什么", - "标准", - "限额", - "流程", - "条件", - "规则", - "怎么", - "如何", - "哪些", - "需要", - "是否", - "区别", - "范围", - "额度", - "金额", - "多少", - "多少钱", - "上限", -) -KNOWLEDGE_QUERY_STOPWORDS = { - "什么", - "多少", - "哪些", - "怎么", - "如何", - "请问", - "一下", - "关于", - "规定", - "标准", - "可以", - "是否", - "一个", - "哪些人", - "目前", - "当前", - "一下子", -} -MAX_KNOWLEDGE_QUERY_TERMS = 12 -MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4 -MAX_KNOWLEDGE_MODEL_HITS = 5 -KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile( - r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$" -) -KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$") -KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile( - r"^(?:(?:\d+[.)、])|(?:[((][一二三四五六七八九十百零0-9]+[))])|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$" -) -KNOWLEDGE_ARTICLE_PATTERN = re.compile(r"^(第[一二三四五六七八九十百零0-9]+条)\s*.*$") - -EXPENSE_STATUS_LABELS = { - "draft": "草稿", - "submitted": "已提交", - "review": "审核中", - "approved": "已通过", - "rejected": "已驳回", - "paid": "已付款", -} - -EXPENSE_STATUS_GROUP_LABELS = { - "draft": "草稿", - "in_progress": "审批中", - "completed": "审批完成", - "other": "其他状态", -} - -SLOT_LABELS = { - "expense_type": "报销类型", - "customer_name": "客户名称", - "time_range": "发生时间", - "location": "地点", - "merchant_name": "酒店/商户", - "amount": "金额", - "reason": "事由说明", - "participants": "参与人员", - "attachments": "票据附件", -} - -DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)") -AMOUNT_TEXT_PATTERN = re.compile( - r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)" -) -DOCUMENT_AMOUNT_PATTERN = re.compile( - r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" - r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" -) -DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") - -SOURCE_LABELS = { - "user_text": "用户描述", - "user_form": "用户修改", - "ocr": "票据识别", - "upload": "上传附件", - "detail_context": "关联单据", - "system_context": "系统上下文", - "inferred": "语义推断", - "system": "系统判断", -} - -SCENE_REQUIRED_SLOT_KEYS = { - "hotel": {"merchant_name"}, - "meeting": {"location"}, - "entertainment": {"location", "customer_name", "participants"}, -} -INFERRED_REASON_LABELS = { - "travel": "出差行程", - "hotel": "住宿报销", - "transport": "交通出行", - "meal": "餐饮用餐", - "meeting": "会务活动", - "entertainment": "客户接待", - "office": "办公采购", - "training": "培训学习", - "communication": "通讯使用", - "welfare": "员工福利", - "other": "其他费用", -} -SYSTEM_GENERATED_REASON_PREFIXES = ( - "我上传了", - "请按当前已识别信息", - "请把当前上传的票据", - "请基于当前上传的多张票据", - "我已核对右侧识别结果", - "请同步修正逐票据识别结果", - "我已修改识别信息", - "查看报销草稿", - "请解释一下当前这笔报销的合规风险和待补充项", -) -AMOUNT_UNIT_ALIASES = { - "员": "元", - "圆": "元", - "园": "元", - "块": "元", - "块钱": "元", - "元整": "元", - "万员": "万元", - "万圆": "万元", - "万园": "万元", - "万块": "万元", - "万元整": "万元", -} - - -class UserAgentService: - def __init__(self, db: Session) -> None: - self.db = db - self.asset_service = AgentAssetService(db) - self.runtime_chat_service = RuntimeChatService(db) - - def respond(self, payload: UserAgentRequest) -> UserAgentResponse: - AgentFoundationService(self.db).ensure_foundation_ready() - citations = self._build_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" - else None - ) - review_payload = self._build_review_payload( - payload, - citations=citations, - draft_payload=draft_payload, - ) - review_answer = self._build_review_body_answer( - payload, - review_payload=review_payload, - draft_payload=draft_payload, - ) - - if payload.degraded and payload.tool_payload.get("message"): - return UserAgentResponse( - answer=review_answer or str(payload.tool_payload["message"]), - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - if review_answer: - return UserAgentResponse( - 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, - requires_confirmation=payload.requires_confirmation, - ) - - guided_answer = None - if draft_payload is None or draft_payload.claim_id is None: - guided_answer = self._build_guided_answer(payload) - if guided_answer: - return UserAgentResponse( - 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, - requires_confirmation=payload.requires_confirmation, - ) - - fast_knowledge_answer = self._build_fast_knowledge_answer( - payload, - citations=citations, - ) - if fast_knowledge_answer: - return UserAgentResponse( - answer=fast_knowledge_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - fallback_answer = self._build_fallback_answer( - payload, - citations=citations, - draft_payload=draft_payload, - ) - answer = None - if not self._should_skip_model_answer(payload, review_payload): - answer = self._generate_answer_with_model( - payload, - citations=citations, - suggested_actions=suggested_actions, - risk_flags=risk_flags, - draft_payload=draft_payload, - fallback_answer=fallback_answer, - ) - - return UserAgentResponse( - 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, - requires_confirmation=payload.requires_confirmation, - ) - - def _build_fallback_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - draft_payload: UserAgentDraftPayload | None, - ) -> str: - if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": - return self._build_explain_answer(payload, citations) - - if payload.ontology.intent in {"query", "compare"}: - return self._build_query_answer(payload) - - if payload.ontology.intent == "risk_check": - return self._build_risk_answer(payload, citations) - - if payload.ontology.intent == "draft": - tool_message = str(payload.tool_payload.get("message") or "").strip() - if payload.tool_payload.get("draft_limit_reached"): - return tool_message or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - if tool_message and ( - str(payload.tool_payload.get("claim_id") or "").strip() - or str(payload.tool_payload.get("claim_no") or "").strip() - ): - return tool_message - if payload.ontology.intent == "draft" and draft_payload is not None: - return ( - f"已生成 {draft_payload.title},当前仅返回待人工确认的草稿内容," - "仍需人工确认后再进入正式流程。" - ) - - return self._build_explain_answer(payload, citations) - - def _build_guided_answer(self, payload: UserAgentRequest) -> str | None: - if not self._is_generic_expense_prompt(payload): - return self._build_implicit_expense_draft_guidance(payload) - - attachment_names = self._resolve_attachment_names(payload) - ocr_summary = str(payload.context_json.get("ocr_summary") or "").strip() - attachment_hint = "" - if ocr_summary: - attachment_hint = f" 我已读取附件 OCR 摘要:{ocr_summary}" - elif attachment_names: - attachment_hint = ( - f" 我已带入 {len(attachment_names)} 份附件名称,但目前还不能直接读取附件内容," - "仍需要你补充关键信息。" - ) - - return ( - "可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象," - "或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿。" - f"{attachment_hint}" - ) - - def _build_implicit_expense_draft_guidance( - self, - payload: UserAgentRequest, - ) -> str | None: - if not self._is_implicit_expense_draft_request(payload): - return None - - amount_text = next( - (item.value for item in payload.ontology.entities if item.type == "amount"), - "", - ) - expense_type = next( - ( - EXPENSE_TYPE_LABELS.get(item.normalized_value, item.value) - for item in payload.ontology.entities - if item.type == "expense_type" - ), - "报销", - ) - time_text = payload.ontology.time_range.raw or "本次" - amount_hint = f",金额 {amount_text}" if amount_text else "" - - return ( - f"已识别到一笔{time_text}的{expense_type}支出{amount_hint}。" - "如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。" - "你也可以继续上传发票或图片,我会把这些信息带入后续对话。" - ) - - def _generate_answer_with_model( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - suggested_actions: list[UserAgentSuggestedAction], - risk_flags: list[str], - draft_payload: UserAgentDraftPayload | None, - fallback_answer: str, - ) -> str | None: - messages = self._build_model_messages( - payload, - citations=citations, - suggested_actions=suggested_actions, - risk_flags=risk_flags, - draft_payload=draft_payload, - fallback_answer=fallback_answer, - ) - answer = self._sanitize_model_answer( - self.runtime_chat_service.complete( - messages, - max_tokens=800 if payload.ontology.scenario == "knowledge" else 420, - temperature=0.2, - timeout_seconds=( - KNOWLEDGE_MODEL_TIMEOUT_SECONDS - if payload.ontology.scenario == "knowledge" - else None - ), - slot_timeouts=( - { - "main": KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS, - "backup": KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS, - } - if payload.ontology.scenario == "knowledge" - else None - ), - max_attempts=1 if payload.ontology.scenario == "knowledge" else None, - ) - ) - return self._reject_unsupported_location_inference(payload, answer) - - def _sanitize_model_answer(self, answer: str | None) -> str | None: - if not answer: - return None - - cleaned = re.sub(r".*?", "", answer, flags=re.DOTALL | re.IGNORECASE) - cleaned = cleaned.strip() - leaked_reasoning_markers = ( - "用户问的是", - "让我分析一下", - "实体识别", - "从对话历史来看", - "从tool_payload来看", - "现在问题是", - "我需要:", - "关键是我", - ) - if any(marker in cleaned[:500] for marker in leaked_reasoning_markers): - return None - return cleaned or None - - @staticmethod - def _extract_query_location(message: str) -> str: - match = re.search(r"(?:去|到|前往)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or "")) - return match.group(1) if match else "" - - def _reject_unsupported_location_inference( - self, - payload: UserAgentRequest, - answer: str | None, - ) -> str | None: - del payload - return answer - - def _build_model_messages( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - suggested_actions: list[UserAgentSuggestedAction], - risk_flags: list[str], - draft_payload: UserAgentDraftPayload | None, - fallback_answer: str, - ) -> list[dict[str, str]]: - knowledge_question = ( - self._resolve_knowledge_question(payload) - if payload.ontology.scenario == "knowledge" - else "" - ) - facts = { - "run_id": payload.run_id, - "user_message": payload.message, - "ontology": payload.ontology.model_dump(mode="json"), - "context": { - "entry_source": payload.context_json.get("entry_source"), - "user_name": payload.context_json.get("name"), - "user_role": payload.context_json.get("role"), - "user_position": payload.context_json.get("position"), - "user_grade": payload.context_json.get("grade"), - "user_role_codes": payload.context_json.get("role_codes", []), - "is_admin": bool(payload.context_json.get("is_admin")), - "request_context": payload.context_json.get("request_context"), - "attachment_count": payload.context_json.get("attachment_count"), - "attachment_names": self._resolve_attachment_names(payload), - "ocr_summary": payload.context_json.get("ocr_summary", ""), - "ocr_documents": payload.context_json.get("ocr_documents", []), - "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": self._resolve_conversation_history(payload), - }, - "tool_payload": self._build_model_tool_payload( - payload.tool_payload, - question=knowledge_question, - ), - "citations": [item.model_dump(mode="json") for item in citations], - "suggested_actions": [item.model_dump(mode="json") for item in suggested_actions], - "risk_flags": risk_flags, - "draft_payload": draft_payload.model_dump(mode="json") if draft_payload is not None else None, - "selected_capability_codes": payload.selected_capability_codes, - "requires_confirmation": payload.requires_confirmation, - "fallback_answer": fallback_answer, - } - if payload.ontology.scenario == "knowledge": - facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks( - payload.tool_payload, - question=knowledge_question, - ) - facts["knowledge_answer_evidence"] = [ - { - "title": str(item.get("title") or "").strip(), - "heading": str(item.get("heading") or "").strip(), - "kind": str(item.get("kind") or "").strip(), - "content": str(item.get("content") or "").strip(), - } - for item in self._build_knowledge_answer_evidence(payload) - ] - - if payload.ontology.scenario == "knowledge": - answer_style_instruction = ( - "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," - "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 。" - "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," - "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" - "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" - "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" - "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" - "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" - "如果命中的知识已经足够支持计算、比较或归纳,就直接给出结论;金额、标准、天数、补贴等问题要把计算过程写清楚。" - "适合时请使用 Markdown 二级标题、短段落和表格,让回答更清晰;表格必须保证每一行列数一致,不要出现空白残列。" - "只能陈述 hits 中明确出现的事实,不能用常识、外部知识或主观推断补齐缺失条件。" - "回答前先在全部 hits 中寻找与问题最直接相关的章节、表格或条目,不能只依赖排在最前面的片段。" - "如果 facts.knowledge_answer_evidence 中已经给出更短的高相关证据,优先基于这些证据组织答案,再回看原始 hits 补上下文。" - "如果某个表格在检索片段中已经被摊平成连续文本,只有在行、列和数值对应关系能够从片段本身明确确认时才能据此计算;" - "如果列对应关系不清楚,必须说明表格结构在当前片段中不够清晰,不能把第一列或相邻数字想当然套给用户。" - "如果 hits 中出现“结构化表格补充”,它表示知识归纳阶段已经把原文表格重新整理过," - "优先使用这类结构化表格来理解行列关系,再回看原文确认上下文。" - "facts.knowledge_evidence_blocks 中保留了原始换行和定宽排版;遇到表格时,优先按这些证据块阅读," - "必须按表头从左到右逐列对应数值,不能把第一列的数值直接套给后面的列名。" - "如果完成计算或归纳仍缺少某个关键映射关系、适用条件或数值依据,必须明确说明当前知识库还缺哪一项信息,再给出已能确认的部分。" - "如果用户问题里没有明确给出某个套用条件,而 hits 或 evidence 里也没有明确出现,就不能自己补一个默认值。" - "当问题涉及追问时,必须结合 conversation_history 延续上一轮上下文,而不是重新泛化成制度全文摘录。" - "不要大段粘贴原始命中文本;只提炼与问题直接相关的规则、条件、金额和注意事项。" - "如果依据仍然不足,明确指出缺少哪一项信息,再给出当前能确认的部分。" - ) - else: - answer_style_instruction = "用 2 到 4 段完成回答,优先给结论,再补充最关键的依据与下一步建议。" - - personalization_instruction = ( - "如果 context.user_name 存在,并且当前问题与员工本人适用标准、报销额度、审批权限、职级待遇有关," - "开头应自然称呼一次用户,例如“曹笑竹,您好”。" - "如果需要根据员工身份判断标准,优先参考 context.user_grade 与 context.user_position。" - "如果问题与用户身份无关,就不要生硬加入姓名、职级或岗位。" - ) - - system_prompt = ( - "你是 X-Financial 的专业财务 AI 助手。" - "回答必须准确、自然、可执行,不要泄露中间推理。" - "当知识问题有命中依据时,先给结论,再给结构化说明。" - "不要把制度全文原样搬出来,不要把检索片段当作最终答案直接粘贴。" - "如果使用表格,确保列名简洁、数值明确。" - f"{personalization_instruction}" - f"{answer_style_instruction}" - ) - user_prompt = ( - "请严格依据下面的 facts 生成最终答复:\n" - f"{json.dumps(facts, ensure_ascii=False, indent=2)}" - ) - return [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - @staticmethod - def _build_model_tool_payload( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> dict[str, Any]: - normalized = dict(tool_payload or {}) - hits = [] - for item in UserAgentService._select_knowledge_model_hits( - tool_payload, - question=question, - ): - if not isinstance(item, dict): - continue - hits.append( - { - "title": str(item.get("title") or "").strip(), - "document_name": str(item.get("document_name") or "").strip(), - "excerpt": str(item.get("excerpt") or "").strip(), - "content": str(item.get("content") or "").strip()[:1200], - "tags": list(item.get("tags") or [])[:5], - "evidence": list(item.get("evidence") or [])[:3], - "code": str(item.get("code") or "").strip(), - } - ) - normalized["hits"] = hits - return normalized - - @staticmethod - def _build_knowledge_evidence_blocks( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> str: - blocks: list[str] = [] - for index, item in enumerate( - UserAgentService._select_knowledge_model_hits( - tool_payload, - question=question, - )[:3], - start=1, - ): - if not isinstance(item, dict): - continue - title = str(item.get("title") or item.get("document_name") or f"证据 {index}").strip() - code = str(item.get("code") or "").strip() - content = str(item.get("content") or "").strip() - if not content: - continue - blocks.append( - "\n".join( - [ - f"[证据 {index}] {title}" + (f" ({code})" if code else ""), - "```text", - content[:1200], - "```", - ] - ) - ) - return "\n\n".join(blocks) - - @staticmethod - def _select_knowledge_model_hits( - tool_payload: dict[str, Any], - *, - question: str | None = None, - ) -> list[dict[str, Any]]: - raw_hits = [ - item - for item in list(tool_payload.get("hits") or []) - if isinstance(item, dict) - ][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)] - if not raw_hits: - return [] - - query_terms = UserAgentService._extract_knowledge_query_terms(question or "") - if not query_terms: - return raw_hits[:MAX_KNOWLEDGE_MODEL_HITS] - - ranked_hits = sorted( - enumerate(raw_hits), - key=lambda value: ( - UserAgentService._score_knowledge_model_hit( - value[1], - query_terms=query_terms, - rank_index=value[0], - ), - -value[0], - ), - reverse=True, - ) - return [item for _, item in ranked_hits[:MAX_KNOWLEDGE_MODEL_HITS]] - - @staticmethod - def _score_knowledge_model_hit( - item: dict[str, Any], - *, - query_terms: list[str], - rank_index: int, - ) -> int: - title = str(item.get("title") or item.get("document_name") or "").lower() - excerpt = str(item.get("excerpt") or "").lower() - content = str(item.get("content") or "").lower() - haystack = "\n".join([title, excerpt, content[:1400]]) - - matched_terms = [term for term in query_terms if term in haystack] - score = max(1, 48 - rank_index * 4) - score += len(matched_terms) * 10 - score += sum(1 for term in matched_terms if term in title) * 8 - - leading_marker = UserAgentService._leading_knowledge_appendix_marker(content) - if leading_marker == "# 章节导航": - score -= 22 - elif leading_marker == "# 问答线索补充": - score += 6 if matched_terms else -8 - elif leading_marker == "# 重点章节摘录": - score += 4 if matched_terms else -4 - elif leading_marker == "# 结构化表格补充": - score += 8 if matched_terms else -3 - - if matched_terms and "|" in content: - score += 8 - if matched_terms and any(marker in content for marker in (":", ":")): - score += 10 - if matched_terms and "\n" in content: - score += 4 - if matched_terms and any(marker in content for marker in ("附表", "第", "条")): - score += 4 - if matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")): - score += 4 - if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content): - score -= 12 - return score - - @staticmethod - def _leading_knowledge_appendix_marker(content: str) -> str: - normalized = str(content or "").lstrip() - for marker in ("# 章节导航", "# 重点章节摘录", "# 问答线索补充", "# 结构化表格补充"): - index = normalized.find(marker) - if 0 <= index <= 220: - return marker - return "" - - def _build_query_answer(self, payload: UserAgentRequest) -> str: - scenario = payload.ontology.scenario - data = payload.tool_payload - subject = self._resolve_subject(payload) - - if scenario == "expense": - query_payload = self._build_query_payload(payload) - scope_label = str(data.get("scope_label") or subject).strip() or subject - if query_payload is None: - return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。" - - 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}。你可以补充时间范围、单号或状态继续筛选。" - - 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} 笔,你可以直接点击单据查看详情。" - ) - 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} 日的单据," - "请前往个人报销中心查看。" - ) - - return " ".join(answer_parts + hint_parts).strip() - - if scenario == "accounts_receivable": - record_count = int(data.get("record_count") or 0) - outstanding_amount = float(data.get("outstanding_amount") or 0) - return ( - f"{subject}共命中 {record_count} 条应收,未回款金额 {outstanding_amount:.2f} 元。" - "建议结合账龄和客户分布继续排查逾期风险。" - ) - - if scenario == "accounts_payable": - record_count = int(data.get("record_count") or 0) - outstanding_amount = float(data.get("outstanding_amount") or 0) - return ( - f"{subject}共命中 {record_count} 条应付,待付金额 {outstanding_amount:.2f} 元。" - "如需推进动作,建议先生成付款建议草稿并发起人工确认。" - ) - - 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_fast_knowledge_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - ) -> str | None: - if payload.ontology.scenario != "knowledge": - return None - if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": - return None - - evidence_items = self._build_knowledge_answer_evidence(payload) - if not evidence_items: - return None - - question = self._resolve_knowledge_question(payload) - if not self._should_use_direct_knowledge_answer(question, evidence_items): - return None - - return self._render_knowledge_direct_answer( - payload, - citations=citations, - evidence_items=evidence_items, - ) - - def _render_knowledge_direct_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - evidence_items: list[dict[str, Any]], - ) -> str | None: - if not evidence_items: - return None - - title = str( - (citations[0].title if citations else "") - or evidence_items[0].get("title") - or "相关制度" - ).strip() - user_name = str(payload.context_json.get("name") or "").strip() - question = self._resolve_knowledge_question(payload) - query_terms = self._extract_knowledge_query_terms(question) - ordered_evidence_items = self._prioritize_knowledge_evidence_items(question, evidence_items) - primary_item = ordered_evidence_items[0] - primary_heading = self._format_knowledge_heading_label( - str(primary_item.get("heading") or "").strip() - ) - primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items) - - lines: list[str] = [] - if user_name: - lines.append(f"{user_name},您好。") - source_prefix = f"根据《{title}》" - if primary_heading: - source_prefix = f"{source_prefix}({primary_heading})" - - if str(primary_item.get("kind") or "") == "table": - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) - else: - if not primary_lines: - lines.append( - f"{source_prefix},当前能直接确认的是:" - f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" - ) - elif len(primary_lines) == 1: - lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") - else: - lines.append(f"{source_prefix},当前能直接确认的是:") - lines.extend(primary_lines) - - notes: list[str] = [] - location_note = self._build_missing_location_grounding_note(question, evidence_items) - if location_note: - notes.append(location_note) - if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items): - notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") - - if notes: - lines.append("") - lines.append("说明:") - lines.extend(f"- {note}" for note in notes) - - return "\n".join(line for line in lines if line is not None).strip() - - def _prioritize_knowledge_evidence_items( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> list[dict[str, Any]]: - if not evidence_items or not self._question_requires_explicit_condition(question): - return evidence_items - - for preferred_kind in ("table", "kv", "clause", "list"): - for index, item in enumerate(evidence_items): - if str(item.get("kind") or "") != preferred_kind: - continue - return [item, *evidence_items[:index], *evidence_items[index + 1 :]] - - for index, item in enumerate(evidence_items): - if re.search(r"\d", str(item.get("content") or "")): - return [item, *evidence_items[:index], *evidence_items[index + 1 :]] - - return evidence_items - - @staticmethod - def _resolve_knowledge_question(payload: UserAgentRequest) -> str: - return str(payload.context_json.get("user_input_text") or payload.message or "").strip() - - @staticmethod - def _looks_like_structured_knowledge_query(question: str) -> bool: - normalized = str(question or "").strip() - if not normalized: - return False - return any(keyword in normalized for keyword in KNOWLEDGE_DIRECT_ANSWER_HINTS) - - def _should_use_direct_knowledge_answer( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> bool: - if not evidence_items: - return False - if self._looks_like_structured_knowledge_query(question): - return True - return str(evidence_items[0].get("kind") or "") in {"table", "kv", "list", "clause"} - - def _build_knowledge_answer_evidence( - self, - payload: UserAgentRequest, - ) -> list[dict[str, Any]]: - question = self._resolve_knowledge_question(payload) - query_terms = self._extract_knowledge_query_terms(question) - candidates: list[dict[str, Any]] = [] - - for hit in self._select_knowledge_model_hits( - payload.tool_payload, - question=question, - ): - if not isinstance(hit, dict): - continue - candidates.extend(self._extract_knowledge_evidence_candidates(hit, query_terms)) - - deduped: list[dict[str, Any]] = [] - seen: set[tuple[str, str, str]] = set() - ranked_candidates = sorted( - candidates, - key=lambda value: ( - float(value.get("score") or 0), - -len(str(value.get("content") or "")), - ), - reverse=True, - ) - top_score = float(ranked_candidates[0].get("score") or 0) if ranked_candidates else 0.0 - - for item in ranked_candidates: - score = float(item.get("score") or 0) - if deduped and score < max(6.0, top_score - 14): - continue - key = ( - str(item.get("title") or "").strip(), - str(item.get("heading") or "").strip(), - self._clean_knowledge_segment_text(str(item.get("content") or ""))[:180], - ) - if key in seen: - continue - seen.add(key) - deduped.append(item) - if len(deduped) >= MAX_KNOWLEDGE_DIRECT_EVIDENCE: - break - return deduped - - def _extract_knowledge_evidence_candidates( - self, - hit: dict[str, Any], - query_terms: list[str], - ) -> list[dict[str, Any]]: - title = str(hit.get("title") or hit.get("document_name") or "相关制度").strip() - content = str(hit.get("content") or "").strip() - if not content: - return [] - - raw_candidates = self._merge_knowledge_lead_in_segments( - self._split_knowledge_hit_into_segments(content) - ) - candidates: list[dict[str, Any]] = [] - for item in raw_candidates: - score = self._score_knowledge_evidence_candidate(item, query_terms) - if query_terms and score <= 0: - continue - normalized = dict(item) - normalized["title"] = title - normalized["score"] = score - candidates.append(normalized) - - if candidates: - return candidates - - fallback_text = str(hit.get("excerpt") or "").strip() or self._extract_excerpt(content) - if not fallback_text: - return [] - return [ - { - "title": title, - "heading": "", - "kind": "paragraph", - "content": fallback_text, - "score": 1, - } - ] - - @staticmethod - def _is_knowledge_lead_in_segment(item: dict[str, str]) -> bool: - kind = str(item.get("kind") or "").strip() - content = str(item.get("content") or "").strip() - return kind in {"kv", "list", "clause"} and content.endswith((":", ":")) - - @staticmethod - def _extract_knowledge_marker_family(content: str) -> str: - normalized = str(content or "").strip() - if not normalized: - return "" - if KNOWLEDGE_ARTICLE_PATTERN.match(normalized): - return "article" - if re.match(r"^\d+[.)、]\s*", normalized): - return "arabic" - if re.match(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", normalized): - return "paren" - if re.match(r"^[①②③④⑤⑥⑦⑧⑨⑩]\s*", normalized): - return "circled" - if KNOWLEDGE_LIST_ITEM_PATTERN.match(normalized): - return "bullet" - return "" - - @staticmethod - def _format_knowledge_heading_label(heading: str) -> str: - parts = [item.strip() for item in str(heading or "").split(">") if item.strip()] - return " / ".join(parts) - - def _merge_knowledge_lead_in_segments( - self, - segments: list[dict[str, str]], - ) -> list[dict[str, str]]: - if not segments: - return [] - - merged: list[dict[str, str]] = [] - index = 0 - while index < len(segments): - current = dict(segments[index]) - if not self._is_knowledge_lead_in_segment(current): - merged.append(current) - index += 1 - continue - - base_heading = str(current.get("heading") or "").strip() - current_marker = self._extract_knowledge_marker_family(str(current.get("content") or "")) - follow_segments: list[dict[str, str]] = [] - next_index = index + 1 - - while next_index < len(segments): - candidate = segments[next_index] - if str(candidate.get("heading") or "").strip() != base_heading: - break - - candidate_kind = str(candidate.get("kind") or "").strip() - candidate_content = str(candidate.get("content") or "").strip() - candidate_marker = self._extract_knowledge_marker_family(candidate_content) - if not candidate_content or candidate_kind == "table": - break - if current_marker and candidate_marker == current_marker: - break - if self._is_knowledge_lead_in_segment(candidate) and follow_segments: - break - if candidate_kind not in {"list", "paragraph", "kv", "clause"}: - break - - follow_segments.append(candidate) - next_index += 1 - if len(follow_segments) >= 4: - break - if candidate_kind == "paragraph" and len(candidate_content) >= 200: - break - - if follow_segments: - current["content"] = "\n".join( - [str(current.get("content") or "").strip()] - + [str(item.get("content") or "").strip() for item in follow_segments] - ) - if any(str(item.get("kind") or "").strip() == "list" for item in follow_segments): - current["kind"] = "list" - merged.append(current) - index = next_index - continue - - merged.append(current) - index += 1 - - return merged - - def _split_knowledge_hit_into_segments(self, content: str) -> list[dict[str, str]]: - segments: list[dict[str, str]] = [] - markdown_headings: list[str] = [] - section_heading = "" - paragraph_lines: list[str] = [] - table_lines: list[str] = [] - - def current_heading() -> str: - heading_parts = [item for item in markdown_headings if item] - if section_heading: - heading_parts.append(section_heading) - return " > ".join(heading_parts) - - def flush_paragraph() -> None: - nonlocal paragraph_lines - if not paragraph_lines: - return - merged = " ".join(line.strip() for line in paragraph_lines if line.strip()).strip() - paragraph_lines = [] - if merged: - segments.append( - { - "heading": current_heading(), - "kind": "paragraph", - "content": merged, - } - ) - - def flush_table() -> None: - nonlocal table_lines - if not table_lines: - return - merged = "\n".join(line.rstrip() for line in table_lines if line.strip()).strip() - table_lines = [] - if merged: - segments.append( - { - "heading": current_heading(), - "kind": "table", - "content": merged, - } - ) - - for raw_line in str(content or "").replace("\r\n", "\n").replace("\r", "\n").splitlines(): - line = raw_line.rstrip() - stripped = line.strip() - - if not stripped: - flush_paragraph() - flush_table() - continue - - markdown_heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped) - if markdown_heading_match: - flush_paragraph() - flush_table() - level = len(markdown_heading_match.group(1)) - heading_text = markdown_heading_match.group(2).strip() - markdown_headings = markdown_headings[: max(0, level - 1)] - markdown_headings.append(heading_text) - section_heading = "" - continue - - if KNOWLEDGE_SECTION_HEADING_PATTERN.match(stripped) and len(stripped) <= 90: - flush_paragraph() - flush_table() - section_heading = stripped.lstrip("#").strip() - continue - - if stripped.count("|") >= 2 and "|" in stripped: - flush_paragraph() - table_lines.append(stripped) - continue - - flush_table() - - if KNOWLEDGE_LIST_ITEM_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "list", - "content": stripped, - } - ) - continue - - if KNOWLEDGE_NUMBERED_ITEM_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "list", - "content": stripped, - } - ) - continue - - if KNOWLEDGE_ARTICLE_PATTERN.match(stripped): - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "clause", - "content": stripped, - } - ) - continue - - if (":" in stripped or ":" in stripped) and len(stripped) <= 180: - flush_paragraph() - segments.append( - { - "heading": current_heading(), - "kind": "kv", - "content": stripped, - } - ) - continue - - paragraph_lines.append(stripped) - - flush_paragraph() - flush_table() - return segments - - def _score_knowledge_evidence_candidate( - self, - item: dict[str, str], - query_terms: list[str], - ) -> int: - heading = str(item.get("heading") or "").lower() - content = str(item.get("content") or "").lower() - kind = str(item.get("kind") or "").strip() - haystack = "\n".join([heading, content]) - - matched_terms = [term for term in query_terms if term in haystack] - score = len(matched_terms) * 10 - score += sum(1 for term in matched_terms if term in heading) * 6 - - if kind == "table": - score += 10 - elif kind in {"kv", "clause", "list"}: - score += 8 - elif kind == "paragraph": - score += 4 - - if "问答线索补充" in heading or "重点章节摘录" in heading: - score += 8 - if "结构化表格补充" in heading: - score += 10 - if "章节导航" in heading or "目录" in heading: - score -= 16 - if re.search(r"[.。…]{6,}", content): - score -= 12 - if any(hint in content for hint in ("应", "需", "不得", "可以", "标准", "条件", "材料", "审批", "流程", "包括")): - score += 3 - - content_length = len(content) - if content_length > 220: - score -= min(8, (content_length - 220) // 40) - return score - - @staticmethod - def _extract_knowledge_query_terms(question: str) -> list[str]: - normalized_question = str(question or "").strip().lower() - if not normalized_question: - return [] - - terms: list[str] = [] - seen: set[str] = set() - - def remember(term: str) -> None: - normalized = str(term or "").strip().lower() - if ( - not normalized - or normalized in seen - or normalized in KNOWLEDGE_QUERY_STOPWORDS - ): - return - seen.add(normalized) - terms.append(normalized) - - for item in re.findall(r"[a-z0-9][a-z0-9_\-]{1,}", normalized_question): - remember(item) - - for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question): - if len(block) <= 4: - remember(block) - continue - for size in (4, 3, 2): - for start in range(0, len(block) - size + 1): - remember(block[start : start + size]) - if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS: - return terms - - return terms[:MAX_KNOWLEDGE_QUERY_TERMS] - - @staticmethod - def _clean_knowledge_segment_text(content: str) -> str: - normalized = str(content or "").strip() - normalized = re.sub(r"^[-*•]\s*", "", normalized) - normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) - normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) - normalized = re.sub(r"\s+", " ", normalized) - if len(normalized) <= 180: - return normalized - return f"{normalized[:177].rstrip()}..." - - @staticmethod - def _normalize_knowledge_line(content: str, *, preserve_marker: bool) -> str: - normalized = str(content or "").strip() - normalized = re.sub(r"^[-*•]\s*", "", normalized) - if not preserve_marker: - normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) - normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) - normalized = re.sub(r"\s+", " ", normalized) - return normalized - - def _split_clean_knowledge_lines( - self, - content: str, - *, - preserve_marker: bool, - ) -> list[str]: - return [ - line - for line in ( - self._normalize_knowledge_line(item, preserve_marker=preserve_marker) - for item in str(content or "").splitlines() - ) - if line - ] - - def _render_knowledge_evidence_text(self, item: dict[str, Any]) -> str: - lines = self._split_clean_knowledge_lines( - str(item.get("content") or ""), - preserve_marker=True, - ) - if not lines: - return "" - if len(lines) == 1: - return self._clean_knowledge_segment_text(lines[0]) - return "\n".join(f" {line}" for line in lines) - - def _collect_direct_knowledge_answer_lines( - self, - ordered_evidence_items: list[dict[str, Any]], - ) -> list[str]: - if not ordered_evidence_items: - return [] - - primary_item = ordered_evidence_items[0] - primary_title = str(primary_item.get("title") or "").strip() - primary_heading = str(primary_item.get("heading") or "").strip() - primary_kind = str(primary_item.get("kind") or "").strip() - - related_items = [primary_item] - if primary_kind != "table": - for item in ordered_evidence_items[1:]: - if len(related_items) >= 3: - break - if str(item.get("kind") or "").strip() != primary_kind: - continue - if str(item.get("title") or "").strip() != primary_title: - continue - if str(item.get("heading") or "").strip() != primary_heading: - continue - related_items.append(item) - - lines: list[str] = [] - seen: set[str] = set() - for item in related_items: - rendered = self._render_knowledge_evidence_text(item) - for line in rendered.splitlines(): - normalized = str(line or "").strip() - if not normalized or normalized in seen: - continue - seen.add(normalized) - lines.append(line) - return lines - - def _summarize_knowledge_evidence_content( - self, - item: dict[str, Any], - query_terms: list[str], - ) -> str: - kind = str(item.get("kind") or "").strip() - content = str(item.get("content") or "").strip() - if kind == "table": - preview = self._extract_relevant_table_preview(content, query_terms) - preview_rows = [line for line in preview.splitlines() if line.strip()][:4] - if len(preview_rows) >= 3: - return "当前命中的直接依据是一张与问题强相关的标准表,已摘出最相关的表头和行。" - return "当前命中的直接依据是一张与问题强相关的标准表。" - lines = self._split_clean_knowledge_lines(content, preserve_marker=True) - if len(lines) >= 2: - return self._clean_knowledge_segment_text(f"{lines[0]} {' '.join(lines[1:4])}") - return self._clean_knowledge_segment_text(content) - - @staticmethod - def _extract_relevant_table_preview(content: str, query_terms: list[str]) -> str: - lines = [line.strip() for line in str(content or "").splitlines() if line.strip()] - if len(lines) <= 3: - return "\n".join(lines) - - header = lines[0] - divider = lines[1] if len(lines) > 1 else "" - body = lines[2:] if divider.count("|") >= 2 else lines[1:] - - matched_rows = [ - row - for row in body - if any(term in row.lower() for term in query_terms) - ] - selected_rows = matched_rows[:3] or body[:2] - preview_lines = [header] - if divider: - preview_lines.append(divider) - preview_lines.extend(selected_rows) - return "\n".join(preview_lines).strip() - - @staticmethod - def _question_requires_explicit_condition(question: str) -> bool: - normalized = str(question or "").strip() - return any(keyword in normalized for keyword in ("多少", "金额", "上限", "限额", "标准", "条件", "需要")) - - def _build_missing_location_grounding_note( - self, - question: str, - evidence_items: list[dict[str, Any]], - ) -> str: - location = self._extract_query_location(question) - if not location: - return "" - - haystack = "\n".join( - str(item.get("heading") or "") + "\n" + str(item.get("content") or "") - for item in evidence_items - ) - if location in haystack: - return "" - return ( - f"当前命中的制度依据没有直接写出“{location}”对应的地区档位或映射关系," - "因此不能直接把它套用到表格中的某一列。" - ) - - @staticmethod - def _answer_evidence_has_numeric_or_condition(evidence_items: list[dict[str, Any]]) -> bool: - for item in evidence_items: - content = str(item.get("content") or "") - if re.search(r"\d", content): - return True - if any( - keyword in content - for keyword in ("应", "需", "不得", "可以", "条件", "材料", "审批", "流程", "标准", "适用") - ): - return True - return False - - def _build_explain_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": - if citations: - return self._build_knowledge_search_answer(payload, citations) - - tool_message = str(payload.tool_payload.get("message") or "").strip() - if tool_message: - return tool_message - - if citations: - titles = "、".join(item.title for item in citations[:2]) - summary = citations[0].excerpt or "请结合制度全文进一步确认。" - return f"已检索到相关依据:{titles}。核心说明:{summary}" - - return ( - f"当前还没有与“{SCENARIO_LABELS.get(payload.ontology.scenario, '当前问题')}”" - "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" - ) - - def _build_knowledge_search_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)] - evidence_items = self._build_knowledge_answer_evidence(payload) - primary_citation = citations[0] if citations else None - title = str( - (primary_citation.title if primary_citation else "") - or (hits[0].get("title") if hits else "") - or "相关制度" - ).strip() - user_name = str(payload.context_json.get("name") or "").strip() - prefix = f"{user_name},您好。\n" if user_name else "" - if not hits: - return ( - f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," - "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," - "建议先检查主对话模型的连通性。" - ) - - evidence_lines: list[str] = [] - for item in evidence_items[:3]: - heading = str(item.get("heading") or "").strip() - heading_text = f" > {heading}" if heading else "" - if str(item.get("kind") or "") == "table": - preview = self._extract_relevant_table_preview( - str(item.get("content") or ""), - self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)), - ) - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}") - continue - rendered = self._render_knowledge_evidence_text(item) - if rendered: - if "\n" in rendered: - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}") - else: - evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}") - - if not evidence_lines: - for item in hits[:2]: - item_title = str(item.get("title") or item.get("document_name") or "相关制度").strip() - excerpt = ( - str(item.get("excerpt") or "").strip() - or self._extract_excerpt(str(item.get("content") or "")) - ) - if not excerpt: - continue - evidence_lines.append(f"- 《{item_title}》:{excerpt}") - - if not evidence_lines: - return ( - f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," - "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," - "建议先检查主对话模型的连通性。" - ) - - return "\n".join( - [ - f"{prefix}我已经命中与你这次问题最相关的制度依据,但答案整理阶段本轮没有及时返回。", - "先给你当前最直接的依据:", - *evidence_lines, - "如果你希望我继续把这些依据整理成更完整的结论、步骤或对比说明,可以继续缩小问题范围后再问一次。", - ] - ).strip() - - def _build_risk_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - risk_flags = self._resolve_risk_flags(payload) - platform_messages = self._evaluate_platform_risk_messages(payload) - if not risk_flags and not platform_messages: - return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" - - reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] - if platform_messages: - reasons.extend(platform_messages) - citation_text = ( - f" 参考规则:{'、'.join(item.title for item in citations[:2])}。" - if citations - else "" - ) - signal_count = len(risk_flags) + (1 if platform_messages else 0) - return ( - f"本次识别到 {signal_count} 类风险信号。" - f"触发原因:{';'.join(reasons)}。" - "建议先复核明细、附件和审批链,再决定是否继续处理。" - f"{citation_text}" - ) - - def _evaluate_platform_risk_messages(self, payload: UserAgentRequest) -> list[str]: - claim_id = str(payload.tool_payload.get("claim_id") or "").strip() - if not claim_id: - return [] - - claim = self.db.scalar( - select(ExpenseClaim) - .where(ExpenseClaim.id == claim_id) - .options(selectinload(ExpenseClaim.items)) - ) - if claim is None: - return [] - - rule_codes = resolve_rule_codes_for_risk_check( - payload.ontology, - query_text=payload.message, - ) - review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( - claim, - rule_codes=rule_codes, - ) - messages: list[str] = [] - for flag in review.get("flags") or []: - if not isinstance(flag, dict): - continue - message = str(flag.get("message") or "").strip() - if message and message not in messages: - messages.append(message) - return messages - - def _build_draft_payload(self, payload: UserAgentRequest) -> UserAgentDraftPayload: - scenario_label = SCENARIO_LABELS.get(payload.ontology.scenario, "业务") - subject = self._resolve_subject(payload) - claim_no = str(payload.tool_payload.get("claim_no") or "").strip() or None - claim_status = str(payload.tool_payload.get("status") or "").strip() or None - approval_stage = str(payload.tool_payload.get("approval_stage") or "").strip() or None - is_submitted = claim_status == "submitted" - title = f"{scenario_label}处理意见草稿" - if claim_no: - title = f"{scenario_label}{'报销单' if is_submitted else '草稿'} {claim_no}" - if is_submitted: - body = ( - f"主题:{subject}\n" - f"结论:报销单已提交,当前节点为 {approval_stage or '审批中'}。\n" - "建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n" - f"原始问题:{payload.message}" - ) - else: - body = ( - f"主题:{subject}\n" - "结论:已根据当前语义解析结果生成草稿,尚未自动执行。\n" - "建议:请先核对明细、规则命中和所需附件,再由人工确认是否提交正式流程。\n" - f"原始问题:{payload.message}" - ) - return UserAgentDraftPayload( - draft_type=payload.ontology.scenario, - title=title, - body=body, - confirmation_required=not is_submitted, - claim_id=str(payload.tool_payload.get("claim_id") or "").strip() or None, - claim_no=claim_no, - status=claim_status, - approval_stage=approval_stage, - ) - - def _build_suggested_actions( - self, - payload: UserAgentRequest, - ) -> list[UserAgentSuggestedAction]: - if payload.ontology.scenario == "knowledge": - return [] - - if self._is_generic_expense_prompt(payload): - return [ - UserAgentSuggestedAction( - label="上传票据", - action_type="ask_clarification", - description="上传发票、行程单或付款截图,继续识别报销内容。", - ), - UserAgentSuggestedAction( - label="补充报销信息", - action_type="ask_clarification", - description="补充费用类型、金额、时间和事由后继续处理。", - ), - ] - - if payload.ontology.intent in {"query", "compare"}: - return [ - UserAgentSuggestedAction( - label="查看明细", - action_type="open_detail", - description="继续查看命中记录和过滤条件。", - ), - UserAgentSuggestedAction( - label="生成处理意见", - action_type="create_draft", - description="把当前查询结果整理成可确认草稿。", - ), - ] - - if payload.ontology.intent == "risk_check": - return [ - UserAgentSuggestedAction( - label="人工复核风险", - action_type="manual_review", - description="优先检查明细、附件和规则命中原因。", - ), - UserAgentSuggestedAction( - label="生成整改建议", - action_type="create_draft", - description="把风险说明整理成处理意见草稿。", - ), - ] - - if payload.ontology.intent == "draft": - return [ - UserAgentSuggestedAction( - label="复制草稿", - action_type="copy_draft", - description="复制当前草稿后交由人工确认。", - ), - UserAgentSuggestedAction( - label="补充上下文", - action_type="ask_clarification", - description="补充单据编号、客户或供应商信息以完善草稿。", - ), - ] - - return [ - UserAgentSuggestedAction( - label="查看规则全文", - action_type="open_rule", - description="继续查看引用规则或知识内容。", - ), - UserAgentSuggestedAction( - label="补充问题上下文", - action_type="ask_clarification", - description="补充业务对象、时间或单据范围,提升回答准确度。", - ), - ] - - def _build_review_payload( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - draft_payload: UserAgentDraftPayload | None, - ) -> UserAgentReviewPayload | None: - attachment_count = self._resolve_attachment_count(payload) - ocr_documents = self._resolve_ocr_documents(payload) - if payload.ontology.scenario != "expense": - return None - if payload.ontology.intent not in {"draft", "operate"} and attachment_count <= 0 and not ocr_documents: - return None - - document_cards = self._build_review_document_cards(payload, ocr_documents=ocr_documents) - claim_groups = self._build_review_claim_groups( - payload, - document_cards=document_cards, - ) - slot_cards = self._build_review_slot_cards( - payload, - ocr_documents=ocr_documents, - claim_groups=claim_groups, - ) - missing_slot_keys = self._resolve_review_missing_slot_keys( - payload, - slot_cards=slot_cards, - ) - risk_briefs = self._build_review_risk_briefs( - payload, - citations=citations, - document_cards=document_cards, - claim_groups=claim_groups, - ) - association_choice_pending = self._is_review_association_choice_pending(payload) - can_proceed = ( - False - if association_choice_pending - else self._can_proceed_review( - payload, - missing_slot_keys=missing_slot_keys, - claim_groups=claim_groups, - ) - ) - confirmation_actions = self._build_review_confirmation_actions( - payload, - can_proceed=can_proceed, - claim_groups=claim_groups, - draft_payload=draft_payload, - ) - edit_fields = self._build_review_edit_fields( - payload, - draft_payload=draft_payload, - slot_cards=slot_cards, - ) - intent_summary = self._build_review_intent_summary( - payload, - slot_cards=slot_cards, - claim_groups=claim_groups, - ) - body_message = self._build_review_body_message( - payload, - slot_cards=slot_cards, - risk_briefs=risk_briefs, - can_proceed=can_proceed, - document_cards=document_cards, - ) - - return UserAgentReviewPayload( - intent_summary=intent_summary, - body_message=body_message, - scenario=payload.ontology.scenario, - intent=payload.ontology.intent, - can_proceed=can_proceed, - missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys], - risk_briefs=risk_briefs, - slot_cards=slot_cards, - document_cards=document_cards, - claim_groups=claim_groups, - confirmation_actions=confirmation_actions, - edit_fields=edit_fields, - ) - - def _build_review_slot_cards( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> list[UserAgentReviewSlotCard]: - entity_map = self._collect_entity_values(payload) - time_slot = self._build_time_slot(payload) - location_slot = self._build_location_slot(payload) - customer_slot = self._build_customer_slot(payload, entity_map=entity_map) - participants_slot = self._build_participants_slot(payload, entity_map=entity_map) - amount_slot = self._build_amount_slot(payload, entity_map=entity_map, ocr_documents=ocr_documents) - expense_type_slot = self._build_expense_type_slot( - payload, - entity_map=entity_map, - ocr_documents=ocr_documents, - ) - merchant_slot = self._build_merchant_slot(payload, ocr_documents=ocr_documents) - reason_slot = self._build_reason_slot( - payload, - claim_groups=claim_groups, - ) - attachment_slot = self._build_attachment_slot(payload) - required_keys = self._resolve_required_review_keys( - payload, - primary_expense_type=str(expense_type_slot["normalized_value"] or ""), - claim_groups=claim_groups, - ) - - cards = [ - self._make_slot_card( - key="expense_type", - value=expense_type_slot["value"], - raw_value=expense_type_slot["raw_value"], - normalized_value=expense_type_slot["normalized_value"], - source=expense_type_slot["source"], - confidence=expense_type_slot["confidence"], - evidence=expense_type_slot["evidence"], - required="expense_type" in required_keys, - ), - self._make_slot_card( - key="customer_name", - value=customer_slot["value"], - raw_value=customer_slot["raw_value"], - normalized_value=customer_slot["normalized_value"], - source=customer_slot["source"], - confidence=customer_slot["confidence"], - evidence=customer_slot["evidence"], - required="customer_name" in required_keys, - ), - self._make_slot_card( - key="time_range", - value=time_slot["value"], - raw_value=time_slot["raw_value"], - normalized_value=time_slot["normalized_value"], - source=time_slot["source"], - confidence=time_slot["confidence"], - evidence=time_slot["evidence"], - required="time_range" in required_keys, - ), - self._make_slot_card( - key="location", - value=location_slot["value"], - raw_value=location_slot["raw_value"], - normalized_value=location_slot["normalized_value"], - source=location_slot["source"], - confidence=location_slot["confidence"], - evidence=location_slot["evidence"], - required="location" in required_keys, - ), - self._make_slot_card( - key="merchant_name", - value=merchant_slot["value"], - raw_value=merchant_slot["raw_value"], - normalized_value=merchant_slot["normalized_value"], - source=merchant_slot["source"], - confidence=merchant_slot["confidence"], - evidence=merchant_slot["evidence"], - required="merchant_name" in required_keys, - ), - self._make_slot_card( - key="amount", - value=amount_slot["value"], - raw_value=amount_slot["raw_value"], - normalized_value=amount_slot["normalized_value"], - source=amount_slot["source"], - confidence=amount_slot["confidence"], - evidence=amount_slot["evidence"], - required="amount" in required_keys, - ), - self._make_slot_card( - key="reason", - value=reason_slot["value"], - raw_value=reason_slot["raw_value"], - normalized_value=reason_slot["normalized_value"], - source=reason_slot["source"], - confidence=reason_slot["confidence"], - evidence=reason_slot["evidence"], - required="reason" in required_keys, - ), - self._make_slot_card( - key="participants", - value=participants_slot["value"], - raw_value=participants_slot["raw_value"], - normalized_value=participants_slot["normalized_value"], - source=participants_slot["source"], - confidence=participants_slot["confidence"], - evidence=participants_slot["evidence"], - required="participants" in required_keys, - ), - self._make_slot_card( - key="attachments", - value=attachment_slot["value"], - raw_value=attachment_slot["raw_value"], - normalized_value=attachment_slot["normalized_value"], - source=attachment_slot["source"], - confidence=attachment_slot["confidence"], - evidence=attachment_slot["evidence"], - required="attachments" in required_keys, - ), - ] - return cards - - def _build_review_document_cards( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - ) -> list[UserAgentReviewDocumentCard]: - cards: list[UserAgentReviewDocumentCard] = [] - for index, item in enumerate(ocr_documents, start=1): - classified = self._classify_document(item, payload) - fields = self._extract_document_fields(item) - cards.append( - UserAgentReviewDocumentCard( - index=index, - filename=str(item.get("filename") or f"document-{index}"), - document_type=classified["document_type"], - suggested_expense_type=classified["expense_type"], - scene_label=GROUP_SCENE_LABELS.get( - classified["group_code"], - classified["scene_label"], - ), - summary=str(item.get("summary") or item.get("text") or "").strip(), - avg_score=float(item.get("avg_score") or 0.0), - preview_kind=str(item.get("preview_kind") or "").strip(), - preview_data_url=str(item.get("preview_data_url") or "").strip(), - warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], - fields=[ - UserAgentReviewDocumentField( - label=label, - value=value, - source="ocr", - ) - for label, value in fields.items() - if str(value).strip() - ], - ) - ) - return cards - - def _build_review_claim_groups( - self, - payload: UserAgentRequest, - *, - document_cards: list[UserAgentReviewDocumentCard], - ) -> list[UserAgentReviewClaimGroup]: - groups: dict[str, dict[str, object]] = {} - for card in document_cards: - group_code = self._normalize_group_code(card.suggested_expense_type) - bucket = groups.setdefault( - group_code, - { - "document_indexes": [], - "amount_total": 0.0, - "expense_type": str(card.suggested_expense_type or group_code).strip() or group_code, - "scene_label": GROUP_SCENE_LABELS.get( - str(card.suggested_expense_type or group_code).strip() or group_code, - GROUP_SCENE_LABELS.get(group_code, "其他费用"), - ), - "reasons": [], - }, - ) - bucket["document_indexes"].append(card.index) - bucket["amount_total"] = float(bucket["amount_total"]) + self._extract_amount_from_card(card) - bucket["reasons"].append(f"{card.filename} 识别为 {card.scene_label}") - current_expense_type = str(bucket["expense_type"] or "").strip() - current_card_type = str(card.suggested_expense_type or "").strip() - if current_expense_type and current_card_type and current_expense_type != current_card_type: - bucket["expense_type"] = group_code - bucket["scene_label"] = GROUP_SCENE_LABELS.get(group_code, "其他费用") - - if not groups: - expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "other") - group_code = self._normalize_group_code(expense_type_code) - groups[group_code] = { - "document_indexes": [], - "amount_total": self._resolve_amount_value(payload), - "expense_type": expense_type_code or "other", - "scene_label": GROUP_SCENE_LABELS.get(group_code, "其他费用"), - "reasons": ["当前主要依据用户文本和页面上下文进行分单建议。"], - } - - claim_groups: list[UserAgentReviewClaimGroup] = [] - for index, (group_code, bucket) in enumerate(groups.items(), start=1): - title = f"建议报销单 {index}:{bucket['scene_label']}" - rationale = ( - ";".join(dict.fromkeys(str(item) for item in bucket["reasons"])) - if bucket["reasons"] - else "当前仅有单一场景,无需拆单。" - ) - claim_groups.append( - UserAgentReviewClaimGroup( - group_code=group_code, - title=title, - expense_type=str(bucket["expense_type"]), - scene_label=str(bucket["scene_label"]), - document_indexes=list(bucket["document_indexes"]), - amount_total=round(float(bucket["amount_total"]), 2), - rationale=rationale, - ) - ) - return claim_groups - - def _build_review_risk_briefs( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - document_cards: list[UserAgentReviewDocumentCard], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> list[UserAgentReviewRiskBrief]: - briefs: list[UserAgentReviewRiskBrief] = [] - employee_name = self._collect_entity_values(payload).get("employee_name") or str( - payload.context_json.get("name") or "" - ).strip() - if employee_name: - since = datetime.now(UTC) - timedelta(days=90) - stmt = select(ExpenseClaim).where( - ExpenseClaim.employee_name == employee_name, - ExpenseClaim.occurred_at >= since, - ) - recent_claims = list(self.db.scalars(stmt).all()) - if recent_claims: - risky_count = sum(1 for item in recent_claims if item.risk_flags_json) - draft_count = sum(1 for item in recent_claims if item.status == "draft") - briefs.append( - UserAgentReviewRiskBrief( - title="历史报销画像", - level="info", - content=( - f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销," - f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。" - ), - ) - ) - current_amount = self._resolve_amount_value(payload) - if current_amount > 0: - duplicate_count = sum( - 1 - for item in recent_claims - if abs(float(item.amount) - current_amount) < 0.01 - ) - if duplicate_count: - briefs.append( - UserAgentReviewRiskBrief( - title="金额重复预警", - level="warning", - content=( - f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," - "提交前建议核对是否为重复报销或拆分不当。" - ), - ) - ) - - if citations: - briefs.append( - UserAgentReviewRiskBrief( - title="制度注意事项", - level="info", - content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。", - ) - ) - - warning_count = sum(len(item.warnings) for item in document_cards) - if warning_count: - briefs.append( - UserAgentReviewRiskBrief( - title="票据识别提醒", - level="warning", - content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。", - ) - ) - - if len(claim_groups) > 1: - briefs.append( - UserAgentReviewRiskBrief( - title="建议拆单", - level="high", - content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", - ) - ) - - return briefs[:4] - - def _build_review_confirmation_actions( - self, - payload: UserAgentRequest, - *, - can_proceed: bool, - claim_groups: list[UserAgentReviewClaimGroup], - draft_payload: UserAgentDraftPayload | None, - ) -> list[UserAgentReviewAction]: - if self._is_review_association_choice_pending(payload): - claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() - link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿" - return [ - UserAgentReviewAction( - label="取消", - action_type="cancel_review", - description="放弃当前识别结果,并退出本次核对流程。", - emphasis="secondary", - ), - UserAgentReviewAction( - label="修改识别信息", - action_type="edit_review", - description="打开结构化模板,按已识别字段逐项修改。", - emphasis="secondary", - ), - UserAgentReviewAction( - label=link_label, - action_type="link_to_existing_draft", - description=( - f"把本次上传票据并入现有草稿 {claim_no}。" - if claim_no - else "把本次上传票据并入现有草稿。" - ), - emphasis="primary", - ), - UserAgentReviewAction( - label="单独建立报销单", - action_type="create_new_claim_from_documents", - description="基于当前上传的多张票据,新建一张独立的报销草稿。", - emphasis="secondary", - ), - ] - - primary_action = UserAgentReviewAction( - label="继续下一步" if can_proceed else "保存为草稿", - action_type="next_step" if can_proceed else "save_draft", - description=( - "当前识别信息已满足继续处理条件,确认后进入下一步。" - if can_proceed - else "暂存当前识别结果,后续可以继续补充或修改。" - ), - emphasis="primary", - ) - if len(claim_groups) > 1 and can_proceed: - primary_action.description = f"系统建议拆分为 {len(claim_groups)} 张报销单,确认后继续下一步。" - if draft_payload is not None and draft_payload.claim_no and not can_proceed: - primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" - - return [ - UserAgentReviewAction( - label="取消", - action_type="cancel_review", - description="放弃当前识别结果,并退出本次核对流程。", - emphasis="secondary", - ), - UserAgentReviewAction( - label="修改识别信息", - action_type="edit_review", - description="打开结构化模板,按已识别字段逐项修改。", - emphasis="secondary", - ), - primary_action, - ] - - def _build_review_intent_summary( - self, - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> str: - slots = {item.key: item for item in slot_cards} - expense_type = slots.get("expense_type") - amount = slots.get("amount") - time_range = slots.get("time_range") - location = slots.get("location") - customer = slots.get("customer_name") - - summary = "我先根据您当前提供的信息整理出一笔报销。" - if expense_type and expense_type.value: - summary = f"识别到您希望报销一笔“{expense_type.value}”费用。" - details: list[str] = [] - if customer and customer.value: - details.append(f"客户为 {customer.value}") - if time_range and time_range.value: - details.append(f"时间为 {time_range.value}") - if location and location.value: - details.append(f"地点为 {location.value}") - if amount and amount.value: - details.append(f"金额为 {amount.value}") - reason = slots.get("reason") - if reason and reason.value: - details.append(f"事由是 {reason.value}") - if details: - return f"{summary} {','.join(details)}。" - return summary - - def _build_review_body_answer( - self, - payload: UserAgentRequest, - *, - review_payload: UserAgentReviewPayload | None, - draft_payload: UserAgentDraftPayload | None, - ) -> str | None: - if review_payload is None: - return None - if payload.ontology.scenario != "expense": - return None - if payload.ontology.intent not in {"draft", "operate"}: - return None - if payload.tool_payload.get("draft_limit_reached"): - return ( - str(payload.tool_payload.get("message") or "").strip() - or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - ) - - review_action = str(payload.context_json.get("review_action") or "").strip() - if review_action == "save_draft": - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" - "后续您可以继续补充缺失项,或修改识别结果后再继续提交。" - ) - return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" - if review_action == "link_to_existing_draft": - document_count = self._resolve_review_document_count(payload) - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" - ) - return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。" - if review_action == "create_new_claim_from_documents": - document_count = self._resolve_review_document_count(payload) - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" - ) - return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" - if review_action == "next_step": - if draft_payload is not None and draft_payload.status == "submitted": - stage_text = draft_payload.approval_stage or "审批中" - return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip() - if payload.tool_payload.get("submission_blocked"): - return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" - return ( - f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " - "当前关键信息已基本齐全,您确认无误后可以继续下一步。" - ) - if review_action == "edit_review": - return ( - f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " - f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}" - ) - return review_payload.body_message or None - - def _build_review_body_message( - self, - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - risk_briefs: list[UserAgentReviewRiskBrief], - can_proceed: bool, - document_cards: list[UserAgentReviewDocumentCard], - ) -> str: - if self._is_review_association_choice_pending(payload): - claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() - document_count = len(document_cards) or self._resolve_review_document_count(payload) - if claim_no: - return ( - f"已识别出本次上传的 {document_count} 张票据。" - f"系统检测到你已有草稿 {claim_no},请选择关联到该草稿,或单独建立一张新的报销单。" - ) - return ( - f"已识别出本次上传的 {document_count} 张票据。" - "系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。" - ) - - review_payload = UserAgentReviewPayload( - intent_summary="", - body_message="", - scenario=payload.ontology.scenario, - intent=payload.ontology.intent, - can_proceed=can_proceed, - missing_slots=self._resolve_review_missing_slot_labels(slot_cards), - risk_briefs=risk_briefs, - slot_cards=slot_cards, - document_cards=[], - claim_groups=[], - confirmation_actions=[], - edit_fields=[], - ) - return ( - f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} " - f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" - ) - - @staticmethod - def _resolve_review_missing_slot_labels( - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[str]: - return [item.label for item in slot_cards if item.status == "missing"] - - @staticmethod - def _build_review_guidance_copy( - review_payload: UserAgentReviewPayload, - *, - mention_save_draft: bool, - ) -> str: - missing_count = len(review_payload.missing_slots) - reminder_count = len(review_payload.risk_briefs) - - if review_payload.can_proceed: - if reminder_count: - return ( - f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。" - "您可以展开下方卡片查看详情,确认无误后继续下一步。" - ) - return "当前关键信息已基本齐全,您确认无误后可以继续下一步。" - - issue_parts: list[str] = [] - if missing_count: - issue_parts.append(f"{missing_count} 项信息待补充") - if reminder_count: - issue_parts.append(f"{reminder_count} 条提醒") - issue_summary = "、".join(issue_parts) if issue_parts else "一些细节还需要进一步确认" - - suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else "。" - return ( - f"当前还有 {issue_summary}。" - f"您可以展开下方卡片查看详情,继续补充或修改{suffix}" - ) - - @staticmethod - def _can_proceed_review( - payload: UserAgentRequest, - *, - missing_slot_keys: list[str], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> bool: - if payload.ontology.ambiguity: - return False - if missing_slot_keys: - return False - if not claim_groups: - return False - return True - - def _build_review_edit_fields( - self, - payload: UserAgentRequest, - *, - draft_payload: UserAgentDraftPayload | None, - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[UserAgentReviewEditField]: - slot_map = {item.key: item for item in slot_cards} - employee = self._resolve_employee_profile(payload) - reporter_name = ( - slot_map.get("reporter_name").value - if slot_map.get("reporter_name") - else str(payload.context_json.get("name") or "").strip() - ) - manager_name = self._resolve_manager_name(employee) - reason = slot_map.get("reason").value if slot_map.get("reason") else "" - attachments = "、".join(self._resolve_attachment_names(payload)) - - fields = [ - UserAgentReviewEditField( - key="claim_no", - label="报销单据编号", - value=str(draft_payload.claim_no if draft_payload is not None and draft_payload.claim_no else "待生成"), - placeholder="保存草稿后自动生成", - required=False, - group="basic", - ), - UserAgentReviewEditField( - key="expense_type", - label="报销类型", - value=slot_map.get("expense_type").value if slot_map.get("expense_type") else "", - placeholder="例如:业务招待费 / 差旅费", - group="basic", - ), - UserAgentReviewEditField( - key="occurred_date", - label="业务发生时间", - value=slot_map.get("time_range").normalized_value if slot_map.get("time_range") and slot_map.get("time_range").normalized_value else slot_map.get("time_range").value if slot_map.get("time_range") else "", - placeholder="例如:2026-05-11", - group="basic", - ), - UserAgentReviewEditField( - key="reporter_name", - label="报销人", - value=reporter_name, - placeholder="请输入报销人姓名", - group="basic", - ), - UserAgentReviewEditField( - key="manager_name", - label="直属上司姓名", - value=manager_name, - placeholder="请输入直属上司姓名", - required=False, - group="basic", - ), - UserAgentReviewEditField( - key="customer_name", - label="客户名称", - value=slot_map.get("customer_name").value if slot_map.get("customer_name") else "", - placeholder="请输入客户名称", - group="business", - ), - UserAgentReviewEditField( - key="business_location", - label="业务地点", - value=slot_map.get("location").normalized_value if slot_map.get("location") and slot_map.get("location").normalized_value else slot_map.get("location").value if slot_map.get("location") else "", - placeholder="例如:北京 / 客户现场", - required=False, - group="business", - ), - UserAgentReviewEditField( - key="merchant_name", - label="酒店/商户", - value=slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "", - placeholder="请输入酒店或商户名称", - required=False, - group="business", - ), - UserAgentReviewEditField( - key="amount", - label="金额", - value=slot_map.get("amount").normalized_value if slot_map.get("amount") and slot_map.get("amount").normalized_value else slot_map.get("amount").value if slot_map.get("amount") else "", - placeholder="例如:200.00元", - group="business", - ), - UserAgentReviewEditField( - key="participants", - label="参与人员", - value=slot_map.get("participants").value if slot_map.get("participants") else "", - placeholder="例如:客户 2 人,我方 1 人", - group="business", - ), - UserAgentReviewEditField( - key="reason", - label="事由", - value=reason, - placeholder="请输入报销事由", - field_type="textarea", - group="business", - ), - UserAgentReviewEditField( - key="attachment_names", - label="附件清单", - value=attachments, - placeholder="例如:发票.jpg、行程单.png", - required=False, - field_type="textarea", - group="attachments", - ), - ] - return fields - - def _resolve_employee_profile(self, payload: UserAgentRequest) -> Employee | None: - candidates = [ - str(payload.context_json.get("name") or "").strip(), - str(payload.user_id or "").strip(), - self._collect_entity_values(payload).get("employee_name", ""), - ] - normalized = [item for item in dict.fromkeys(candidates) if item] - if not normalized: - return None - - stmt = ( - select(Employee) - .where( - or_( - Employee.name.in_(normalized), - Employee.employee_no.in_(normalized), - Employee.email.in_(normalized), - ) - ) - .limit(1) - ) - return self.db.scalar(stmt) - - @staticmethod - def _resolve_manager_name(employee: Employee | None) -> str: - if employee is None: - return "" - if employee.manager is not None and employee.manager.name: - return employee.manager.name - if employee.organization_unit is not None and employee.organization_unit.manager_name: - return employee.organization_unit.manager_name - return "" - - @staticmethod - def _extract_message_reason(message: str) -> str: - for line in str(message or "").splitlines(): - cleaned = line.strip() - if not cleaned: - continue - if cleaned.startswith(("附件名称:", "OCR摘要:", "关联单号:")): - continue - return cleaned[:300] - return "" - - @staticmethod - def _looks_like_system_generated_reason_message(message: str) -> bool: - cleaned = str(message or "").strip() - if not cleaned: - return False - compact = re.sub(r"\s+", "", cleaned) - return compact.startswith(SYSTEM_GENERATED_REASON_PREFIXES) - - def _resolve_reason_source_text(self, payload: UserAgentRequest) -> str: - explicit_text = payload.context_json.get("user_input_text") - if isinstance(explicit_text, str): - return explicit_text.strip() - if self._looks_like_system_generated_reason_message(payload.message): - return "" - return str(payload.message or "").strip() - - @classmethod - def _resolve_reason_text(cls, message: str) -> str: - reason = cls._extract_message_reason(message) - if not reason: - return "" - - compact = re.sub(r"\s+", "", reason) - if compact in GENERIC_EXPENSE_PROMPTS: - return "" - - instruction_prefixes = ( - "帮我生成", - "请帮我生成", - "生成", - "起草", - "创建", - "发起", - "准备", - "帮我报销", - "我要报销", - "我想报销", - ) - if compact.startswith(instruction_prefixes): - for separator in (",", ",", "。", ";", ";", ":", ":"): - if separator in reason: - trailing = reason.split(separator, 1)[1].strip() - if trailing: - return trailing[:300] - return "" - - return reason - - @staticmethod - def _should_skip_model_answer( - payload: UserAgentRequest, - review_payload: UserAgentReviewPayload | None, - ) -> bool: - if payload.ontology.scenario == "expense" and payload.ontology.intent in {"query", "compare"}: - return True - if review_payload is None: - return False - return payload.ontology.scenario == "expense" and ( - payload.ontology.intent == "draft" - or int(payload.context_json.get("attachment_count") or 0) > 0 - ) - - def _build_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: - knowledge_citations = self._build_knowledge_citations(payload) - if payload.ontology.scenario == "knowledge": - return knowledge_citations[:3] - - rule_citations = self._build_rule_asset_citations(payload) - if knowledge_citations: - return (knowledge_citations + rule_citations)[:3] - return rule_citations - - @staticmethod - def _build_knowledge_citations(payload: UserAgentRequest) -> list[UserAgentCitation]: - citations: list[UserAgentCitation] = [] - for item in list(payload.tool_payload.get("hits") or [])[:3]: - if not isinstance(item, dict): - continue - title = str(item.get("title") or item.get("document_name") or "").strip() - code = str(item.get("code") or item.get("candidate_id") or "").strip() - if not title or not code: - continue - citations.append( - UserAgentCitation( - source_type="knowledge", - code=code, - title=title, - version=str(item.get("version") or "").strip() or None, - updated_at=str(item.get("updated_at") or "").strip() or None, - excerpt=( - str(item.get("excerpt") or "").strip() - or str(item.get("content") or "").strip() - or None - ), - ) - ) - return citations - - def _build_rule_asset_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: - domain = self._resolve_domain(payload.ontology.scenario) - items = self.asset_service.list_assets( - asset_type=AgentAssetType.RULE.value, - status=AgentAssetStatus.ACTIVE.value, - domain=domain, - ) - ranked = self._rank_rule_assets(items, payload) - citations: list[UserAgentCitation] = [] - for item in ranked[:2]: - detail = self.asset_service.get_asset(item.id) - if detail is None: - continue - excerpt = self._extract_excerpt(str(detail.current_version_content or "")) - citations.append( - UserAgentCitation( - source_type="rule", - code=detail.code, - title=detail.name, - version=detail.current_version, - updated_at=detail.updated_at.date().isoformat(), - excerpt=excerpt, - ) - ) - return citations - - @staticmethod - def _resolve_risk_flags(payload: UserAgentRequest) -> list[str]: - tool_flags = payload.tool_payload.get("risk_flags") - if isinstance(tool_flags, list) and tool_flags: - return [str(item) for item in tool_flags] - return [str(item) for item in payload.ontology.risk_flags] - - @staticmethod - def _resolve_subject(payload: UserAgentRequest) -> str: - named_entities = [ - item.value - for item in payload.ontology.entities - if item.type in {"employee", "customer", "vendor", "project"} - ] - if named_entities: - return f"{'、'.join(named_entities)} 相关数据" - return f"{SCENARIO_LABELS.get(payload.ontology.scenario, '当前')}场景数据" - - @staticmethod - def _is_generic_expense_prompt(payload: UserAgentRequest) -> bool: - if payload.ontology.scenario != "expense": - return False - normalized_message = re.sub(r"\s+", "", payload.message) - return normalized_message in GENERIC_EXPENSE_PROMPTS - - @staticmethod - def _is_implicit_expense_draft_request(payload: UserAgentRequest) -> bool: - if payload.ontology.scenario != "expense" or payload.ontology.intent != "draft": - return False - - compact_message = re.sub(r"\s+", "", payload.message) - if any(keyword in compact_message for keyword in EXPLICIT_DRAFT_KEYWORDS): - return False - - return True - - @staticmethod - def _resolve_attachment_names(payload: UserAgentRequest) -> list[str]: - names = payload.context_json.get("attachment_names") - if not isinstance(names, list): - return [] - return [str(name) for name in names if str(name).strip()] - - @staticmethod - def _resolve_attachment_count(payload: UserAgentRequest) -> int: - names = UserAgentService._resolve_attachment_names(payload) - if names: - return len(names) - try: - return max(0, int(payload.context_json.get("attachment_count") or 0)) - except (TypeError, ValueError): - return 0 - - @staticmethod - def _resolve_ocr_documents(payload: UserAgentRequest) -> list[dict[str, object]]: - documents = payload.context_json.get("ocr_documents") - if not isinstance(documents, list): - return [] - overrides = payload.context_json.get("review_document_form_values") - override_map: dict[tuple[int, str], dict[str, object]] = {} - if isinstance(overrides, list): - for item in overrides: - if not isinstance(item, dict): - continue - filename = str(item.get("filename") or "").strip() - index = int(item.get("index") or 0) - if not filename and index <= 0: - continue - override_map[(index, filename)] = item - normalized: list[dict[str, object]] = [] - for index, item in enumerate(documents[:8], start=1): - if not isinstance(item, dict): - continue - normalized_item = dict(item) - override = override_map.get((index, str(normalized_item.get("filename") or "").strip())) - if override is None: - override = override_map.get((index, "")) - if override is not None: - summary = str(override.get("summary") or "").strip() - scene_label = str(override.get("scene_label") or "").strip() - fields = override.get("fields") - if summary: - normalized_item["summary"] = summary - if scene_label: - normalized_item["scene_label"] = scene_label - if isinstance(fields, list): - normalized_item["document_fields"] = [ - { - "key": str(field.get("key") or field.get("label") or "").strip(), - "label": str(field.get("label") or "").strip(), - "value": str(field.get("value") or "").strip(), - } - for field in fields - if isinstance(field, dict) - and str(field.get("label") or "").strip() - and str(field.get("value") or "").strip() - ] - normalized.append(normalized_item) - return normalized - - @staticmethod - def _is_review_association_choice_pending(payload: UserAgentRequest) -> bool: - return bool(payload.tool_payload.get("pending_association_decision")) - - def _resolve_review_document_count(self, payload: UserAgentRequest) -> int: - return max( - len(self._resolve_ocr_documents(payload)), - self._resolve_attachment_count(payload), - ) - - @staticmethod - def _resolve_conversation_history(payload: UserAgentRequest) -> list[dict[str, object]]: - history = payload.context_json.get("conversation_history") - if not isinstance(history, list): - return [] - - normalized: list[dict[str, object]] = [] - for item in history[-8:]: - if not isinstance(item, dict): - continue - role = str(item.get("role") or "").strip() - content = str(item.get("content") or "").strip() - if not role or not content: - continue - normalized.append({"role": role, "content": content}) - return normalized - - @staticmethod - def _resolve_domain(scenario: str) -> str | None: - if scenario == "expense": - return "expense" - if scenario == "accounts_receivable": - return "ar" - if scenario == "accounts_payable": - return "ap" - return None - - @staticmethod - def _rank_rule_assets( - items: list[AgentAssetListItem], - payload: UserAgentRequest, - ) -> list[AgentAssetListItem]: - def score(item: AgentAssetListItem) -> tuple[int, str]: - tags = {str(value) for value in item.scenario_json or []} - weight = 0 - if payload.ontology.scenario in tags: - weight += 3 - if payload.ontology.intent in tags: - weight += 2 - for risk_flag in payload.ontology.risk_flags: - if risk_flag in tags: - weight += 4 - return weight, item.code - - ranked = sorted(items, key=score, reverse=True) - return [item for item in ranked if score(item)[0] > 0] - - @staticmethod - def _extract_excerpt(content: str) -> str: - lines = [line.strip() for line in str(content).splitlines() if line.strip()] - cleaned: list[str] = [] - for line in lines: - normalized = re.sub(r"^[#>\-\*\d\.\s`]+", "", line).strip() - if normalized: - cleaned.append(normalized) - if len(cleaned) >= 2: - break - return ";".join(cleaned[:2]) - - def _collect_entity_values(self, payload: UserAgentRequest) -> dict[str, str]: - values = { - "employee_name": "", - "customer": "", - "participants": "", - "amount": "", - "expense_type": "", - "expense_type_code": "", - } - participants: list[str] = [] - for item in payload.ontology.entities: - if item.type == "employee" and not values["employee_name"]: - values["employee_name"] = item.value - elif item.type == "customer" and not values["customer"]: - values["customer"] = item.value - elif item.type == "amount" and item.role != "threshold" and not values["amount"]: - normalized_amount = str(item.normalized_value or "").strip() - values["amount"] = f"{normalized_amount}元" if normalized_amount else item.value - elif item.type == "expense_type" and not values["expense_type_code"]: - values["expense_type_code"] = item.normalized_value - values["expense_type"] = EXPENSE_TYPE_LABELS.get( - item.normalized_value, - item.value, - ) - elif item.type in {"participant", "person"} and item.value.strip(): - participants.append(item.value.strip()) - if participants: - values["participants"] = "、".join(dict.fromkeys(participants)) - return values - - def _format_time_range(self, payload: UserAgentRequest) -> str: - time_range = payload.ontology.time_range - if time_range.start_date and time_range.end_date: - if time_range.start_date == time_range.end_date: - return time_range.start_date - normalized = f"{time_range.start_date} 至 {time_range.end_date}" - return normalized - if time_range.raw: - return time_range.raw - return "" - - def _resolve_location_value(self, payload: UserAgentRequest) -> str: - review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return value - - if str(payload.context_json.get("entry_source") or "").strip() == "detail": - request_context = payload.context_json.get("request_context") - if isinstance(request_context, dict): - for key in ("city", "location"): - value = str(request_context.get(key) or "").strip() - if value: - return value - - labeled_match = re.search(r"(?:业务地点|发生地点|地点)[::]\s*(?P[^\n,。;]+)", payload.message) - if labeled_match: - return labeled_match.group("value").strip() - - city_match = re.search(r"去(?P[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message) - if city_match: - return city_match.group("city").strip() - if "客户现场" in payload.message.replace(" ", ""): - return "客户现场" - return "" - - @staticmethod - def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: - values = payload.context_json.get("review_form_values") - if not isinstance(values, dict): - return {} - normalized: dict[str, str] = {} - for key, value in values.items(): - cleaned_key = str(key or "").strip() - if not cleaned_key: - continue - normalized[cleaned_key] = str(value or "").strip() - return normalized - - @staticmethod - def _build_slot_value( - *, - value: str = "", - raw_value: str = "", - normalized_value: str = "", - source: str = "system", - confidence: float = 0.0, - evidence: str = "", - ) -> dict[str, str | float]: - return { - "value": str(value or "").strip(), - "raw_value": str(raw_value or "").strip(), - "normalized_value": str(normalized_value or "").strip(), - "source": str(source or "system").strip() or "system", - "confidence": float(confidence), - "evidence": str(evidence or "").strip(), - } - - def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str( - review_form_values.get("occurred_date") - or review_form_values.get("time_range") - or review_form_values.get("business_time") - or "" - ).strip() - if edited_value: - raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() - return self._build_slot_value( - value=edited_value, - raw_value=raw_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - time_range = payload.ontology.time_range - if time_range.start_date and time_range.end_date: - normalized_value = ( - time_range.start_date - if time_range.start_date == time_range.end_date - else f"{time_range.start_date} 至 {time_range.end_date}" - ) - raw_value = str(time_range.raw or "").strip() - return self._build_slot_value( - value=normalized_value, - raw_value=raw_value, - normalized_value=normalized_value, - source="user_text", - confidence=0.92, - evidence="系统已根据当前日期将相对时间换算为标准日期。", - ) - - return self._build_slot_value() - - def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - if str(payload.context_json.get("entry_source") or "").strip() == "detail": - request_context = payload.context_json.get("request_context") - if isinstance(request_context, dict): - for key in ("city", "location"): - value = str(request_context.get(key) or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="detail_context", - confidence=0.68, - evidence="来源于当前关联单据,仅作为辅助上下文,需要用户再次核对。", - ) - - value = self._resolve_location_value(payload) - if value: - evidence = "用户在文本中明确描述了业务地点。" - if value == "客户现场": - evidence = "用户明确提到“客户现场”,但未提供具体城市或地址。" - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.82, - evidence=evidence, - ) - return self._build_slot_value() - - def _build_customer_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - value = str(review_form_values.get("customer_name") or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - value = entity_map.get("customer", "") - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.88, - evidence="用户在原始描述中直接提到了客户对象。", - ) - return self._build_slot_value() - - def _build_participants_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - value = str(review_form_values.get("participants") or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - value = entity_map.get("participants", "") - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.8, - evidence="用户在当前描述中补充了参与人员。", - ) - return self._build_slot_value() - - def _build_reason_slot( - self, - payload: UserAgentRequest, - *, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("reason") or "").strip() - if edited_value: - return self._build_slot_value( - value=edited_value, - raw_value=edited_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - inferred_reason = self._infer_reason_from_claim_groups( - claim_groups=claim_groups, - ) - reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload)) - if inferred_reason: - return self._build_slot_value( - value=inferred_reason, - raw_value=reason_value or inferred_reason, - normalized_value=inferred_reason, - source="ocr", - confidence=0.82, - evidence=( - "系统已根据票据识别结果预置场景类型;原始描述仍保留为补充说明。" - if reason_value - else "系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。" - ), - ) - - if reason_value: - return self._build_slot_value( - value=reason_value, - raw_value=reason_value, - normalized_value=reason_value, - source="user_text", - confidence=0.76, - evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。", - ) - return self._build_slot_value() - - def _build_amount_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_amount = str(review_form_values.get("amount") or "").strip() - if edited_amount: - normalized = self._normalize_amount_text(edited_amount) - return self._build_slot_value( - value=normalized, - raw_value=edited_amount, - normalized_value=normalized, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - amount_value = entity_map.get("amount", "") - if amount_value: - normalized = self._normalize_amount_text(amount_value) - return self._build_slot_value( - value=normalized, - raw_value=amount_value, - normalized_value=normalized, - source="user_text", - confidence=0.92, - evidence="用户在原始描述中直接给出了金额。", - ) - - ocr_total_amount = self._sum_ocr_amounts(ocr_documents) - if ocr_total_amount > 0: - normalized = f"{ocr_total_amount:.2f}元" - return self._build_slot_value( - value=normalized, - normalized_value=normalized, - source="ocr", - confidence=0.76, - evidence="金额来自 OCR 汇总结果,仍建议用户核对票据原文。", - ) - return self._build_slot_value() - - def _build_expense_type_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip() - if edited_value: - normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) - return self._build_slot_value( - value=normalized_label, - raw_value=edited_value, - normalized_value=normalized_code, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - expense_type_code = entity_map.get("expense_type_code", "") - expense_type_value = EXPENSE_TYPE_LABELS.get(expense_type_code, entity_map.get("expense_type", "")) - if expense_type_value: - return self._build_slot_value( - value=expense_type_value, - raw_value=expense_type_value, - normalized_value=expense_type_code, - source="user_text", - confidence=0.9, - evidence="系统根据用户描述中的业务场景判断费用类型。", - ) - - inferred_label = self._infer_expense_type_from_documents(payload, ocr_documents) if ocr_documents else "" - if inferred_label: - normalized_code, normalized_label = self._normalize_expense_type_input(inferred_label) - return self._build_slot_value( - value=normalized_label, - raw_value=inferred_label, - normalized_value=normalized_code, - source="ocr", - confidence=0.74, - evidence="系统根据票据内容推断费用类型,仍建议用户确认。", - ) - return self._build_slot_value() - - def _build_merchant_slot( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("merchant_name") or "").strip() - if edited_value: - return self._build_slot_value( - value=edited_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else "" - if merchant_value: - return self._build_slot_value( - value=merchant_value, - normalized_value=merchant_value, - source="ocr", - confidence=0.72, - evidence="商户名称来自 OCR 票据识别结果,仍建议用户核对。", - ) - return self._build_slot_value() - - def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - attachment_names = str(review_form_values.get("attachment_names") or "").strip() - if attachment_names: - return self._build_slot_value( - value=attachment_names, - normalized_value=attachment_names, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - count = self._resolve_attachment_count(payload) - if count > 0: - names = self._resolve_attachment_names(payload) - value = "、".join(names) if names else f"{count} 份附件" - return self._build_slot_value( - value=value, - raw_value=value, - normalized_value=str(count), - source="upload", - confidence=1.0, - evidence="系统已接收到用户上传的附件。", - ) - return self._build_slot_value() - - @staticmethod - def _normalize_amount_text(value: str) -> str: - cleaned = str(value or "").strip() - if not cleaned: - return "" - for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True): - cleaned = cleaned.replace(alias, canonical) - match = AMOUNT_TEXT_PATTERN.search(cleaned) - if not match: - return cleaned - number = float(match.group(1)) - return f"{number:.2f}元" - - @staticmethod - def _normalize_expense_type_input(value: str) -> tuple[str, str]: - compact = str(value or "").replace(" ", "") - if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))): - return "entertainment", "业务招待费" - if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")): - return "travel", "差旅费" - if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")): - return "hotel", "住宿费" - if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")): - return "transport", "交通费" - if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): - return "meal", "餐费" - if "会务" in compact: - return "meeting", "会务费" - if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")): - return "office", "办公费" - if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")): - return "training", "培训费" - if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")): - return "communication", "通讯费" - if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")): - return "welfare", "福利费" - return "other", str(value or "").strip() or "其他费用" - - def _resolve_required_review_keys( - self, - payload: UserAgentRequest, - *, - primary_expense_type: str, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> set[str]: - required = {"expense_type", "time_range", "amount", "reason", "attachments"} - scene_codes = { - str(item.group_code or "").strip() - for item in claim_groups - if str(item.group_code or "").strip() - } - if primary_expense_type: - scene_codes.add(primary_expense_type) - - for scene_code in scene_codes: - required.update(SCENE_REQUIRED_SLOT_KEYS.get(scene_code, set())) - - compact_message = re.sub(r"\s+", "", self._resolve_reason_source_text(payload) or payload.message) - if "entertainment" in scene_codes or ( - "客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客")) - ): - required.update({"customer_name", "participants"}) - - return required - - @staticmethod - def _infer_reason_from_claim_groups( - *, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> str: - if len(claim_groups) == 1: - document_indexes = list(claim_groups[0].document_indexes or []) - if not document_indexes: - return "" - - expense_type = str(claim_groups[0].expense_type or "").strip() - group_code = str(claim_groups[0].group_code or "").strip() - if expense_type: - return INFERRED_REASON_LABELS.get(expense_type, "") or str(claim_groups[0].scene_label or "").strip() - if group_code: - return INFERRED_REASON_LABELS.get(group_code, "") or str(claim_groups[0].scene_label or "").strip() - return "" - - @staticmethod - def _resolve_review_missing_slot_keys( - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[str]: - required_keys = {item.key for item in slot_cards if item.required} - slot_map = {item.key: item for item in slot_cards} - missing_keys = { - item.key - for item in slot_cards - if item.required and (item.status == "missing" or not str(item.value).strip()) - } - for key in payload.ontology.missing_slots: - normalized_key = str(key or "").strip() - if ( - normalized_key - and normalized_key in required_keys - and ( - normalized_key not in slot_map - or slot_map[normalized_key].status == "missing" - or not str(slot_map[normalized_key].value).strip() - ) - ): - missing_keys.add(normalized_key) - - ordered_keys: list[str] = [] - for item in slot_cards: - if item.required and item.key in missing_keys and item.key not in ordered_keys: - ordered_keys.append(item.key) - return ordered_keys - - def _make_slot_card( - self, - *, - key: str, - value: str, - raw_value: str, - normalized_value: str, - source: str, - confidence: float, - evidence: str, - required: bool = True, - ) -> UserAgentReviewSlotCard: - is_missing = required and not str(value).strip() - source_key = source if source in SOURCE_LABELS else "system" - return UserAgentReviewSlotCard( - key=key, - label=SLOT_LABELS.get(key, key), - value=str(value or "").strip(), - raw_value=str(raw_value or "").strip(), - normalized_value=str(normalized_value or "").strip(), - source=source, - source_label=SOURCE_LABELS.get(source_key, "系统判断"), - confidence=confidence, - required=required, - confirmed=not is_missing and source in {"user_text", "user_form"}, - status="missing" if is_missing else "identified" if source in {"user_text", "user_form"} else "inferred", - hint=f"建议补充 {SLOT_LABELS.get(key, key)}。" - if is_missing and required - else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""), - evidence=evidence, - ) - - def _classify_document( - self, - item: dict[str, object], - payload: UserAgentRequest, - ) -> dict[str, str]: - provided_type = str(item.get("document_type") or "").strip().lower() - expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "") - has_customer = bool(self._collect_entity_values(payload).get("customer")) - if provided_type: - if provided_type in {"flight_itinerary", "train_ticket"}: - return { - "document_type": provided_type, - "expense_type": "travel", - "group_code": "travel", - "scene_label": "差旅票据", - } - if provided_type == "hotel_invoice": - return { - "document_type": provided_type, - "expense_type": "hotel", - "group_code": "travel", - "scene_label": "住宿票据", - } - if provided_type in {"taxi_receipt", "parking_toll_receipt"}: - return { - "document_type": provided_type, - "expense_type": "transport", - "group_code": "travel", - "scene_label": "交通票据", - } - if provided_type == "meal_receipt": - group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" - return { - "document_type": provided_type, - "expense_type": group_code, - "group_code": group_code, - "scene_label": "餐饮票据", - } - if provided_type == "office_invoice": - return { - "document_type": provided_type, - "expense_type": "office", - "group_code": "office", - "scene_label": "办公用品票据", - } - if provided_type == "meeting_invoice": - return { - "document_type": provided_type, - "expense_type": "meeting", - "group_code": "meeting", - "scene_label": "会务票据", - } - if provided_type == "training_invoice": - return { - "document_type": provided_type, - "expense_type": "training", - "group_code": "training", - "scene_label": "培训票据", - } - - text = " ".join( - [ - str(item.get("filename") or ""), - str(item.get("summary") or ""), - str(item.get("text") or ""), - ] - ).lower() - compact = text.replace(" ", "") - - if any(keyword in compact for keyword in ("机票", "航班", "火车", "高铁", "行程单")): - return { - "document_type": "travel_ticket", - "expense_type": "travel", - "group_code": "travel", - "scene_label": "差旅票据", - } - if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")): - return { - "document_type": "hotel_invoice", - "expense_type": "hotel", - "group_code": "travel", - "scene_label": "住宿票据", - } - if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")): - return { - "document_type": "transport_receipt", - "expense_type": "transport", - "group_code": "travel", - "scene_label": "交通票据", - } - if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")): - group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" - return { - "document_type": "meal_receipt", - "expense_type": group_code, - "group_code": group_code, - "scene_label": "餐饮票据", - } - if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")): - return { - "document_type": "other", - "expense_type": "office", - "group_code": "office", - "scene_label": "办公用品票据", - } - return { - "document_type": "other", - "expense_type": expense_type_code or "other", - "group_code": self._normalize_group_code(expense_type_code or "other"), - "scene_label": "其他票据", - } - - @staticmethod - def _normalize_group_code(expense_type_code: str) -> str: - if expense_type_code in {"travel", "hotel", "transport"}: - return "travel" - if expense_type_code in {"entertainment", "meal", "office", "training", "communication", "welfare"}: - return expense_type_code - return "other" - - def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]: - raw_fields = item.get("document_fields") - normalized_fields: dict[str, str] = {} - if isinstance(raw_fields, list): - for field in raw_fields: - if not isinstance(field, dict): - continue - key = str(field.get("key") or "").strip() - label = str(field.get("label") or "").strip() - value = str(field.get("value") or "").strip() - if not value: - continue - normalized_label = self._normalize_document_field_label(key=key, label=label) - display_label = normalized_label or label - normalized_value = self._normalize_document_field_value( - label=display_label, - value=value, - ) - if display_label and normalized_value: - normalized_fields.setdefault(display_label, normalized_value) - - text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() - amount_value = self._extract_amount_text_from_value(text) - if amount_value and "金额" not in normalized_fields: - normalized_fields["金额"] = amount_value - date_match = DATE_TEXT_PATTERN.search(text) - if date_match and "时间" not in normalized_fields: - normalized_fields["时间"] = date_match.group(1) - - merchant = self._extract_document_merchant_name_from_text(text) - if merchant and "商户/酒店" not in normalized_fields: - normalized_fields["商户/酒店"] = merchant - return normalized_fields - - @staticmethod - def _normalize_document_field_label(*, key: str, label: str) -> str: - compact_key = str(key or "").strip().lower().replace("_", "") - compact_label = str(label or "").replace(" ", "") - if compact_key in { - "amount", - "totalamount", - "paymentamount", - "paidamount", - "actualamount", - } or any( - token in compact_label - for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") - ): - return "金额" - if compact_key in {"date", "time", "issuedat", "invoicedate"} or any( - token in compact_label for token in ("日期", "时间", "开票日期", "发生时间") - ): - return "时间" - if compact_key in {"merchant", "merchantname", "sellername", "vendorname"} or any( - token in compact_label for token in ("商户", "酒店", "销售方", "开票方", "收款方") - ): - return "商户/酒店" - return label - - def _normalize_document_field_value(self, *, label: str, value: str) -> str: - normalized_label = str(label or "").strip() - raw_value = str(value or "").strip() - if not normalized_label or not raw_value: - return "" - if normalized_label == "金额": - return self._extract_amount_text_from_value(raw_value) or raw_value - if normalized_label == "时间": - match = DATE_TEXT_PATTERN.search(raw_value) - return match.group(1) if match else raw_value - return raw_value - - def _extract_amount_text_from_value(self, value: str) -> str: - raw_value = str(value or "").strip() - if not raw_value: - return "" - best_amount: Decimal | None = None - for pattern in (DOCUMENT_AMOUNT_PATTERN, DOCUMENT_CURRENCY_AMOUNT_PATTERN, AMOUNT_TEXT_PATTERN): - for match in pattern.finditer(raw_value): - try: - candidate = Decimal(str(match.group(1)).replace(",", ".")) - except (InvalidOperation, TypeError): - continue - if candidate <= Decimal("0.00"): - continue - if best_amount is None or candidate > best_amount: - best_amount = candidate - if best_amount is None: - return "" - return f"{best_amount.quantize(Decimal('0.01')):.2f}元" - - def _extract_document_merchant_name(self, item: dict[str, object]) -> str: - fields = self._extract_document_fields(item) - merchant = str(fields.get("商户/酒店") or "").strip() - if merchant: - return merchant - text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() - return self._extract_document_merchant_name_from_text(text) - - @staticmethod - def _extract_document_merchant_name_from_text(text: str) -> str: - for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): - if keyword in text: - return keyword - return "" - - @staticmethod - def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float: - for item in card.fields: - if item.label != "金额": - continue - try: - normalized_value = str(item.value).replace("元", "").replace("¥", "").replace("¥", "").strip() - return float(normalized_value) - except ValueError: - return 0.0 - return 0.0 - - def _resolve_amount_value(self, payload: UserAgentRequest) -> float: - for item in payload.ontology.entities: - if item.type == "amount" and item.role != "threshold": - try: - return float(item.normalized_value) - except ValueError: - return 0.0 - return 0.0 - - def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float: - total = 0.0 - for item in ocr_documents: - fields = self._extract_document_fields(item) - amount_text = str(fields.get("金额") or "").replace("元", "").replace("¥", "").replace("¥", "").strip() - if not amount_text: - continue - try: - total += float(amount_text) - except ValueError: - continue - return total - - def _infer_expense_type_from_documents( - self, - payload: UserAgentRequest, - ocr_documents: list[dict[str, object]], - ) -> str: - labels: list[str] = [] - for item in ocr_documents: - classified = self._classify_document(item, payload) - label = GROUP_SCENE_LABELS.get(classified["group_code"], "") - if label and label not in labels: - labels.append(label) - return " + ".join(labels[:3]) +from __future__ import annotations + +import json +import re +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.core.agent_enums import AgentAssetStatus, AgentAssetType +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.schemas.agent_asset import AgentAssetListItem +from app.schemas.user_agent import ( + UserAgentCitation, + UserAgentDraftPayload, + UserAgentExpenseQueryRecord, + UserAgentQueryPayload, + UserAgentQueryStatusGroup, + UserAgentReviewAction, + UserAgentReviewEditField, + UserAgentReviewClaimGroup, + UserAgentReviewDocumentCard, + UserAgentReviewDocumentField, + UserAgentReviewPayload, + UserAgentReviewRiskBrief, + UserAgentReviewSlotCard, + UserAgentRequest, + UserAgentResponse, + UserAgentSuggestedAction, +) +from app.services.agent_assets import AgentAssetService +from app.services.agent_foundation import AgentFoundationService +from app.services.expense_claims import ExpenseClaimService +from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check +from app.services.runtime_chat import RuntimeChatService + +SCENARIO_LABELS = { + "expense": "报销", + "accounts_receivable": "应收", + "accounts_payable": "应付", + "knowledge": "知识", + "unknown": "通用", +} + +RISK_REASON_MAP = { + "duplicate_expense": "检测到同员工、同金额或近似单据存在重复提交迹象。", + "location_mismatch": "申报出差地点与票据识别地点可能不一致,需要核对行程或补充说明。", + "amount_over_limit": "金额超过当前制度或预算阈值,需要补充例外说明。", + "invoice_anomaly": "票据或附件完整性不满足当前规则要求,需要补件或人工复核。", + "ar_overdue": "应收账款已出现逾期,存在回款延迟风险。", + "ap_overdue": "应付付款已出现逾期,可能影响供应商履约或合作关系。", +} + +GENERIC_EXPENSE_PROMPTS = { + "报销", + "我要报销", + "我想报销", + "帮我报销", + "我要申请报销", + "发起报销", + "提交报销", +} + +EXPLICIT_DRAFT_KEYWORDS = ("生成", "草稿", "起草", "创建", "发起", "准备") + +EXPENSE_TYPE_LABELS = { + "travel": "差旅费", + "hotel": "住宿费", + "transport": "交通费", + "meal": "餐费", + "meeting": "会务费", + "entertainment": "业务招待费", + "office": "办公费", + "training": "培训费", + "communication": "通讯费", + "welfare": "福利费", + "other": "其他费用", +} + +GROUP_SCENE_LABELS = { + "travel": "差旅费", + "entertainment": "业务招待费", + "meal": "伙食费", + "transport": "交通费", + "hotel": "住宿费", + "office": "办公费", + "training": "培训费", + "communication": "通讯费", + "welfare": "福利费", + "other": "其他费用", +} + +KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3 +KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5 +KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS + +KNOWLEDGE_DIRECT_ANSWER_HINTS = ( + "是什么", + "标准", + "限额", + "流程", + "条件", + "规则", + "怎么", + "如何", + "哪些", + "需要", + "是否", + "区别", + "范围", + "额度", + "金额", + "多少", + "多少钱", + "上限", +) +KNOWLEDGE_QUERY_STOPWORDS = { + "什么", + "多少", + "哪些", + "怎么", + "如何", + "请问", + "一下", + "关于", + "规定", + "标准", + "可以", + "是否", + "一个", + "哪些人", + "目前", + "当前", + "一下子", +} +MAX_KNOWLEDGE_QUERY_TERMS = 12 +MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4 +MAX_KNOWLEDGE_MODEL_HITS = 5 +KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile( + r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$" +) +KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$") +KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile( + r"^(?:(?:\d+[.)、])|(?:[((][一二三四五六七八九十百零0-9]+[))])|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$" +) +KNOWLEDGE_ARTICLE_PATTERN = re.compile(r"^(第[一二三四五六七八九十百零0-9]+条)\s*.*$") + +EXPENSE_STATUS_LABELS = { + "draft": "草稿", + "submitted": "已提交", + "review": "审核中", + "approved": "已通过", + "rejected": "已驳回", + "paid": "已付款", +} + +EXPENSE_STATUS_GROUP_LABELS = { + "draft": "草稿", + "in_progress": "审批中", + "completed": "审批完成", + "other": "其他状态", +} + +SLOT_LABELS = { + "expense_type": "报销类型", + "customer_name": "客户名称", + "time_range": "发生时间", + "location": "地点", + "merchant_name": "酒店/商户", + "amount": "金额", + "reason": "事由说明", + "participants": "参与人员", + "attachments": "票据附件", +} + +DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)") +AMOUNT_TEXT_PATTERN = re.compile( + r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)" +) +DOCUMENT_AMOUNT_PATTERN = re.compile( + r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" + r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" +) +DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") + +SOURCE_LABELS = { + "user_text": "用户描述", + "user_form": "用户修改", + "ocr": "票据识别", + "upload": "上传附件", + "detail_context": "关联单据", + "system_context": "系统上下文", + "inferred": "语义推断", + "system": "系统判断", +} + +SCENE_REQUIRED_SLOT_KEYS = { + "hotel": {"merchant_name"}, + "meeting": {"location"}, + "entertainment": {"location", "customer_name", "participants"}, +} +INFERRED_REASON_LABELS = { + "travel": "出差行程", + "hotel": "住宿报销", + "transport": "交通出行", + "meal": "餐饮用餐", + "meeting": "会务活动", + "entertainment": "客户接待", + "office": "办公采购", + "training": "培训学习", + "communication": "通讯使用", + "welfare": "员工福利", + "other": "其他费用", +} +SYSTEM_GENERATED_REASON_PREFIXES = ( + "我上传了", + "请按当前已识别信息", + "请把当前上传的票据", + "请基于当前上传的多张票据", + "我已核对右侧识别结果", + "请同步修正逐票据识别结果", + "我已修改识别信息", + "查看报销草稿", + "请解释一下当前这笔报销的合规风险和待补充项", +) +AMOUNT_UNIT_ALIASES = { + "员": "元", + "圆": "元", + "园": "元", + "块": "元", + "块钱": "元", + "元整": "元", + "万员": "万元", + "万圆": "万元", + "万园": "万元", + "万块": "万元", + "万元整": "万元", +} + + +class UserAgentService: + def __init__(self, db: Session) -> None: + self.db = db + self.asset_service = AgentAssetService(db) + self.runtime_chat_service = RuntimeChatService(db) + + def respond(self, payload: UserAgentRequest) -> UserAgentResponse: + AgentFoundationService(self.db).ensure_foundation_ready() + citations = self._build_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" + else None + ) + review_payload = self._build_review_payload( + payload, + citations=citations, + draft_payload=draft_payload, + ) + review_answer = self._build_review_body_answer( + payload, + review_payload=review_payload, + draft_payload=draft_payload, + ) + + if payload.degraded and payload.tool_payload.get("message"): + return UserAgentResponse( + answer=review_answer or str(payload.tool_payload["message"]), + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + if review_answer: + return UserAgentResponse( + 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, + requires_confirmation=payload.requires_confirmation, + ) + + guided_answer = None + if draft_payload is None or draft_payload.claim_id is None: + guided_answer = self._build_guided_answer(payload) + if guided_answer: + return UserAgentResponse( + 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, + requires_confirmation=payload.requires_confirmation, + ) + + fast_knowledge_answer = self._build_fast_knowledge_answer( + payload, + citations=citations, + ) + if fast_knowledge_answer: + return UserAgentResponse( + answer=fast_knowledge_answer, + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + fallback_answer = self._build_fallback_answer( + payload, + citations=citations, + draft_payload=draft_payload, + ) + answer = None + if not self._should_skip_model_answer(payload, review_payload): + answer = self._generate_answer_with_model( + payload, + citations=citations, + suggested_actions=suggested_actions, + risk_flags=risk_flags, + draft_payload=draft_payload, + fallback_answer=fallback_answer, + ) + + return UserAgentResponse( + 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, + requires_confirmation=payload.requires_confirmation, + ) + + def _build_fallback_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + draft_payload: UserAgentDraftPayload | None, + ) -> str: + if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": + return self._build_explain_answer(payload, citations) + + if payload.ontology.intent in {"query", "compare"}: + return self._build_query_answer(payload) + + if payload.ontology.intent == "risk_check": + return self._build_risk_answer(payload, citations) + + if payload.ontology.intent == "draft": + tool_message = str(payload.tool_payload.get("message") or "").strip() + if payload.tool_payload.get("draft_limit_reached"): + return tool_message or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + if tool_message and ( + str(payload.tool_payload.get("claim_id") or "").strip() + or str(payload.tool_payload.get("claim_no") or "").strip() + ): + return tool_message + if payload.ontology.intent == "draft" and draft_payload is not None: + return ( + f"已生成 {draft_payload.title},当前仅返回待人工确认的草稿内容," + "仍需人工确认后再进入正式流程。" + ) + + return self._build_explain_answer(payload, citations) + + def _build_guided_answer(self, payload: UserAgentRequest) -> str | None: + if not self._is_generic_expense_prompt(payload): + return self._build_implicit_expense_draft_guidance(payload) + + attachment_names = self._resolve_attachment_names(payload) + ocr_summary = str(payload.context_json.get("ocr_summary") or "").strip() + attachment_hint = "" + if ocr_summary: + attachment_hint = f" 我已读取附件 OCR 摘要:{ocr_summary}" + elif attachment_names: + attachment_hint = ( + f" 我已带入 {len(attachment_names)} 份附件名称,但目前还不能直接读取附件内容," + "仍需要你补充关键信息。" + ) + + return ( + "可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象," + "或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿。" + f"{attachment_hint}" + ) + + def _build_implicit_expense_draft_guidance( + self, + payload: UserAgentRequest, + ) -> str | None: + if not self._is_implicit_expense_draft_request(payload): + return None + + amount_text = next( + (item.value for item in payload.ontology.entities if item.type == "amount"), + "", + ) + expense_type = next( + ( + EXPENSE_TYPE_LABELS.get(item.normalized_value, item.value) + for item in payload.ontology.entities + if item.type == "expense_type" + ), + "报销", + ) + time_text = payload.ontology.time_range.raw or "本次" + amount_hint = f",金额 {amount_text}" if amount_text else "" + + return ( + f"已识别到一笔{time_text}的{expense_type}支出{amount_hint}。" + "如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。" + "你也可以继续上传发票或图片,我会把这些信息带入后续对话。" + ) + + def _generate_answer_with_model( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + suggested_actions: list[UserAgentSuggestedAction], + risk_flags: list[str], + draft_payload: UserAgentDraftPayload | None, + fallback_answer: str, + ) -> str | None: + messages = self._build_model_messages( + payload, + citations=citations, + suggested_actions=suggested_actions, + risk_flags=risk_flags, + draft_payload=draft_payload, + fallback_answer=fallback_answer, + ) + answer = self._sanitize_model_answer( + self.runtime_chat_service.complete( + messages, + max_tokens=800 if payload.ontology.scenario == "knowledge" else 420, + temperature=0.2, + timeout_seconds=( + KNOWLEDGE_MODEL_TIMEOUT_SECONDS + if payload.ontology.scenario == "knowledge" + else None + ), + slot_timeouts=( + { + "main": KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS, + "backup": KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS, + } + if payload.ontology.scenario == "knowledge" + else None + ), + max_attempts=1 if payload.ontology.scenario == "knowledge" else None, + ) + ) + return self._reject_unsupported_location_inference(payload, answer) + + def _sanitize_model_answer(self, answer: str | None) -> str | None: + if not answer: + return None + + cleaned = re.sub(r".*?", "", answer, flags=re.DOTALL | re.IGNORECASE) + cleaned = cleaned.strip() + leaked_reasoning_markers = ( + "用户问的是", + "让我分析一下", + "实体识别", + "从对话历史来看", + "从tool_payload来看", + "现在问题是", + "我需要:", + "关键是我", + ) + if any(marker in cleaned[:500] for marker in leaked_reasoning_markers): + return None + return cleaned or None + + @staticmethod + def _extract_query_location(message: str) -> str: + match = re.search(r"(?:去|到|前往)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or "")) + return match.group(1) if match else "" + + def _reject_unsupported_location_inference( + self, + payload: UserAgentRequest, + answer: str | None, + ) -> str | None: + del payload + return answer + + def _build_model_messages( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + suggested_actions: list[UserAgentSuggestedAction], + risk_flags: list[str], + draft_payload: UserAgentDraftPayload | None, + fallback_answer: str, + ) -> list[dict[str, str]]: + knowledge_question = ( + self._resolve_knowledge_question(payload) + if payload.ontology.scenario == "knowledge" + else "" + ) + facts = { + "run_id": payload.run_id, + "user_message": payload.message, + "ontology": payload.ontology.model_dump(mode="json"), + "context": { + "entry_source": payload.context_json.get("entry_source"), + "user_name": payload.context_json.get("name"), + "user_role": payload.context_json.get("role"), + "user_position": payload.context_json.get("position"), + "user_grade": payload.context_json.get("grade"), + "user_role_codes": payload.context_json.get("role_codes", []), + "is_admin": bool(payload.context_json.get("is_admin")), + "request_context": payload.context_json.get("request_context"), + "attachment_count": payload.context_json.get("attachment_count"), + "attachment_names": self._resolve_attachment_names(payload), + "ocr_summary": payload.context_json.get("ocr_summary", ""), + "ocr_documents": payload.context_json.get("ocr_documents", []), + "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": self._resolve_conversation_history(payload), + }, + "tool_payload": self._build_model_tool_payload( + payload.tool_payload, + question=knowledge_question, + ), + "citations": [item.model_dump(mode="json") for item in citations], + "suggested_actions": [item.model_dump(mode="json") for item in suggested_actions], + "risk_flags": risk_flags, + "draft_payload": draft_payload.model_dump(mode="json") if draft_payload is not None else None, + "selected_capability_codes": payload.selected_capability_codes, + "requires_confirmation": payload.requires_confirmation, + "fallback_answer": fallback_answer, + } + if payload.ontology.scenario == "knowledge": + facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks( + payload.tool_payload, + question=knowledge_question, + ) + facts["knowledge_answer_evidence"] = [ + { + "title": str(item.get("title") or "").strip(), + "heading": str(item.get("heading") or "").strip(), + "kind": str(item.get("kind") or "").strip(), + "content": str(item.get("content") or "").strip(), + } + for item in self._build_knowledge_answer_evidence(payload) + ] + + if payload.ontology.scenario == "knowledge": + answer_style_instruction = ( + "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," + "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 。" + "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," + "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" + "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" + "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" + "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" + "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" + "如果命中的知识已经足够支持计算、比较或归纳,就直接给出结论;金额、标准、天数、补贴等问题要把计算过程写清楚。" + "适合时请使用 Markdown 二级标题、短段落和表格,让回答更清晰;表格必须保证每一行列数一致,不要出现空白残列。" + "只能陈述 hits 中明确出现的事实,不能用常识、外部知识或主观推断补齐缺失条件。" + "回答前先在全部 hits 中寻找与问题最直接相关的章节、表格或条目,不能只依赖排在最前面的片段。" + "如果 facts.knowledge_answer_evidence 中已经给出更短的高相关证据,优先基于这些证据组织答案,再回看原始 hits 补上下文。" + "如果某个表格在检索片段中已经被摊平成连续文本,只有在行、列和数值对应关系能够从片段本身明确确认时才能据此计算;" + "如果列对应关系不清楚,必须说明表格结构在当前片段中不够清晰,不能把第一列或相邻数字想当然套给用户。" + "如果 hits 中出现“结构化表格补充”,它表示知识归纳阶段已经把原文表格重新整理过," + "优先使用这类结构化表格来理解行列关系,再回看原文确认上下文。" + "facts.knowledge_evidence_blocks 中保留了原始换行和定宽排版;遇到表格时,优先按这些证据块阅读," + "必须按表头从左到右逐列对应数值,不能把第一列的数值直接套给后面的列名。" + "如果完成计算或归纳仍缺少某个关键映射关系、适用条件或数值依据,必须明确说明当前知识库还缺哪一项信息,再给出已能确认的部分。" + "如果用户问题里没有明确给出某个套用条件,而 hits 或 evidence 里也没有明确出现,就不能自己补一个默认值。" + "当问题涉及追问时,必须结合 conversation_history 延续上一轮上下文,而不是重新泛化成制度全文摘录。" + "不要大段粘贴原始命中文本;只提炼与问题直接相关的规则、条件、金额和注意事项。" + "如果依据仍然不足,明确指出缺少哪一项信息,再给出当前能确认的部分。" + ) + else: + answer_style_instruction = "用 2 到 4 段完成回答,优先给结论,再补充最关键的依据与下一步建议。" + + personalization_instruction = ( + "如果 context.user_name 存在,并且当前问题与员工本人适用标准、报销额度、审批权限、职级待遇有关," + "开头应自然称呼一次用户,例如“曹笑竹,您好”。" + "如果需要根据员工身份判断标准,优先参考 context.user_grade 与 context.user_position。" + "如果问题与用户身份无关,就不要生硬加入姓名、职级或岗位。" + ) + + system_prompt = ( + "你是 X-Financial 的专业财务 AI 助手。" + "回答必须准确、自然、可执行,不要泄露中间推理。" + "当知识问题有命中依据时,先给结论,再给结构化说明。" + "不要把制度全文原样搬出来,不要把检索片段当作最终答案直接粘贴。" + "如果使用表格,确保列名简洁、数值明确。" + f"{personalization_instruction}" + f"{answer_style_instruction}" + ) + user_prompt = ( + "请严格依据下面的 facts 生成最终答复:\n" + f"{json.dumps(facts, ensure_ascii=False, indent=2)}" + ) + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + @staticmethod + def _build_model_tool_payload( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> dict[str, Any]: + normalized = dict(tool_payload or {}) + hits = [] + for item in UserAgentService._select_knowledge_model_hits( + tool_payload, + question=question, + ): + if not isinstance(item, dict): + continue + hits.append( + { + "title": str(item.get("title") or "").strip(), + "document_name": str(item.get("document_name") or "").strip(), + "excerpt": str(item.get("excerpt") or "").strip(), + "content": str(item.get("content") or "").strip()[:1200], + "tags": list(item.get("tags") or [])[:5], + "evidence": list(item.get("evidence") or [])[:3], + "code": str(item.get("code") or "").strip(), + } + ) + normalized["hits"] = hits + return normalized + + @staticmethod + def _build_knowledge_evidence_blocks( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> str: + blocks: list[str] = [] + for index, item in enumerate( + UserAgentService._select_knowledge_model_hits( + tool_payload, + question=question, + )[:3], + start=1, + ): + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("document_name") or f"证据 {index}").strip() + code = str(item.get("code") or "").strip() + content = str(item.get("content") or "").strip() + if not content: + continue + blocks.append( + "\n".join( + [ + f"[证据 {index}] {title}" + (f" ({code})" if code else ""), + "```text", + content[:1200], + "```", + ] + ) + ) + return "\n\n".join(blocks) + + @staticmethod + def _select_knowledge_model_hits( + tool_payload: dict[str, Any], + *, + question: str | None = None, + ) -> list[dict[str, Any]]: + raw_hits = [ + item + for item in list(tool_payload.get("hits") or []) + if isinstance(item, dict) + ][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)] + if not raw_hits: + return [] + + query_terms = UserAgentService._extract_knowledge_query_terms(question or "") + if not query_terms: + return raw_hits[:MAX_KNOWLEDGE_MODEL_HITS] + + ranked_hits = sorted( + enumerate(raw_hits), + key=lambda value: ( + UserAgentService._score_knowledge_model_hit( + value[1], + query_terms=query_terms, + rank_index=value[0], + ), + -value[0], + ), + reverse=True, + ) + return [item for _, item in ranked_hits[:MAX_KNOWLEDGE_MODEL_HITS]] + + @staticmethod + def _score_knowledge_model_hit( + item: dict[str, Any], + *, + query_terms: list[str], + rank_index: int, + ) -> int: + title = str(item.get("title") or item.get("document_name") or "").lower() + excerpt = str(item.get("excerpt") or "").lower() + content = str(item.get("content") or "").lower() + haystack = "\n".join([title, excerpt, content[:1400]]) + + matched_terms = [term for term in query_terms if term in haystack] + score = max(1, 48 - rank_index * 4) + score += len(matched_terms) * 10 + score += sum(1 for term in matched_terms if term in title) * 8 + + leading_marker = UserAgentService._leading_knowledge_appendix_marker(content) + if leading_marker == "# 章节导航": + score -= 22 + elif leading_marker == "# 问答线索补充": + score += 6 if matched_terms else -8 + elif leading_marker == "# 重点章节摘录": + score += 4 if matched_terms else -4 + elif leading_marker == "# 结构化表格补充": + score += 8 if matched_terms else -3 + + if matched_terms and "|" in content: + score += 8 + if matched_terms and any(marker in content for marker in (":", ":")): + score += 10 + if matched_terms and "\n" in content: + score += 4 + if matched_terms and any(marker in content for marker in ("附表", "第", "条")): + score += 4 + if matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")): + score += 4 + if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content): + score -= 12 + return score + + @staticmethod + def _leading_knowledge_appendix_marker(content: str) -> str: + normalized = str(content or "").lstrip() + for marker in ("# 章节导航", "# 重点章节摘录", "# 问答线索补充", "# 结构化表格补充"): + index = normalized.find(marker) + if 0 <= index <= 220: + return marker + return "" + + def _build_query_answer(self, payload: UserAgentRequest) -> str: + scenario = payload.ontology.scenario + data = payload.tool_payload + subject = self._resolve_subject(payload) + + if scenario == "expense": + query_payload = self._build_query_payload(payload) + scope_label = str(data.get("scope_label") or subject).strip() or subject + if query_payload is None: + return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。" + + 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}。你可以补充时间范围、单号或状态继续筛选。" + + 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} 笔,你可以直接点击单据查看详情。" + ) + 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} 日的单据," + "请前往个人报销中心查看。" + ) + + return " ".join(answer_parts + hint_parts).strip() + + if scenario == "accounts_receivable": + record_count = int(data.get("record_count") or 0) + outstanding_amount = float(data.get("outstanding_amount") or 0) + return ( + f"{subject}共命中 {record_count} 条应收,未回款金额 {outstanding_amount:.2f} 元。" + "建议结合账龄和客户分布继续排查逾期风险。" + ) + + if scenario == "accounts_payable": + record_count = int(data.get("record_count") or 0) + outstanding_amount = float(data.get("outstanding_amount") or 0) + return ( + f"{subject}共命中 {record_count} 条应付,待付金额 {outstanding_amount:.2f} 元。" + "如需推进动作,建议先生成付款建议草稿并发起人工确认。" + ) + + 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_fast_knowledge_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + ) -> str | None: + if payload.ontology.scenario != "knowledge": + return None + if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": + return None + + evidence_items = self._build_knowledge_answer_evidence(payload) + if not evidence_items: + return None + + question = self._resolve_knowledge_question(payload) + if not self._should_use_direct_knowledge_answer(question, evidence_items): + return None + + return self._render_knowledge_direct_answer( + payload, + citations=citations, + evidence_items=evidence_items, + ) + + def _render_knowledge_direct_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + evidence_items: list[dict[str, Any]], + ) -> str | None: + if not evidence_items: + return None + + title = str( + (citations[0].title if citations else "") + or evidence_items[0].get("title") + or "相关制度" + ).strip() + user_name = str(payload.context_json.get("name") or "").strip() + question = self._resolve_knowledge_question(payload) + query_terms = self._extract_knowledge_query_terms(question) + ordered_evidence_items = self._prioritize_knowledge_evidence_items(question, evidence_items) + primary_item = ordered_evidence_items[0] + primary_heading = self._format_knowledge_heading_label( + str(primary_item.get("heading") or "").strip() + ) + primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items) + + lines: list[str] = [] + if user_name: + lines.append(f"{user_name},您好。") + source_prefix = f"根据《{title}》" + if primary_heading: + source_prefix = f"{source_prefix}({primary_heading})" + + if str(primary_item.get("kind") or "") == "table": + lines.append(f"{source_prefix},当前能直接确认的是:") + lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) + else: + if not primary_lines: + lines.append( + f"{source_prefix},当前能直接确认的是:" + f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" + ) + elif len(primary_lines) == 1: + lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}") + else: + lines.append(f"{source_prefix},当前能直接确认的是:") + lines.extend(primary_lines) + + notes: list[str] = [] + location_note = self._build_missing_location_grounding_note(question, evidence_items) + if location_note: + notes.append(location_note) + if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items): + notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") + + if notes: + lines.append("") + lines.append("说明:") + lines.extend(f"- {note}" for note in notes) + + return "\n".join(line for line in lines if line is not None).strip() + + def _prioritize_knowledge_evidence_items( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if not evidence_items or not self._question_requires_explicit_condition(question): + return evidence_items + + for preferred_kind in ("table", "kv", "clause", "list"): + for index, item in enumerate(evidence_items): + if str(item.get("kind") or "") != preferred_kind: + continue + return [item, *evidence_items[:index], *evidence_items[index + 1 :]] + + for index, item in enumerate(evidence_items): + if re.search(r"\d", str(item.get("content") or "")): + return [item, *evidence_items[:index], *evidence_items[index + 1 :]] + + return evidence_items + + @staticmethod + def _resolve_knowledge_question(payload: UserAgentRequest) -> str: + return str(payload.context_json.get("user_input_text") or payload.message or "").strip() + + @staticmethod + def _looks_like_structured_knowledge_query(question: str) -> bool: + normalized = str(question or "").strip() + if not normalized: + return False + return any(keyword in normalized for keyword in KNOWLEDGE_DIRECT_ANSWER_HINTS) + + def _should_use_direct_knowledge_answer( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> bool: + if not evidence_items: + return False + if self._looks_like_structured_knowledge_query(question): + return True + return str(evidence_items[0].get("kind") or "") in {"table", "kv", "list", "clause"} + + def _build_knowledge_answer_evidence( + self, + payload: UserAgentRequest, + ) -> list[dict[str, Any]]: + question = self._resolve_knowledge_question(payload) + query_terms = self._extract_knowledge_query_terms(question) + candidates: list[dict[str, Any]] = [] + + for hit in self._select_knowledge_model_hits( + payload.tool_payload, + question=question, + ): + if not isinstance(hit, dict): + continue + candidates.extend(self._extract_knowledge_evidence_candidates(hit, query_terms)) + + deduped: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + ranked_candidates = sorted( + candidates, + key=lambda value: ( + float(value.get("score") or 0), + -len(str(value.get("content") or "")), + ), + reverse=True, + ) + top_score = float(ranked_candidates[0].get("score") or 0) if ranked_candidates else 0.0 + + for item in ranked_candidates: + score = float(item.get("score") or 0) + if deduped and score < max(6.0, top_score - 14): + continue + key = ( + str(item.get("title") or "").strip(), + str(item.get("heading") or "").strip(), + self._clean_knowledge_segment_text(str(item.get("content") or ""))[:180], + ) + if key in seen: + continue + seen.add(key) + deduped.append(item) + if len(deduped) >= MAX_KNOWLEDGE_DIRECT_EVIDENCE: + break + return deduped + + def _extract_knowledge_evidence_candidates( + self, + hit: dict[str, Any], + query_terms: list[str], + ) -> list[dict[str, Any]]: + title = str(hit.get("title") or hit.get("document_name") or "相关制度").strip() + content = str(hit.get("content") or "").strip() + if not content: + return [] + + raw_candidates = self._merge_knowledge_lead_in_segments( + self._split_knowledge_hit_into_segments(content) + ) + candidates: list[dict[str, Any]] = [] + for item in raw_candidates: + score = self._score_knowledge_evidence_candidate(item, query_terms) + if query_terms and score <= 0: + continue + normalized = dict(item) + normalized["title"] = title + normalized["score"] = score + candidates.append(normalized) + + if candidates: + return candidates + + fallback_text = str(hit.get("excerpt") or "").strip() or self._extract_excerpt(content) + if not fallback_text: + return [] + return [ + { + "title": title, + "heading": "", + "kind": "paragraph", + "content": fallback_text, + "score": 1, + } + ] + + @staticmethod + def _is_knowledge_lead_in_segment(item: dict[str, str]) -> bool: + kind = str(item.get("kind") or "").strip() + content = str(item.get("content") or "").strip() + return kind in {"kv", "list", "clause"} and content.endswith((":", ":")) + + @staticmethod + def _extract_knowledge_marker_family(content: str) -> str: + normalized = str(content or "").strip() + if not normalized: + return "" + if KNOWLEDGE_ARTICLE_PATTERN.match(normalized): + return "article" + if re.match(r"^\d+[.)、]\s*", normalized): + return "arabic" + if re.match(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", normalized): + return "paren" + if re.match(r"^[①②③④⑤⑥⑦⑧⑨⑩]\s*", normalized): + return "circled" + if KNOWLEDGE_LIST_ITEM_PATTERN.match(normalized): + return "bullet" + return "" + + @staticmethod + def _format_knowledge_heading_label(heading: str) -> str: + parts = [item.strip() for item in str(heading or "").split(">") if item.strip()] + return " / ".join(parts) + + def _merge_knowledge_lead_in_segments( + self, + segments: list[dict[str, str]], + ) -> list[dict[str, str]]: + if not segments: + return [] + + merged: list[dict[str, str]] = [] + index = 0 + while index < len(segments): + current = dict(segments[index]) + if not self._is_knowledge_lead_in_segment(current): + merged.append(current) + index += 1 + continue + + base_heading = str(current.get("heading") or "").strip() + current_marker = self._extract_knowledge_marker_family(str(current.get("content") or "")) + follow_segments: list[dict[str, str]] = [] + next_index = index + 1 + + while next_index < len(segments): + candidate = segments[next_index] + if str(candidate.get("heading") or "").strip() != base_heading: + break + + candidate_kind = str(candidate.get("kind") or "").strip() + candidate_content = str(candidate.get("content") or "").strip() + candidate_marker = self._extract_knowledge_marker_family(candidate_content) + if not candidate_content or candidate_kind == "table": + break + if current_marker and candidate_marker == current_marker: + break + if self._is_knowledge_lead_in_segment(candidate) and follow_segments: + break + if candidate_kind not in {"list", "paragraph", "kv", "clause"}: + break + + follow_segments.append(candidate) + next_index += 1 + if len(follow_segments) >= 4: + break + if candidate_kind == "paragraph" and len(candidate_content) >= 200: + break + + if follow_segments: + current["content"] = "\n".join( + [str(current.get("content") or "").strip()] + + [str(item.get("content") or "").strip() for item in follow_segments] + ) + if any(str(item.get("kind") or "").strip() == "list" for item in follow_segments): + current["kind"] = "list" + merged.append(current) + index = next_index + continue + + merged.append(current) + index += 1 + + return merged + + def _split_knowledge_hit_into_segments(self, content: str) -> list[dict[str, str]]: + segments: list[dict[str, str]] = [] + markdown_headings: list[str] = [] + section_heading = "" + paragraph_lines: list[str] = [] + table_lines: list[str] = [] + + def current_heading() -> str: + heading_parts = [item for item in markdown_headings if item] + if section_heading: + heading_parts.append(section_heading) + return " > ".join(heading_parts) + + def flush_paragraph() -> None: + nonlocal paragraph_lines + if not paragraph_lines: + return + merged = " ".join(line.strip() for line in paragraph_lines if line.strip()).strip() + paragraph_lines = [] + if merged: + segments.append( + { + "heading": current_heading(), + "kind": "paragraph", + "content": merged, + } + ) + + def flush_table() -> None: + nonlocal table_lines + if not table_lines: + return + merged = "\n".join(line.rstrip() for line in table_lines if line.strip()).strip() + table_lines = [] + if merged: + segments.append( + { + "heading": current_heading(), + "kind": "table", + "content": merged, + } + ) + + for raw_line in str(content or "").replace("\r\n", "\n").replace("\r", "\n").splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + + if not stripped: + flush_paragraph() + flush_table() + continue + + markdown_heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped) + if markdown_heading_match: + flush_paragraph() + flush_table() + level = len(markdown_heading_match.group(1)) + heading_text = markdown_heading_match.group(2).strip() + markdown_headings = markdown_headings[: max(0, level - 1)] + markdown_headings.append(heading_text) + section_heading = "" + continue + + if KNOWLEDGE_SECTION_HEADING_PATTERN.match(stripped) and len(stripped) <= 90: + flush_paragraph() + flush_table() + section_heading = stripped.lstrip("#").strip() + continue + + if stripped.count("|") >= 2 and "|" in stripped: + flush_paragraph() + table_lines.append(stripped) + continue + + flush_table() + + if KNOWLEDGE_LIST_ITEM_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "list", + "content": stripped, + } + ) + continue + + if KNOWLEDGE_NUMBERED_ITEM_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "list", + "content": stripped, + } + ) + continue + + if KNOWLEDGE_ARTICLE_PATTERN.match(stripped): + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "clause", + "content": stripped, + } + ) + continue + + if (":" in stripped or ":" in stripped) and len(stripped) <= 180: + flush_paragraph() + segments.append( + { + "heading": current_heading(), + "kind": "kv", + "content": stripped, + } + ) + continue + + paragraph_lines.append(stripped) + + flush_paragraph() + flush_table() + return segments + + def _score_knowledge_evidence_candidate( + self, + item: dict[str, str], + query_terms: list[str], + ) -> int: + heading = str(item.get("heading") or "").lower() + content = str(item.get("content") or "").lower() + kind = str(item.get("kind") or "").strip() + haystack = "\n".join([heading, content]) + + matched_terms = [term for term in query_terms if term in haystack] + score = len(matched_terms) * 10 + score += sum(1 for term in matched_terms if term in heading) * 6 + + if kind == "table": + score += 10 + elif kind in {"kv", "clause", "list"}: + score += 8 + elif kind == "paragraph": + score += 4 + + if "问答线索补充" in heading or "重点章节摘录" in heading: + score += 8 + if "结构化表格补充" in heading: + score += 10 + if "章节导航" in heading or "目录" in heading: + score -= 16 + if re.search(r"[.。…]{6,}", content): + score -= 12 + if any(hint in content for hint in ("应", "需", "不得", "可以", "标准", "条件", "材料", "审批", "流程", "包括")): + score += 3 + + content_length = len(content) + if content_length > 220: + score -= min(8, (content_length - 220) // 40) + return score + + @staticmethod + def _extract_knowledge_query_terms(question: str) -> list[str]: + normalized_question = str(question or "").strip().lower() + if not normalized_question: + return [] + + terms: list[str] = [] + seen: set[str] = set() + + def remember(term: str) -> None: + normalized = str(term or "").strip().lower() + if ( + not normalized + or normalized in seen + or normalized in KNOWLEDGE_QUERY_STOPWORDS + ): + return + seen.add(normalized) + terms.append(normalized) + + for item in re.findall(r"[a-z0-9][a-z0-9_\-]{1,}", normalized_question): + remember(item) + + for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question): + if len(block) <= 4: + remember(block) + continue + for size in (4, 3, 2): + for start in range(0, len(block) - size + 1): + remember(block[start : start + size]) + if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS: + return terms + + return terms[:MAX_KNOWLEDGE_QUERY_TERMS] + + @staticmethod + def _clean_knowledge_segment_text(content: str) -> str: + normalized = str(content or "").strip() + normalized = re.sub(r"^[-*•]\s*", "", normalized) + normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) + normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) + normalized = re.sub(r"\s+", " ", normalized) + if len(normalized) <= 180: + return normalized + return f"{normalized[:177].rstrip()}..." + + @staticmethod + def _normalize_knowledge_line(content: str, *, preserve_marker: bool) -> str: + normalized = str(content or "").strip() + normalized = re.sub(r"^[-*•]\s*", "", normalized) + if not preserve_marker: + normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized) + normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized) + normalized = re.sub(r"\s+", " ", normalized) + return normalized + + def _split_clean_knowledge_lines( + self, + content: str, + *, + preserve_marker: bool, + ) -> list[str]: + return [ + line + for line in ( + self._normalize_knowledge_line(item, preserve_marker=preserve_marker) + for item in str(content or "").splitlines() + ) + if line + ] + + def _render_knowledge_evidence_text(self, item: dict[str, Any]) -> str: + lines = self._split_clean_knowledge_lines( + str(item.get("content") or ""), + preserve_marker=True, + ) + if not lines: + return "" + if len(lines) == 1: + return self._clean_knowledge_segment_text(lines[0]) + return "\n".join(f" {line}" for line in lines) + + def _collect_direct_knowledge_answer_lines( + self, + ordered_evidence_items: list[dict[str, Any]], + ) -> list[str]: + if not ordered_evidence_items: + return [] + + primary_item = ordered_evidence_items[0] + primary_title = str(primary_item.get("title") or "").strip() + primary_heading = str(primary_item.get("heading") or "").strip() + primary_kind = str(primary_item.get("kind") or "").strip() + + related_items = [primary_item] + if primary_kind != "table": + for item in ordered_evidence_items[1:]: + if len(related_items) >= 3: + break + if str(item.get("kind") or "").strip() != primary_kind: + continue + if str(item.get("title") or "").strip() != primary_title: + continue + if str(item.get("heading") or "").strip() != primary_heading: + continue + related_items.append(item) + + lines: list[str] = [] + seen: set[str] = set() + for item in related_items: + rendered = self._render_knowledge_evidence_text(item) + for line in rendered.splitlines(): + normalized = str(line or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + lines.append(line) + return lines + + def _summarize_knowledge_evidence_content( + self, + item: dict[str, Any], + query_terms: list[str], + ) -> str: + kind = str(item.get("kind") or "").strip() + content = str(item.get("content") or "").strip() + if kind == "table": + preview = self._extract_relevant_table_preview(content, query_terms) + preview_rows = [line for line in preview.splitlines() if line.strip()][:4] + if len(preview_rows) >= 3: + return "当前命中的直接依据是一张与问题强相关的标准表,已摘出最相关的表头和行。" + return "当前命中的直接依据是一张与问题强相关的标准表。" + lines = self._split_clean_knowledge_lines(content, preserve_marker=True) + if len(lines) >= 2: + return self._clean_knowledge_segment_text(f"{lines[0]} {' '.join(lines[1:4])}") + return self._clean_knowledge_segment_text(content) + + @staticmethod + def _extract_relevant_table_preview(content: str, query_terms: list[str]) -> str: + lines = [line.strip() for line in str(content or "").splitlines() if line.strip()] + if len(lines) <= 3: + return "\n".join(lines) + + header = lines[0] + divider = lines[1] if len(lines) > 1 else "" + body = lines[2:] if divider.count("|") >= 2 else lines[1:] + + matched_rows = [ + row + for row in body + if any(term in row.lower() for term in query_terms) + ] + selected_rows = matched_rows[:3] or body[:2] + preview_lines = [header] + if divider: + preview_lines.append(divider) + preview_lines.extend(selected_rows) + return "\n".join(preview_lines).strip() + + @staticmethod + def _question_requires_explicit_condition(question: str) -> bool: + normalized = str(question or "").strip() + return any(keyword in normalized for keyword in ("多少", "金额", "上限", "限额", "标准", "条件", "需要")) + + def _build_missing_location_grounding_note( + self, + question: str, + evidence_items: list[dict[str, Any]], + ) -> str: + location = self._extract_query_location(question) + if not location: + return "" + + haystack = "\n".join( + str(item.get("heading") or "") + "\n" + str(item.get("content") or "") + for item in evidence_items + ) + if location in haystack: + return "" + return ( + f"当前命中的制度依据没有直接写出“{location}”对应的地区档位或映射关系," + "因此不能直接把它套用到表格中的某一列。" + ) + + @staticmethod + def _answer_evidence_has_numeric_or_condition(evidence_items: list[dict[str, Any]]) -> bool: + for item in evidence_items: + content = str(item.get("content") or "") + if re.search(r"\d", content): + return True + if any( + keyword in content + for keyword in ("应", "需", "不得", "可以", "条件", "材料", "审批", "流程", "标准", "适用") + ): + return True + return False + + def _build_explain_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": + if citations: + return self._build_knowledge_search_answer(payload, citations) + + tool_message = str(payload.tool_payload.get("message") or "").strip() + if tool_message: + return tool_message + + if citations: + titles = "、".join(item.title for item in citations[:2]) + summary = citations[0].excerpt or "请结合制度全文进一步确认。" + return f"已检索到相关依据:{titles}。核心说明:{summary}" + + return ( + f"当前还没有与“{SCENARIO_LABELS.get(payload.ontology.scenario, '当前问题')}”" + "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" + ) + + def _build_knowledge_search_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)] + evidence_items = self._build_knowledge_answer_evidence(payload) + primary_citation = citations[0] if citations else None + title = str( + (primary_citation.title if primary_citation else "") + or (hits[0].get("title") if hits else "") + or "相关制度" + ).strip() + user_name = str(payload.context_json.get("name") or "").strip() + prefix = f"{user_name},您好。\n" if user_name else "" + if not hits: + return ( + f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," + "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," + "建议先检查主对话模型的连通性。" + ) + + evidence_lines: list[str] = [] + for item in evidence_items[:3]: + heading = str(item.get("heading") or "").strip() + heading_text = f" > {heading}" if heading else "" + if str(item.get("kind") or "") == "table": + preview = self._extract_relevant_table_preview( + str(item.get("content") or ""), + self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)), + ) + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}") + continue + rendered = self._render_knowledge_evidence_text(item) + if rendered: + if "\n" in rendered: + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}") + else: + evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}") + + if not evidence_lines: + for item in hits[:2]: + item_title = str(item.get("title") or item.get("document_name") or "相关制度").strip() + excerpt = ( + str(item.get("excerpt") or "").strip() + or self._extract_excerpt(str(item.get("content") or "")) + ) + if not excerpt: + continue + evidence_lines.append(f"- 《{item_title}》:{excerpt}") + + if not evidence_lines: + return ( + f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," + "但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," + "建议先检查主对话模型的连通性。" + ) + + return "\n".join( + [ + f"{prefix}我已经命中与你这次问题最相关的制度依据,但答案整理阶段本轮没有及时返回。", + "先给你当前最直接的依据:", + *evidence_lines, + "如果你希望我继续把这些依据整理成更完整的结论、步骤或对比说明,可以继续缩小问题范围后再问一次。", + ] + ).strip() + + def _build_risk_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + risk_flags = self._resolve_risk_flags(payload) + platform_messages = self._evaluate_platform_risk_messages(payload) + if not risk_flags and not platform_messages: + return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" + + reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] + if platform_messages: + reasons.extend(platform_messages) + citation_text = ( + f" 参考规则:{'、'.join(item.title for item in citations[:2])}。" + if citations + else "" + ) + signal_count = len(risk_flags) + (1 if platform_messages else 0) + return ( + f"本次识别到 {signal_count} 类风险信号。" + f"触发原因:{';'.join(reasons)}。" + "建议先复核明细、附件和审批链,再决定是否继续处理。" + f"{citation_text}" + ) + + def _evaluate_platform_risk_messages(self, payload: UserAgentRequest) -> list[str]: + claim_id = str(payload.tool_payload.get("claim_id") or "").strip() + if not claim_id: + return [] + + claim = self.db.scalar( + select(ExpenseClaim) + .where(ExpenseClaim.id == claim_id) + .options(selectinload(ExpenseClaim.items)) + ) + if claim is None: + return [] + + rule_codes = resolve_rule_codes_for_risk_check( + payload.ontology, + query_text=payload.message, + ) + review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( + claim, + rule_codes=rule_codes, + ) + messages: list[str] = [] + for flag in review.get("flags") or []: + if not isinstance(flag, dict): + continue + message = str(flag.get("message") or "").strip() + if message and message not in messages: + messages.append(message) + return messages + + def _build_draft_payload(self, payload: UserAgentRequest) -> UserAgentDraftPayload: + scenario_label = SCENARIO_LABELS.get(payload.ontology.scenario, "业务") + subject = self._resolve_subject(payload) + claim_no = str(payload.tool_payload.get("claim_no") or "").strip() or None + claim_status = str(payload.tool_payload.get("status") or "").strip() or None + approval_stage = str(payload.tool_payload.get("approval_stage") or "").strip() or None + is_submitted = claim_status == "submitted" + title = f"{scenario_label}处理意见草稿" + if claim_no: + title = f"{scenario_label}{'报销单' if is_submitted else '草稿'} {claim_no}" + if is_submitted: + body = ( + f"主题:{subject}\n" + f"结论:报销单已提交,当前节点为 {approval_stage or '审批中'}。\n" + "建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n" + f"原始问题:{payload.message}" + ) + else: + body = ( + f"主题:{subject}\n" + "结论:已根据当前语义解析结果生成草稿,尚未自动执行。\n" + "建议:请先核对明细、规则命中和所需附件,再由人工确认是否提交正式流程。\n" + f"原始问题:{payload.message}" + ) + return UserAgentDraftPayload( + draft_type=payload.ontology.scenario, + title=title, + body=body, + confirmation_required=not is_submitted, + claim_id=str(payload.tool_payload.get("claim_id") or "").strip() or None, + claim_no=claim_no, + status=claim_status, + approval_stage=approval_stage, + ) + + def _build_suggested_actions( + self, + payload: UserAgentRequest, + ) -> list[UserAgentSuggestedAction]: + if payload.ontology.scenario == "knowledge": + return [] + + if self._is_generic_expense_prompt(payload): + return [ + UserAgentSuggestedAction( + label="上传票据", + action_type="ask_clarification", + description="上传发票、行程单或付款截图,继续识别报销内容。", + ), + UserAgentSuggestedAction( + label="补充报销信息", + action_type="ask_clarification", + description="补充费用类型、金额、时间和事由后继续处理。", + ), + ] + + if payload.ontology.intent in {"query", "compare"}: + return [ + UserAgentSuggestedAction( + label="查看明细", + action_type="open_detail", + description="继续查看命中记录和过滤条件。", + ), + UserAgentSuggestedAction( + label="生成处理意见", + action_type="create_draft", + description="把当前查询结果整理成可确认草稿。", + ), + ] + + if payload.ontology.intent == "risk_check": + return [ + UserAgentSuggestedAction( + label="人工复核风险", + action_type="manual_review", + description="优先检查明细、附件和规则命中原因。", + ), + UserAgentSuggestedAction( + label="生成整改建议", + action_type="create_draft", + description="把风险说明整理成处理意见草稿。", + ), + ] + + if payload.ontology.intent == "draft": + return [ + UserAgentSuggestedAction( + label="复制草稿", + action_type="copy_draft", + description="复制当前草稿后交由人工确认。", + ), + UserAgentSuggestedAction( + label="补充上下文", + action_type="ask_clarification", + description="补充单据编号、客户或供应商信息以完善草稿。", + ), + ] + + return [ + UserAgentSuggestedAction( + label="查看规则全文", + action_type="open_rule", + description="继续查看引用规则或知识内容。", + ), + UserAgentSuggestedAction( + label="补充问题上下文", + action_type="ask_clarification", + description="补充业务对象、时间或单据范围,提升回答准确度。", + ), + ] + + def _build_review_payload( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + draft_payload: UserAgentDraftPayload | None, + ) -> UserAgentReviewPayload | None: + attachment_count = self._resolve_attachment_count(payload) + ocr_documents = self._resolve_ocr_documents(payload) + if payload.ontology.scenario != "expense": + return None + if payload.ontology.intent not in {"draft", "operate"} and attachment_count <= 0 and not ocr_documents: + return None + + document_cards = self._build_review_document_cards(payload, ocr_documents=ocr_documents) + claim_groups = self._build_review_claim_groups( + payload, + document_cards=document_cards, + ) + slot_cards = self._build_review_slot_cards( + payload, + ocr_documents=ocr_documents, + claim_groups=claim_groups, + ) + missing_slot_keys = self._resolve_review_missing_slot_keys( + payload, + slot_cards=slot_cards, + ) + risk_briefs = self._build_review_risk_briefs( + payload, + citations=citations, + document_cards=document_cards, + claim_groups=claim_groups, + ) + association_choice_pending = self._is_review_association_choice_pending(payload) + can_proceed = ( + False + if association_choice_pending + else self._can_proceed_review( + payload, + missing_slot_keys=missing_slot_keys, + claim_groups=claim_groups, + ) + ) + confirmation_actions = self._build_review_confirmation_actions( + payload, + can_proceed=can_proceed, + claim_groups=claim_groups, + draft_payload=draft_payload, + ) + edit_fields = self._build_review_edit_fields( + payload, + draft_payload=draft_payload, + slot_cards=slot_cards, + ) + intent_summary = self._build_review_intent_summary( + payload, + slot_cards=slot_cards, + claim_groups=claim_groups, + ) + body_message = self._build_review_body_message( + payload, + slot_cards=slot_cards, + risk_briefs=risk_briefs, + can_proceed=can_proceed, + document_cards=document_cards, + ) + + return UserAgentReviewPayload( + intent_summary=intent_summary, + body_message=body_message, + scenario=payload.ontology.scenario, + intent=payload.ontology.intent, + can_proceed=can_proceed, + missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys], + risk_briefs=risk_briefs, + slot_cards=slot_cards, + document_cards=document_cards, + claim_groups=claim_groups, + confirmation_actions=confirmation_actions, + edit_fields=edit_fields, + ) + + def _build_review_slot_cards( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> list[UserAgentReviewSlotCard]: + entity_map = self._collect_entity_values(payload) + time_slot = self._build_time_slot(payload) + location_slot = self._build_location_slot(payload) + customer_slot = self._build_customer_slot(payload, entity_map=entity_map) + participants_slot = self._build_participants_slot(payload, entity_map=entity_map) + amount_slot = self._build_amount_slot(payload, entity_map=entity_map, ocr_documents=ocr_documents) + expense_type_slot = self._build_expense_type_slot( + payload, + entity_map=entity_map, + ocr_documents=ocr_documents, + ) + merchant_slot = self._build_merchant_slot(payload, ocr_documents=ocr_documents) + reason_slot = self._build_reason_slot( + payload, + claim_groups=claim_groups, + ) + attachment_slot = self._build_attachment_slot(payload) + required_keys = self._resolve_required_review_keys( + payload, + primary_expense_type=str(expense_type_slot["normalized_value"] or ""), + claim_groups=claim_groups, + ) + + cards = [ + self._make_slot_card( + key="expense_type", + value=expense_type_slot["value"], + raw_value=expense_type_slot["raw_value"], + normalized_value=expense_type_slot["normalized_value"], + source=expense_type_slot["source"], + confidence=expense_type_slot["confidence"], + evidence=expense_type_slot["evidence"], + required="expense_type" in required_keys, + ), + self._make_slot_card( + key="customer_name", + value=customer_slot["value"], + raw_value=customer_slot["raw_value"], + normalized_value=customer_slot["normalized_value"], + source=customer_slot["source"], + confidence=customer_slot["confidence"], + evidence=customer_slot["evidence"], + required="customer_name" in required_keys, + ), + self._make_slot_card( + key="time_range", + value=time_slot["value"], + raw_value=time_slot["raw_value"], + normalized_value=time_slot["normalized_value"], + source=time_slot["source"], + confidence=time_slot["confidence"], + evidence=time_slot["evidence"], + required="time_range" in required_keys, + ), + self._make_slot_card( + key="location", + value=location_slot["value"], + raw_value=location_slot["raw_value"], + normalized_value=location_slot["normalized_value"], + source=location_slot["source"], + confidence=location_slot["confidence"], + evidence=location_slot["evidence"], + required="location" in required_keys, + ), + self._make_slot_card( + key="merchant_name", + value=merchant_slot["value"], + raw_value=merchant_slot["raw_value"], + normalized_value=merchant_slot["normalized_value"], + source=merchant_slot["source"], + confidence=merchant_slot["confidence"], + evidence=merchant_slot["evidence"], + required="merchant_name" in required_keys, + ), + self._make_slot_card( + key="amount", + value=amount_slot["value"], + raw_value=amount_slot["raw_value"], + normalized_value=amount_slot["normalized_value"], + source=amount_slot["source"], + confidence=amount_slot["confidence"], + evidence=amount_slot["evidence"], + required="amount" in required_keys, + ), + self._make_slot_card( + key="reason", + value=reason_slot["value"], + raw_value=reason_slot["raw_value"], + normalized_value=reason_slot["normalized_value"], + source=reason_slot["source"], + confidence=reason_slot["confidence"], + evidence=reason_slot["evidence"], + required="reason" in required_keys, + ), + self._make_slot_card( + key="participants", + value=participants_slot["value"], + raw_value=participants_slot["raw_value"], + normalized_value=participants_slot["normalized_value"], + source=participants_slot["source"], + confidence=participants_slot["confidence"], + evidence=participants_slot["evidence"], + required="participants" in required_keys, + ), + self._make_slot_card( + key="attachments", + value=attachment_slot["value"], + raw_value=attachment_slot["raw_value"], + normalized_value=attachment_slot["normalized_value"], + source=attachment_slot["source"], + confidence=attachment_slot["confidence"], + evidence=attachment_slot["evidence"], + required="attachments" in required_keys, + ), + ] + return cards + + def _build_review_document_cards( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + ) -> list[UserAgentReviewDocumentCard]: + cards: list[UserAgentReviewDocumentCard] = [] + for index, item in enumerate(ocr_documents, start=1): + classified = self._classify_document(item, payload) + fields = self._extract_document_fields(item) + cards.append( + UserAgentReviewDocumentCard( + index=index, + filename=str(item.get("filename") or f"document-{index}"), + document_type=classified["document_type"], + suggested_expense_type=classified["expense_type"], + scene_label=GROUP_SCENE_LABELS.get( + classified["group_code"], + classified["scene_label"], + ), + summary=str(item.get("summary") or item.get("text") or "").strip(), + avg_score=float(item.get("avg_score") or 0.0), + preview_kind=str(item.get("preview_kind") or "").strip(), + preview_data_url=str(item.get("preview_data_url") or "").strip(), + warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], + fields=[ + UserAgentReviewDocumentField( + label=label, + value=value, + source="ocr", + ) + for label, value in fields.items() + if str(value).strip() + ], + ) + ) + return cards + + def _build_review_claim_groups( + self, + payload: UserAgentRequest, + *, + document_cards: list[UserAgentReviewDocumentCard], + ) -> list[UserAgentReviewClaimGroup]: + groups: dict[str, dict[str, object]] = {} + for card in document_cards: + group_code = self._normalize_group_code(card.suggested_expense_type) + bucket = groups.setdefault( + group_code, + { + "document_indexes": [], + "amount_total": 0.0, + "expense_type": str(card.suggested_expense_type or group_code).strip() or group_code, + "scene_label": GROUP_SCENE_LABELS.get( + str(card.suggested_expense_type or group_code).strip() or group_code, + GROUP_SCENE_LABELS.get(group_code, "其他费用"), + ), + "reasons": [], + }, + ) + bucket["document_indexes"].append(card.index) + bucket["amount_total"] = float(bucket["amount_total"]) + self._extract_amount_from_card(card) + bucket["reasons"].append(f"{card.filename} 识别为 {card.scene_label}") + current_expense_type = str(bucket["expense_type"] or "").strip() + current_card_type = str(card.suggested_expense_type or "").strip() + if current_expense_type and current_card_type and current_expense_type != current_card_type: + bucket["expense_type"] = group_code + bucket["scene_label"] = GROUP_SCENE_LABELS.get(group_code, "其他费用") + + if not groups: + expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "other") + group_code = self._normalize_group_code(expense_type_code) + groups[group_code] = { + "document_indexes": [], + "amount_total": self._resolve_amount_value(payload), + "expense_type": expense_type_code or "other", + "scene_label": GROUP_SCENE_LABELS.get(group_code, "其他费用"), + "reasons": ["当前主要依据用户文本和页面上下文进行分单建议。"], + } + + claim_groups: list[UserAgentReviewClaimGroup] = [] + for index, (group_code, bucket) in enumerate(groups.items(), start=1): + title = f"建议报销单 {index}:{bucket['scene_label']}" + rationale = ( + ";".join(dict.fromkeys(str(item) for item in bucket["reasons"])) + if bucket["reasons"] + else "当前仅有单一场景,无需拆单。" + ) + claim_groups.append( + UserAgentReviewClaimGroup( + group_code=group_code, + title=title, + expense_type=str(bucket["expense_type"]), + scene_label=str(bucket["scene_label"]), + document_indexes=list(bucket["document_indexes"]), + amount_total=round(float(bucket["amount_total"]), 2), + rationale=rationale, + ) + ) + return claim_groups + + def _build_review_risk_briefs( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + document_cards: list[UserAgentReviewDocumentCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> list[UserAgentReviewRiskBrief]: + briefs: list[UserAgentReviewRiskBrief] = [] + employee_name = self._collect_entity_values(payload).get("employee_name") or str( + payload.context_json.get("name") or "" + ).strip() + if employee_name: + since = datetime.now(UTC) - timedelta(days=90) + stmt = select(ExpenseClaim).where( + ExpenseClaim.employee_name == employee_name, + ExpenseClaim.occurred_at >= since, + ) + recent_claims = list(self.db.scalars(stmt).all()) + if recent_claims: + risky_count = sum(1 for item in recent_claims if item.risk_flags_json) + draft_count = sum(1 for item in recent_claims if item.status == "draft") + briefs.append( + UserAgentReviewRiskBrief( + title="历史报销画像", + level="info", + content=( + f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销," + f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。" + ), + ) + ) + current_amount = self._resolve_amount_value(payload) + if current_amount > 0: + duplicate_count = sum( + 1 + for item in recent_claims + if abs(float(item.amount) - current_amount) < 0.01 + ) + if duplicate_count: + briefs.append( + UserAgentReviewRiskBrief( + title="金额重复预警", + level="warning", + content=( + f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," + "提交前建议核对是否为重复报销或拆分不当。" + ), + ) + ) + + if citations: + briefs.append( + UserAgentReviewRiskBrief( + title="制度注意事项", + level="info", + content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。", + ) + ) + + warning_count = sum(len(item.warnings) for item in document_cards) + if warning_count: + briefs.append( + UserAgentReviewRiskBrief( + title="票据识别提醒", + level="warning", + content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。", + ) + ) + + if len(claim_groups) > 1: + briefs.append( + UserAgentReviewRiskBrief( + title="建议拆单", + level="high", + content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", + ) + ) + + return briefs[:4] + + def _build_review_confirmation_actions( + self, + payload: UserAgentRequest, + *, + can_proceed: bool, + claim_groups: list[UserAgentReviewClaimGroup], + draft_payload: UserAgentDraftPayload | None, + ) -> list[UserAgentReviewAction]: + if self._is_review_association_choice_pending(payload): + claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() + link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿" + return [ + UserAgentReviewAction( + label="取消", + action_type="cancel_review", + description="放弃当前识别结果,并退出本次核对流程。", + emphasis="secondary", + ), + UserAgentReviewAction( + label="修改识别信息", + action_type="edit_review", + description="打开结构化模板,按已识别字段逐项修改。", + emphasis="secondary", + ), + UserAgentReviewAction( + label=link_label, + action_type="link_to_existing_draft", + description=( + f"把本次上传票据并入现有草稿 {claim_no}。" + if claim_no + else "把本次上传票据并入现有草稿。" + ), + emphasis="primary", + ), + UserAgentReviewAction( + label="单独建立报销单", + action_type="create_new_claim_from_documents", + description="基于当前上传的多张票据,新建一张独立的报销草稿。", + emphasis="secondary", + ), + ] + + primary_action = UserAgentReviewAction( + label="继续下一步" if can_proceed else "保存为草稿", + action_type="next_step" if can_proceed else "save_draft", + description=( + "当前识别信息已满足继续处理条件,确认后进入下一步。" + if can_proceed + else "暂存当前识别结果,后续可以继续补充或修改。" + ), + emphasis="primary", + ) + if len(claim_groups) > 1 and can_proceed: + primary_action.description = f"系统建议拆分为 {len(claim_groups)} 张报销单,确认后继续下一步。" + if draft_payload is not None and draft_payload.claim_no and not can_proceed: + primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" + + return [ + UserAgentReviewAction( + label="取消", + action_type="cancel_review", + description="放弃当前识别结果,并退出本次核对流程。", + emphasis="secondary", + ), + UserAgentReviewAction( + label="修改识别信息", + action_type="edit_review", + description="打开结构化模板,按已识别字段逐项修改。", + emphasis="secondary", + ), + primary_action, + ] + + def _build_review_intent_summary( + self, + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> str: + slots = {item.key: item for item in slot_cards} + expense_type = slots.get("expense_type") + amount = slots.get("amount") + time_range = slots.get("time_range") + location = slots.get("location") + customer = slots.get("customer_name") + + summary = "我先根据您当前提供的信息整理出一笔报销。" + if expense_type and expense_type.value: + summary = f"识别到您希望报销一笔“{expense_type.value}”费用。" + details: list[str] = [] + if customer and customer.value: + details.append(f"客户为 {customer.value}") + if time_range and time_range.value: + details.append(f"时间为 {time_range.value}") + if location and location.value: + details.append(f"地点为 {location.value}") + if amount and amount.value: + details.append(f"金额为 {amount.value}") + reason = slots.get("reason") + if reason and reason.value: + details.append(f"事由是 {reason.value}") + if details: + return f"{summary} {','.join(details)}。" + return summary + + def _build_review_body_answer( + self, + payload: UserAgentRequest, + *, + review_payload: UserAgentReviewPayload | None, + draft_payload: UserAgentDraftPayload | None, + ) -> str | None: + if review_payload is None: + return None + if payload.ontology.scenario != "expense": + return None + if payload.ontology.intent not in {"draft", "operate"}: + return None + if payload.tool_payload.get("draft_limit_reached"): + return ( + str(payload.tool_payload.get("message") or "").strip() + or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + ) + + review_action = str(payload.context_json.get("review_action") or "").strip() + if review_action == "save_draft": + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" + "后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + ) + return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + if review_action == "link_to_existing_draft": + document_count = self._resolve_review_document_count(payload) + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。" + "您可以继续补充识别字段,确认无误后再提交审批。" + ) + return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。" + if review_action == "create_new_claim_from_documents": + document_count = self._resolve_review_document_count(payload) + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。" + "您可以继续补充识别字段,确认无误后再提交审批。" + ) + return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" + if review_action == "next_step": + if draft_payload is not None and draft_payload.status == "submitted": + stage_text = draft_payload.approval_stage or "审批中" + return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip() + if payload.tool_payload.get("submission_blocked"): + return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" + return ( + f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " + "当前关键信息已基本齐全,您确认无误后可以继续下一步。" + ) + if review_action == "edit_review": + return ( + f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " + f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}" + ) + return review_payload.body_message or None + + def _build_review_body_message( + self, + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + risk_briefs: list[UserAgentReviewRiskBrief], + can_proceed: bool, + document_cards: list[UserAgentReviewDocumentCard], + ) -> str: + if self._is_review_association_choice_pending(payload): + claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() + document_count = len(document_cards) or self._resolve_review_document_count(payload) + if claim_no: + return ( + f"已识别出本次上传的 {document_count} 张票据。" + f"系统检测到你已有草稿 {claim_no},请选择关联到该草稿,或单独建立一张新的报销单。" + ) + return ( + f"已识别出本次上传的 {document_count} 张票据。" + "系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。" + ) + + review_payload = UserAgentReviewPayload( + intent_summary="", + body_message="", + scenario=payload.ontology.scenario, + intent=payload.ontology.intent, + can_proceed=can_proceed, + missing_slots=self._resolve_review_missing_slot_labels(slot_cards), + risk_briefs=risk_briefs, + slot_cards=slot_cards, + document_cards=[], + claim_groups=[], + confirmation_actions=[], + edit_fields=[], + ) + return ( + f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} " + f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" + ) + + @staticmethod + def _resolve_review_missing_slot_labels( + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[str]: + return [item.label for item in slot_cards if item.status == "missing"] + + @staticmethod + def _build_review_guidance_copy( + review_payload: UserAgentReviewPayload, + *, + mention_save_draft: bool, + ) -> str: + missing_count = len(review_payload.missing_slots) + reminder_count = len(review_payload.risk_briefs) + + if review_payload.can_proceed: + if reminder_count: + return ( + f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。" + "您可以展开下方卡片查看详情,确认无误后继续下一步。" + ) + return "当前关键信息已基本齐全,您确认无误后可以继续下一步。" + + issue_parts: list[str] = [] + if missing_count: + issue_parts.append(f"{missing_count} 项信息待补充") + if reminder_count: + issue_parts.append(f"{reminder_count} 条提醒") + issue_summary = "、".join(issue_parts) if issue_parts else "一些细节还需要进一步确认" + + suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else "。" + return ( + f"当前还有 {issue_summary}。" + f"您可以展开下方卡片查看详情,继续补充或修改{suffix}" + ) + + @staticmethod + def _can_proceed_review( + payload: UserAgentRequest, + *, + missing_slot_keys: list[str], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> bool: + if payload.ontology.ambiguity: + return False + if missing_slot_keys: + return False + if not claim_groups: + return False + return True + + def _build_review_edit_fields( + self, + payload: UserAgentRequest, + *, + draft_payload: UserAgentDraftPayload | None, + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[UserAgentReviewEditField]: + slot_map = {item.key: item for item in slot_cards} + employee = self._resolve_employee_profile(payload) + reporter_name = ( + slot_map.get("reporter_name").value + if slot_map.get("reporter_name") + else str(payload.context_json.get("name") or "").strip() + ) + manager_name = self._resolve_manager_name(employee) + reason = slot_map.get("reason").value if slot_map.get("reason") else "" + attachments = "、".join(self._resolve_attachment_names(payload)) + + fields = [ + UserAgentReviewEditField( + key="claim_no", + label="报销单据编号", + value=str(draft_payload.claim_no if draft_payload is not None and draft_payload.claim_no else "待生成"), + placeholder="保存草稿后自动生成", + required=False, + group="basic", + ), + UserAgentReviewEditField( + key="expense_type", + label="报销类型", + value=slot_map.get("expense_type").value if slot_map.get("expense_type") else "", + placeholder="例如:业务招待费 / 差旅费", + group="basic", + ), + UserAgentReviewEditField( + key="occurred_date", + label="业务发生时间", + value=slot_map.get("time_range").normalized_value if slot_map.get("time_range") and slot_map.get("time_range").normalized_value else slot_map.get("time_range").value if slot_map.get("time_range") else "", + placeholder="例如:2026-05-11", + group="basic", + ), + UserAgentReviewEditField( + key="reporter_name", + label="报销人", + value=reporter_name, + placeholder="请输入报销人姓名", + group="basic", + ), + UserAgentReviewEditField( + key="manager_name", + label="直属上司姓名", + value=manager_name, + placeholder="请输入直属上司姓名", + required=False, + group="basic", + ), + UserAgentReviewEditField( + key="customer_name", + label="客户名称", + value=slot_map.get("customer_name").value if slot_map.get("customer_name") else "", + placeholder="请输入客户名称", + group="business", + ), + UserAgentReviewEditField( + key="business_location", + label="业务地点", + value=slot_map.get("location").normalized_value if slot_map.get("location") and slot_map.get("location").normalized_value else slot_map.get("location").value if slot_map.get("location") else "", + placeholder="例如:北京 / 客户现场", + required=False, + group="business", + ), + UserAgentReviewEditField( + key="merchant_name", + label="酒店/商户", + value=slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "", + placeholder="请输入酒店或商户名称", + required=False, + group="business", + ), + UserAgentReviewEditField( + key="amount", + label="金额", + value=slot_map.get("amount").normalized_value if slot_map.get("amount") and slot_map.get("amount").normalized_value else slot_map.get("amount").value if slot_map.get("amount") else "", + placeholder="例如:200.00元", + group="business", + ), + UserAgentReviewEditField( + key="participants", + label="参与人员", + value=slot_map.get("participants").value if slot_map.get("participants") else "", + placeholder="例如:客户 2 人,我方 1 人", + group="business", + ), + UserAgentReviewEditField( + key="reason", + label="事由", + value=reason, + placeholder="请输入报销事由", + field_type="textarea", + group="business", + ), + UserAgentReviewEditField( + key="attachment_names", + label="附件清单", + value=attachments, + placeholder="例如:发票.jpg、行程单.png", + required=False, + field_type="textarea", + group="attachments", + ), + ] + return fields + + def _resolve_employee_profile(self, payload: UserAgentRequest) -> Employee | None: + candidates = [ + str(payload.context_json.get("name") or "").strip(), + str(payload.user_id or "").strip(), + self._collect_entity_values(payload).get("employee_name", ""), + ] + normalized = [item for item in dict.fromkeys(candidates) if item] + if not normalized: + return None + + stmt = ( + select(Employee) + .where( + or_( + Employee.name.in_(normalized), + Employee.employee_no.in_(normalized), + Employee.email.in_(normalized), + ) + ) + .limit(1) + ) + return self.db.scalar(stmt) + + @staticmethod + def _resolve_manager_name(employee: Employee | None) -> str: + if employee is None: + return "" + if employee.manager is not None and employee.manager.name: + return employee.manager.name + if employee.organization_unit is not None and employee.organization_unit.manager_name: + return employee.organization_unit.manager_name + return "" + + @staticmethod + def _extract_message_reason(message: str) -> str: + for line in str(message or "").splitlines(): + cleaned = line.strip() + if not cleaned: + continue + if cleaned.startswith(("附件名称:", "OCR摘要:", "关联单号:")): + continue + return cleaned[:300] + return "" + + @staticmethod + def _looks_like_system_generated_reason_message(message: str) -> bool: + cleaned = str(message or "").strip() + if not cleaned: + return False + compact = re.sub(r"\s+", "", cleaned) + return compact.startswith(SYSTEM_GENERATED_REASON_PREFIXES) + + def _resolve_reason_source_text(self, payload: UserAgentRequest) -> str: + explicit_text = payload.context_json.get("user_input_text") + if isinstance(explicit_text, str): + return explicit_text.strip() + if self._looks_like_system_generated_reason_message(payload.message): + return "" + return str(payload.message or "").strip() + + @classmethod + def _resolve_reason_text(cls, message: str) -> str: + reason = cls._extract_message_reason(message) + if not reason: + return "" + + compact = re.sub(r"\s+", "", reason) + if compact in GENERIC_EXPENSE_PROMPTS: + return "" + + instruction_prefixes = ( + "帮我生成", + "请帮我生成", + "生成", + "起草", + "创建", + "发起", + "准备", + "帮我报销", + "我要报销", + "我想报销", + ) + if compact.startswith(instruction_prefixes): + for separator in (",", ",", "。", ";", ";", ":", ":"): + if separator in reason: + trailing = reason.split(separator, 1)[1].strip() + if trailing: + return trailing[:300] + return "" + + return reason + + @staticmethod + def _should_skip_model_answer( + payload: UserAgentRequest, + review_payload: UserAgentReviewPayload | None, + ) -> bool: + if payload.ontology.scenario == "expense" and payload.ontology.intent in {"query", "compare"}: + return True + if review_payload is None: + return False + return payload.ontology.scenario == "expense" and ( + payload.ontology.intent == "draft" + or int(payload.context_json.get("attachment_count") or 0) > 0 + ) + + def _build_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: + knowledge_citations = self._build_knowledge_citations(payload) + if payload.ontology.scenario == "knowledge": + return knowledge_citations[:3] + + rule_citations = self._build_rule_asset_citations(payload) + if knowledge_citations: + return (knowledge_citations + rule_citations)[:3] + return rule_citations + + @staticmethod + def _build_knowledge_citations(payload: UserAgentRequest) -> list[UserAgentCitation]: + citations: list[UserAgentCitation] = [] + for item in list(payload.tool_payload.get("hits") or [])[:3]: + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("document_name") or "").strip() + code = str(item.get("code") or item.get("candidate_id") or "").strip() + if not title or not code: + continue + citations.append( + UserAgentCitation( + source_type="knowledge", + code=code, + title=title, + version=str(item.get("version") or "").strip() or None, + updated_at=str(item.get("updated_at") or "").strip() or None, + excerpt=( + str(item.get("excerpt") or "").strip() + or str(item.get("content") or "").strip() + or None + ), + ) + ) + return citations + + def _build_rule_asset_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: + domain = self._resolve_domain(payload.ontology.scenario) + items = self.asset_service.list_assets( + asset_type=AgentAssetType.RULE.value, + status=AgentAssetStatus.ACTIVE.value, + domain=domain, + ) + ranked = self._rank_rule_assets(items, payload) + citations: list[UserAgentCitation] = [] + for item in ranked[:2]: + detail = self.asset_service.get_asset(item.id) + if detail is None: + continue + excerpt = self._extract_excerpt(str(detail.current_version_content or "")) + citations.append( + UserAgentCitation( + source_type="rule", + code=detail.code, + title=detail.name, + version=detail.current_version, + updated_at=detail.updated_at.date().isoformat(), + excerpt=excerpt, + ) + ) + return citations + + @staticmethod + def _resolve_risk_flags(payload: UserAgentRequest) -> list[str]: + tool_flags = payload.tool_payload.get("risk_flags") + if isinstance(tool_flags, list) and tool_flags: + return [str(item) for item in tool_flags] + return [str(item) for item in payload.ontology.risk_flags] + + @staticmethod + def _resolve_subject(payload: UserAgentRequest) -> str: + named_entities = [ + item.value + for item in payload.ontology.entities + if item.type in {"employee", "customer", "vendor", "project"} + ] + if named_entities: + return f"{'、'.join(named_entities)} 相关数据" + return f"{SCENARIO_LABELS.get(payload.ontology.scenario, '当前')}场景数据" + + @staticmethod + def _is_generic_expense_prompt(payload: UserAgentRequest) -> bool: + if payload.ontology.scenario != "expense": + return False + normalized_message = re.sub(r"\s+", "", payload.message) + return normalized_message in GENERIC_EXPENSE_PROMPTS + + @staticmethod + def _is_implicit_expense_draft_request(payload: UserAgentRequest) -> bool: + if payload.ontology.scenario != "expense" or payload.ontology.intent != "draft": + return False + + compact_message = re.sub(r"\s+", "", payload.message) + if any(keyword in compact_message for keyword in EXPLICIT_DRAFT_KEYWORDS): + return False + + return True + + @staticmethod + def _resolve_attachment_names(payload: UserAgentRequest) -> list[str]: + names = payload.context_json.get("attachment_names") + if not isinstance(names, list): + return [] + return [str(name) for name in names if str(name).strip()] + + @staticmethod + def _resolve_attachment_count(payload: UserAgentRequest) -> int: + names = UserAgentService._resolve_attachment_names(payload) + if names: + return len(names) + try: + return max(0, int(payload.context_json.get("attachment_count") or 0)) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _resolve_ocr_documents(payload: UserAgentRequest) -> list[dict[str, object]]: + documents = payload.context_json.get("ocr_documents") + if not isinstance(documents, list): + return [] + overrides = payload.context_json.get("review_document_form_values") + override_map: dict[tuple[int, str], dict[str, object]] = {} + if isinstance(overrides, list): + for item in overrides: + if not isinstance(item, dict): + continue + filename = str(item.get("filename") or "").strip() + index = int(item.get("index") or 0) + if not filename and index <= 0: + continue + override_map[(index, filename)] = item + normalized: list[dict[str, object]] = [] + for index, item in enumerate(documents[:8], start=1): + if not isinstance(item, dict): + continue + normalized_item = dict(item) + override = override_map.get((index, str(normalized_item.get("filename") or "").strip())) + if override is None: + override = override_map.get((index, "")) + if override is not None: + summary = str(override.get("summary") or "").strip() + scene_label = str(override.get("scene_label") or "").strip() + fields = override.get("fields") + if summary: + normalized_item["summary"] = summary + if scene_label: + normalized_item["scene_label"] = scene_label + if isinstance(fields, list): + normalized_item["document_fields"] = [ + { + "key": str(field.get("key") or field.get("label") or "").strip(), + "label": str(field.get("label") or "").strip(), + "value": str(field.get("value") or "").strip(), + } + for field in fields + if isinstance(field, dict) + and str(field.get("label") or "").strip() + and str(field.get("value") or "").strip() + ] + normalized.append(normalized_item) + return normalized + + @staticmethod + def _is_review_association_choice_pending(payload: UserAgentRequest) -> bool: + return bool(payload.tool_payload.get("pending_association_decision")) + + def _resolve_review_document_count(self, payload: UserAgentRequest) -> int: + return max( + len(self._resolve_ocr_documents(payload)), + self._resolve_attachment_count(payload), + ) + + @staticmethod + def _resolve_conversation_history(payload: UserAgentRequest) -> list[dict[str, object]]: + history = payload.context_json.get("conversation_history") + if not isinstance(history, list): + return [] + + normalized: list[dict[str, object]] = [] + for item in history[-8:]: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip() + content = str(item.get("content") or "").strip() + if not role or not content: + continue + normalized.append({"role": role, "content": content}) + return normalized + + @staticmethod + def _resolve_domain(scenario: str) -> str | None: + if scenario == "expense": + return "expense" + if scenario == "accounts_receivable": + return "ar" + if scenario == "accounts_payable": + return "ap" + return None + + @staticmethod + def _rank_rule_assets( + items: list[AgentAssetListItem], + payload: UserAgentRequest, + ) -> list[AgentAssetListItem]: + def score(item: AgentAssetListItem) -> tuple[int, str]: + tags = {str(value) for value in item.scenario_json or []} + weight = 0 + if payload.ontology.scenario in tags: + weight += 3 + if payload.ontology.intent in tags: + weight += 2 + for risk_flag in payload.ontology.risk_flags: + if risk_flag in tags: + weight += 4 + return weight, item.code + + ranked = sorted(items, key=score, reverse=True) + return [item for item in ranked if score(item)[0] > 0] + + @staticmethod + def _extract_excerpt(content: str) -> str: + lines = [line.strip() for line in str(content).splitlines() if line.strip()] + cleaned: list[str] = [] + for line in lines: + normalized = re.sub(r"^[#>\-\*\d\.\s`]+", "", line).strip() + if normalized: + cleaned.append(normalized) + if len(cleaned) >= 2: + break + return ";".join(cleaned[:2]) + + def _collect_entity_values(self, payload: UserAgentRequest) -> dict[str, str]: + values = { + "employee_name": "", + "customer": "", + "participants": "", + "amount": "", + "expense_type": "", + "expense_type_code": "", + } + participants: list[str] = [] + for item in payload.ontology.entities: + if item.type == "employee" and not values["employee_name"]: + values["employee_name"] = item.value + elif item.type == "customer" and not values["customer"]: + values["customer"] = item.value + elif item.type == "amount" and item.role != "threshold" and not values["amount"]: + normalized_amount = str(item.normalized_value or "").strip() + values["amount"] = f"{normalized_amount}元" if normalized_amount else item.value + elif item.type == "expense_type" and not values["expense_type_code"]: + values["expense_type_code"] = item.normalized_value + values["expense_type"] = EXPENSE_TYPE_LABELS.get( + item.normalized_value, + item.value, + ) + elif item.type in {"participant", "person"} and item.value.strip(): + participants.append(item.value.strip()) + if participants: + values["participants"] = "、".join(dict.fromkeys(participants)) + return values + + def _format_time_range(self, payload: UserAgentRequest) -> str: + time_range = payload.ontology.time_range + if time_range.start_date and time_range.end_date: + if time_range.start_date == time_range.end_date: + return time_range.start_date + normalized = f"{time_range.start_date} 至 {time_range.end_date}" + return normalized + if time_range.raw: + return time_range.raw + return "" + + def _resolve_location_value(self, payload: UserAgentRequest) -> str: + review_form_values = self._resolve_review_form_values(payload) + for key in ("business_location", "location"): + value = str(review_form_values.get(key) or "").strip() + if value: + return value + + if str(payload.context_json.get("entry_source") or "").strip() == "detail": + request_context = payload.context_json.get("request_context") + if isinstance(request_context, dict): + for key in ("city", "location"): + value = str(request_context.get(key) or "").strip() + if value: + return value + + labeled_match = re.search(r"(?:业务地点|发生地点|地点)[::]\s*(?P[^\n,。;]+)", payload.message) + if labeled_match: + return labeled_match.group("value").strip() + + city_match = re.search(r"去(?P[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message) + if city_match: + return city_match.group("city").strip() + if "客户现场" in payload.message.replace(" ", ""): + return "客户现场" + return "" + + @staticmethod + def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: + values = payload.context_json.get("review_form_values") + if not isinstance(values, dict): + return {} + normalized: dict[str, str] = {} + for key, value in values.items(): + cleaned_key = str(key or "").strip() + if not cleaned_key: + continue + normalized[cleaned_key] = str(value or "").strip() + return normalized + + @staticmethod + def _build_slot_value( + *, + value: str = "", + raw_value: str = "", + normalized_value: str = "", + source: str = "system", + confidence: float = 0.0, + evidence: str = "", + ) -> dict[str, str | float]: + return { + "value": str(value or "").strip(), + "raw_value": str(raw_value or "").strip(), + "normalized_value": str(normalized_value or "").strip(), + "source": str(source or "system").strip() or "system", + "confidence": float(confidence), + "evidence": str(evidence or "").strip(), + } + + def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str( + review_form_values.get("occurred_date") + or review_form_values.get("time_range") + or review_form_values.get("business_time") + or "" + ).strip() + if edited_value: + raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() + return self._build_slot_value( + value=edited_value, + raw_value=raw_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + time_range = payload.ontology.time_range + if time_range.start_date and time_range.end_date: + normalized_value = ( + time_range.start_date + if time_range.start_date == time_range.end_date + else f"{time_range.start_date} 至 {time_range.end_date}" + ) + raw_value = str(time_range.raw or "").strip() + return self._build_slot_value( + value=normalized_value, + raw_value=raw_value, + normalized_value=normalized_value, + source="user_text", + confidence=0.92, + evidence="系统已根据当前日期将相对时间换算为标准日期。", + ) + + return self._build_slot_value() + + def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + for key in ("business_location", "location"): + value = str(review_form_values.get(key) or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + if str(payload.context_json.get("entry_source") or "").strip() == "detail": + request_context = payload.context_json.get("request_context") + if isinstance(request_context, dict): + for key in ("city", "location"): + value = str(request_context.get(key) or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="detail_context", + confidence=0.68, + evidence="来源于当前关联单据,仅作为辅助上下文,需要用户再次核对。", + ) + + value = self._resolve_location_value(payload) + if value: + evidence = "用户在文本中明确描述了业务地点。" + if value == "客户现场": + evidence = "用户明确提到“客户现场”,但未提供具体城市或地址。" + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.82, + evidence=evidence, + ) + return self._build_slot_value() + + def _build_customer_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + value = str(review_form_values.get("customer_name") or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + value = entity_map.get("customer", "") + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.88, + evidence="用户在原始描述中直接提到了客户对象。", + ) + return self._build_slot_value() + + def _build_participants_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + value = str(review_form_values.get("participants") or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + value = entity_map.get("participants", "") + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.8, + evidence="用户在当前描述中补充了参与人员。", + ) + return self._build_slot_value() + + def _build_reason_slot( + self, + payload: UserAgentRequest, + *, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("reason") or "").strip() + if edited_value: + return self._build_slot_value( + value=edited_value, + raw_value=edited_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + inferred_reason = self._infer_reason_from_claim_groups( + claim_groups=claim_groups, + ) + reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload)) + if inferred_reason: + return self._build_slot_value( + value=inferred_reason, + raw_value=reason_value or inferred_reason, + normalized_value=inferred_reason, + source="ocr", + confidence=0.82, + evidence=( + "系统已根据票据识别结果预置场景类型;原始描述仍保留为补充说明。" + if reason_value + else "系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。" + ), + ) + + if reason_value: + return self._build_slot_value( + value=reason_value, + raw_value=reason_value, + normalized_value=reason_value, + source="user_text", + confidence=0.76, + evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。", + ) + return self._build_slot_value() + + def _build_amount_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_amount = str(review_form_values.get("amount") or "").strip() + if edited_amount: + normalized = self._normalize_amount_text(edited_amount) + return self._build_slot_value( + value=normalized, + raw_value=edited_amount, + normalized_value=normalized, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + amount_value = entity_map.get("amount", "") + if amount_value: + normalized = self._normalize_amount_text(amount_value) + return self._build_slot_value( + value=normalized, + raw_value=amount_value, + normalized_value=normalized, + source="user_text", + confidence=0.92, + evidence="用户在原始描述中直接给出了金额。", + ) + + ocr_total_amount = self._sum_ocr_amounts(ocr_documents) + if ocr_total_amount > 0: + normalized = f"{ocr_total_amount:.2f}元" + return self._build_slot_value( + value=normalized, + normalized_value=normalized, + source="ocr", + confidence=0.76, + evidence="金额来自 OCR 汇总结果,仍建议用户核对票据原文。", + ) + return self._build_slot_value() + + def _build_expense_type_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip() + if edited_value: + normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) + return self._build_slot_value( + value=normalized_label, + raw_value=edited_value, + normalized_value=normalized_code, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + expense_type_code = entity_map.get("expense_type_code", "") + expense_type_value = EXPENSE_TYPE_LABELS.get(expense_type_code, entity_map.get("expense_type", "")) + if expense_type_value: + return self._build_slot_value( + value=expense_type_value, + raw_value=expense_type_value, + normalized_value=expense_type_code, + source="user_text", + confidence=0.9, + evidence="系统根据用户描述中的业务场景判断费用类型。", + ) + + inferred_label = self._infer_expense_type_from_documents(payload, ocr_documents) if ocr_documents else "" + if inferred_label: + normalized_code, normalized_label = self._normalize_expense_type_input(inferred_label) + return self._build_slot_value( + value=normalized_label, + raw_value=inferred_label, + normalized_value=normalized_code, + source="ocr", + confidence=0.74, + evidence="系统根据票据内容推断费用类型,仍建议用户确认。", + ) + return self._build_slot_value() + + def _build_merchant_slot( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("merchant_name") or "").strip() + if edited_value: + return self._build_slot_value( + value=edited_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else "" + if merchant_value: + return self._build_slot_value( + value=merchant_value, + normalized_value=merchant_value, + source="ocr", + confidence=0.72, + evidence="商户名称来自 OCR 票据识别结果,仍建议用户核对。", + ) + return self._build_slot_value() + + def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + attachment_names = str(review_form_values.get("attachment_names") or "").strip() + if attachment_names: + return self._build_slot_value( + value=attachment_names, + normalized_value=attachment_names, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + count = self._resolve_attachment_count(payload) + if count > 0: + names = self._resolve_attachment_names(payload) + value = "、".join(names) if names else f"{count} 份附件" + return self._build_slot_value( + value=value, + raw_value=value, + normalized_value=str(count), + source="upload", + confidence=1.0, + evidence="系统已接收到用户上传的附件。", + ) + return self._build_slot_value() + + @staticmethod + def _normalize_amount_text(value: str) -> str: + cleaned = str(value or "").strip() + if not cleaned: + return "" + for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True): + cleaned = cleaned.replace(alias, canonical) + match = AMOUNT_TEXT_PATTERN.search(cleaned) + if not match: + return cleaned + number = float(match.group(1)) + return f"{number:.2f}元" + + @staticmethod + def _normalize_expense_type_input(value: str) -> tuple[str, str]: + compact = str(value or "").replace(" ", "") + if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))): + return "entertainment", "业务招待费" + if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")): + return "travel", "差旅费" + if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")): + return "hotel", "住宿费" + if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")): + return "transport", "交通费" + if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): + return "meal", "餐费" + if "会务" in compact: + return "meeting", "会务费" + if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")): + return "office", "办公费" + if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")): + return "training", "培训费" + if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")): + return "communication", "通讯费" + if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")): + return "welfare", "福利费" + return "other", str(value or "").strip() or "其他费用" + + def _resolve_required_review_keys( + self, + payload: UserAgentRequest, + *, + primary_expense_type: str, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> set[str]: + required = {"expense_type", "time_range", "amount", "reason", "attachments"} + scene_codes = { + str(item.group_code or "").strip() + for item in claim_groups + if str(item.group_code or "").strip() + } + if primary_expense_type: + scene_codes.add(primary_expense_type) + + for scene_code in scene_codes: + required.update(SCENE_REQUIRED_SLOT_KEYS.get(scene_code, set())) + + compact_message = re.sub(r"\s+", "", self._resolve_reason_source_text(payload) or payload.message) + if "entertainment" in scene_codes or ( + "客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客")) + ): + required.update({"customer_name", "participants"}) + + return required + + @staticmethod + def _infer_reason_from_claim_groups( + *, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> str: + if len(claim_groups) == 1: + document_indexes = list(claim_groups[0].document_indexes or []) + if not document_indexes: + return "" + + expense_type = str(claim_groups[0].expense_type or "").strip() + group_code = str(claim_groups[0].group_code or "").strip() + if expense_type: + return INFERRED_REASON_LABELS.get(expense_type, "") or str(claim_groups[0].scene_label or "").strip() + if group_code: + return INFERRED_REASON_LABELS.get(group_code, "") or str(claim_groups[0].scene_label or "").strip() + return "" + + @staticmethod + def _resolve_review_missing_slot_keys( + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[str]: + required_keys = {item.key for item in slot_cards if item.required} + slot_map = {item.key: item for item in slot_cards} + missing_keys = { + item.key + for item in slot_cards + if item.required and (item.status == "missing" or not str(item.value).strip()) + } + for key in payload.ontology.missing_slots: + normalized_key = str(key or "").strip() + if ( + normalized_key + and normalized_key in required_keys + and ( + normalized_key not in slot_map + or slot_map[normalized_key].status == "missing" + or not str(slot_map[normalized_key].value).strip() + ) + ): + missing_keys.add(normalized_key) + + ordered_keys: list[str] = [] + for item in slot_cards: + if item.required and item.key in missing_keys and item.key not in ordered_keys: + ordered_keys.append(item.key) + return ordered_keys + + def _make_slot_card( + self, + *, + key: str, + value: str, + raw_value: str, + normalized_value: str, + source: str, + confidence: float, + evidence: str, + required: bool = True, + ) -> UserAgentReviewSlotCard: + is_missing = required and not str(value).strip() + source_key = source if source in SOURCE_LABELS else "system" + return UserAgentReviewSlotCard( + key=key, + label=SLOT_LABELS.get(key, key), + value=str(value or "").strip(), + raw_value=str(raw_value or "").strip(), + normalized_value=str(normalized_value or "").strip(), + source=source, + source_label=SOURCE_LABELS.get(source_key, "系统判断"), + confidence=confidence, + required=required, + confirmed=not is_missing and source in {"user_text", "user_form"}, + status="missing" if is_missing else "identified" if source in {"user_text", "user_form"} else "inferred", + hint=f"建议补充 {SLOT_LABELS.get(key, key)}。" + if is_missing and required + else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""), + evidence=evidence, + ) + + def _classify_document( + self, + item: dict[str, object], + payload: UserAgentRequest, + ) -> dict[str, str]: + provided_type = str(item.get("document_type") or "").strip().lower() + expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "") + has_customer = bool(self._collect_entity_values(payload).get("customer")) + if provided_type: + if provided_type in {"flight_itinerary", "train_ticket"}: + return { + "document_type": provided_type, + "expense_type": "travel", + "group_code": "travel", + "scene_label": "差旅票据", + } + if provided_type == "hotel_invoice": + return { + "document_type": provided_type, + "expense_type": "hotel", + "group_code": "travel", + "scene_label": "住宿票据", + } + if provided_type in {"taxi_receipt", "parking_toll_receipt"}: + return { + "document_type": provided_type, + "expense_type": "transport", + "group_code": "travel", + "scene_label": "交通票据", + } + if provided_type == "meal_receipt": + group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" + return { + "document_type": provided_type, + "expense_type": group_code, + "group_code": group_code, + "scene_label": "餐饮票据", + } + if provided_type == "office_invoice": + return { + "document_type": provided_type, + "expense_type": "office", + "group_code": "office", + "scene_label": "办公用品票据", + } + if provided_type == "meeting_invoice": + return { + "document_type": provided_type, + "expense_type": "meeting", + "group_code": "meeting", + "scene_label": "会务票据", + } + if provided_type == "training_invoice": + return { + "document_type": provided_type, + "expense_type": "training", + "group_code": "training", + "scene_label": "培训票据", + } + + text = " ".join( + [ + str(item.get("filename") or ""), + str(item.get("summary") or ""), + str(item.get("text") or ""), + ] + ).lower() + compact = text.replace(" ", "") + + if any(keyword in compact for keyword in ("机票", "航班", "火车", "高铁", "行程单")): + return { + "document_type": "travel_ticket", + "expense_type": "travel", + "group_code": "travel", + "scene_label": "差旅票据", + } + if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")): + return { + "document_type": "hotel_invoice", + "expense_type": "hotel", + "group_code": "travel", + "scene_label": "住宿票据", + } + if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")): + return { + "document_type": "transport_receipt", + "expense_type": "transport", + "group_code": "travel", + "scene_label": "交通票据", + } + if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")): + group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" + return { + "document_type": "meal_receipt", + "expense_type": group_code, + "group_code": group_code, + "scene_label": "餐饮票据", + } + if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")): + return { + "document_type": "other", + "expense_type": "office", + "group_code": "office", + "scene_label": "办公用品票据", + } + return { + "document_type": "other", + "expense_type": expense_type_code or "other", + "group_code": self._normalize_group_code(expense_type_code or "other"), + "scene_label": "其他票据", + } + + @staticmethod + def _normalize_group_code(expense_type_code: str) -> str: + if expense_type_code in {"travel", "hotel", "transport"}: + return "travel" + if expense_type_code in {"entertainment", "meal", "office", "training", "communication", "welfare"}: + return expense_type_code + return "other" + + def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]: + raw_fields = item.get("document_fields") + normalized_fields: dict[str, str] = {} + if isinstance(raw_fields, list): + for field in raw_fields: + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + if not value: + continue + normalized_label = self._normalize_document_field_label(key=key, label=label) + display_label = normalized_label or label + normalized_value = self._normalize_document_field_value( + label=display_label, + value=value, + ) + if display_label and normalized_value: + normalized_fields.setdefault(display_label, normalized_value) + + text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() + amount_value = self._extract_amount_text_from_value(text) + if amount_value and "金额" not in normalized_fields: + normalized_fields["金额"] = amount_value + date_match = DATE_TEXT_PATTERN.search(text) + if date_match and "时间" not in normalized_fields: + normalized_fields["时间"] = date_match.group(1) + + merchant = self._extract_document_merchant_name_from_text(text) + if merchant and "商户/酒店" not in normalized_fields: + normalized_fields["商户/酒店"] = merchant + return normalized_fields + + @staticmethod + def _normalize_document_field_label(*, key: str, label: str) -> str: + compact_key = str(key or "").strip().lower().replace("_", "") + compact_label = str(label or "").replace(" ", "") + if compact_key in { + "amount", + "totalamount", + "paymentamount", + "paidamount", + "actualamount", + } or any( + token in compact_label + for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") + ): + return "金额" + if compact_key in {"date", "time", "issuedat", "invoicedate"} or any( + token in compact_label for token in ("日期", "时间", "开票日期", "发生时间") + ): + return "时间" + if compact_key in {"merchant", "merchantname", "sellername", "vendorname"} or any( + token in compact_label for token in ("商户", "酒店", "销售方", "开票方", "收款方") + ): + return "商户/酒店" + return label + + def _normalize_document_field_value(self, *, label: str, value: str) -> str: + normalized_label = str(label or "").strip() + raw_value = str(value or "").strip() + if not normalized_label or not raw_value: + return "" + if normalized_label == "金额": + return self._extract_amount_text_from_value(raw_value) or raw_value + if normalized_label == "时间": + match = DATE_TEXT_PATTERN.search(raw_value) + return match.group(1) if match else raw_value + return raw_value + + def _extract_amount_text_from_value(self, value: str) -> str: + raw_value = str(value or "").strip() + if not raw_value: + return "" + best_amount: Decimal | None = None + for pattern in (DOCUMENT_AMOUNT_PATTERN, DOCUMENT_CURRENCY_AMOUNT_PATTERN, AMOUNT_TEXT_PATTERN): + for match in pattern.finditer(raw_value): + try: + candidate = Decimal(str(match.group(1)).replace(",", ".")) + except (InvalidOperation, TypeError): + continue + if candidate <= Decimal("0.00"): + continue + if best_amount is None or candidate > best_amount: + best_amount = candidate + if best_amount is None: + return "" + return f"{best_amount.quantize(Decimal('0.01')):.2f}元" + + def _extract_document_merchant_name(self, item: dict[str, object]) -> str: + fields = self._extract_document_fields(item) + merchant = str(fields.get("商户/酒店") or "").strip() + if merchant: + return merchant + text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() + return self._extract_document_merchant_name_from_text(text) + + @staticmethod + def _extract_document_merchant_name_from_text(text: str) -> str: + for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): + if keyword in text: + return keyword + return "" + + @staticmethod + def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float: + for item in card.fields: + if item.label != "金额": + continue + try: + normalized_value = str(item.value).replace("元", "").replace("¥", "").replace("¥", "").strip() + return float(normalized_value) + except ValueError: + return 0.0 + return 0.0 + + def _resolve_amount_value(self, payload: UserAgentRequest) -> float: + for item in payload.ontology.entities: + if item.type == "amount" and item.role != "threshold": + try: + return float(item.normalized_value) + except ValueError: + return 0.0 + return 0.0 + + def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float: + total = 0.0 + for item in ocr_documents: + fields = self._extract_document_fields(item) + amount_text = str(fields.get("金额") or "").replace("元", "").replace("¥", "").replace("¥", "").strip() + if not amount_text: + continue + try: + total += float(amount_text) + except ValueError: + continue + return total + + def _infer_expense_type_from_documents( + self, + payload: UserAgentRequest, + ocr_documents: list[dict[str, object]], + ) -> str: + labels: list[str] = [] + for item in ocr_documents: + classified = self._classify_document(item, payload) + label = GROUP_SCENE_LABELS.get(classified["group_code"], "") + if label and label not in labels: + labels.append(label) + return " + ".join(labels[:3]) diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 9056f85..c2e0713 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -1,4037 +1,4714 @@ -.assistant-overlay { - /* 距屏幕边 10–18px,随视口微调;高度用 dvh 适配笔记本浏览器工具栏 */ - --assistant-viewport-inset: clamp(10px, 1.25vmin, 18px); - position: fixed; - inset: 0; - width: 100vw; - height: 100dvh; - max-height: 100dvh; - z-index: 9999; - display: flex; - align-items: stretch; - justify-content: stretch; - padding: var(--assistant-viewport-inset); - box-sizing: border-box; - background: - radial-gradient(circle at 18% 14%, rgba(16, 185, 129, 0.18), transparent 24%), - radial-gradient(circle at 82% 12%, rgba(59, 130, 246, 0.12), transparent 28%), - rgba(97, 110, 131, 0.34); - backdrop-filter: blur(18px) saturate(1.02); - -webkit-backdrop-filter: blur(18px) saturate(1.02); -} - -.assistant-modal { - position: relative; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - background: transparent; - box-shadow: none; - border: 0; - border-radius: 24px; - backdrop-filter: none; - -webkit-backdrop-filter: none; - overflow: hidden; - isolation: isolate; -} - -.assistant-modal-stage { - /* 工作台字号令牌:笔记本断点见文末 @media */ - --wb-fs-title: 22px; - --wb-fs-desc: 13px; - --wb-fs-badge: 12px; - --wb-fs-bubble: 14px; - --wb-fs-bubble-meta: 13px; - --wb-fs-bubble-time: 12px; - --wb-fs-chip: 12px; - --wb-fs-composer: 14px; - --wb-fs-tool-icon: 18px; - --wb-fs-md-h1: 18px; - --wb-fs-md-h2: 16px; - --wb-fs-md-h3: 14px; - --wb-fs-insight-title: 19px; - --wb-fs-insight-num: 19px; - --wb-fs-insight-body: 12px; - --wb-fs-insight-h4: 15px; - --wb-fs-metric: 13px; - --wb-fs-metric-strong: 13px; - --wb-fs-welcome: 20px; - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - width: 100%; - height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - transform: none; - border-radius: 24px; - background: - radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 26%), - radial-gradient(circle at top left, rgba(59, 130, 246, 0.10), transparent 24%), - linear-gradient(180deg, rgba(241, 246, 245, 0.92) 0%, rgba(230, 237, 235, 0.88) 100%); - box-shadow: - 0 28px 72px rgba(15, 23, 42, 0.22), - 0 10px 28px rgba(15, 23, 42, 0.09), - inset 0 1px 0 rgba(255, 255, 255, 0.42); - border: 1px solid rgba(255, 255, 255, 0.44); - background-clip: padding-box; - overflow: hidden; - isolation: isolate; -} - -.assistant-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-shrink: 0; - padding: clamp(14px, 2vh, 22px) clamp(148px, 11vw, 172px) clamp(12px, 1.6vh, 18px) clamp(18px, 2vw, 26px); - border-bottom: 1px solid rgba(203, 213, 225, 0.78); - background: linear-gradient(180deg, rgba(247, 250, 249, 0.82) 0%, rgba(240, 246, 244, 0.7) 100%); -} - -.assistant-header-main { - display: flex; - align-items: flex-start; - gap: 14px; - min-width: 0; -} - -.assistant-badge { - min-height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 14px; - border-radius: 999px; - background: linear-gradient(135deg, #22c55e, #10b981); - color: #fff; - font-size: var(--wb-fs-badge); - font-weight: 800; - box-shadow: 0 8px 16px rgba(16, 185, 129, 0.14); - white-space: nowrap; -} - -.assistant-badge.warning { - background: rgba(249, 115, 22, 0.12); - color: #c2410c; -} - -.assistant-header h2 { - color: #0f172a; - font-size: clamp(17px, 1.1vw, var(--wb-fs-title)); - font-weight: 900; - letter-spacing: 0.01em; - line-height: 1.25; -} - -.assistant-header p { - margin-top: 4px; - color: #64748b; - font-size: clamp(11px, 0.85vw, var(--wb-fs-desc)); - line-height: 1.55; -} - -.assistant-header-actions { - position: absolute; - top: 16px; - right: 16px; - z-index: 60; - display: flex; - align-items: center; - gap: 10px; - pointer-events: auto; -} - -.assistant-toggle-btn, -.session-trash-btn { - width: 38px; - height: 38px; - display: grid; - place-items: center; - padding: 0; - border: 1px solid rgba(248, 113, 113, 0.28); - border-radius: 14px; - flex: none; -} - -.assistant-toggle-btn { - border-color: rgba(16, 185, 129, 0.18); - background: rgba(245, 252, 249, 0.96); - color: #166534; - font-size: 16px; - box-shadow: 0 8px 18px rgba(16, 185, 129, 0.1); -} - -.assistant-toggle-btn:hover:not(:disabled) { - background: rgba(236, 253, 245, 0.98); - border-color: rgba(16, 185, 129, 0.28); -} - -.assistant-toggle-btn:disabled, -.assistant-toggle-btn.disabled { - opacity: 0.48; - cursor: not-allowed; - box-shadow: none; -} - -.session-trash-btn { - background: rgba(254, 242, 242, 0.96); - color: #dc2626; - font-size: 16px; - box-shadow: 0 8px 18px rgba(239, 68, 68, 0.12); -} - -.session-trash-btn:hover:not(:disabled) { - background: rgba(254, 226, 226, 0.98); - border-color: rgba(239, 68, 68, 0.34); -} - -.session-trash-btn:disabled { - opacity: 0.42; - cursor: not-allowed; - box-shadow: none; -} - -.assistant-close-btn, -.close-btn { - position: relative; - width: 38px; - height: 38px; - display: grid; - place-items: center; - padding: 0; - flex: none; - border: 1px solid rgba(193, 204, 216, 0.92); - border-radius: 14px; - background: rgba(248, 251, 251, 0.94); - color: #475569; - font-size: 16px; - box-shadow: 0 8px 18px rgba(148, 163, 184, 0.18); - cursor: pointer; - pointer-events: auto; - user-select: none; - -webkit-user-select: none; -} - -.assistant-close-btn { - z-index: 61; - pointer-events: auto; -} - -.assistant-close-btn i { - pointer-events: none; -} - -.assistant-close-btn:hover, -.close-btn:hover { - background: rgba(241, 245, 249, 0.98); - border-color: rgba(148, 163, 184, 0.34); - color: #0f172a; -} - -.assistant-layout { - min-height: 0; - flex: 1; - display: flex; - padding: clamp(12px, 1.5vw, 16px); - align-items: stretch; - gap: clamp(12px, 1.5vw, 16px); -} - -.dialog-panel, -.insight-panel { - min-width: 0; - min-height: 0; - border: 1px solid rgba(189, 201, 214, 0.74); - border-radius: 24px; - background: rgba(248, 251, 251, 0.84); - box-shadow: - 0 14px 32px rgba(148, 163, 184, 0.16), - 0 2px 6px rgba(15, 23, 42, 0.05); - backdrop-filter: blur(22px); - -webkit-backdrop-filter: blur(22px); -} - -.dialog-panel { - flex: 1 1 auto; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - background: - radial-gradient(circle at top right, rgba(59, 130, 246, 0.07), transparent 22%), - linear-gradient(180deg, rgba(252, 253, 253, 0.88) 0%, rgba(243, 247, 248, 0.84) 100%); - transition: - transform 320ms cubic-bezier(0.22, 1, 0.36, 1), - box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1); - will-change: transform; -} - -.insight-panel-shell { - flex: none; - width: clamp(300px, 28vw, 420px); - min-width: 0; - max-width: 100%; - margin-left: 0; - overflow: hidden; - transition: - width 360ms cubic-bezier(0.22, 1, 0.36, 1), - margin-left 360ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.insight-panel-shell.collapsed { - width: 0; - margin-left: 0; -} - -.dialog-toolbar { - display: flex; - gap: 12px; - flex-wrap: wrap; - padding: 16px 18px 12px; - border-bottom: 1px solid rgba(238, 242, 247, 0.9); -} - -.shortcut-chip { - min-height: 36px; - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0 14px; - border: 1px solid rgba(219, 230, 240, 0.9); - border-radius: 999px; - background: rgba(255, 255, 255, 0.95); - color: #334155; - font-size: var(--wb-fs-chip); - font-weight: 750; - box-shadow: 0 4px 12px rgba(241, 245, 249, 0.78); - white-space: nowrap; -} - -.shortcut-chip i { - color: #059669; -} - -.shortcut-chip:disabled { - opacity: 0.48; - cursor: not-allowed; - box-shadow: none; -} - -.message-list { - min-height: 0; - display: grid; - align-content: start; - gap: 14px; - padding: 18px; - overflow-y: auto; -} - -.message-row { - display: grid; - grid-template-columns: 38px minmax(0, 1fr); - align-items: start; - gap: 12px; -} - -.message-row.user { - grid-template-columns: minmax(0, 1fr) 38px; -} - -.message-row.user .message-avatar { - order: 2; - background: transparent; -} - -.message-row.user .message-bubble { - order: 1; - justify-self: end; - background: linear-gradient(135deg, rgba(226, 238, 255, 0.98), rgba(242, 247, 255, 0.9)); - border-color: rgba(96, 165, 250, 0.24); - box-shadow: 0 14px 30px rgba(59, 130, 246, 0.08); -} - -.message-avatar { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 999px; - overflow: hidden; - background: transparent; - box-shadow: 0 10px 20px rgba(148, 163, 184, 0.24); -} - -.message-avatar img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; -} - -.message-bubble { - max-width: min(100%, 720px); - padding: 14px 16px; - border: 1px solid rgba(210, 220, 230, 0.94); - border-radius: 20px; - background: rgba(253, 254, 254, 0.94); - color: #24324a; - line-height: 1.65; - box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48); -} - -.message-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.message-meta strong { - color: #0f172a; - font-size: var(--wb-fs-bubble-meta); - font-weight: 850; -} - -.message-meta time { - color: #94a3b8; - font-size: var(--wb-fs-bubble-time); -} - -.message-bubble p { - color: #334155; - font-size: var(--wb-fs-bubble); -} - -.message-answer-content { - display: grid; - gap: 12px; -} - -.message-answer-content :deep(p), -.message-answer-content :deep(ul), -.message-answer-content :deep(ol), -.message-answer-content :deep(blockquote), -.message-answer-content :deep(pre) { - margin: 0; -} - -.message-answer-markdown :deep(h1), -.message-answer-markdown :deep(h2), -.message-answer-markdown :deep(h3), -.message-answer-markdown :deep(h4) { - margin: 0; - color: #0f172a; - line-height: 1.35; -} - -.message-answer-markdown :deep(h1) { - font-size: var(--wb-fs-md-h1); -} - -.message-answer-markdown :deep(h2) { - font-size: var(--wb-fs-md-h2); -} - -.message-answer-markdown :deep(h3), -.message-answer-markdown :deep(h4) { - font-size: var(--wb-fs-md-h3); -} - -.message-answer-markdown { - overflow-x: auto; - font-size: var(--wb-fs-bubble); - color: #334155; - line-height: 1.65; -} - -/* v-html 注入的 Markdown 节点无 scoped 标记,需用 :deep 与用户气泡 p 对齐字号 */ -.message-answer-markdown :deep(p), -.message-answer-markdown :deep(li), -.message-answer-markdown :deep(td), -.message-answer-markdown :deep(th), -.message-answer-markdown :deep(blockquote) { - font-size: inherit; - color: inherit; - line-height: 1.65; -} - -.message-answer-markdown :deep(ul), -.message-answer-markdown :deep(ol) { - padding-left: 22px; -} - -.message-answer-markdown :deep(strong) { - color: #0f172a; -} - -.message-answer-markdown :deep(blockquote) { - padding: 10px 12px; - border-left: 4px solid #93c5fd; - border-radius: 0 12px 12px 0; - background: #eff6ff; - color: #475569; -} - -.message-answer-markdown :deep(code) { - padding: 2px 6px; - border-radius: 6px; - background: #e2e8f0; - font-size: 12px; -} - -.message-answer-markdown :deep(pre) { - overflow-x: auto; - padding: 12px; - border-radius: 14px; - background: #0f172a; - color: #e2e8f0; -} - -.message-answer-markdown :deep(pre code) { - padding: 0; - background: transparent; - color: inherit; -} - -.message-answer-markdown :deep(a) { - color: #2563eb; - text-decoration: underline; -} - -.message-answer-markdown :deep(table) { - width: auto; - max-width: 100%; - border: 1px solid #dbe4ee; - border-radius: 16px; - border-collapse: collapse; - background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); - font-size: inherit; -} - -.message-answer-markdown :deep(th), -.message-answer-markdown :deep(td) { - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - text-align: left; - white-space: nowrap; -} - -.message-answer-markdown :deep(th) { - background: #eff6ff; - color: #0f172a; - font-weight: 850; -} - -.message-answer-markdown :deep(td) { - color: #334155; - font-weight: 650; -} - -.message-answer-markdown :deep(tbody tr:last-child td) { - border-bottom: 0; -} - -.message-meta-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; -} - -.message-meta-chip, -.capability-chip, -.risk-chip, -.message-risk-chip, -.message-action-chip { - min-height: 28px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - font-size: var(--wb-fs-chip); - font-weight: 800; -} - -.message-meta-chip, -.capability-chip { - background: #eef6ff; - color: #1d4ed8; -} - -.risk-chip, -.message-risk-chip { - background: #fff1f2; - color: #be123c; -} - -.message-action-chip { - background: #ecfdf5; - color: #059669; -} - -.message-detail-block { - margin-top: 14px; - display: grid; - gap: 10px; -} - -.message-detail-block > strong { - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.message-citation-disclosure { - overflow: hidden; - border: 1px solid #dbe4ee; - border-radius: 16px; - background: #fbfdff; -} - -.message-citation-disclosure summary { - min-height: 42px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 14px; - color: #0f172a; - cursor: pointer; - list-style: none; -} - -.message-citation-disclosure summary::-webkit-details-marker { - display: none; -} - -.message-citation-disclosure summary strong { - font-size: 12px; - font-weight: 850; -} - -.message-citation-disclosure summary span { - color: #64748b; - font-size: 12px; - font-weight: 750; -} - -.message-citation-disclosure summary i { - margin-left: auto; - color: #64748b; - font-size: 16px; - transition: transform 0.18s ease; -} - -.message-citation-disclosure[open] summary { - border-bottom: 1px solid #e2e8f0; -} - -.message-citation-disclosure[open] summary i { - transform: rotate(180deg); -} - -.message-citation-disclosure .message-citation-list { - padding: 12px; -} - -.expense-query-block { - gap: 10px; -} - -.expense-query-window-label { - margin: -4px 0 0; - color: #64748b; - font-size: 11px; - line-height: 1.5; -} - -.expense-query-summary-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.expense-query-summary-chip { - min-height: 24px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - font-size: 11px; - font-weight: 800; - background: #eef2ff; - color: #3730a3; -} - -.expense-query-summary-chip.draft { - background: #fef3c7; - color: #b45309; -} - -.expense-query-summary-chip.in_progress { - background: #dbeafe; - color: #1d4ed8; -} - -.expense-query-summary-chip.completed { - background: #dcfce7; - color: #15803d; -} - -.expense-query-summary-chip.other { - background: #f1f5f9; - color: #475569; -} - -.expense-query-record-list { - display: grid; - gap: 8px; -} - -.expense-query-record-list.compact .expense-query-record-card { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 12px; - border: 1px solid #dbe4ee; - border-radius: 14px; - background: #fbfdff; - cursor: pointer; - font: inherit; - text-align: left; - transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; -} - -.expense-query-record-list.compact .expense-query-record-card:hover { - transform: translateY(-1px); - border-color: #bfdbfe; - box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12); -} - -.expense-query-record-card > i { - color: #94a3b8; - font-size: 16px; -} - -.expense-query-record-main { - min-width: 0; - display: grid; - gap: 5px; - flex: 1; -} - -.expense-query-record-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.expense-query-record-top strong { - min-width: 0; - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.expense-query-record-top strong, -.expense-query-record-card p, -.expense-query-record-meta span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.expense-query-record-status { - flex-shrink: 0; - min-height: 22px; - display: inline-flex; - align-items: center; - padding: 0 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 800; - background: #f1f5f9; - color: #475569; -} - -.expense-query-record-status.draft { - background: #fef3c7; - color: #b45309; -} - -.expense-query-record-status.in_progress { - background: #dbeafe; - color: #1d4ed8; -} - -.expense-query-record-status.completed { - background: #dcfce7; - color: #15803d; -} - -.expense-query-record-card p { - margin: 0; - color: #334155; - font-size: 12px; -} - -.expense-query-record-meta { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; - color: #64748b; - font-size: 11px; - font-weight: 700; -} - -.expense-query-pager { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - margin-top: 2px; -} - -.expense-query-pager-btn { - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid #dbe4ee; - border-radius: 999px; - background: #fff; - color: #475569; - transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; -} - -.expense-query-pager-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.expense-query-pager-btn:not(:disabled):hover { - border-color: #bfdbfe; - color: #2563eb; - background: #f8fbff; -} - -.expense-query-pager-dots { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.expense-query-pager-dot { - width: 8px; - height: 8px; - padding: 0; - border: 0; - border-radius: 999px; - background: #cbd5e1; - transition: transform 0.2s ease, background 0.2s ease; -} - -.expense-query-pager-dot.active { - background: #2563eb; - transform: scale(1.15); -} - -.expense-query-empty { - min-height: 52px; - display: flex; - align-items: center; - gap: 10px; - padding: 0 14px; - border: 1px dashed #dbe4ee; - border-radius: 16px; - color: #64748b; - font-size: 12px; - font-weight: 700; -} - -.expense-query-empty i { - font-size: 18px; - color: #94a3b8; -} - -.expense-query-hint { - margin: 0; - color: #64748b; - font-size: 11px; - line-height: 1.6; -} - -.message-detail-chip-row, -.capability-chip-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.message-citation-list, -.citation-stack, -.action-list { - display: grid; - gap: 10px; -} - -.message-citation-card, -.citation-card, -.action-card { - padding: 12px 14px; - border: 1px solid #e2e8f0; - border-radius: 16px; - background: #f8fbff; -} - -.message-citation-card header, -.citation-card header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 6px; -} - -.message-citation-card header span, -.citation-card header strong, -.action-card strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; -} - -.message-citation-card header small, -.citation-card header span { - color: #64748b; - font-size: 11px; - font-weight: 700; -} - -.message-citation-card p, -.citation-card p, -.action-card p, -.draft-preview pre { - margin: 0; - color: #475569; - font-size: 12px; - line-height: 1.65; -} - -.draft-preview { - margin-top: 12px; - padding: 12px 14px; - border: 1px solid #dbe3ec; - border-radius: 16px; - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); -} - -.draft-preview header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.draft-preview header strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; -} - -.draft-preview header span { - color: #b45309; - font-size: 12px; - font-weight: 800; -} - -.draft-preview pre { - white-space: pre-wrap; - word-break: break-word; - font-family: inherit; -} - -.message-files { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -.file-chip { - min-height: 28px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 10px; - border: 0; - border-radius: 999px; - background: #f1f5f9; - color: #475569; - font-size: 12px; - font-weight: 700; - max-width: 100%; -} - -.file-chip.active { - background: #eef6ff; - color: #2563eb; -} - -.composer { - padding: 0 18px 18px; - display: grid; - gap: 12px; -} - -.hidden-file-input { - display: none; -} - -.composer-row { - --composer-control-size: 44px; -} - -.composer-leading-actions { - display: flex; - align-items: center; - gap: 8px; - flex: none; -} - -.composer-date-anchor { - position: relative; -} - -.tool-btn.composer-side-btn.active { - border-color: rgba(59, 130, 246, 0.42); - background: rgba(239, 246, 255, 0.96); - color: #2563eb; - box-shadow: 0 6px 14px rgba(59, 130, 246, 0.14); -} - -.composer-date-popover { - position: absolute; - bottom: calc(100% + 10px); - left: 0; - z-index: 30; - width: min(320px, calc(100vw - 48px)); - display: grid; - gap: 12px; - padding: 14px; - border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 16px; - background: rgba(255, 255, 255, 0.98); - box-shadow: - 0 18px 40px rgba(15, 23, 42, 0.16), - 0 4px 12px rgba(15, 23, 42, 0.06); -} - -.composer-date-mode-tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - padding: 4px; - border-radius: 12px; - background: rgba(241, 245, 249, 0.92); -} - -.composer-date-mode-btn { - min-height: 34px; - border: 0; - border-radius: 10px; - background: transparent; - color: #64748b; - font-size: 12px; - font-weight: 800; -} - -.composer-date-mode-btn.active { - background: #fff; - color: #0f172a; - box-shadow: 0 4px 10px rgba(148, 163, 184, 0.18); -} - -.composer-date-fields { - display: grid; - gap: 8px; -} - -.composer-date-fields-range { - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - align-items: end; - gap: 8px; -} - -.composer-date-field { - display: grid; - gap: 6px; - min-width: 0; -} - -.composer-date-field span { - color: #64748b; - font-size: 11px; - font-weight: 800; -} - -.composer-date-field input { - width: 100%; - min-height: 36px; - padding: 0 10px; - border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 10px; - background: #fff; - color: #0f172a; - font-size: 12px; - font-weight: 700; -} - -.composer-date-range-sep { - align-self: center; - color: #94a3b8; - font-size: 12px; - font-weight: 800; -} - -.composer-date-hint { - margin: 0; - color: #dc2626; - font-size: 11px; - line-height: 1.5; -} - -.composer-date-popover-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.composer-date-cancel-btn, -.composer-date-apply-btn { - min-height: 34px; - padding: 0 14px; - border-radius: 10px; - font-size: 12px; - font-weight: 800; -} - -.composer-date-cancel-btn { - border: 1px solid rgba(203, 213, 225, 0.92); - background: #fff; - color: #64748b; -} - -.composer-date-apply-btn { - border: 0; - background: linear-gradient(135deg, #22c55e, #10b981); - color: #fff; -} - -.composer-date-apply-btn:disabled { - opacity: 0.48; - cursor: not-allowed; -} - -.composer-shell { - min-width: 0; - min-height: var(--composer-control-size, 44px); - border: 1px solid rgba(214, 225, 234, 0.95); - border-radius: 999px; - background: rgba(255, 255, 255, 0.98); - box-shadow: - 0 10px 22px rgba(226, 232, 240, 0.24), - 0 1px 4px rgba(15, 23, 42, 0.03); -} - -.composer-shell-body { - min-height: var(--composer-control-size, 44px); - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - padding: 4px 12px; -} - -.composer-biz-time-tag { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: min(100%, 320px); - min-height: 28px; - padding: 0 8px 0 10px; - border-radius: 999px; - border: 1px solid rgba(59, 130, 246, 0.28); - background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(16, 185, 129, 0.12)); - color: #1d4ed8; - font-size: 11px; - font-weight: 800; - flex: none; -} - -.composer-biz-time-tag i { - font-size: 14px; - color: #2563eb; -} - -.composer-biz-time-tag-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.composer-biz-time-tag-remove { - width: 18px; - height: 18px; - display: grid; - place-items: center; - padding: 0; - border: 0; - border-radius: 999px; - background: rgba(255, 255, 255, 0.72); - color: #3b82f6; - flex: none; -} - -.composer-biz-time-tag-remove:disabled { - opacity: 0.48; -} - -.composer-files-panel { - display: grid; - gap: 10px; - padding: 14px; - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 18px; - background: linear-gradient(180deg, rgba(248, 251, 255, 0.92) 0%, rgba(242, 247, 251, 0.78) 100%); -} - -.composer-files-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.composer-files-head strong { - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.composer-files-actions { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.composer-file-link { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0; - border: 0; - background: transparent; - color: #2563eb; - font-size: 11px; - font-weight: 800; -} - -.composer-file-link.danger { - color: #dc2626; -} - -.composer-file-link:disabled { - opacity: 0.48; -} - -.composer-file-chip-row { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.composer-file-chip { - max-width: min(100%, 280px); -} - -.file-chip.summary { - border: 1px dashed rgba(96, 165, 250, 0.34); - background: rgba(239, 246, 255, 0.92); - cursor: pointer; -} - -.file-chip-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-chip-remove { - width: 18px; - height: 18px; - display: grid; - place-items: center; - padding: 0; - border: 0; - border-radius: 999px; - background: rgba(255, 255, 255, 0.82); - color: inherit; - flex: none; -} - -.file-chip-remove:disabled { - opacity: 0.48; -} - -.composer-files-expanded { - display: grid; - gap: 8px; - max-height: 176px; - overflow-y: auto; - padding-right: 2px; -} - -.composer-expanded-file { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 12px; - border-radius: 14px; - border: 1px solid rgba(219, 230, 240, 0.92); - background: rgba(255, 255, 255, 0.88); -} - -.composer-expanded-file-copy { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - color: #334155; -} - -.composer-expanded-file-copy span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - font-weight: 700; -} - -.composer-expanded-file-remove { - width: 28px; - height: 28px; - display: grid; - place-items: center; - border: 0; - border-radius: 10px; - background: rgba(248, 250, 252, 0.92); - color: #64748b; -} - -.composer-expanded-file-remove:disabled { - opacity: 0.48; -} - -.composer-shell textarea { - flex: 1 1 120px; - width: auto; - min-width: 0; - min-height: 36px; - max-height: 120px; - resize: none; - border: 0; - padding: 8px 4px; - background: transparent; - color: #0f172a; - font-size: var(--wb-fs-composer); - line-height: 20px; -} - -.composer-shell textarea::placeholder { - color: #94a3b8; -} - -.composer-shell textarea:focus { - outline: none; -} - -.composer-shell textarea:disabled { - color: #94a3b8; -} - -.composer-row { - display: flex; - align-items: center; - gap: 10px; -} - -.composer-row .composer-shell { - flex: 1 1 auto; -} - -.composer-side-btn, -.composer-row .tool-btn, -.composer-row .send-btn { - width: var(--composer-control-size, 44px); - height: var(--composer-control-size, 44px); - display: grid; - place-items: center; - border: 0; - border-radius: 999px; - flex: none; -} - -.tool-btn { - background: #ffffff; - color: #475569; - font-size: var(--wb-fs-tool-icon); - border: 1px solid #dbe6f0; - box-shadow: 0 4px 12px rgba(241, 245, 249, 0.76); -} - -.tool-btn:disabled { - opacity: 0.48; - cursor: not-allowed; -} - -.send-btn { - background: linear-gradient(135deg, #22c55e, #10b981); - color: #fff; - font-size: var(--wb-fs-tool-icon); - box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18); -} - -.send-btn:disabled { - opacity: 0.48; - cursor: not-allowed; - box-shadow: none; -} - -.insight-panel { - position: relative; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: 100%; - height: 100%; - overflow: hidden; - background: - linear-gradient(180deg, rgba(239, 245, 243, 0.9) 0%, rgba(231, 238, 236, 0.84) 100%); - opacity: 1; - transform: translateX(0) scale(1); - transform-origin: right center; - transition: - opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), - transform 320ms cubic-bezier(0.22, 1, 0.36, 1), - box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1); - will-change: transform, opacity; -} - -.insight-panel-shell.collapsed .insight-panel { - opacity: 0; - transform: translateX(44px) scale(0.985); - pointer-events: none; -} - -.insight-panel::before { - content: ""; - position: absolute; - top: -18px; - right: -34px; - width: 240px; - height: 150px; - border-radius: 0 0 0 140px; - background: - radial-gradient(circle at 0 100%, rgba(16, 185, 129, 0.14), transparent 54%), - linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(96, 165, 250, 0.06)); - opacity: 0.9; - pointer-events: none; -} - -.insight-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - padding: 18px 18px 14px; - border-bottom: 1px solid rgba(205, 215, 224, 0.82); - position: relative; - z-index: 1; -} - -.insight-head.review-mode { - justify-content: space-between; -} - -.insight-head-eyebrow { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.insight-head-badge { - min-height: 24px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - background: rgba(240, 253, 244, 0.95); - color: #059669; - font-size: 11px; - font-weight: 800; - border: 1px solid rgba(16, 185, 129, 0.12); -} - -.review-insight-title-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.review-insight-title-copy { - min-width: 0; -} - -.review-insight-title-copy h3 { - margin: 0; -} - -.review-insight-tools { - display: inline-flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; - align-self: center; -} - -.review-insight-switch-icon-btn { - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - border: 1px solid rgba(203, 213, 225, 0.92); - background: rgba(248, 250, 252, 0.96); - color: #94a3b8; - font-size: 14px; - flex: 0 0 auto; - transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, transform 0.18s ease; -} - -.review-insight-switch-icon-btn.available { - border-color: rgba(245, 158, 11, 0.28); - background: rgba(255, 247, 237, 0.94); - color: #d97706; -} - -.review-insight-switch-icon-btn.active { - border-color: rgba(217, 119, 6, 0.42); - background: rgba(254, 243, 199, 0.98); - color: #b45309; - box-shadow: 0 6px 14px rgba(245, 158, 11, 0.16); -} - -.review-insight-switch-icon-btn.risk.available { - border-color: rgba(239, 68, 68, 0.28); - background: rgba(254, 242, 242, 0.96); - color: #dc2626; -} - -.review-insight-switch-icon-btn.risk.active { - border-color: rgba(220, 38, 38, 0.42); - background: rgba(254, 226, 226, 0.98); - color: #b91c1c; - box-shadow: 0 6px 14px rgba(239, 68, 68, 0.16); -} - -.review-insight-switch-icon-btn:hover:not(:disabled) { - transform: translateY(-1px); -} - -.review-insight-switch-icon-btn:disabled { - cursor: not-allowed; - opacity: 1; - color: #cbd5e1; - background: rgba(248, 250, 252, 0.9); -} - -.intent-pill { - min-height: 30px; - display: inline-flex; - align-items: center; - padding: 0 13px; - border-radius: 999px; - font-size: var(--wb-fs-chip); - font-weight: 800; -} - -.intent-pill.welcome { - background: #eef2ff; - color: #4f46e5; -} - -.intent-pill.draft { - background: #ecfdf5; - color: #059669; -} - -.intent-pill.approval { - background: #fff7ed; - color: #ea580c; -} - -.intent-pill.recognition { - background: #eff6ff; - color: #2563eb; -} - -.intent-pill.note { - background: #fdf2f8; - color: #db2777; -} - -.insight-head h3 { - margin-top: 10px; - color: #0f172a; - font-size: var(--wb-fs-insight-title); - font-weight: 900; - line-height: 1.25; -} - -.insight-head p { - margin-top: 6px; - color: #64748b; - font-size: var(--wb-fs-insight-body); - line-height: 1.6; -} - -.confidence-card { - min-width: 92px; - padding: 10px 12px; - border-radius: 14px; - background: rgba(250, 252, 252, 0.9); - border: 1px solid rgba(202, 213, 223, 0.9); - box-shadow: 0 8px 18px rgba(203, 213, 225, 0.3); - text-align: right; -} - -.confidence-card span { - display: block; - color: #94a3b8; - font-size: 11px; - font-weight: 800; -} - -.confidence-card strong { - display: block; - margin-top: 4px; - color: #0f172a; - font-size: var(--wb-fs-insight-num); - font-weight: 900; -} - -.insight-body { - min-height: 0; - display: grid; - align-content: start; - gap: 12px; - padding: 14px 18px 18px; - overflow-y: auto; - position: relative; - z-index: 1; -} - -.review-side-card { - display: grid; - gap: 10px; - padding: 14px; - border-radius: 18px; - border: 1px solid rgba(197, 209, 221, 0.88); - background: rgba(249, 251, 251, 0.88); - box-shadow: 0 10px 20px rgba(226, 232, 240, 0.3); -} - -.review-side-overview-card { - gap: 12px; - background: linear-gradient(180deg, rgba(251, 252, 252, 0.92) 0%, rgba(240, 246, 244, 0.86) 100%); -} - -.review-side-intent-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - color: #475569; - font-size: var(--wb-fs-metric); -} - -.review-side-intent-row i { - color: #059669; - font-size: 16px; -} - -.review-side-intent-row strong { - color: #0f172a; - font-size: var(--wb-fs-bubble); - font-weight: 850; -} - -.review-side-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.review-side-grid.compact { - gap: 8px; -} - -.review-side-metric-card { - display: grid; - grid-template-columns: 32px minmax(0, 1fr); - gap: 8px; - align-items: start; - padding: 12px; - border-radius: 14px; - border: 1px solid rgba(206, 216, 226, 0.88); - background: rgba(251, 252, 252, 0.82); - position: relative; - cursor: pointer; - transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; -} - -.review-side-metric-card.invalid { - border-color: rgba(239, 68, 68, 0.34); - background: rgba(254, 242, 242, 0.72); -} - -.review-side-metric-card.editable:hover, -.review-side-metric-card.editing { - border-color: rgba(16, 185, 129, 0.34); - background: rgba(248, 252, 250, 0.92); - transform: translateY(-1px); -} - -.review-side-metric-icon { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border-radius: 10px; - background: rgba(240, 253, 244, 0.95); - color: #059669; - font-size: 15px; -} - -.review-side-metric-copy { - display: grid; - gap: 4px; -} - -.review-side-metric-copy small { - color: #64748b; - font-size: 11px; - font-weight: 800; -} - -.review-side-metric-copy strong { - color: #0f172a; - font-size: var(--wb-fs-metric-strong); - font-weight: 850; - line-height: 1.35; - word-break: break-word; -} - -.review-inline-input { - width: 100%; - min-height: 34px; - padding: 0 10px; - border: 1px solid rgba(16, 185, 129, 0.2); - border-radius: 10px; - background: rgba(255, 255, 255, 0.96); - color: #0f172a; - font-size: 12px; - font-weight: 700; -} - -.review-inline-input.invalid { - border-color: rgba(239, 68, 68, 0.4); - color: #b91c1c; - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.08); -} - -.review-inline-input:focus { - outline: none; - border-color: rgba(16, 185, 129, 0.42); - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08); -} - -.review-inline-select-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.review-inline-select-option { - min-height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 10px; - border-radius: 999px; - border: 1px solid rgba(203, 213, 225, 0.92); - background: rgba(255, 255, 255, 0.96); - color: #475569; - font-size: 11px; - font-weight: 700; -} - -.review-inline-select-option.active { - border-color: rgba(16, 185, 129, 0.36); - background: rgba(240, 253, 244, 0.94); - color: #047857; -} - -.review-inline-error { - color: #dc2626; - font-size: 11px; - font-weight: 800; - line-height: 1.45; -} - -.review-side-edit-hint { - position: absolute; - top: 8px; - right: 8px; - min-height: 20px; - display: inline-flex; - align-items: center; - padding: 0 6px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(226, 232, 240, 0.92); - color: #94a3b8; - font-size: 10px; - font-weight: 800; - opacity: 0; - transition: opacity 0.18s ease; -} - -.review-side-edit-hint.upload { - color: #059669; -} - -.review-side-metric-card:hover .review-side-edit-hint, -.review-side-metric-card.editing .review-side-edit-hint { - opacity: 1; -} - -.review-side-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.review-side-head strong { - color: #0f172a; - font-size: 14px; - font-weight: 900; -} - -.review-side-head-copy { - display: grid; - gap: 4px; - min-width: 0; -} - -.review-side-head-copy p { - margin: 0; - color: #64748b; - font-size: 11px; - line-height: 1.55; -} - -.review-side-confidence { - color: #10b981; - font-size: 12px; - font-weight: 900; -} - -.review-side-category-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.review-side-category-card { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - padding: 12px; - min-height: 66px; - border-radius: 14px; - border: 1px solid rgba(226, 232, 240, 0.94); - background: rgba(255, 255, 255, 0.68); - cursor: pointer; - transition: border-color 0.18s ease, background 0.18s ease; -} - -.review-side-category-card.active { - border-color: rgba(52, 211, 153, 0.62); - background: rgba(240, 253, 244, 0.9); - box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.08); -} - -.review-side-category-copy { - display: grid; - gap: 4px; - min-width: 0; -} - -.review-side-category-copy strong { - color: #0f172a; - font-size: 12px; - font-weight: 850; - line-height: 1.35; - white-space: nowrap; -} - -.review-side-category-copy p { - margin: 0; - color: #64748b; - font-size: 10px; - line-height: 1.4; -} - -.review-side-group-check { - color: #10b981; - font-size: 18px; -} - -.review-other-category-popover { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding-top: 2px; -} - -.review-other-category-option { - min-height: 30px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 12px; - border-radius: 999px; - border: 1px solid rgba(203, 213, 225, 0.92); - background: rgba(255, 255, 255, 0.94); - color: #475569; - font-size: 11px; - font-weight: 750; -} - -.review-other-category-option.active { - border-color: rgba(16, 185, 129, 0.36); - background: rgba(240, 253, 244, 0.94); - color: #047857; -} - -.review-side-risk-card { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%); -} - -.review-side-risk-score { - color: #f97316; - font-size: 13px; - font-weight: 900; -} - -.review-side-risk-score.empty { - color: #94a3b8; -} - -.review-side-risk-summary { - margin: 0; - color: #334155; - font-size: 12px; - line-height: 1.6; -} - -.review-side-risk-list { - display: grid; - gap: 8px; - margin: 0; - padding-left: 16px; - color: #475569; - font-size: 12px; - line-height: 1.6; -} - -.review-side-link { - width: fit-content; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0; - border: 0; - background: transparent; - color: #059669; - font-size: 12px; - font-weight: 850; -} - -.review-side-link:disabled { - opacity: 0.5; -} - -.review-side-empty { - display: grid; - justify-items: start; - gap: 8px; - padding: 14px; - border: 1px dashed rgba(203, 213, 225, 0.92); - border-radius: 16px; - background: rgba(255, 255, 255, 0.52); -} - -.review-side-empty-icon { - width: 36px; - height: 36px; - display: grid; - place-items: center; - border-radius: 12px; - background: rgba(240, 244, 248, 0.96); - color: #94a3b8; - font-size: 18px; -} - -.review-side-empty strong { - color: #475569; - font-size: 13px; - font-weight: 850; -} - -.review-side-empty p { - margin: 0; - color: #94a3b8; - font-size: 12px; - line-height: 1.6; -} - -.review-side-save-pill { - position: sticky; - bottom: 0; - justify-self: end; - min-height: 36px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 14px; - border: 1px solid rgba(16, 185, 129, 0.22); - border-radius: 999px; - background: rgba(255, 255, 255, 0.94); - color: #059669; - font-size: 12px; - font-weight: 850; - box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); -} - -.review-side-save-pill:disabled { - opacity: 0.5; - box-shadow: none; -} - -.review-document-switch-card { - gap: 14px; -} - -.review-ticket-drawer { - min-height: 0; -} - -.review-document-switch-head { - align-items: flex-start; -} - -.review-document-nav { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 4px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(226, 232, 240, 0.92); - white-space: nowrap; -} - -.review-document-nav span { - color: #334155; - font-size: 11px; - font-weight: 850; -} - -.review-document-nav-btn { - width: 28px; - height: 28px; - display: grid; - place-items: center; - border: 0; - border-radius: 999px; - background: rgba(241, 245, 249, 0.96); - color: #334155; -} - -.review-document-nav-btn:disabled { - opacity: 0.4; -} - -.review-document-stage { - display: grid; - gap: 12px; - min-height: 0; -} - -.review-document-stage-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.review-document-stage-copy { - min-width: 0; - display: grid; - gap: 6px; -} - -.review-document-stage-copy strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; - line-height: 1.5; - word-break: break-word; -} - -.review-document-index-chip { - width: fit-content; - min-height: 24px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - background: rgba(236, 253, 245, 0.92); - color: #059669; - font-size: 11px; - font-weight: 850; -} - -.review-document-meta-chip-row { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.review-document-meta-chip { - min-height: 26px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - background: rgba(248, 250, 252, 0.94); - border: 1px solid rgba(226, 232, 240, 0.92); - color: #475569; - font-size: 11px; - font-weight: 800; -} - -.review-document-meta-chip.confidence { - background: rgba(236, 253, 245, 0.92); - color: #047857; - border-color: rgba(167, 243, 208, 0.92); -} - -.review-document-scroll { - display: grid; - gap: 12px; - max-height: 430px; - overflow-y: auto; - padding-right: 4px; -} - -.review-document-preview-card { - min-height: 168px; - overflow: hidden; - border-radius: 16px; - border: 1px solid rgba(226, 232, 240, 0.94); - background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); -} - -.review-document-preview-card.clickable { - cursor: zoom-in; -} - -.review-document-preview-card.clickable img { - transition: transform 0.18s ease; -} - -.review-document-preview-card.clickable:hover img { - transform: scale(1.02); -} - -.review-document-preview-card.image img { - display: block; - width: 100%; - height: 188px; - object-fit: cover; -} - -.review-document-preview-placeholder { - min-height: 168px; - display: grid; - place-items: center; - gap: 6px; - padding: 18px; - text-align: center; -} - -.review-document-preview-placeholder i { - color: #64748b; - font-size: 34px; -} - -.review-document-preview-placeholder strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; -} - -.review-document-preview-placeholder p { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.65; -} - -.review-document-edit-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.review-document-edit-field { - display: grid; - gap: 8px; -} - -.review-document-edit-field span { - color: #334155; - font-size: 12px; - font-weight: 800; -} - -.review-document-edit-field input, -.review-document-edit-field textarea { - width: 100%; - border: 1px solid rgba(219, 230, 240, 0.96); - border-radius: 14px; - background: rgba(255, 255, 255, 0.96); - color: #0f172a; - font-size: 13px; - line-height: 1.6; - padding: 10px 12px; - resize: vertical; -} - -.review-document-edit-field input:focus, -.review-document-edit-field textarea:focus { - outline: none; - border-color: rgba(16, 185, 129, 0.36); - box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.08); -} - -.review-document-edit-field textarea { - min-height: 88px; -} - -.review-document-warning-list { - display: grid; - gap: 8px; -} - -.review-document-warning-item { - display: grid; - grid-template-columns: 18px minmax(0, 1fr); - gap: 8px; - align-items: start; - padding: 10px 12px; - border-radius: 14px; - background: rgba(255, 247, 237, 0.92); - border: 1px solid rgba(253, 186, 116, 0.6); - color: #c2410c; - font-size: 12px; - line-height: 1.6; -} - -.review-side-empty.compact { - padding: 12px; -} - -.insight-card { - padding: 16px; - border: 1px solid #e7eef6; - border-radius: 20px; - background: rgba(255, 255, 255, 0.95); - box-shadow: 0 14px 24px rgba(241, 245, 249, 0.86); -} - -.insight-card.primary { - background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); -} - -.card-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; -} - -.card-head h4 { - color: #0f172a; - font-size: 15px; - font-weight: 850; -} - -.knowledge-question-list { - display: grid; - gap: 10px; -} - -.knowledge-question-btn { - width: 100%; - display: grid; - grid-template-columns: 28px minmax(0, 1fr) 18px; - align-items: center; - gap: 10px; - padding: 12px 14px; - border: 1px solid rgba(226, 232, 240, 0.92); - border-radius: 16px; - background: rgba(248, 250, 252, 0.86); - color: #1e293b; - text-align: left; - transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; -} - -.knowledge-question-btn:hover:not(:disabled) { - border-color: rgba(16, 185, 129, 0.3); - background: rgba(240, 253, 244, 0.9); - transform: translateY(-1px); -} - -.knowledge-question-btn:disabled { - opacity: 0.48; - cursor: not-allowed; - transform: none; -} - -.knowledge-question-btn i { - justify-self: end; - color: #059669; - font-size: 16px; -} - -.knowledge-question-index { - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: rgba(226, 232, 240, 0.9); - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.knowledge-question-index.gold { - background: linear-gradient(135deg, #fbbf24, #f59e0b); - color: #7c2d12; - box-shadow: 0 6px 14px rgba(245, 158, 11, 0.22); -} - -.knowledge-question-index.silver { - background: linear-gradient(135deg, #e2e8f0, #cbd5e1); - color: #334155; - box-shadow: 0 6px 14px rgba(148, 163, 184, 0.18); -} - -.knowledge-question-index.bronze { - background: linear-gradient(135deg, #fdba74, #ea580c); - color: #7c2d12; - box-shadow: 0 6px 14px rgba(234, 88, 12, 0.18); -} - -.knowledge-question-copy { - min-width: 0; - color: #334155; - font-size: 13px; - font-weight: 750; - line-height: 1.5; -} - -.status-pill { - min-height: 28px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 800; - white-space: nowrap; -} - -.status-pill.success { - background: #ecfdf5; - color: #059669; -} - -.status-pill.warning { - background: #fff7ed; - color: #ea580c; -} - -.status-pill.note { - background: #fdf2f8; - color: #db2777; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.metric-grid.single { - grid-template-columns: 1fr; -} - -.metric-item { - padding: 12px 14px; - border-radius: 16px; - background: #f8fafc; -} - -.metric-item span { - display: block; - color: #94a3b8; - font-size: 11px; - font-weight: 800; -} - -.metric-item strong { - display: block; - margin-top: 6px; - color: #0f172a; - font-size: 14px; - font-weight: 850; - line-height: 1.5; -} - -.timeline-list, -.bullet-list { - display: grid; - gap: 12px; - padding: 0; - margin: 0; - list-style: none; -} - -.timeline-list li { - display: grid; - grid-template-columns: 14px minmax(0, 1fr); - gap: 12px; - align-items: start; -} - -.timeline-dot { - width: 10px; - height: 10px; - margin-top: 5px; - border-radius: 999px; - background: #cbd5e1; -} - -.timeline-list li.done .timeline-dot, -.timeline-list li.current .timeline-dot { - background: #10b981; -} - -.timeline-list strong { - display: block; - color: #0f172a; - font-size: 13px; - font-weight: 800; -} - -.timeline-list p, -.bullet-list li, -.welcome-card p, -.note-block p { - color: #64748b; - font-size: var(--wb-fs-metric); - line-height: 1.6; -} - -.receipt-list { - display: grid; - gap: 10px; -} - -.receipt-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: 16px; - background: #f8fafc; -} - -.receipt-row strong, -.welcome-card strong, -.note-block strong { - color: #0f172a; - font-size: var(--wb-fs-bubble); - font-weight: 850; -} - -.action-card { - background: #fff; -} - -.receipt-row p, -.receipt-row span { - color: #64748b; - font-size: 12px; -} - -.receipt-side { - text-align: right; -} - -.receipt-side strong { - display: block; -} - -.review-message-block { - margin-top: 12px; -} - -.review-summary { - margin: 0; - color: #1f2937; - font-size: 13px; - line-height: 1.75; - white-space: pre-line; -} - -.review-card-shell { - display: grid; - gap: 12px; - padding: 15px; - border-radius: 20px; - border: 1px solid rgba(16, 185, 129, 0.14); - background: - radial-gradient(circle at top right, rgba(34, 197, 94, 0.08), transparent 28%), - linear-gradient(180deg, #fbfffd 0%, #f6fbf9 100%); - box-shadow: 0 8px 20px rgba(226, 232, 240, 0.28); -} - -.review-flow-card { - display: grid; - gap: 10px; - padding-top: 2px; - border-top: 1px solid rgba(226, 232, 240, 0.72); -} - -.review-disclosure-card { - display: grid; - gap: 0; - border-top: 1px solid rgba(226, 232, 240, 0.72); - padding-top: 6px; -} - -.review-disclosure-card summary { - list-style: none; -} - -.review-disclosure-card summary::-webkit-details-marker { - display: none; -} - -.review-disclosure-summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-radius: 16px; - border: 1px solid rgba(226, 232, 240, 0.92); - background: rgba(255, 255, 255, 0.78); - cursor: pointer; - transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; -} - -.review-disclosure-summary:hover { - border-color: rgba(16, 185, 129, 0.2); - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 6px 16px rgba(226, 232, 240, 0.24); -} - -.review-disclosure-copy { - min-width: 0; - display: grid; - gap: 4px; -} - -.review-disclosure-copy strong { - color: #0f172a; - font-size: 12px; - font-weight: 900; - line-height: 1.4; -} - -.review-disclosure-copy p { - margin: 0; - color: #64748b; - font-size: 11px; - line-height: 1.55; -} - -.review-disclosure-toggle { - width: 28px; - height: 28px; - flex: none; - display: grid; - place-items: center; - border-radius: 999px; - background: rgba(240, 253, 244, 0.86); - color: #059669; - font-size: 16px; - transition: transform 0.18s ease, background 0.18s ease; -} - -.review-disclosure-card[open] .review-disclosure-toggle { - transform: rotate(180deg); - background: rgba(220, 252, 231, 0.92); -} - -.review-disclosure-body { - display: grid; - gap: 10px; - padding: 12px 4px 0; -} - -.review-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.review-card-head-main { - min-width: 0; - display: flex; - align-items: flex-start; - gap: 10px; -} - -.review-card-icon { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border-radius: 10px; - background: linear-gradient(135deg, #22c55e, #10b981); - color: #fff; - font-size: 16px; - box-shadow: 0 8px 16px rgba(16, 185, 129, 0.16); -} - -.review-card-head-copy { - display: grid; - gap: 4px; -} - -.review-card-head-copy strong { - color: #0f172a; - font-size: 14px; - font-weight: 900; - line-height: 1.35; -} - -.review-card-head-copy p { - margin: 0; - color: #64748b; - font-size: 11px; - line-height: 1.55; -} - -.review-card-state { - min-height: 26px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - font-size: 11px; - font-weight: 850; - white-space: nowrap; -} - -.review-card-state.ready { - background: rgba(240, 253, 244, 0.95); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.14); -} - -.review-card-state.pending { - background: rgba(255, 251, 235, 0.95); - color: #b45309; - border: 1px solid rgba(245, 158, 11, 0.16); -} - -.review-section-card { - display: grid; - gap: 10px; - padding: 12px 14px; - border-radius: 16px; - border: 1px solid rgba(226, 232, 240, 0.92); - background: rgba(255, 255, 255, 0.76); -} - -.review-section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.review-section-head strong { - color: #0f172a; - font-size: 12px; - font-weight: 900; -} - -.review-section-head span { - min-height: 22px; - display: inline-flex; - align-items: center; - padding: 0 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - border: 1px solid #e2e8f0; - color: #475569; - font-size: 10px; - font-weight: 800; -} - -.review-alert-card { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(251, 248, 243, 0.82) 100%); -} - -/* 已删除:review-alert-chip-row 相关样式(冗余气泡) */ -/* 已删除:主对话框中的风险提示(与右侧边栏重复,已移除) */ - -/* 风险提示样式已统一到 review-pending-item */ -.review-risk-brief-list { - display: none; /* 隐藏原有的独立风险提示列表 */ -} - -.review-risk-brief { - display: none; /* 隐藏原有的独立风险提示项 */ -} - -.review-pending-list { - display: grid; - gap: 8px; -} - -.review-pending-list.plain { - gap: 0; -} - -.review-pending-item { - display: grid; - grid-template-columns: 36px minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - padding: 11px 12px; - border-radius: 14px; - background: rgba(255, 255, 255, 0.88); - border: 1px solid rgba(226, 232, 240, 0.92); -} - -.review-pending-list.plain .review-pending-item { - padding: 10px 0; - border: 0; - border-radius: 0; - background: transparent; - border-bottom: 1px solid rgba(226, 232, 240, 0.7); -} - -.review-pending-list.plain .review-pending-item:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.review-pending-list.plain .review-pending-item:first-child { - padding-top: 2px; -} - -.review-pending-icon { - width: 36px; - height: 36px; - display: grid; - place-items: center; - border-radius: 10px; - background: rgba(236, 253, 245, 0.95); - color: #059669; - font-size: 16px; -} - -/* 风险级别的图标样式(已删除主对话框中的风险提示,保留样式备用) */ -.review-pending-icon.high { - background: rgba(254, 226, 226, 0.95); - color: #dc2626; -} - -.review-pending-icon.warning { - background: rgba(255, 237, 213, 0.95); - color: #ea580c; -} - -.review-pending-list.plain .review-pending-icon { - background: rgba(236, 253, 245, 0.62); -} - -.review-pending-list.plain .review-pending-icon.high { - background: rgba(254, 226, 226, 0.62); -} - -.review-pending-list.plain .review-pending-icon.warning { - background: rgba(255, 237, 213, 0.62); -} - -.review-pending-copy { - display: grid; - gap: 4px; -} - -.review-pending-copy strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; -} - -.review-pending-copy p { - margin: 0; - color: #64748b; - font-size: 11px; - line-height: 1.5; -} - -.review-pending-status { - min-height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 10px; - border-radius: 999px; - font-size: 10px; - font-weight: 800; - white-space: nowrap; -} - -.review-pending-status.warning { - background: rgba(255, 241, 242, 0.96); - color: #e11d48; - border: 1px solid #fecdd3; -} - -.review-pending-status.danger { - background: rgba(254, 242, 242, 0.96); - color: #dc2626; - border: 1px solid #fca5a5; -} - -.review-pending-status.ready { - background: rgba(240, 253, 244, 0.96); - color: #059669; - border: 1px solid #86efac; -} - -.review-footer-actions { - display: grid; - gap: 8px; - padding-top: 6px; - border-top: 1px solid rgba(226, 232, 240, 0.72); -} - -.review-footer-btn-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.review-footer-btn { - min-height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 14px; - border-radius: 12px; - border: 1px solid #dbe6f0; - background: rgba(255, 255, 255, 0.92); - color: #334155; - font-size: 12px; - font-weight: 800; - box-shadow: 0 3px 10px rgba(241, 245, 249, 0.58); -} - -.review-footer-btn.primary { - border-color: rgba(16, 185, 129, 0.26); - background: linear-gradient(135deg, #10b981, #059669); - color: #fff; - box-shadow: 0 6px 14px rgba(16, 185, 129, 0.16); -} - -.review-footer-btn:disabled { - cursor: not-allowed; - opacity: 0.6; - box-shadow: none; -} - -.review-summary { - margin: 0; - color: #1f2937; - font-size: 14px; - line-height: 1.7; -} - -.review-inline-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-start; -} - -.review-inline-btn, -.primary-dialog-btn, -.secondary-dialog-btn, -.danger-dialog-btn { - min-height: 38px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 16px; - border-radius: 999px; - font-size: 12px; - font-weight: 800; -} - -.review-inline-btn { - border: 1px solid #dbe6f0; - background: #fff; - color: #334155; -} - -.review-inline-btn.primary, -.primary-dialog-btn { - border: 1px solid rgba(16, 185, 129, 0.22); - background: linear-gradient(135deg, #10b981, #059669); - color: #fff; - box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18); -} - -.review-inline-btn.secondary, -.secondary-dialog-btn { - border: 1px solid #dbe6f0; - background: #fff; - color: #334155; -} - -.danger-dialog-btn { - border: 1px solid rgba(239, 68, 68, 0.22); - background: linear-gradient(135deg, #ef4444, #dc2626); - color: #fff; - box-shadow: 0 10px 22px rgba(239, 68, 68, 0.18); -} - -.review-inline-btn:disabled, -.primary-dialog-btn:disabled, -.secondary-dialog-btn:disabled, -.danger-dialog-btn:disabled { - cursor: not-allowed; - opacity: 0.62; - box-shadow: none; -} - -.review-inline-note { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.6; -} - -.review-inline-guidance { - margin: 0; - color: #0f766e; - font-size: 12px; - line-height: 1.7; -} - -.review-status-banner { - display: grid; - gap: 8px; - padding: 14px 16px; - border-radius: 18px; - border: 1px solid #dbeafe; - background: linear-gradient(180deg, #f8fbff 0%, #f0f7ff 100%); -} - -.review-status-banner.ready { - border-color: #bbf7d0; - background: linear-gradient(180deg, #f5fffa 0%, #ecfdf5 100%); -} - -.review-status-banner.pending { - border-color: #fde68a; - background: linear-gradient(180deg, #fffdf7 0%, #fffbeb 100%); -} - -.review-status-tag { - width: fit-content; - min-height: 26px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.86); - color: #0f172a; - font-size: 12px; - font-weight: 850; - border: 1px solid rgba(148, 163, 184, 0.22); -} - -.review-inline-section { - display: grid; - gap: 10px; - padding: 14px 16px; - border-radius: 18px; - border: 1px solid #e2e8f0; - background: rgba(255, 255, 255, 0.88); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66); -} - -.review-inline-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.review-inline-head > strong { - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.review-inline-head > span { - min-height: 24px; - display: inline-flex; - align-items: center; - padding: 0 9px; - border-radius: 999px; - background: #fff; - color: #475569; - font-size: 11px; - font-weight: 800; - border: 1px solid #e2e8f0; -} - -.review-inline-caption { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.65; -} - -.review-inline-list { - display: grid; - gap: 8px; -} - -.review-missing-chip-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.review-missing-chip { - min-height: 30px; - display: inline-flex; - align-items: center; - padding: 0 12px; - border-radius: 999px; - background: #fff; - color: #0f172a; - font-size: 12px; - font-weight: 800; - border: 1px solid #fed7aa; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); -} - -.review-inline-item { - display: grid; - gap: 4px; - padding: 10px 12px; - border-radius: 14px; - border: 1px solid #e2e8f0; - background: #fff; -} - -.review-inline-item.warning { - background: #fff7ed; - border-color: #fed7aa; -} - -.review-inline-item.high { - background: #fff1f2; - border-color: #fecdd3; -} - -.review-inline-item span { - color: #0f172a; - font-size: 12px; - font-weight: 800; -} - -.review-inline-item p { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.65; -} - -.review-inline-footer { - display: grid; - gap: 10px; - padding-top: 2px; - border-top: 1px dashed rgba(203, 213, 225, 0.78); -} - -.review-mini-grid, -.review-slot-grid, -.review-doc-field-grid { - display: grid; - gap: 10px; -} - -.review-mini-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.review-slot-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.review-slot-card, -.review-doc-field-card, -.review-brief-card, -.review-claim-card, -.review-document-card { - border: 1px solid #e2e8f0; - border-radius: 16px; - background: #f8fbff; -} - -.review-slot-card { - display: grid; - gap: 8px; - padding: 12px 14px; -} - -.review-slot-card.compact { - gap: 4px; - padding: 10px 12px; -} - -.review-slot-card header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.review-slot-card span, -.review-doc-field-card span, -.review-brief-card strong, -.review-document-card header span { - color: #64748b; - font-size: 11px; - font-weight: 800; -} - -.review-slot-card strong, -.review-doc-field-card strong, -.review-claim-card strong, -.review-document-card header strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; -} - -.review-slot-card p, -.review-brief-card p, -.review-claim-card p, -.review-document-card p { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.6; -} - -.review-slot-card.missing { - border-color: #fecdd3; - background: #fff7f7; -} - -.review-slot-card.inferred { - border-color: #dbeafe; - background: #f8fbff; -} - -.review-slot-meta-list { - display: grid; - gap: 8px; -} - -.review-slot-meta-item { - padding: 9px 10px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.82); - border: 1px solid rgba(226, 232, 240, 0.9); -} - -.review-slot-meta-item span { - color: #94a3b8; - font-size: 11px; - font-weight: 800; -} - -.review-slot-meta-item strong { - display: block; - margin-top: 4px; - font-size: 12px; -} - -.review-brief-list, -.review-claim-list, -.review-document-list { - display: grid; - gap: 10px; -} - -.review-brief-card, -.review-claim-card, -.review-document-card { - padding: 12px 14px; -} - -.review-brief-card.warning { - background: #fff7ed; - border-color: #fed7aa; -} - -.review-brief-card.high { - background: #fff1f2; - border-color: #fecdd3; -} - -.review-claim-card header, -.review-document-card header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.review-document-card { - display: grid; - gap: 10px; -} - -.document-preview { - min-height: 124px; - overflow: hidden; - border-radius: 14px; - background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); - border: 1px dashed #dbe3ec; -} - -.document-preview.image img { - display: block; - width: 100%; - height: 180px; - object-fit: cover; -} - -.document-preview-placeholder { - min-height: 124px; - display: grid; - place-items: center; - gap: 6px; - color: #64748b; - text-align: center; -} - -.document-preview-placeholder i { - font-size: 28px; -} - -.review-doc-field-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.review-doc-field-card { - padding: 10px 12px; -} - -.action-list.compact { - grid-template-columns: 1fr; -} - -.action-card.primary { - border-color: #bbf7d0; - background: #f0fdf4; -} - -.action-card.secondary { - background: #fff; -} - -.action-card.warning { - border-color: #fed7aa; - background: #fff7ed; -} - -.note-block { - display: grid; - gap: 8px; - padding: 14px; - border-radius: 16px; - background: #f8fafc; -} - -.note-block span { - color: #94a3b8; - font-size: 11px; - font-weight: 800; -} - -.review-conclusion strong { - font-size: var(--wb-fs-insight-h4); - line-height: 1.6; -} - -.insight-text-section { - display: grid; - gap: 12px; - padding: 2px 0 0; -} - -.insight-text-section h4 { - color: #0f172a; - font-size: var(--wb-fs-insight-h4); - font-weight: 850; -} - -.insight-text-list, -.review-document-plain-list { - display: grid; - gap: 12px; -} - -.recognition-bubble { - display: grid; - gap: 10px; - padding: 16px 18px; - border-radius: 22px; - border: 1px solid rgba(191, 219, 254, 0.9); - background: linear-gradient(180deg, #ffffff 0%, #f5fbff 100%); - box-shadow: 0 16px 28px rgba(241, 245, 249, 0.9); -} - -.recognition-bubble.secondary { - border-color: #e2e8f0; - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); -} - -.recognition-bubble-label { - color: #0f766e; - font-size: 11px; - font-weight: 850; - letter-spacing: 0.02em; -} - -.recognition-bubble.secondary .recognition-bubble-label { - color: #475569; -} - -.recognition-bubble-copy { - display: grid; - gap: 8px; -} - -.recognition-bubble-line, -.recognition-bubble-note { - margin: 0; - color: #334155; - font-size: 13px; - line-height: 1.75; -} - -.recognition-bubble-line { - font-weight: 700; - color: #0f172a; -} - -.recognition-bubble-note { - color: #64748b; -} - -.review-document-bubble { - display: grid; - grid-template-columns: minmax(0, 1fr) 140px; - gap: 14px; - align-items: start; - padding: 16px; - border-radius: 22px; - background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%); - border: 1px solid rgba(226, 232, 240, 0.95); - box-shadow: 0 16px 28px rgba(241, 245, 249, 0.92); -} - -.review-document-copy { - display: grid; - gap: 6px; -} - -.review-document-index { - color: #1d4ed8; - font-size: 11px; - font-weight: 850; -} - -.review-document-copy strong { - color: #0f172a; - font-size: 13px; - font-weight: 850; - line-height: 1.6; -} - -.review-document-copy p { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.7; -} - -.review-overlay { - z-index: 10001; -} - -.review-confirm-modal, -.review-edit-modal { - width: min(720px, calc(100vw - 40px)); - border-radius: 24px; - background: - radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%), - linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); - box-shadow: - 0 24px 80px rgba(15, 23, 42, 0.22), - 0 2px 12px rgba(15, 23, 42, 0.08); - border: 1px solid #e7eef6; -} - -.review-confirm-modal { - padding: 24px; - display: grid; - gap: 18px; -} - -.review-confirm-modal h3, -.review-edit-head h3 { - margin-top: 12px; - color: #0f172a; - font-size: 22px; - font-weight: 900; - line-height: 1.35; -} - -.review-confirm-modal p, -.review-edit-head p { - margin-top: 8px; - color: #64748b; - font-size: 14px; - line-height: 1.7; -} - -.review-confirm-actions, -.review-edit-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - flex-wrap: wrap; -} - -.review-upload-decision-modal { - display: grid; - gap: 18px; -} - -.review-upload-decision-copy { - display: grid; - gap: 10px; -} - -.review-upload-decision-actions { - justify-content: stretch; -} - -.review-upload-decision-actions .primary-dialog-btn, -.review-upload-decision-actions .secondary-dialog-btn { - flex: 1 1 168px; -} - -.review-edit-modal { - max-height: min(860px, calc(100vh - 48px)); - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; -} - -.review-edit-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 22px 24px 18px; - border-bottom: 1px solid #eef2f7; -} - -.review-edit-form { - min-height: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - padding: 20px 24px; - overflow-y: auto; -} - -.review-edit-field { - display: grid; - gap: 8px; -} - -.review-edit-field.attachments, -.review-edit-field.business, -.review-edit-field.basic { - min-width: 0; -} - -.review-edit-field span { - color: #334155; - font-size: 13px; - font-weight: 800; -} - -.review-edit-field span em { - margin-left: 4px; - color: #dc2626; - font-style: normal; -} - -.review-edit-field input, -.review-edit-field textarea { - width: 100%; - border: 1px solid #dbe6f0; - border-radius: 16px; - background: #fff; - color: #0f172a; - font-size: 14px; - line-height: 1.6; - padding: 12px 14px; - resize: vertical; -} - -.review-edit-field input:focus, -.review-edit-field textarea:focus { - outline: none; - border-color: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); -} - -.review-edit-field textarea { - min-height: 96px; -} - -.review-edit-field.attachments, -.review-edit-field textarea, -.review-edit-field .textarea { - grid-column: span 2; -} - -.review-edit-actions { - padding: 0 24px 24px; -} - -.review-preview-modal { - width: min(980px, calc(100vw - 40px)); - max-height: min(92vh, calc(100vh - 32px)); - display: grid; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - border-radius: 24px; - background: - radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%), - linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); - box-shadow: - 0 24px 80px rgba(15, 23, 42, 0.22), - 0 2px 12px rgba(15, 23, 42, 0.08); - border: 1px solid #e7eef6; -} - -.review-preview-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 22px 24px 18px; - border-bottom: 1px solid #eef2f7; -} - -.review-preview-head h3 { - margin-top: 12px; - color: #0f172a; - font-size: 22px; - font-weight: 900; - line-height: 1.35; -} - -.review-preview-body { - min-height: 0; - display: grid; - place-items: center; - padding: 18px; - background: rgba(248, 250, 252, 0.88); -} - -.review-preview-body.image img { - max-width: 100%; - max-height: calc(92vh - 170px); - display: block; - border-radius: 20px; - object-fit: contain; - box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26); -} - -.review-preview-body.pdf iframe { - width: 100%; - height: min(78vh, 820px); - border: 0; - border-radius: 18px; - background: #fff; -} - -.welcome-grid { - display: grid; - gap: 12px; -} - -.welcome-card { - padding: 14px; - border-radius: 18px; - background: #f8fafc; -} - -.welcome-card i { - color: #10b981; - font-size: var(--wb-fs-welcome); -} - -.welcome-card strong { - display: block; - margin-top: 10px; -} - -.assistant-modal-enter-active, -.assistant-modal-leave-active { - transition: opacity 220ms ease; -} - -.assistant-modal-enter-active .assistant-modal, -.assistant-modal-leave-active .assistant-modal { - transition: transform 260ms ease, opacity 220ms ease; -} - -.assistant-modal-enter-from, -.assistant-modal-leave-to { - opacity: 0; -} - -.assistant-modal-enter-from .assistant-modal, -.assistant-modal-leave-to .assistant-modal { - transform: translateY(10px) scale(0.985); - opacity: 0; -} - -.insight-switch-enter-active, -.insight-switch-leave-active { - transition: opacity 180ms ease, transform 180ms ease; -} - -.insight-switch-enter-from, -.insight-switch-leave-to { - opacity: 0; - transform: translateY(8px); -} - -/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */ -@media (max-width: 1680px) { - .assistant-modal-stage { - --wb-fs-title: 19px; - --wb-fs-desc: 12px; - --wb-fs-badge: 11px; - --wb-fs-bubble: 13px; - --wb-fs-bubble-meta: 12px; - --wb-fs-bubble-time: 11px; - --wb-fs-chip: 11px; - --wb-fs-composer: 13px; - --wb-fs-tool-icon: 16px; - --wb-fs-md-h1: 16px; - --wb-fs-md-h2: 15px; - --wb-fs-md-h3: 13px; - --wb-fs-insight-title: 17px; - --wb-fs-insight-num: 17px; - --wb-fs-insight-body: 11px; - --wb-fs-insight-h4: 14px; - --wb-fs-metric: 12px; - --wb-fs-metric-strong: 12px; - --wb-fs-welcome: 18px; - } - - .assistant-modal-stage .message-answer-markdown :deep(table) { - font-size: 12px; - } - - .assistant-modal-stage .intent-pill { - font-size: var(--wb-fs-chip); - } -} - -@media (max-width: 1440px) { - .assistant-modal-stage { - --wb-fs-title: 18px; - --wb-fs-bubble: 12px; - --wb-fs-bubble-meta: 11px; - --wb-fs-composer: 12px; - --wb-fs-insight-title: 16px; - --wb-fs-insight-num: 16px; - --wb-fs-md-h1: 15px; - --wb-fs-md-h2: 14px; - --wb-fs-insight-h4: 13px; - --wb-fs-welcome: 17px; - } -} - -/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */ -@media (min-width: 1441px) and (max-width: 1680px) { - .insight-panel-shell { - width: clamp(280px, 26vw, 360px); - } -} - -/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */ -@media (max-width: 1440px) { - .assistant-layout { - flex-direction: column; - } - - .dialog-panel { - flex: 1 1 auto; - min-height: 0; - } - - .insight-panel-shell { - width: 100%; - flex: 0 0 auto; - max-height: min(38dvh, 400px); - transition: - max-height 320ms cubic-bezier(0.22, 1, 0.36, 1), - opacity 240ms cubic-bezier(0.22, 1, 0.36, 1), - transform 280ms cubic-bezier(0.22, 1, 0.36, 1); - } - - .insight-panel-shell.collapsed { - max-height: 0; - } - - .insight-panel { - width: 100%; - min-height: min(280px, 32dvh); - } - - .insight-panel-shell.collapsed .insight-panel { - transform: translateY(-12px); - } - - .review-side-grid.compact { - grid-template-columns: 1fr; - } -} - -/* 矮屏笔记本(如 1366×768):压缩顶栏与间距,把高度留给对话列表 */ -@media (max-height: 820px) { - .assistant-modal-stage { - --wb-fs-title: 17px; - --wb-fs-bubble: 12px; - --wb-fs-composer: 12px; - --wb-fs-insight-title: 15px; - --wb-fs-insight-num: 15px; - } - - .assistant-header { - padding-top: 12px; - padding-bottom: 10px; - } - - .assistant-header-actions { - top: 12px; - right: 12px; - } - - .assistant-layout { - padding: 10px; - gap: 10px; - } - - .dialog-toolbar { - padding: 12px 14px 10px; - } - - .message-list { - padding: 12px; - gap: 10px; - } - - .composer-shell-body { - padding: 4px 10px; - } -} - -@media (max-width: 1280px) { - .insight-panel-shell:not(.collapsed) { - max-height: min(34dvh, 360px); - } -} - -@media (max-width: 760px) { - .assistant-overlay { - --assistant-viewport-inset: 10px; - } - - .assistant-modal, - .assistant-modal-stage { - border-radius: 18px; - } - - .assistant-header { - padding: 18px 18px 16px; - align-items: flex-start; - flex-direction: column; - } - - .assistant-header-actions { - top: 18px; - right: 18px; - gap: 10px; - width: auto; - justify-content: space-between; - } - - .assistant-toggle-btn, - .session-trash-btn, - .assistant-close-btn, - .close-btn { - width: 40px; - height: 40px; - border-radius: 14px; - font-size: 16px; - } - - .assistant-layout { - padding: 14px; - } - - .composer-row { - gap: 8px; - --composer-control-size: 40px; - } - - .composer-shell textarea { - min-height: 32px; - } - - .dialog-toolbar { - padding: 16px 16px 12px; - } - - .shortcut-chip { - width: 100%; - justify-content: center; - } - - .message-list { - padding: 16px; - } - - .message-row, - .message-row.user { - grid-template-columns: 34px minmax(0, 1fr); - } - - .message-row.user .message-avatar { - order: 0; - } - - .message-row.user .message-bubble { - order: 0; - justify-self: stretch; - } - - .composer { - padding: 0 16px 16px; - } - - .composer-files-head, - .review-insight-title-row, - .review-document-stage-head, - .review-document-switch-head { - align-items: flex-start; - flex-direction: column; - } - - .composer-files-actions, - .review-document-nav { - width: 100%; - justify-content: space-between; - } - - .review-card-head { - flex-direction: column; - } - - .metric-grid { - grid-template-columns: 1fr; - } - - .review-side-grid, - .review-side-category-grid, - .review-document-edit-grid { - grid-template-columns: 1fr; - } - - .review-pending-item { - grid-template-columns: 42px minmax(0, 1fr); - } - - .review-pending-status { - grid-column: 2; - justify-self: start; - } - - .review-footer-btn-row { - flex-direction: column; - } - - .review-footer-btn { - width: 100%; - } - - .review-slot-grid, - .review-doc-field-grid, - .review-mini-grid { - grid-template-columns: 1fr; - } - - .review-document-plain, - .review-document-bubble { - grid-template-columns: 1fr; - } - - .review-edit-modal { - width: calc(100vw - 24px); - } - - .review-preview-modal { - width: calc(100vw - 24px); - } - - .review-edit-form { - grid-template-columns: 1fr; - padding: 18px; - } - - .review-edit-field.attachments, - .review-edit-field textarea, - .review-edit-field .textarea { - grid-column: auto; - } - - .review-edit-actions, - .review-confirm-actions { - padding: 0 18px 18px; - justify-content: stretch; - } - - .review-upload-decision-actions { - width: 100%; - } - - .primary-dialog-btn, - .secondary-dialog-btn, - .danger-dialog-btn { - width: 100%; - } -} +.assistant-overlay { + /* 距屏幕边 10–18px,随视口微调;高度用 dvh 适配笔记本浏览器工具栏 */ + --assistant-viewport-inset: clamp(10px, 1.25vmin, 18px); + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + max-height: 100dvh; + z-index: 9999; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: var(--assistant-viewport-inset); + box-sizing: border-box; + background: + radial-gradient(circle at 18% 14%, rgba(16, 185, 129, 0.18), transparent 24%), + radial-gradient(circle at 82% 12%, rgba(59, 130, 246, 0.12), transparent 28%), + rgba(97, 110, 131, 0.34); + backdrop-filter: blur(18px) saturate(1.02); + -webkit-backdrop-filter: blur(18px) saturate(1.02); +} + +.assistant-modal { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + background: transparent; + box-shadow: none; + border: 0; + border-radius: 24px; + backdrop-filter: none; + -webkit-backdrop-filter: none; + overflow: hidden; + isolation: isolate; +} + +.assistant-modal-stage { + /* 工作台字号令牌:笔记本断点见文末 @media */ + --wb-fs-title: 22px; + --wb-fs-desc: 13px; + --wb-fs-badge: 12px; + --wb-fs-bubble: 14px; + --wb-fs-bubble-meta: 13px; + --wb-fs-bubble-time: 12px; + --wb-fs-chip: 12px; + --wb-fs-composer: 14px; + --wb-fs-tool-icon: 18px; + --wb-fs-md-h1: 18px; + --wb-fs-md-h2: 16px; + --wb-fs-md-h3: 14px; + --wb-fs-insight-title: 19px; + --wb-fs-insight-num: 19px; + --wb-fs-insight-body: 12px; + --wb-fs-insight-h4: 15px; + --wb-fs-metric: 13px; + --wb-fs-metric-strong: 13px; + --wb-fs-welcome: 20px; + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + transform: none; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 26%), + radial-gradient(circle at top left, rgba(59, 130, 246, 0.10), transparent 24%), + linear-gradient(180deg, rgba(241, 246, 245, 0.92) 0%, rgba(230, 237, 235, 0.88) 100%); + box-shadow: + 0 28px 72px rgba(15, 23, 42, 0.22), + 0 10px 28px rgba(15, 23, 42, 0.09), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + border: 1px solid rgba(255, 255, 255, 0.44); + background-clip: padding-box; + overflow: hidden; + isolation: isolate; +} + +.assistant-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; + padding: clamp(14px, 2vh, 22px) clamp(148px, 11vw, 172px) clamp(12px, 1.6vh, 18px) clamp(18px, 2vw, 26px); + border-bottom: 1px solid rgba(203, 213, 225, 0.78); + background: linear-gradient(180deg, rgba(247, 250, 249, 0.82) 0%, rgba(240, 246, 244, 0.7) 100%); +} + +.assistant-header-main { + display: flex; + align-items: flex-start; + gap: 14px; + min-width: 0; +} + +.assistant-badge { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + border-radius: 999px; + background: linear-gradient(135deg, #22c55e, #10b981); + color: #fff; + font-size: var(--wb-fs-badge); + font-weight: 800; + box-shadow: 0 8px 16px rgba(16, 185, 129, 0.14); + white-space: nowrap; +} + +.assistant-badge.warning { + background: rgba(249, 115, 22, 0.12); + color: #c2410c; +} + +.assistant-header h2 { + color: #0f172a; + font-size: clamp(17px, 1.1vw, var(--wb-fs-title)); + font-weight: 900; + letter-spacing: 0.01em; + line-height: 1.25; +} + +.assistant-header p { + margin-top: 4px; + color: #64748b; + font-size: clamp(11px, 0.85vw, var(--wb-fs-desc)); + line-height: 1.55; +} + +.assistant-header-actions { + position: absolute; + top: 16px; + right: 16px; + z-index: 60; + display: flex; + align-items: center; + gap: 10px; + pointer-events: auto; +} + +.assistant-toggle-btn, +.session-trash-btn { + width: 38px; + height: 38px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid rgba(248, 113, 113, 0.28); + border-radius: 14px; + flex: none; +} + +.assistant-toggle-btn { + border-color: rgba(16, 185, 129, 0.18); + background: rgba(245, 252, 249, 0.96); + color: #166534; + font-size: 16px; + box-shadow: 0 8px 18px rgba(16, 185, 129, 0.1); +} + +.assistant-toggle-btn:hover:not(:disabled) { + background: rgba(236, 253, 245, 0.98); + border-color: rgba(16, 185, 129, 0.28); +} + +.assistant-toggle-btn:disabled, +.assistant-toggle-btn.disabled { + opacity: 0.48; + cursor: not-allowed; + box-shadow: none; +} + +.session-trash-btn { + background: rgba(254, 242, 242, 0.96); + color: #dc2626; + font-size: 16px; + box-shadow: 0 8px 18px rgba(239, 68, 68, 0.12); +} + +.session-trash-btn:hover:not(:disabled) { + background: rgba(254, 226, 226, 0.98); + border-color: rgba(239, 68, 68, 0.34); +} + +.session-trash-btn:disabled { + opacity: 0.42; + cursor: not-allowed; + box-shadow: none; +} + +.assistant-close-btn, +.close-btn { + position: relative; + width: 38px; + height: 38px; + display: grid; + place-items: center; + padding: 0; + flex: none; + border: 1px solid rgba(193, 204, 216, 0.92); + border-radius: 14px; + background: rgba(248, 251, 251, 0.94); + color: #475569; + font-size: 16px; + box-shadow: 0 8px 18px rgba(148, 163, 184, 0.18); + cursor: pointer; + pointer-events: auto; + user-select: none; + -webkit-user-select: none; +} + +.assistant-close-btn { + z-index: 61; + pointer-events: auto; +} + +.assistant-close-btn i { + pointer-events: none; +} + +.assistant-close-btn:hover, +.close-btn:hover { + background: rgba(241, 245, 249, 0.98); + border-color: rgba(148, 163, 184, 0.34); + color: #0f172a; +} + +.flow-status-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; + font-weight: 900; + white-space: nowrap; +} + +.flow-status-chip.running { + background: #eff6ff; + color: #2563eb; +} + +.flow-status-chip.completed { + background: #ecfdf5; + color: #059669; +} + +.flow-status-chip.failed { + background: #fef2f2; + color: #dc2626; +} + +.flow-icon-btn { + width: 34px; + height: 34px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid rgba(203, 213, 225, 0.86); + border-radius: 12px; + background: rgba(255, 255, 255, 0.92); + color: #475569; + font-size: 16px; + box-shadow: 0 6px 14px rgba(148, 163, 184, 0.12); +} + +.flow-icon-btn:hover:not(:disabled) { + border-color: rgba(37, 99, 235, 0.28); + color: #1d4ed8; +} + +.flow-icon-btn:disabled { + opacity: 0.46; + cursor: not-allowed; + box-shadow: none; +} + +.flow-step-item { + position: relative; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + padding-bottom: 12px; +} + +.flow-step-item:last-child { + padding-bottom: 0; +} + +.flow-step-rail { + position: relative; + display: flex; + justify-content: center; +} + +.flow-step-rail::after { + content: ""; + position: absolute; + top: 34px; + bottom: -12px; + left: 50%; + width: 2px; + transform: translateX(-50%); + background: #e2e8f0; +} + +.flow-step-item:last-child .flow-step-rail::after { + display: none; +} + +.flow-step-rail span { + position: relative; + z-index: 1; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 999px; + background: #e2e8f0; + color: #64748b; + font-size: 12px; + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.flow-step-item.completed .flow-step-rail span { + background: #10b981; + color: #fff; +} + +.flow-step-item.running .flow-step-rail span { + background: #2563eb; + color: #fff; + box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.12); +} + +.flow-step-item.failed .flow-step-rail span { + background: #ef4444; + color: #fff; +} + +.flow-step-card { + min-width: 0; + display: grid; + gap: 7px; + padding: 13px 14px; + border: 1px solid #e5edf5; + border-radius: 12px; + background: #fff; + box-shadow: 0 8px 22px rgba(226, 232, 240, 0.34); +} + +.flow-step-item.running .flow-step-card { + border-color: rgba(37, 99, 235, 0.42); + background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%); +} + +.flow-step-card header, +.flow-step-side { + display: flex; + align-items: center; + gap: 10px; +} + +.flow-step-card header { + justify-content: space-between; +} + +.flow-step-card strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 900; +} + +.flow-step-side { + flex: none; + gap: 8px; +} + +.flow-step-status { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 9px; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; + font-weight: 900; + white-space: nowrap; +} + +.flow-step-status.completed { + background: #ecfdf5; + color: #059669; +} + +.flow-step-status.running { + background: #eff6ff; + color: #2563eb; +} + +.flow-step-status.failed { + background: #fef2f2; + color: #dc2626; +} + +.flow-step-side time { + min-width: 36px; + color: #475569; + font-size: 12px; + font-weight: 850; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.flow-step-tool, +.flow-step-detail, +.flow-step-error { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.55; +} + +.flow-step-detail { + color: #475569; +} + +.flow-step-error { + color: #dc2626; +} + +.flow-empty-state { + display: grid; + place-items: center; + align-content: center; + gap: 8px; + padding: 30px; + color: #64748b; + text-align: center; +} + +.flow-empty-state i { + font-size: 34px; + color: #94a3b8; +} + +.flow-empty-state strong { + color: #0f172a; + font-size: 14px; + font-weight: 900; +} + +.flow-empty-state p { + max-width: 260px; + margin: 0; + font-size: 12px; + line-height: 1.6; +} + +@keyframes flowPulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.12); + } +} + +.assistant-layout { + min-height: 0; + flex: 1; + display: flex; + padding: clamp(12px, 1.5vw, 16px); + align-items: stretch; + gap: clamp(12px, 1.5vw, 16px); +} + +.dialog-panel, +.insight-panel { + min-width: 0; + min-height: 0; + border: 1px solid rgba(189, 201, 214, 0.74); + border-radius: 24px; + background: rgba(248, 251, 251, 0.84); + box-shadow: + 0 14px 32px rgba(148, 163, 184, 0.16), + 0 2px 6px rgba(15, 23, 42, 0.05); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} + +.dialog-panel { + flex: 1 1 auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + background: + radial-gradient(circle at top right, rgba(59, 130, 246, 0.07), transparent 22%), + linear-gradient(180deg, rgba(252, 253, 253, 0.88) 0%, rgba(243, 247, 248, 0.84) 100%); + transition: + transform 320ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; +} + +.insight-panel-shell { + flex: none; + width: clamp(300px, 28vw, 420px); + min-width: 0; + max-width: 100%; + margin-left: 0; + overflow: hidden; + transition: + width 360ms cubic-bezier(0.22, 1, 0.36, 1), + margin-left 360ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.insight-panel-shell.collapsed { + width: 0; + margin-left: 0; +} + +.dialog-toolbar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 16px 18px 12px; + border-bottom: 1px solid rgba(238, 242, 247, 0.9); +} + +.shortcut-chip { + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 1px solid rgba(219, 230, 240, 0.9); + border-radius: 999px; + background: rgba(255, 255, 255, 0.95); + color: #334155; + font-size: var(--wb-fs-chip); + font-weight: 750; + box-shadow: 0 4px 12px rgba(241, 245, 249, 0.78); + white-space: nowrap; +} + +.shortcut-chip i { + color: #059669; +} + +.shortcut-chip:disabled { + opacity: 0.48; + cursor: not-allowed; + box-shadow: none; +} + +.message-list { + min-height: 0; + display: grid; + align-content: start; + gap: 14px; + padding: 18px; + overflow-y: auto; +} + +.message-row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + align-items: start; + gap: 12px; +} + +.message-row.user { + grid-template-columns: minmax(0, 1fr) 38px; +} + +.message-row.user .message-avatar { + order: 2; + background: transparent; +} + +.message-row.user .message-bubble { + order: 1; + justify-self: end; + background: linear-gradient(135deg, rgba(226, 238, 255, 0.98), rgba(242, 247, 255, 0.9)); + border-color: rgba(96, 165, 250, 0.24); + box-shadow: 0 14px 30px rgba(59, 130, 246, 0.08); +} + +.message-avatar { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 999px; + overflow: hidden; + background: transparent; + box-shadow: 0 10px 20px rgba(148, 163, 184, 0.24); +} + +.message-avatar img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.message-bubble { + max-width: min(100%, 720px); + padding: 14px 16px; + border: 1px solid rgba(210, 220, 230, 0.94); + border-radius: 20px; + background: rgba(253, 254, 254, 0.94); + color: #24324a; + line-height: 1.65; + box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48); +} + +.message-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.message-meta strong { + color: #0f172a; + font-size: var(--wb-fs-bubble-meta); + font-weight: 850; +} + +.message-meta time { + color: #94a3b8; + font-size: var(--wb-fs-bubble-time); +} + +.message-bubble p { + color: #334155; + font-size: var(--wb-fs-bubble); +} + +.message-answer-content { + display: grid; + gap: 12px; +} + +.message-answer-content :deep(p), +.message-answer-content :deep(ul), +.message-answer-content :deep(ol), +.message-answer-content :deep(blockquote), +.message-answer-content :deep(pre) { + margin: 0; +} + +.message-answer-markdown :deep(h1), +.message-answer-markdown :deep(h2), +.message-answer-markdown :deep(h3), +.message-answer-markdown :deep(h4) { + margin: 0; + color: #0f172a; + line-height: 1.35; +} + +.message-answer-markdown :deep(h1) { + font-size: var(--wb-fs-md-h1); +} + +.message-answer-markdown :deep(h2) { + font-size: var(--wb-fs-md-h2); +} + +.message-answer-markdown :deep(h3), +.message-answer-markdown :deep(h4) { + font-size: var(--wb-fs-md-h3); +} + +.message-answer-markdown { + overflow-x: auto; + font-size: var(--wb-fs-bubble); + color: #334155; + line-height: 1.65; +} + +/* v-html 注入的 Markdown 节点无 scoped 标记,需用 :deep 与用户气泡 p 对齐字号 */ +.message-answer-markdown :deep(p), +.message-answer-markdown :deep(li), +.message-answer-markdown :deep(td), +.message-answer-markdown :deep(th), +.message-answer-markdown :deep(blockquote) { + font-size: inherit; + color: inherit; + line-height: 1.65; +} + +.message-answer-markdown :deep(ul), +.message-answer-markdown :deep(ol) { + padding-left: 22px; +} + +.message-answer-markdown :deep(strong) { + color: #0f172a; +} + +.message-answer-markdown :deep(blockquote) { + padding: 10px 12px; + border-left: 4px solid #93c5fd; + border-radius: 0 12px 12px 0; + background: #eff6ff; + color: #475569; +} + +.message-answer-markdown :deep(code) { + padding: 2px 6px; + border-radius: 6px; + background: #e2e8f0; + font-size: 12px; +} + +.message-answer-markdown :deep(pre) { + overflow-x: auto; + padding: 12px; + border-radius: 14px; + background: #0f172a; + color: #e2e8f0; +} + +.message-answer-markdown :deep(pre code) { + padding: 0; + background: transparent; + color: inherit; +} + +.message-answer-markdown :deep(a) { + color: #2563eb; + text-decoration: underline; +} + +.message-answer-markdown :deep(table) { + width: auto; + max-width: 100%; + border: 1px solid #dbe4ee; + border-radius: 16px; + border-collapse: collapse; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + font-size: inherit; +} + +.message-answer-markdown :deep(th), +.message-answer-markdown :deep(td) { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + text-align: left; + white-space: nowrap; +} + +.message-answer-markdown :deep(th) { + background: #eff6ff; + color: #0f172a; + font-weight: 850; +} + +.message-answer-markdown :deep(td) { + color: #334155; + font-weight: 650; +} + +.message-answer-markdown :deep(tbody tr:last-child td) { + border-bottom: 0; +} + +.message-meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.message-meta-chip, +.capability-chip, +.risk-chip, +.message-risk-chip, +.message-action-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + font-size: var(--wb-fs-chip); + font-weight: 800; +} + +.message-meta-chip, +.capability-chip { + background: #eef6ff; + color: #1d4ed8; +} + +.risk-chip, +.message-risk-chip { + background: #fff1f2; + color: #be123c; +} + +.message-action-chip { + background: #ecfdf5; + color: #059669; +} + +.message-detail-block { + margin-top: 14px; + display: grid; + gap: 10px; +} + +.message-detail-block > strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.message-citation-disclosure { + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 16px; + background: #fbfdff; +} + +.message-citation-disclosure summary { + min-height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + color: #0f172a; + cursor: pointer; + list-style: none; +} + +.message-citation-disclosure summary::-webkit-details-marker { + display: none; +} + +.message-citation-disclosure summary strong { + font-size: 12px; + font-weight: 850; +} + +.message-citation-disclosure summary span { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.message-citation-disclosure summary i { + margin-left: auto; + color: #64748b; + font-size: 16px; + transition: transform 0.18s ease; +} + +.message-citation-disclosure[open] summary { + border-bottom: 1px solid #e2e8f0; +} + +.message-citation-disclosure[open] summary i { + transform: rotate(180deg); +} + +.message-citation-disclosure .message-citation-list { + padding: 12px; +} + +.expense-query-block { + gap: 10px; +} + +.expense-query-window-label { + margin: -4px 0 0; + color: #64748b; + font-size: 11px; + line-height: 1.5; +} + +.expense-query-summary-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.expense-query-summary-chip { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + background: #eef2ff; + color: #3730a3; +} + +.expense-query-summary-chip.draft { + background: #fef3c7; + color: #b45309; +} + +.expense-query-summary-chip.in_progress { + background: #dbeafe; + color: #1d4ed8; +} + +.expense-query-summary-chip.completed { + background: #dcfce7; + color: #15803d; +} + +.expense-query-summary-chip.other { + background: #f1f5f9; + color: #475569; +} + +.expense-query-record-list { + display: grid; + gap: 8px; +} + +.expense-query-record-list.compact .expense-query-record-card { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid #dbe4ee; + border-radius: 14px; + background: #fbfdff; + cursor: pointer; + font: inherit; + text-align: left; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.expense-query-record-list.compact .expense-query-record-card:hover { + transform: translateY(-1px); + border-color: #bfdbfe; + box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12); +} + +.expense-query-record-card > i { + color: #94a3b8; + font-size: 16px; +} + +.expense-query-record-main { + min-width: 0; + display: grid; + gap: 5px; + flex: 1; +} + +.expense-query-record-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.expense-query-record-top strong { + min-width: 0; + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.expense-query-record-top strong, +.expense-query-record-card p, +.expense-query-record-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.expense-query-record-status { + flex-shrink: 0; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + background: #f1f5f9; + color: #475569; +} + +.expense-query-record-status.draft { + background: #fef3c7; + color: #b45309; +} + +.expense-query-record-status.in_progress { + background: #dbeafe; + color: #1d4ed8; +} + +.expense-query-record-status.completed { + background: #dcfce7; + color: #15803d; +} + +.expense-query-record-card p { + margin: 0; + color: #334155; + font-size: 12px; +} + +.expense-query-record-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + color: #64748b; + font-size: 11px; + font-weight: 700; +} + +.expense-query-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 2px; +} + +.expense-query-pager-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #dbe4ee; + border-radius: 999px; + background: #fff; + color: #475569; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; +} + +.expense-query-pager-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.expense-query-pager-btn:not(:disabled):hover { + border-color: #bfdbfe; + color: #2563eb; + background: #f8fbff; +} + +.expense-query-pager-dots { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.expense-query-pager-dot { + width: 8px; + height: 8px; + padding: 0; + border: 0; + border-radius: 999px; + background: #cbd5e1; + transition: transform 0.2s ease, background 0.2s ease; +} + +.expense-query-pager-dot.active { + background: #2563eb; + transform: scale(1.15); +} + +.expense-query-empty { + min-height: 52px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + border: 1px dashed #dbe4ee; + border-radius: 16px; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.expense-query-empty i { + font-size: 18px; + color: #94a3b8; +} + +.expense-query-hint { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.6; +} + +.message-detail-chip-row, +.capability-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.message-citation-list, +.citation-stack, +.action-list { + display: grid; + gap: 10px; +} + +.message-citation-card, +.citation-card, +.action-card { + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #f8fbff; +} + +.message-citation-card header, +.citation-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.message-citation-card header span, +.citation-card header strong, +.action-card strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.message-citation-card header small, +.citation-card header span { + color: #64748b; + font-size: 11px; + font-weight: 700; +} + +.message-citation-card p, +.citation-card p, +.action-card p, +.draft-preview pre { + margin: 0; + color: #475569; + font-size: 12px; + line-height: 1.65; +} + +.draft-preview { + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #dbe3ec; + border-radius: 16px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.draft-preview header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.draft-preview header strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.draft-preview header span { + color: #b45309; + font-size: 12px; + font-weight: 800; +} + +.draft-preview pre { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; +} + +.message-files { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +.file-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 700; + max-width: 100%; +} + +.file-chip.active { + background: #eef6ff; + color: #2563eb; +} + +.composer { + padding: 0 18px 18px; + display: grid; + gap: 12px; +} + +.hidden-file-input { + display: none; +} + +.composer-row { + --composer-control-size: 44px; +} + +.composer-leading-actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.composer-date-anchor { + position: relative; +} + +.tool-btn.composer-side-btn.active { + border-color: rgba(59, 130, 246, 0.42); + background: rgba(239, 246, 255, 0.96); + color: #2563eb; + box-shadow: 0 6px 14px rgba(59, 130, 246, 0.14); +} + +.composer-date-popover { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 30; + width: min(320px, calc(100vw - 48px)); + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid rgba(203, 213, 225, 0.92); + border-radius: 16px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 18px 40px rgba(15, 23, 42, 0.16), + 0 4px 12px rgba(15, 23, 42, 0.06); +} + +.composer-date-mode-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 4px; + border-radius: 12px; + background: rgba(241, 245, 249, 0.92); +} + +.composer-date-mode-btn { + min-height: 34px; + border: 0; + border-radius: 10px; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.composer-date-mode-btn.active { + background: #fff; + color: #0f172a; + box-shadow: 0 4px 10px rgba(148, 163, 184, 0.18); +} + +.composer-date-fields { + display: grid; + gap: 8px; +} + +.composer-date-fields-range { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: end; + gap: 8px; +} + +.composer-date-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.composer-date-field span { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.composer-date-field input { + width: 100%; + min-height: 36px; + padding: 0 10px; + border: 1px solid rgba(203, 213, 225, 0.92); + border-radius: 10px; + background: #fff; + color: #0f172a; + font-size: 12px; + font-weight: 700; +} + +.composer-date-range-sep { + align-self: center; + color: #94a3b8; + font-size: 12px; + font-weight: 800; +} + +.composer-date-hint { + margin: 0; + color: #dc2626; + font-size: 11px; + line-height: 1.5; +} + +.composer-date-popover-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.composer-date-cancel-btn, +.composer-date-apply-btn { + min-height: 34px; + padding: 0 14px; + border-radius: 10px; + font-size: 12px; + font-weight: 800; +} + +.composer-date-cancel-btn { + border: 1px solid rgba(203, 213, 225, 0.92); + background: #fff; + color: #64748b; +} + +.composer-date-apply-btn { + border: 0; + background: linear-gradient(135deg, #22c55e, #10b981); + color: #fff; +} + +.composer-date-apply-btn:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +.composer-shell { + min-width: 0; + min-height: var(--composer-control-size, 44px); + border: 1px solid rgba(214, 225, 234, 0.95); + border-radius: 999px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 10px 22px rgba(226, 232, 240, 0.24), + 0 1px 4px rgba(15, 23, 42, 0.03); +} + +.composer-shell-body { + min-height: var(--composer-control-size, 44px); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px 12px; +} + +.composer-biz-time-tag { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: min(100%, 320px); + min-height: 28px; + padding: 0 8px 0 10px; + border-radius: 999px; + border: 1px solid rgba(59, 130, 246, 0.28); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(16, 185, 129, 0.12)); + color: #1d4ed8; + font-size: 11px; + font-weight: 800; + flex: none; +} + +.composer-biz-time-tag i { + font-size: 14px; + color: #2563eb; +} + +.composer-biz-time-tag-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-biz-time-tag-remove { + width: 18px; + height: 18px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: #3b82f6; + flex: none; +} + +.composer-biz-time-tag-remove:disabled { + opacity: 0.48; +} + +.composer-files-panel { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 18px; + background: linear-gradient(180deg, rgba(248, 251, 255, 0.92) 0%, rgba(242, 247, 251, 0.78) 100%); +} + +.composer-files-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.composer-files-head strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.composer-files-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.composer-file-link { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: #2563eb; + font-size: 11px; + font-weight: 800; +} + +.composer-file-link.danger { + color: #dc2626; +} + +.composer-file-link:disabled { + opacity: 0.48; +} + +.composer-file-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.composer-file-chip { + max-width: min(100%, 280px); +} + +.file-chip.summary { + border: 1px dashed rgba(96, 165, 250, 0.34); + background: rgba(239, 246, 255, 0.92); + cursor: pointer; +} + +.file-chip-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-chip-remove { + width: 18px; + height: 18px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + color: inherit; + flex: none; +} + +.file-chip-remove:disabled { + opacity: 0.48; +} + +.composer-files-expanded { + display: grid; + gap: 8px; + max-height: 176px; + overflow-y: auto; + padding-right: 2px; +} + +.composer-expanded-file { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(219, 230, 240, 0.92); + background: rgba(255, 255, 255, 0.88); +} + +.composer-expanded-file-copy { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + color: #334155; +} + +.composer-expanded-file-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 700; +} + +.composer-expanded-file-remove { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: 10px; + background: rgba(248, 250, 252, 0.92); + color: #64748b; +} + +.composer-expanded-file-remove:disabled { + opacity: 0.48; +} + +.composer-shell textarea { + flex: 1 1 120px; + width: auto; + min-width: 0; + min-height: 36px; + max-height: 120px; + resize: none; + border: 0; + padding: 8px 4px; + background: transparent; + color: #0f172a; + font-size: var(--wb-fs-composer); + line-height: 20px; +} + +.composer-shell textarea::placeholder { + color: #94a3b8; +} + +.composer-shell textarea:focus { + outline: none; +} + +.composer-shell textarea:disabled { + color: #94a3b8; +} + +.composer-row { + display: flex; + align-items: center; + gap: 10px; +} + +.composer-row .composer-shell { + flex: 1 1 auto; +} + +.composer-side-btn, +.composer-row .tool-btn, +.composer-row .send-btn { + width: var(--composer-control-size, 44px); + height: var(--composer-control-size, 44px); + display: grid; + place-items: center; + border: 0; + border-radius: 999px; + flex: none; +} + +.tool-btn { + background: #ffffff; + color: #475569; + font-size: var(--wb-fs-tool-icon); + border: 1px solid #dbe6f0; + box-shadow: 0 4px 12px rgba(241, 245, 249, 0.76); +} + +.tool-btn:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +.send-btn { + background: linear-gradient(135deg, #22c55e, #10b981); + color: #fff; + font-size: var(--wb-fs-tool-icon); + box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18); +} + +.send-btn:disabled { + opacity: 0.48; + cursor: not-allowed; + box-shadow: none; +} + +.insight-panel { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + overflow: hidden; + background: + linear-gradient(180deg, rgba(239, 245, 243, 0.9) 0%, rgba(231, 238, 236, 0.84) 100%); + opacity: 1; + transform: translateX(0) scale(1); + transform-origin: right center; + transition: + opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), + transform 320ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform, opacity; +} + +.insight-panel-shell.collapsed .insight-panel { + opacity: 0; + transform: translateX(44px) scale(0.985); + pointer-events: none; +} + +.insight-panel::before { + content: ""; + position: absolute; + top: -18px; + right: -34px; + width: 240px; + height: 150px; + border-radius: 0 0 0 140px; + background: + radial-gradient(circle at 0 100%, rgba(16, 185, 129, 0.14), transparent 54%), + linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(96, 165, 250, 0.06)); + opacity: 0.9; + pointer-events: none; +} + +.insight-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 18px 18px 14px; + border-bottom: 1px solid rgba(205, 215, 224, 0.82); + position: relative; + z-index: 1; +} + +.insight-head.review-mode { + justify-content: space-between; +} + +.insight-head-eyebrow { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.insight-head-badge { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: rgba(240, 253, 244, 0.95); + color: #059669; + font-size: 11px; + font-weight: 800; + border: 1px solid rgba(16, 185, 129, 0.12); +} + +.review-insight-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.review-insight-title-copy { + min-width: 0; +} + +.review-insight-title-copy h3 { + margin: 0; +} + +.review-insight-tools { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + align-self: center; +} + +.review-insight-switch-icon-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(203, 213, 225, 0.92); + background: rgba(248, 250, 252, 0.96); + color: #94a3b8; + font-size: 14px; + flex: 0 0 auto; + transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, transform 0.18s ease; +} + +.review-insight-switch-icon-btn.available { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(255, 247, 237, 0.94); + color: #d97706; +} + +.review-insight-switch-icon-btn.active { + border-color: rgba(217, 119, 6, 0.42); + background: rgba(254, 243, 199, 0.98); + color: #b45309; + box-shadow: 0 6px 14px rgba(245, 158, 11, 0.16); +} + +.review-insight-switch-icon-btn.risk.available { + border-color: rgba(239, 68, 68, 0.28); + background: rgba(254, 242, 242, 0.96); + color: #dc2626; +} + +.review-insight-switch-icon-btn.risk.active { + border-color: rgba(220, 38, 38, 0.42); + background: rgba(254, 226, 226, 0.98); + color: #b91c1c; + box-shadow: 0 6px 14px rgba(239, 68, 68, 0.16); +} + +.review-insight-switch-icon-btn.flow.available { + border-color: rgba(37, 99, 235, 0.28); + background: rgba(239, 246, 255, 0.96); + color: #2563eb; +} + +.review-insight-switch-icon-btn.flow.active { + border-color: rgba(37, 99, 235, 0.42); + background: rgba(219, 234, 254, 0.98); + color: #1d4ed8; + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.16); +} + +.review-insight-switch-icon-btn.flow.running i { + animation: flowPulse 1.2s ease-in-out infinite; +} + +.review-insight-switch-icon-btn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.review-insight-switch-icon-btn:disabled { + cursor: not-allowed; + opacity: 1; + color: #cbd5e1; + background: rgba(248, 250, 252, 0.9); +} + +.intent-pill { + min-height: 30px; + display: inline-flex; + align-items: center; + padding: 0 13px; + border-radius: 999px; + font-size: var(--wb-fs-chip); + font-weight: 800; +} + +.intent-pill.welcome { + background: #eef2ff; + color: #4f46e5; +} + +.intent-pill.draft { + background: #ecfdf5; + color: #059669; +} + +.intent-pill.approval { + background: #fff7ed; + color: #ea580c; +} + +.intent-pill.recognition { + background: #eff6ff; + color: #2563eb; +} + +.intent-pill.note { + background: #fdf2f8; + color: #db2777; +} + +.insight-head h3 { + margin-top: 10px; + color: #0f172a; + font-size: var(--wb-fs-insight-title); + font-weight: 900; + line-height: 1.25; +} + +.insight-head p { + margin-top: 6px; + color: #64748b; + font-size: var(--wb-fs-insight-body); + line-height: 1.6; +} + +.confidence-card { + min-width: 92px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(250, 252, 252, 0.9); + border: 1px solid rgba(202, 213, 223, 0.9); + box-shadow: 0 8px 18px rgba(203, 213, 225, 0.3); + text-align: right; +} + +.confidence-card span { + display: block; + color: #94a3b8; + font-size: 11px; + font-weight: 800; +} + +.confidence-card strong { + display: block; + margin-top: 4px; + color: #0f172a; + font-size: var(--wb-fs-insight-num); + font-weight: 900; +} + +.insight-body { + min-height: 0; + display: grid; + align-content: start; + gap: 12px; + padding: 14px 18px 18px; + overflow-y: auto; + position: relative; + z-index: 1; +} + +.review-side-card { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(197, 209, 221, 0.88); + background: rgba(249, 251, 251, 0.88); + box-shadow: 0 10px 20px rgba(226, 232, 240, 0.3); +} + +.review-side-overview-card { + gap: 12px; + background: linear-gradient(180deg, rgba(251, 252, 252, 0.92) 0%, rgba(240, 246, 244, 0.86) 100%); +} + +.review-side-intent-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + color: #475569; + font-size: var(--wb-fs-metric); +} + +.review-side-intent-row i { + color: #059669; + font-size: 16px; +} + +.review-side-intent-row strong { + color: #0f172a; + font-size: var(--wb-fs-bubble); + font-weight: 850; +} + +.review-side-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.review-side-grid.compact { + gap: 8px; +} + +.review-side-metric-card { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(206, 216, 226, 0.88); + background: rgba(251, 252, 252, 0.82); + position: relative; + cursor: pointer; + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; +} + +.review-side-metric-card.invalid { + border-color: rgba(239, 68, 68, 0.34); + background: rgba(254, 242, 242, 0.72); +} + +.review-side-metric-card.editable:hover, +.review-side-metric-card.editing { + border-color: rgba(16, 185, 129, 0.34); + background: rgba(248, 252, 250, 0.92); + transform: translateY(-1px); +} + +.review-side-metric-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 10px; + background: rgba(240, 253, 244, 0.95); + color: #059669; + font-size: 15px; +} + +.review-side-metric-copy { + display: grid; + gap: 4px; +} + +.review-side-metric-copy small { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.review-side-metric-copy strong { + color: #0f172a; + font-size: var(--wb-fs-metric-strong); + font-weight: 850; + line-height: 1.35; + word-break: break-word; +} + +.review-inline-input { + width: 100%; + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + color: #0f172a; + font-size: 12px; + font-weight: 700; +} + +.review-inline-input.invalid { + border-color: rgba(239, 68, 68, 0.4); + color: #b91c1c; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.08); +} + +.review-inline-input:focus { + outline: none; + border-color: rgba(16, 185, 129, 0.42); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08); +} + +.review-inline-select-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.review-inline-select-option { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border-radius: 999px; + border: 1px solid rgba(203, 213, 225, 0.92); + background: rgba(255, 255, 255, 0.96); + color: #475569; + font-size: 11px; + font-weight: 700; +} + +.review-inline-select-option.active { + border-color: rgba(16, 185, 129, 0.36); + background: rgba(240, 253, 244, 0.94); + color: #047857; +} + +.review-inline-error { + color: #dc2626; + font-size: 11px; + font-weight: 800; + line-height: 1.45; +} + +.review-side-edit-hint { + position: absolute; + top: 8px; + right: 8px; + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(226, 232, 240, 0.92); + color: #94a3b8; + font-size: 10px; + font-weight: 800; + opacity: 0; + transition: opacity 0.18s ease; +} + +.review-side-edit-hint.upload { + color: #059669; +} + +.review-side-metric-card:hover .review-side-edit-hint, +.review-side-metric-card.editing .review-side-edit-hint { + opacity: 1; +} + +.review-side-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-side-head strong { + color: #0f172a; + font-size: 14px; + font-weight: 900; +} + +.review-side-head-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.review-side-head-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.55; +} + +.review-side-confidence { + color: #10b981; + font-size: 12px; + font-weight: 900; +} + +.review-side-category-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.review-side-category-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 12px; + min-height: 66px; + border-radius: 14px; + border: 1px solid rgba(226, 232, 240, 0.94); + background: rgba(255, 255, 255, 0.68); + cursor: pointer; + transition: border-color 0.18s ease, background 0.18s ease; +} + +.review-side-category-card.active { + border-color: rgba(52, 211, 153, 0.62); + background: rgba(240, 253, 244, 0.9); + box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.08); +} + +.review-side-category-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.review-side-category-copy strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; + line-height: 1.35; + white-space: nowrap; +} + +.review-side-category-copy p { + margin: 0; + color: #64748b; + font-size: 10px; + line-height: 1.4; +} + +.review-side-group-check { + color: #10b981; + font-size: 18px; +} + +.review-other-category-popover { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 2px; +} + +.review-other-category-option { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(203, 213, 225, 0.92); + background: rgba(255, 255, 255, 0.94); + color: #475569; + font-size: 11px; + font-weight: 750; +} + +.review-other-category-option.active { + border-color: rgba(16, 185, 129, 0.36); + background: rgba(240, 253, 244, 0.94); + color: #047857; +} + +.review-side-risk-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%); +} + +.review-side-risk-score { + color: #f97316; + font-size: 13px; + font-weight: 900; +} + +.review-side-risk-score.empty { + color: #94a3b8; +} + +.review-side-risk-summary { + margin: 0; + color: #334155; + font-size: 12px; + line-height: 1.6; +} + +.review-side-risk-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 16px; + color: #475569; + font-size: 12px; + line-height: 1.6; +} + +.review-side-link { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: #059669; + font-size: 12px; + font-weight: 850; +} + +.review-side-link:disabled { + opacity: 0.5; +} + +.review-side-empty { + display: grid; + justify-items: start; + gap: 8px; + padding: 14px; + border: 1px dashed rgba(203, 213, 225, 0.92); + border-radius: 16px; + background: rgba(255, 255, 255, 0.52); +} + +.review-side-empty-icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 12px; + background: rgba(240, 244, 248, 0.96); + color: #94a3b8; + font-size: 18px; +} + +.review-side-empty strong { + color: #475569; + font-size: 13px; + font-weight: 850; +} + +.review-side-empty p { + margin: 0; + color: #94a3b8; + font-size: 12px; + line-height: 1.6; +} + +.review-side-save-pill { + position: sticky; + bottom: 0; + justify-self: end; + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 14px; + border: 1px solid rgba(16, 185, 129, 0.22); + border-radius: 999px; + background: rgba(255, 255, 255, 0.94); + color: #059669; + font-size: 12px; + font-weight: 850; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); +} + +.review-side-save-pill:disabled { + opacity: 0.5; + box-shadow: none; +} + +.review-document-switch-card { + gap: 14px; +} + +.review-ticket-drawer { + min-height: 0; +} + +.review-document-switch-head { + align-items: flex-start; +} + +.review-document-nav { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(226, 232, 240, 0.92); + white-space: nowrap; +} + +.review-document-nav span { + color: #334155; + font-size: 11px; + font-weight: 850; +} + +.review-document-nav-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: 999px; + background: rgba(241, 245, 249, 0.96); + color: #334155; +} + +.review-document-nav-btn:disabled { + opacity: 0.4; +} + +.review-document-stage { + display: grid; + gap: 12px; + min-height: 0; +} + +.review-document-stage-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.review-document-stage-copy { + min-width: 0; + display: grid; + gap: 6px; +} + +.review-document-stage-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; + line-height: 1.5; + word-break: break-word; +} + +.review-document-index-chip { + width: fit-content; + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: rgba(236, 253, 245, 0.92); + color: #059669; + font-size: 11px; + font-weight: 850; +} + +.review-document-meta-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.review-document-meta-chip { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: rgba(248, 250, 252, 0.94); + border: 1px solid rgba(226, 232, 240, 0.92); + color: #475569; + font-size: 11px; + font-weight: 800; +} + +.review-document-meta-chip.confidence { + background: rgba(236, 253, 245, 0.92); + color: #047857; + border-color: rgba(167, 243, 208, 0.92); +} + +.review-document-scroll { + display: grid; + gap: 12px; + max-height: 430px; + overflow-y: auto; + padding-right: 4px; +} + +.review-document-preview-card { + min-height: 168px; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(226, 232, 240, 0.94); + background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); +} + +.review-document-preview-card.clickable { + cursor: zoom-in; +} + +.review-document-preview-card.clickable img { + transition: transform 0.18s ease; +} + +.review-document-preview-card.clickable:hover img { + transform: scale(1.02); +} + +.review-document-preview-card.image img { + display: block; + width: 100%; + height: 188px; + object-fit: cover; +} + +.review-document-preview-placeholder { + min-height: 168px; + display: grid; + place-items: center; + gap: 6px; + padding: 18px; + text-align: center; +} + +.review-document-preview-placeholder i { + color: #64748b; + font-size: 34px; +} + +.review-document-preview-placeholder strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.review-document-preview-placeholder p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.65; +} + +.review-document-edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.review-document-edit-field { + display: grid; + gap: 8px; +} + +.review-document-edit-field span { + color: #334155; + font-size: 12px; + font-weight: 800; +} + +.review-document-edit-field input, +.review-document-edit-field textarea { + width: 100%; + border: 1px solid rgba(219, 230, 240, 0.96); + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); + color: #0f172a; + font-size: 13px; + line-height: 1.6; + padding: 10px 12px; + resize: vertical; +} + +.review-document-edit-field input:focus, +.review-document-edit-field textarea:focus { + outline: none; + border-color: rgba(16, 185, 129, 0.36); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.08); +} + +.review-document-edit-field textarea { + min-height: 88px; +} + +.review-document-warning-list { + display: grid; + gap: 8px; +} + +.review-document-warning-item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 247, 237, 0.92); + border: 1px solid rgba(253, 186, 116, 0.6); + color: #c2410c; + font-size: 12px; + line-height: 1.6; +} + +.review-side-empty.compact { + padding: 12px; +} + +.insight-card { + padding: 16px; + border: 1px solid #e7eef6; + border-radius: 20px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 14px 24px rgba(241, 245, 249, 0.86); +} + +.insight-card.primary { + background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.card-head h4 { + color: #0f172a; + font-size: 15px; + font-weight: 850; +} + +.knowledge-question-list { + display: grid; + gap: 10px; +} + +.knowledge-question-btn { + width: 100%; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: 16px; + background: rgba(248, 250, 252, 0.86); + color: #1e293b; + text-align: left; + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; +} + +.knowledge-question-btn:hover:not(:disabled) { + border-color: rgba(16, 185, 129, 0.3); + background: rgba(240, 253, 244, 0.9); + transform: translateY(-1px); +} + +.knowledge-question-btn:disabled { + opacity: 0.48; + cursor: not-allowed; + transform: none; +} + +.knowledge-question-btn i { + justify-self: end; + color: #059669; + font-size: 16px; +} + +.knowledge-question-index { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(226, 232, 240, 0.9); + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.knowledge-question-index.gold { + background: linear-gradient(135deg, #fbbf24, #f59e0b); + color: #7c2d12; + box-shadow: 0 6px 14px rgba(245, 158, 11, 0.22); +} + +.knowledge-question-index.silver { + background: linear-gradient(135deg, #e2e8f0, #cbd5e1); + color: #334155; + box-shadow: 0 6px 14px rgba(148, 163, 184, 0.18); +} + +.knowledge-question-index.bronze { + background: linear-gradient(135deg, #fdba74, #ea580c); + color: #7c2d12; + box-shadow: 0 6px 14px rgba(234, 88, 12, 0.18); +} + +.knowledge-question-copy { + min-width: 0; + color: #334155; + font-size: 13px; + font-weight: 750; + line-height: 1.5; +} + +.status-pill { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.status-pill.success { + background: #ecfdf5; + color: #059669; +} + +.status-pill.warning { + background: #fff7ed; + color: #ea580c; +} + +.status-pill.note { + background: #fdf2f8; + color: #db2777; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.metric-grid.single { + grid-template-columns: 1fr; +} + +.metric-item { + padding: 12px 14px; + border-radius: 16px; + background: #f8fafc; +} + +.metric-item span { + display: block; + color: #94a3b8; + font-size: 11px; + font-weight: 800; +} + +.metric-item strong { + display: block; + margin-top: 6px; + color: #0f172a; + font-size: 14px; + font-weight: 850; + line-height: 1.5; +} + +.timeline-list, +.bullet-list { + display: grid; + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} + +.timeline-list li { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.timeline-dot { + width: 10px; + height: 10px; + margin-top: 5px; + border-radius: 999px; + background: #cbd5e1; +} + +.timeline-list li.done .timeline-dot, +.timeline-list li.current .timeline-dot { + background: #10b981; +} + +.timeline-list strong { + display: block; + color: #0f172a; + font-size: 13px; + font-weight: 800; +} + +.timeline-list p, +.bullet-list li, +.welcome-card p, +.note-block p { + color: #64748b; + font-size: var(--wb-fs-metric); + line-height: 1.6; +} + +.receipt-list { + display: grid; + gap: 10px; +} + +.receipt-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 16px; + background: #f8fafc; +} + +.receipt-row strong, +.welcome-card strong, +.note-block strong { + color: #0f172a; + font-size: var(--wb-fs-bubble); + font-weight: 850; +} + +.action-card { + background: #fff; +} + +.receipt-row p, +.receipt-row span { + color: #64748b; + font-size: 12px; +} + +.receipt-side { + text-align: right; +} + +.receipt-side strong { + display: block; +} + +.review-flow-panel { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; +} + +.review-flow-summary { + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.review-flow-summary .flow-icon-btn { + margin-left: auto; +} + +.review-flow-list { + min-height: 0; + display: grid; + align-content: start; + gap: 0; + overflow-y: auto; + padding-right: 2px; +} + +.review-flow-panel .flow-step-card { + border-radius: 14px; + box-shadow: none; +} + +.review-flow-panel .flow-empty-state { + min-height: 260px; +} + +.flow-empty-state.compact { + padding: 22px; +} + +.review-message-block { + margin-top: 12px; +} + +.review-summary { + margin: 0; + color: #1f2937; + font-size: 13px; + line-height: 1.75; + white-space: pre-line; +} + +.review-card-shell { + display: grid; + gap: 12px; + padding: 15px; + border-radius: 18px; + border: 1px solid rgba(37, 99, 235, 0.14); + background: + radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 30%), + linear-gradient(180deg, #ffffff 0%, #f7fafc 100%); + box-shadow: + 0 14px 30px rgba(15, 23, 42, 0.06), + 0 1px 0 rgba(255, 255, 255, 0.9) inset; +} + +.review-flow-card { + display: grid; + gap: 10px; + padding-top: 2px; + border-top: 1px solid rgba(226, 232, 240, 0.72); +} + +.review-disclosure-card { + display: grid; + gap: 0; + border-top: 1px solid rgba(226, 232, 240, 0.72); + padding-top: 6px; +} + +.review-disclosure-card summary { + list-style: none; +} + +.review-disclosure-card summary::-webkit-details-marker { + display: none; +} + +.review-disclosure-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 16px; + border: 1px solid rgba(226, 232, 240, 0.92); + background: rgba(255, 255, 255, 0.78); + cursor: pointer; + transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.review-disclosure-summary:hover { + border-color: rgba(16, 185, 129, 0.2); + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 6px 16px rgba(226, 232, 240, 0.24); +} + +.review-disclosure-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.review-disclosure-copy strong { + color: #0f172a; + font-size: 12px; + font-weight: 900; + line-height: 1.4; +} + +.review-disclosure-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.55; +} + +.review-disclosure-toggle { + width: 28px; + height: 28px; + flex: none; + display: grid; + place-items: center; + border-radius: 999px; + background: rgba(240, 253, 244, 0.86); + color: #059669; + font-size: 16px; + transition: transform 0.18s ease, background 0.18s ease; +} + +.review-disclosure-card[open] .review-disclosure-toggle { + transform: rotate(180deg); + background: rgba(220, 252, 231, 0.92); +} + +.review-disclosure-body { + display: grid; + gap: 10px; + padding: 12px 4px 0; +} + +.review-followup-panel { + display: grid; + gap: 0; + border: 1px solid #e2e8f0; + border-radius: 14px; + background: #fff; + overflow: hidden; +} + +.review-followup-panel.pending { + border-color: #e2e8f0; + background: #fff; +} + +.review-followup-panel.ready { + border-color: #d1fae5; + background: #fff; +} + +.review-followup-panel summary { + list-style: none; +} + +.review-followup-panel summary::-webkit-details-marker { + display: none; +} + +.review-followup-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; + cursor: pointer; + transition: background 0.18s ease; +} + +.review-followup-head:hover { + background: #f8fafc; +} + +.review-followup-head-main { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.review-followup-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: none; + border-radius: 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + color: #64748b; + font-size: 17px; +} + +.review-followup-panel.pending .review-followup-mark { + background: #fffbeb; + border-color: #fde68a; + color: #b45309; +} + +.review-followup-panel.ready .review-followup-mark { + background: #ecfdf5; + border-color: #bbf7d0; + color: #059669; +} + +.review-followup-title-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.review-followup-title-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 900; + line-height: 1.35; +} + +.review-followup-title-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.55; +} + +.review-followup-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; +} + +.review-followup-panel[open] .review-followup-preview { + display: none; +} + +.review-followup-preview span { + min-height: 22px; + display: inline-flex; + align-items: center; + max-width: 160px; + padding: 0 8px; + border-radius: 999px; + background: #f8fafc; + border: 1px solid #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-followup-side { + display: inline-flex; + align-items: center; + gap: 8px; + flex: none; +} + +.review-followup-count { + min-height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #f8fafc; + color: #475569; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + +.review-followup-panel.pending .review-followup-count { + border-color: #fde68a; + background: #fffbeb; + color: #b45309; +} + +.review-followup-panel.ready .review-followup-count { + border-color: rgba(16, 185, 129, 0.22); + background: #ecfdf5; + color: #047857; +} + +.review-followup-chevron { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 999px; + color: #94a3b8; + font-size: 16px; + transition: transform 0.18s ease, color 0.18s ease; +} + +.review-followup-panel[open] .review-followup-chevron { + transform: rotate(180deg); + color: #475569; +} + +.review-followup-body { + display: grid; + gap: 10px; + padding: 0 12px 12px; +} + +.review-followup-list { + display: grid; + gap: 0; + border-top: 1px solid #e2e8f0; +} + +.review-followup-item { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 52px; + padding: 10px 0; + border-bottom: 1px solid #f1f5f9; +} + +.review-followup-item:last-child { + border-bottom: 0; +} + +.review-followup-icon { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 9px; + background: #f8fafc; + color: #64748b; + font-size: 16px; +} + +.review-followup-item.warning .review-followup-icon { + background: #fffbeb; + color: #b45309; +} + +.review-followup-item.danger .review-followup-icon { + background: #fff1f2; + color: #e11d48; +} + +.review-followup-item.ready .review-followup-icon { + background: #ecfdf5; + color: #059669; +} + +.review-followup-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.review-followup-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; + line-height: 1.35; +} + +.review-followup-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.5; +} + +.review-followup-status { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border-radius: 999px; + background: transparent; + border: 1px solid #e2e8f0; + color: #64748b; + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.review-followup-item.warning .review-followup-status { + border-color: #fde68a; + background: transparent; + color: #b45309; +} + +.review-followup-item.danger .review-followup-status { + border-color: #fecdd3; + background: transparent; + color: #e11d48; +} + +.review-followup-item.ready .review-followup-status { + border-color: #bbf7d0; + background: transparent; + color: #047857; +} + +.review-followup-helper { + margin: 0; + padding: 9px 10px; + border-radius: 10px; + background: #f8fafc; + border: 1px solid #eef2f7; + color: #64748b; + font-size: 11px; + line-height: 1.6; +} + +.review-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.review-card-head-main { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.review-card-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 10px; + background: linear-gradient(135deg, #2563eb, #0f766e); + color: #fff; + font-size: 16px; + box-shadow: 0 8px 16px rgba(37, 99, 235, 0.16); +} + +.review-card-head-copy { + display: grid; + gap: 4px; +} + +.review-card-head-copy strong { + color: #0f172a; + font-size: 14px; + font-weight: 900; + line-height: 1.35; +} + +.review-card-head-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.55; +} + +.review-card-state { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + +.review-card-state.ready { + background: rgba(240, 253, 244, 0.95); + color: #059669; + border: 1px solid rgba(16, 185, 129, 0.14); +} + +.review-card-state.pending { + background: rgba(255, 251, 235, 0.95); + color: #b45309; + border: 1px solid rgba(245, 158, 11, 0.16); +} + +.review-section-card { + display: grid; + gap: 10px; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(226, 232, 240, 0.92); + background: rgba(255, 255, 255, 0.76); +} + +.review-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-section-head strong { + color: #0f172a; + font-size: 12px; + font-weight: 900; +} + +.review-section-head span { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 800; +} + +.review-alert-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(251, 248, 243, 0.82) 100%); +} + +/* 已删除:review-alert-chip-row 相关样式(冗余气泡) */ +/* 已删除:主对话框中的风险提示(与右侧边栏重复,已移除) */ + +/* 风险提示样式已统一到 review-pending-item */ +.review-risk-brief-list { + display: none; /* 隐藏原有的独立风险提示列表 */ +} + +.review-risk-brief { + display: none; /* 隐藏原有的独立风险提示项 */ +} + +.review-pending-list { + display: grid; + gap: 8px; +} + +.review-pending-list.plain { + gap: 0; +} + +.review-pending-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 11px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(226, 232, 240, 0.92); +} + +.review-pending-list.plain .review-pending-item { + padding: 10px 0; + border: 0; + border-radius: 0; + background: transparent; + border-bottom: 1px solid rgba(226, 232, 240, 0.7); +} + +.review-pending-list.plain .review-pending-item:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.review-pending-list.plain .review-pending-item:first-child { + padding-top: 2px; +} + +.review-pending-icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 10px; + background: rgba(236, 253, 245, 0.95); + color: #059669; + font-size: 16px; +} + +/* 风险级别的图标样式(已删除主对话框中的风险提示,保留样式备用) */ +.review-pending-icon.high { + background: rgba(254, 226, 226, 0.95); + color: #dc2626; +} + +.review-pending-icon.warning { + background: rgba(255, 237, 213, 0.95); + color: #ea580c; +} + +.review-pending-list.plain .review-pending-icon { + background: rgba(236, 253, 245, 0.62); +} + +.review-pending-list.plain .review-pending-icon.high { + background: rgba(254, 226, 226, 0.62); +} + +.review-pending-list.plain .review-pending-icon.warning { + background: rgba(255, 237, 213, 0.62); +} + +.review-pending-copy { + display: grid; + gap: 4px; +} + +.review-pending-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.review-pending-copy p { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.5; +} + +.review-pending-status { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.review-pending-status.warning { + background: rgba(255, 241, 242, 0.96); + color: #e11d48; + border: 1px solid #fecdd3; +} + +.review-pending-status.danger { + background: rgba(254, 242, 242, 0.96); + color: #dc2626; + border: 1px solid #fca5a5; +} + +.review-pending-status.ready { + background: rgba(240, 253, 244, 0.96); + color: #059669; + border: 1px solid #86efac; +} + +.review-footer-actions { + display: grid; + gap: 8px; + padding-top: 6px; + border-top: 1px solid rgba(226, 232, 240, 0.72); +} + +.review-footer-btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.review-footer-btn { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + border-radius: 12px; + border: 1px solid #dbe6f0; + background: rgba(255, 255, 255, 0.92); + color: #334155; + font-size: 12px; + font-weight: 800; + box-shadow: 0 3px 10px rgba(241, 245, 249, 0.58); +} + +.review-footer-btn.primary { + border-color: rgba(16, 185, 129, 0.26); + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + box-shadow: 0 6px 14px rgba(16, 185, 129, 0.16); +} + +.review-footer-btn:disabled { + cursor: not-allowed; + opacity: 0.6; + box-shadow: none; +} + +.review-summary { + margin: 0; + color: #1f2937; + font-size: 14px; + line-height: 1.7; +} + +.review-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-start; +} + +.review-inline-btn, +.primary-dialog-btn, +.secondary-dialog-btn, +.danger-dialog-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; +} + +.review-inline-btn { + border: 1px solid #dbe6f0; + background: #fff; + color: #334155; +} + +.review-inline-btn.primary, +.primary-dialog-btn { + border: 1px solid rgba(16, 185, 129, 0.22); + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18); +} + +.review-inline-btn.secondary, +.secondary-dialog-btn { + border: 1px solid #dbe6f0; + background: #fff; + color: #334155; +} + +.danger-dialog-btn { + border: 1px solid rgba(239, 68, 68, 0.22); + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + box-shadow: 0 10px 22px rgba(239, 68, 68, 0.18); +} + +.review-inline-btn:disabled, +.primary-dialog-btn:disabled, +.secondary-dialog-btn:disabled, +.danger-dialog-btn:disabled { + cursor: not-allowed; + opacity: 0.62; + box-shadow: none; +} + +.review-inline-note { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + +.review-inline-guidance { + margin: 0; + color: #0f766e; + font-size: 12px; + line-height: 1.7; +} + +.review-status-banner { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #dbeafe; + background: linear-gradient(180deg, #f8fbff 0%, #f0f7ff 100%); +} + +.review-status-banner.ready { + border-color: #bbf7d0; + background: linear-gradient(180deg, #f5fffa 0%, #ecfdf5 100%); +} + +.review-status-banner.pending { + border-color: #fde68a; + background: linear-gradient(180deg, #fffdf7 0%, #fffbeb 100%); +} + +.review-status-tag { + width: fit-content; + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.86); + color: #0f172a; + font-size: 12px; + font-weight: 850; + border: 1px solid rgba(148, 163, 184, 0.22); +} + +.review-inline-section { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #e2e8f0; + background: rgba(255, 255, 255, 0.88); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66); +} + +.review-inline-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-inline-head > strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.review-inline-head > span { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 9px; + border-radius: 999px; + background: #fff; + color: #475569; + font-size: 11px; + font-weight: 800; + border: 1px solid #e2e8f0; +} + +.review-inline-caption { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.65; +} + +.review-inline-list { + display: grid; + gap: 8px; +} + +.review-missing-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.review-missing-chip { + min-height: 30px; + display: inline-flex; + align-items: center; + padding: 0 12px; + border-radius: 999px; + background: #fff; + color: #0f172a; + font-size: 12px; + font-weight: 800; + border: 1px solid #fed7aa; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); +} + +.review-inline-item { + display: grid; + gap: 4px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid #e2e8f0; + background: #fff; +} + +.review-inline-item.warning { + background: #fff7ed; + border-color: #fed7aa; +} + +.review-inline-item.high { + background: #fff1f2; + border-color: #fecdd3; +} + +.review-inline-item span { + color: #0f172a; + font-size: 12px; + font-weight: 800; +} + +.review-inline-item p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.65; +} + +.review-inline-footer { + display: grid; + gap: 10px; + padding-top: 2px; + border-top: 1px dashed rgba(203, 213, 225, 0.78); +} + +.review-mini-grid, +.review-slot-grid, +.review-doc-field-grid { + display: grid; + gap: 10px; +} + +.review-mini-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.review-slot-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.review-slot-card, +.review-doc-field-card, +.review-brief-card, +.review-claim-card, +.review-document-card { + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #f8fbff; +} + +.review-slot-card { + display: grid; + gap: 8px; + padding: 12px 14px; +} + +.review-slot-card.compact { + gap: 4px; + padding: 10px 12px; +} + +.review-slot-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.review-slot-card span, +.review-doc-field-card span, +.review-brief-card strong, +.review-document-card header span { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.review-slot-card strong, +.review-doc-field-card strong, +.review-claim-card strong, +.review-document-card header strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.review-slot-card p, +.review-brief-card p, +.review-claim-card p, +.review-document-card p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + +.review-slot-card.missing { + border-color: #fecdd3; + background: #fff7f7; +} + +.review-slot-card.inferred { + border-color: #dbeafe; + background: #f8fbff; +} + +.review-slot-meta-list { + display: grid; + gap: 8px; +} + +.review-slot-meta-item { + padding: 9px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(226, 232, 240, 0.9); +} + +.review-slot-meta-item span { + color: #94a3b8; + font-size: 11px; + font-weight: 800; +} + +.review-slot-meta-item strong { + display: block; + margin-top: 4px; + font-size: 12px; +} + +.review-brief-list, +.review-claim-list, +.review-document-list { + display: grid; + gap: 10px; +} + +.review-brief-card, +.review-claim-card, +.review-document-card { + padding: 12px 14px; +} + +.review-brief-card.warning { + background: #fff7ed; + border-color: #fed7aa; +} + +.review-brief-card.high { + background: #fff1f2; + border-color: #fecdd3; +} + +.review-claim-card header, +.review-document-card header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.review-document-card { + display: grid; + gap: 10px; +} + +.document-preview { + min-height: 124px; + overflow: hidden; + border-radius: 14px; + background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); + border: 1px dashed #dbe3ec; +} + +.document-preview.image img { + display: block; + width: 100%; + height: 180px; + object-fit: cover; +} + +.document-preview-placeholder { + min-height: 124px; + display: grid; + place-items: center; + gap: 6px; + color: #64748b; + text-align: center; +} + +.document-preview-placeholder i { + font-size: 28px; +} + +.review-doc-field-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.review-doc-field-card { + padding: 10px 12px; +} + +.action-list.compact { + grid-template-columns: 1fr; +} + +.action-card.primary { + border-color: #bbf7d0; + background: #f0fdf4; +} + +.action-card.secondary { + background: #fff; +} + +.action-card.warning { + border-color: #fed7aa; + background: #fff7ed; +} + +.note-block { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 16px; + background: #f8fafc; +} + +.note-block span { + color: #94a3b8; + font-size: 11px; + font-weight: 800; +} + +.review-conclusion strong { + font-size: var(--wb-fs-insight-h4); + line-height: 1.6; +} + +.insight-text-section { + display: grid; + gap: 12px; + padding: 2px 0 0; +} + +.insight-text-section h4 { + color: #0f172a; + font-size: var(--wb-fs-insight-h4); + font-weight: 850; +} + +.insight-text-list, +.review-document-plain-list { + display: grid; + gap: 12px; +} + +.recognition-bubble { + display: grid; + gap: 10px; + padding: 16px 18px; + border-radius: 22px; + border: 1px solid rgba(191, 219, 254, 0.9); + background: linear-gradient(180deg, #ffffff 0%, #f5fbff 100%); + box-shadow: 0 16px 28px rgba(241, 245, 249, 0.9); +} + +.recognition-bubble.secondary { + border-color: #e2e8f0; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.recognition-bubble-label { + color: #0f766e; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.02em; +} + +.recognition-bubble.secondary .recognition-bubble-label { + color: #475569; +} + +.recognition-bubble-copy { + display: grid; + gap: 8px; +} + +.recognition-bubble-line, +.recognition-bubble-note { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.75; +} + +.recognition-bubble-line { + font-weight: 700; + color: #0f172a; +} + +.recognition-bubble-note { + color: #64748b; +} + +.review-document-bubble { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px; + gap: 14px; + align-items: start; + padding: 16px; + border-radius: 22px; + background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%); + border: 1px solid rgba(226, 232, 240, 0.95); + box-shadow: 0 16px 28px rgba(241, 245, 249, 0.92); +} + +.review-document-copy { + display: grid; + gap: 6px; +} + +.review-document-index { + color: #1d4ed8; + font-size: 11px; + font-weight: 850; +} + +.review-document-copy strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; + line-height: 1.6; +} + +.review-document-copy p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.7; +} + +.review-overlay { + z-index: 10001; +} + +.review-confirm-modal, +.review-edit-modal { + width: min(720px, calc(100vw - 40px)); + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%), + linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); + box-shadow: + 0 24px 80px rgba(15, 23, 42, 0.22), + 0 2px 12px rgba(15, 23, 42, 0.08); + border: 1px solid #e7eef6; +} + +.review-confirm-modal { + padding: 24px; + display: grid; + gap: 18px; +} + +.review-confirm-modal h3, +.review-edit-head h3 { + margin-top: 12px; + color: #0f172a; + font-size: 22px; + font-weight: 900; + line-height: 1.35; +} + +.review-confirm-modal p, +.review-edit-head p { + margin-top: 8px; + color: #64748b; + font-size: 14px; + line-height: 1.7; +} + +.review-confirm-actions, +.review-edit-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.review-upload-decision-modal { + display: grid; + gap: 18px; +} + +.review-upload-decision-copy { + display: grid; + gap: 10px; +} + +.review-upload-decision-actions { + justify-content: stretch; +} + +.review-upload-decision-actions .primary-dialog-btn, +.review-upload-decision-actions .secondary-dialog-btn { + flex: 1 1 168px; +} + +.review-edit-modal { + max-height: min(860px, calc(100vh - 48px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.review-edit-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + border-bottom: 1px solid #eef2f7; +} + +.review-edit-form { + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 20px 24px; + overflow-y: auto; +} + +.review-edit-field { + display: grid; + gap: 8px; +} + +.review-edit-field.attachments, +.review-edit-field.business, +.review-edit-field.basic { + min-width: 0; +} + +.review-edit-field span { + color: #334155; + font-size: 13px; + font-weight: 800; +} + +.review-edit-field span em { + margin-left: 4px; + color: #dc2626; + font-style: normal; +} + +.review-edit-field input, +.review-edit-field textarea { + width: 100%; + border: 1px solid #dbe6f0; + border-radius: 16px; + background: #fff; + color: #0f172a; + font-size: 14px; + line-height: 1.6; + padding: 12px 14px; + resize: vertical; +} + +.review-edit-field input:focus, +.review-edit-field textarea:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); +} + +.review-edit-field textarea { + min-height: 96px; +} + +.review-edit-field.attachments, +.review-edit-field textarea, +.review-edit-field .textarea { + grid-column: span 2; +} + +.review-edit-actions { + padding: 0 24px 24px; +} + +.review-preview-modal { + width: min(980px, calc(100vw - 40px)); + max-height: min(92vh, calc(100vh - 32px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%), + linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); + box-shadow: + 0 24px 80px rgba(15, 23, 42, 0.22), + 0 2px 12px rgba(15, 23, 42, 0.08); + border: 1px solid #e7eef6; +} + +.review-preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + border-bottom: 1px solid #eef2f7; +} + +.review-preview-head h3 { + margin-top: 12px; + color: #0f172a; + font-size: 22px; + font-weight: 900; + line-height: 1.35; +} + +.review-preview-body { + min-height: 0; + display: grid; + place-items: center; + padding: 18px; + background: rgba(248, 250, 252, 0.88); +} + +.review-preview-body.image img { + max-width: 100%; + max-height: calc(92vh - 170px); + display: block; + border-radius: 20px; + object-fit: contain; + box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26); +} + +.review-preview-body.pdf iframe { + width: 100%; + height: min(78vh, 820px); + border: 0; + border-radius: 18px; + background: #fff; +} + +.welcome-quick-actions { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed rgba(203, 213, 225, 0.82); +} + +.welcome-quick-actions-title { + margin: 0 0 10px; + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.welcome-quick-action-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.welcome-quick-action-btn { + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 14px; + border: 1px solid rgba(191, 219, 254, 0.92); + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%); + color: #1d4ed8; + font-size: 12px; + font-weight: 750; + box-shadow: 0 6px 14px rgba(59, 130, 246, 0.08); + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} + +.welcome-quick-action-btn i { + font-size: 15px; + color: #2563eb; +} + +.welcome-quick-action-btn:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(59, 130, 246, 0.34); + box-shadow: 0 10px 18px rgba(59, 130, 246, 0.14); +} + +.welcome-quick-action-btn:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +.welcome-grid { + display: grid; + gap: 12px; +} + +.welcome-card { + padding: 14px; + border-radius: 18px; + background: #f8fafc; +} + +.welcome-card i { + color: #10b981; + font-size: var(--wb-fs-welcome); +} + +.welcome-card strong { + display: block; + margin-top: 10px; +} + +.assistant-modal-enter-active, +.assistant-modal-leave-active { + transition: opacity 220ms ease; +} + +.assistant-modal-enter-active .assistant-modal, +.assistant-modal-leave-active .assistant-modal { + transition: transform 260ms ease, opacity 220ms ease; +} + +.assistant-modal-enter-from, +.assistant-modal-leave-to { + opacity: 0; +} + +.assistant-modal-enter-from .assistant-modal, +.assistant-modal-leave-to .assistant-modal { + transform: translateY(10px) scale(0.985); + opacity: 0; +} + +.insight-switch-enter-active, +.insight-switch-leave-active { + transition: opacity 180ms ease, transform 180ms ease; +} + +.insight-switch-enter-from, +.insight-switch-leave-to { + opacity: 0; + transform: translateY(8px); +} + +/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */ +@media (max-width: 1680px) { + .assistant-modal-stage { + --wb-fs-title: 19px; + --wb-fs-desc: 12px; + --wb-fs-badge: 11px; + --wb-fs-bubble: 13px; + --wb-fs-bubble-meta: 12px; + --wb-fs-bubble-time: 11px; + --wb-fs-chip: 11px; + --wb-fs-composer: 13px; + --wb-fs-tool-icon: 16px; + --wb-fs-md-h1: 16px; + --wb-fs-md-h2: 15px; + --wb-fs-md-h3: 13px; + --wb-fs-insight-title: 17px; + --wb-fs-insight-num: 17px; + --wb-fs-insight-body: 11px; + --wb-fs-insight-h4: 14px; + --wb-fs-metric: 12px; + --wb-fs-metric-strong: 12px; + --wb-fs-welcome: 18px; + } + + .assistant-modal-stage .message-answer-markdown :deep(table) { + font-size: 12px; + } + + .assistant-modal-stage .intent-pill { + font-size: var(--wb-fs-chip); + } +} + +@media (max-width: 1440px) { + .assistant-modal-stage { + --wb-fs-title: 18px; + --wb-fs-bubble: 12px; + --wb-fs-bubble-meta: 11px; + --wb-fs-composer: 12px; + --wb-fs-insight-title: 16px; + --wb-fs-insight-num: 16px; + --wb-fs-md-h1: 15px; + --wb-fs-md-h2: 14px; + --wb-fs-insight-h4: 13px; + --wb-fs-welcome: 17px; + } +} + +/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */ +@media (min-width: 1441px) and (max-width: 1680px) { + .insight-panel-shell { + width: clamp(280px, 26vw, 360px); + } +} + +/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */ +@media (max-width: 1440px) { + .assistant-layout { + flex-direction: column; + } + + .dialog-panel { + flex: 1 1 auto; + min-height: 0; + } + + .insight-panel-shell { + width: 100%; + flex: 0 0 auto; + max-height: min(38dvh, 400px); + transition: + max-height 320ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 240ms cubic-bezier(0.22, 1, 0.36, 1), + transform 280ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .insight-panel-shell.collapsed { + max-height: 0; + } + + .insight-panel { + width: 100%; + min-height: min(280px, 32dvh); + } + + .insight-panel-shell.collapsed .insight-panel { + transform: translateY(-12px); + } + + .review-side-grid.compact { + grid-template-columns: 1fr; + } +} + +/* 矮屏笔记本(如 1366×768):压缩顶栏与间距,把高度留给对话列表 */ +@media (max-height: 820px) { + .assistant-modal-stage { + --wb-fs-title: 17px; + --wb-fs-bubble: 12px; + --wb-fs-composer: 12px; + --wb-fs-insight-title: 15px; + --wb-fs-insight-num: 15px; + } + + .assistant-header { + padding-top: 12px; + padding-bottom: 10px; + } + + .assistant-header-actions { + top: 12px; + right: 12px; + } + + .assistant-layout { + padding: 10px; + gap: 10px; + } + + .dialog-toolbar { + padding: 12px 14px 10px; + } + + .message-list { + padding: 12px; + gap: 10px; + } + + .composer-shell-body { + padding: 4px 10px; + } +} + +@media (max-width: 1280px) { + .insight-panel-shell:not(.collapsed) { + max-height: min(34dvh, 360px); + } + +} + +@media (max-width: 760px) { + .assistant-overlay { + --assistant-viewport-inset: 10px; + } + + .assistant-modal, + .assistant-modal-stage { + border-radius: 18px; + } + + .assistant-header { + padding: 18px 18px 16px; + align-items: flex-start; + flex-direction: column; + } + + .assistant-header-actions { + top: 18px; + right: 18px; + gap: 10px; + width: auto; + justify-content: space-between; + } + + .assistant-toggle-btn, + .session-trash-btn, + .assistant-close-btn, + .close-btn { + width: 40px; + height: 40px; + border-radius: 14px; + font-size: 16px; + } + + .flow-step-card header { + align-items: flex-start; + } + + .assistant-layout { + padding: 14px; + } + + .composer-row { + gap: 8px; + --composer-control-size: 40px; + } + + .composer-shell textarea { + min-height: 32px; + } + + .dialog-toolbar { + padding: 16px 16px 12px; + } + + .shortcut-chip { + width: 100%; + justify-content: center; + } + + .message-list { + padding: 16px; + } + + .message-row, + .message-row.user { + grid-template-columns: 34px minmax(0, 1fr); + } + + .message-row.user .message-avatar { + order: 0; + } + + .message-row.user .message-bubble { + order: 0; + justify-self: stretch; + } + + .composer { + padding: 0 16px 16px; + } + + .composer-files-head, + .review-insight-title-row, + .review-document-stage-head, + .review-document-switch-head { + align-items: flex-start; + flex-direction: column; + } + + .composer-files-actions, + .review-document-nav { + width: 100%; + justify-content: space-between; + } + + .review-card-head { + flex-direction: column; + } + + .review-followup-head { + flex-direction: column; + } + + .review-followup-side { + width: 100%; + justify-content: space-between; + } + + .review-followup-count { + align-self: flex-start; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .review-side-grid, + .review-side-category-grid, + .review-document-edit-grid { + grid-template-columns: 1fr; + } + + .review-pending-item { + grid-template-columns: 42px minmax(0, 1fr); + } + + .review-pending-status { + grid-column: 2; + justify-self: start; + } + + .review-followup-item { + grid-template-columns: 34px minmax(0, 1fr); + } + + .review-followup-status { + grid-column: 2; + justify-self: start; + } + + .review-footer-btn-row { + flex-direction: column; + } + + .review-footer-btn { + width: 100%; + } + + .review-slot-grid, + .review-doc-field-grid, + .review-mini-grid { + grid-template-columns: 1fr; + } + + .review-document-plain, + .review-document-bubble { + grid-template-columns: 1fr; + } + + .review-edit-modal { + width: calc(100vw - 24px); + } + + .review-preview-modal { + width: calc(100vw - 24px); + } + + .review-edit-form { + grid-template-columns: 1fr; + padding: 18px; + } + + .review-edit-field.attachments, + .review-edit-field textarea, + .review-edit-field .textarea { + grid-column: auto; + } + + .review-edit-actions, + .review-confirm-actions { + padding: 0 18px 18px; + justify-content: stretch; + } + + .review-upload-decision-actions { + width: 100%; + } + + .primary-dialog-btn, + .secondary-dialog-btn, + .danger-dialog-btn { + width: 100%; + } +} diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 73685ec..15a1e7c 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -280,8 +280,21 @@ async function handleExpenseConversationAction() { return } - pendingAction.value = 'expense' const nextPayload = buildAssistantPayload() + const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length) + + if (shouldOpenImmediately) { + emitAssistant({ + ...nextPayload, + conversation: null + }) + void clearKnowledgeHistoryBeforeExpense().catch((error) => { + console.warn('Failed to clear knowledge history before expense:', error) + }) + return + } + + pendingAction.value = 'expense' try { await clearKnowledgeHistoryBeforeExpense() @@ -1131,4 +1144,3 @@ watch( } } - diff --git a/web/src/views/PersonalWorkbenchView.vue b/web/src/views/PersonalWorkbenchView.vue index 11381ea..68e55ac 100644 --- a/web/src/views/PersonalWorkbenchView.vue +++ b/web/src/views/PersonalWorkbenchView.vue @@ -1,9 +1,17 @@ diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 0f90910..2ba9b2b 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -1,1150 +1,1308 @@ - - - - - - - -
-
-

制度依据

-
-
-
-
- {{ item.title }} - {{ item.version || item.source_type }} -
-

{{ item.excerpt || item.code }}

-
-
-
- - - - - - - - - - - - - - - - - - -
-
-
- 上传票据 -

检测到你已有单据事件

-

这次新上传的附件需要先确认处理方式。你可以继续归集到上一笔单据,也可以重新开启一张新单据。

-
- -
- - - -
-
-
-
- - -
-
-
-
- 票据原图 -

{{ documentPreviewDialog.filename }}

-
- -
- -
- - -
- - - - 当前文件暂不支持内置预览 -

请重新上传图片或 PDF 票据,以便在这里查看原图。

-
-
-
-
-
- - -
-
-
-
- 修改识别信息 -

请按当前识别结果逐项修改

-

修改后会重新发送到智能体,右侧识别结果会按新内容刷新。

-
- -
- -
- -
- -
- - -
-
-
-
- - - - - - + + + + + + + +
+
+

制度依据

+
+
+
+
+ {{ item.title }} + {{ item.version || item.source_type }} +
+

{{ item.excerpt || item.code }}

+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ 上传票据 +

检测到你已有单据事件

+

这次新上传的附件需要先确认处理方式。你可以继续归集到上一笔单据,也可以重新开启一张新单据。

+
+ +
+ + + +
+
+
+
+ + +
+
+
+
+ 票据原图 +

{{ documentPreviewDialog.filename }}

+
+ +
+ +
+ + +
+ + + + 当前文件暂不支持内置预览 +

请重新上传图片或 PDF 票据,以便在这里查看原图。

+
+
+
+
+
+ + +
+
+
+
+ 修改识别信息 +

请按当前识别结果逐项修改

+

修改后会重新发送到智能体,右侧识别结果会按新内容刷新。

+
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ + + + + + diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index d4a6210..56feed0 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -1,5051 +1,5163 @@ -import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { useRouter } from 'vue-router' - -import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' -import { useSystemState } from '../../composables/useSystemState.js' -import { useToast } from '../../composables/useToast.js' -import { recognizeOcrFiles } from '../../services/ocr.js' -import { fetchAgentRunDetail } from '../../services/agentAssets.js' -import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' -import { renderMarkdown } from '../../utils/markdown.js' -import { - fetchExpenseClaimAttachmentAsset, - fetchExpenseClaimDetail, - fetchExpenseClaimItemAttachmentMeta, - uploadExpenseClaimItemAttachment -} from '../../services/reimbursements.js' - -const aiAvatar = '/assets/header.png' -const userAvatar = '/assets/person.png' - -const SOURCE_LABELS = { - workbench: '来自个人工作台', - topbar: '来自发起报销', - detail: '来自智能录入', - upload: '来自附件上传', - requests: '来自报销列表' -} - -const SCENARIO_LABELS = { - expense: '报销', - accounts_receivable: '应收', - accounts_payable: '应付', - knowledge: '知识', - unknown: '通用' -} - -const INTENT_LABELS = { - query: '查询', - explain: '解释', - compare: '对比', - risk_check: '风险检查', - draft: '草稿生成', - operate: '动作请求' -} - -const DOCUMENT_TYPE_LABELS = { - travel_ticket: '行程单/机票/车票', - flight_itinerary: '机票/航班行程单', - train_ticket: '火车/高铁票', - hotel_invoice: '酒店住宿票据', - taxi_receipt: '出租车/网约车票据', - parking_toll_receipt: '停车/通行费票据', - transport_receipt: '交通出行票据', - meal_receipt: '餐饮票据', - office_invoice: '办公用品票据', - meeting_invoice: '会议/会务票据', - training_invoice: '培训票据', - vat_invoice: '增值税发票', - receipt: '一般收据/凭证', - other: '其他单据' -} - -const EXPENSE_TYPE_LABELS = { - travel: '差旅费', - hotel: '住宿费', - transport: '交通费', - meal: '伙食费', - meeting: '会务费', - entertainment: '业务招待费', - office: '办公费', - training: '培训费', - communication: '通讯费', - welfare: '福利费', - other: '其他费用' -} - -const REVIEW_SLOT_CONFIG = { - expense_type: { - title: '报销分类', - hint: '请选择本次报销分类', - status: '待确认', - icon: 'mdi mdi-shape-outline' - }, - customer_name: { - title: '关联客户', - hint: '请补充客户单位全称', - status: '待补充', - icon: 'mdi mdi-domain' - }, - time_range: { - title: '发生时间', - hint: '请按 YYYY-MM-DD 补充业务发生日期', - status: '待补充', - icon: 'mdi mdi-calendar-month-outline' - }, - location: { - title: '业务地点', - hint: '请补充业务发生地点', - status: '待补充', - icon: 'mdi mdi-map-marker-outline' - }, - merchant_name: { - title: '酒店/商户', - hint: '请补充酒店或商户名称', - status: '待补充', - icon: 'mdi mdi-storefront-outline' - }, - amount: { - title: '金额', - hint: '请补充本次费用金额', - status: '待补充', - icon: 'mdi mdi-cash' - }, - reason: { - title: '场景 / 事由', - hint: '请补充本次费用场景或事由', - status: '待补充', - icon: 'mdi mdi-text-box-outline' - }, - participants: { - title: '同行人员', - hint: '请至少填写 1 名同行人员', - status: '待补充', - icon: 'mdi mdi-account-group-outline' - }, - attachments: { - title: '票据状态', - hint: '请上传发票/收据等票据附件', - status: '未上传', - icon: 'mdi mdi-paperclip' - } -} - -const REVIEW_FALLBACK_GROUP_CODES = [ - 'other', - 'travel', - 'transport', - 'hotel', - 'meal', - 'meeting', - 'entertainment', - 'office', - 'training', - 'communication', - 'welfare' -] - -const REVIEW_CATEGORY_PRESET_OPTIONS = [ - { key: 'travel', label: '差旅费' }, - { key: 'transport', label: '交通费' }, - { key: 'hotel', label: '住宿费' }, - { key: 'meal', label: '餐费' }, - { key: 'entertainment', label: '业务招待费' }, - { key: 'other_trigger', label: '其他类型', is_other: true } -] - -const REVIEW_OTHER_CATEGORY_OPTIONS = [ - { key: 'meeting', label: '会务费' }, - { key: 'office', label: '办公费' }, - { key: 'training', label: '培训费' }, - { key: 'communication', label: '通讯费' }, - { key: 'welfare', label: '福利费' }, - { key: 'other', label: '其他费用' } -] - -const REVIEW_SCENE_OTHER_OPTION = '其他场景' -const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION] -const EXPENSE_CODE_TO_PRESET_SCENE = { - travel: '出差行程', - hotel: '住宿报销', - transport: '交通出行', - meeting: '会务活动', - entertainment: '请客户吃饭', - meal: '请客户吃饭' -} -const DATE_INPUT_FORMAT = 'YYYY-MM-DD' -const MAX_ATTACHMENTS = 10 -const MAX_OCR_DOCUMENTS = 10 -const VISIBLE_ATTACHMENT_CHIPS = 2 -const COMPOSER_TEXTAREA_HEIGHT = 36 -const COMPOSER_MAX_ROWS = 5 -const EXPENSE_QUERY_PAGE_SIZE = 5 -const SESSION_TYPE_EXPENSE = 'expense' -const SESSION_TYPE_KNOWLEDGE = 'knowledge' -const REVIEW_DRAWER_MODE_REVIEW = 'review' -const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' -const REVIEW_DRAWER_MODE_RISK = 'risk' -const FLOW_STEP_STATUS_PENDING = 'pending' -const FLOW_STEP_STATUS_RUNNING = 'running' -const FLOW_STEP_STATUS_COMPLETED = 'completed' -const FLOW_STEP_STATUS_FAILED = 'failed' -const FLOW_STEP_FALLBACKS = { - intent: { - title: '意图识别', - tool: 'IntentRecognizer', - runningText: '正在识别业务意图...', - completedText: '意图识别完成' - }, - extraction: { - title: '信息提取', - tool: 'SemanticExtractor', - runningText: '正在提取时间、金额、费用类型和待补项...', - completedText: '信息提取完成' - }, - ocr: { - title: '票据/OCR识别', - tool: 'OCRService', - runningText: '正在识别票据附件...', - completedText: '票据识别完成' - }, - result: { - title: '生成结果', - tool: 'ResultGenerator', - runningText: '正在生成解释与草稿...', - completedText: '结果已生成' - } -} -const ASSISTANT_DISPLAY_NAME = '财务助手' - -const EXPENSE_WELCOME_QUICK_ACTIONS = [ - { - label: '发起差旅报销', - prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', - icon: 'mdi mdi-bag-suitcase-outline' - }, - { - label: '招待费报销', - prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', - icon: 'mdi mdi-food-fork-drink' - }, - { - label: '交通费报销', - prompt: '我要报销交通出行费用,请帮我识别常见票据类型和报销注意事项。', - icon: 'mdi mdi-car-outline' - }, - { - label: '上传票据识别', - prompt: '我已准备好票据,请帮我识别并生成报销草稿。', - icon: 'mdi mdi-file-upload-outline' - }, - { - label: '查询近期报销', - prompt: '帮我查询近10天的报销记录和金额汇总。', - icon: 'mdi mdi-chart-timeline-variant' - }, - { - label: '解释报销风险', - prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。', - icon: 'mdi mdi-shield-alert-outline' - } -] - -const ASSISTANT_DISPLAY_NAME = '财务助手' - -const EXPENSE_WELCOME_QUICK_ACTIONS = [ - { - label: '发起差旅报销', - prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', - icon: 'mdi mdi-bag-suitcase-outline' - }, - { - label: '招待费报销', - prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', - icon: 'mdi mdi-food-fork-drink' - }, - { - label: '交通费报销', - prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。', - icon: 'mdi mdi-car-outline' - }, - { - label: '上传票据识别', - prompt: '我已准备好票据,请帮我识别票据内容并生成报销草稿。', - icon: 'mdi mdi-file-upload-outline' - }, - { - label: '查询近期报销', - prompt: '帮我查询近10天的报销记录和金额汇总。', - icon: 'mdi mdi-chart-timeline-variant' - }, - { - label: '解释报销风险', - prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险与处理方式。', - icon: 'mdi mdi-shield-alert-outline' - } -] - -const HOT_KNOWLEDGE_QUESTIONS = [ - '差旅住宿标准按什么规则执行?', - '酒店超标后如何申请例外报销?', - '招待费报销需要哪些凭证?', - '发票抬头不一致还能报销吗?', - '电子发票验真失败怎么处理?', - '借款多久内需要冲销?', - '预算不足还能先提交报销吗?', - '会议费和招待费如何区分?', - '跨部门项目费用应该怎么归集?', - '员工退票手续费是否可以报销?' -] -const CATEGORY_CONFIDENCE_KEYWORDS = { - travel: [/出差|差旅|行程|机票|火车|高铁|航班/], - hotel: [/住宿|酒店|宾馆|民宿/], - transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/], - meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], - meeting: [/会务|会议|论坛|展会|参会|会场/], - entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], - office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], - training: [/培训|授课|讲师|课程|签到|讲义/], - communication: [/通讯|电话|流量|话费|宽带|网络/], - welfare: [/福利|体检|团建|节日|慰问|关怀/] -} -const FLOW_MISSING_SLOT_LABELS = { - expense_type: '报销类型', - customer_name: '客户名称', - time_range: '发生时间', - location: '地点', - merchant_name: '酒店/商户', - amount: '金额', - reason: '事由说明', - participants: '参与人员', - attachments: '票据附件' -} -const FLOW_INTENT_KEYWORDS = { - draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'], - query: ['查询', '查一下', '多少', '明细', '统计'], - risk_check: ['风险', '异常', '重复', '超标'], - explain: ['为什么', '依据', '规则', '怎么'] -} - -let messageSeed = 0 - -function nowTime() { - return new Date().toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }) -} - -function createMessage(role, text, attachments = [], extras = {}) { - messageSeed += 1 - return { - id: `msg-${messageSeed}`, - role, - text, - attachments, - time: nowTime(), - meta: [], - citations: [], - suggestedActions: [], - queryPayload: null, - draftPayload: null, - reviewPayload: null, - riskFlags: [], - ...extras - } -} - -function formatMessageTime(value) { - if (!value) { - return nowTime() - } - - const parsed = new Date(value) - if (Number.isNaN(parsed.getTime())) { - return nowTime() - } - - return parsed.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }) -} - -function createFlowSteps() { - return [] -} - -function formatSemanticEntityValue(entity) { - const normalizedValue = String(entity?.normalized_value || '').trim() - const rawValue = String(entity?.value || '').trim() - const entityType = String(entity?.type || '').trim() - - if (entityType === 'amount') { - const numericValue = Number(normalizedValue || rawValue) - if (Number.isFinite(numericValue) && numericValue > 0) { - return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` - } - } - - return rawValue || normalizedValue -} - -function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { - if (!semanticParse || typeof semanticParse !== 'object') { - return FLOW_STEP_FALLBACKS.extraction.completedText - } - - const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] - const entityMap = new Map() - for (const item of entities) { - const entityType = String(item?.type || '').trim() - if (!entityType || entityMap.has(entityType)) continue - entityMap.set(entityType, item) - } - - const extractedParts = [] - const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' - ? semanticParse.time_range_json - : {} - const startDate = String(timeRange.start_date || '').trim() - const endDate = String(timeRange.end_date || '').trim() - if (startDate) { - extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) - } - - const amountEntity = entityMap.get('amount') - if (amountEntity) { - const amountValue = formatSemanticEntityValue(amountEntity) - if (amountValue) { - extractedParts.push(`金额 ${amountValue}`) - } - } - - const expenseTypeEntity = entityMap.get('expense_type') - if (expenseTypeEntity) { - const expenseTypeLabel = resolveExpenseTypeLabel( - String(expenseTypeEntity?.normalized_value || '').trim(), - String(expenseTypeEntity?.value || '').trim() - ) - if (expenseTypeLabel) { - extractedParts.push(`费用类型 ${expenseTypeLabel}`) - } - } - - const customerEntity = entityMap.get('customer') - if (customerEntity) { - const customerValue = formatSemanticEntityValue(customerEntity) - if (customerValue) { - extractedParts.push(`客户 ${customerValue}`) - } - } - - const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] - const missingLabels = missingSlots - .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) - .filter(Boolean) - - if (extractedParts.length && missingLabels.length) { - return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` - } - if (extractedParts.length) { - return `已提取${extractedParts.join('、')}` - } - if (missingLabels.length) { - return `已完成信息提取;待补充 ${missingLabels.join('、')}` - } - return FLOW_STEP_FALLBACKS.extraction.completedText -} - -function summarizeSemanticIntentDetail(semanticParse) { - if (!semanticParse || typeof semanticParse !== 'object') { - return FLOW_STEP_FALLBACKS.intent.completedText - } - - const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用' - const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理' - return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}` -} - -function extractLocalFlowCandidates(rawText) { - const text = String(rawText || '').trim() - const compact = text.replace(/\s+/g, '') - - let time = '' - const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) - if (explicitTimeMatch?.[1]) { - time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') - } else { - const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) - if (dateMatch?.[1]) { - time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') - } else if (/今天|今日/.test(compact)) { - time = '今天' - } else if (/昨天|昨日/.test(compact)) { - time = '昨天' - } else if (/前天/.test(compact)) { - time = '前天' - } - } - - let amount = '' - const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/) - if (amountMatch?.[1]) { - const numericValue = Number(amountMatch[1]) - if (Number.isFinite(numericValue)) { - amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` - } - } - - let event = '' - let expenseType = '' - if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) { - event = '请客户吃饭' - expenseType = '业务招待费' - } else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) { - event = '出差行程' - expenseType = '差旅费' - } else if (/打车|网约车|出租车|车费|停车/.test(compact)) { - event = '交通出行' - expenseType = '交通费' - } else if (/住宿|酒店|宾馆/.test(compact)) { - event = '住宿报销' - expenseType = '住宿费' - } else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) { - event = '餐饮用餐' - expenseType = '餐费' - } - - return { - time, - amount, - event, - expenseType - } -} - -function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) { - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return '初步识别为财务知识问答,正在准备检索范围' - } - - const text = String(rawText || '').trim() - const compact = text.replace(/\s+/g, '') - const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) => - keywords.some((keyword) => compact.includes(keyword)) - )?.[0] || 'draft' - const intentLabel = INTENT_LABELS[intentKey] || '处理' - return `初步识别为报销场景,准备进入${intentLabel}` -} - -function buildLocalExtractionProgressMessages(rawText, options = {}) { - const candidates = extractLocalFlowCandidates(rawText) - const messages = [] - - messages.push('正在提取发生时间...') - messages.push( - candidates.time - ? `发现发生时间 ${candidates.time},继续提取金额...` - : '暂未定位到明确时间,继续提取金额...' - ) - messages.push( - candidates.amount - ? `发现金额 ${candidates.amount},继续识别事件类型...` - : '暂未定位到明确金额,继续识别事件类型...' - ) - - if (candidates.event || candidates.expenseType) { - const eventParts = [candidates.event, candidates.expenseType].filter(Boolean) - messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`) - } else { - messages.push('正在识别事件类型和费用分类...') - } - - const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件' - messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`) - - return messages -} - -function formatFlowDuration(ms) { - const numericValue = Number(ms) - if (!Number.isFinite(numericValue) || numericValue < 0) { - return '--' - } - if (numericValue < 100) { - return '<0.1s' - } - if (numericValue < 1000) { - return `${(numericValue / 1000).toFixed(1)}s` - } - if (numericValue < 10000) { - return `${(numericValue / 1000).toFixed(1)}s` - } - return `${Math.round(numericValue / 1000)}s` -} - -function parseFlowTimestamp(value) { - const timestamp = new Date(value || '').getTime() - return Number.isFinite(timestamp) ? timestamp : 0 -} - -function resolveSemanticPhaseDurations(run) { - const runStart = parseFlowTimestamp(run?.started_at) - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - const firstToolStartedAt = toolCalls - .map((item) => parseFlowTimestamp(item?.created_at)) - .filter((value) => value > 0) - .sort((left, right) => left - right)[0] || 0 - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - const semanticFinishedAt = firstToolStartedAt || runFinishedAt - - if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { - return { intentMs: null, extractionMs: null } - } - - const totalMs = semanticFinishedAt - runStart - const intentMs = Math.max(120, Math.round(totalMs * 0.35)) - const extractionMs = Math.max(160, totalMs - intentMs) - return { - intentMs, - extractionMs - } -} - -function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { - const explicitDuration = Number(toolCall?.duration_ms) - if (Number.isFinite(explicitDuration) && explicitDuration > 0) { - return explicitDuration - } - - const startedAt = parseFlowTimestamp(toolCall?.created_at) - if (!startedAt) { - return null - } - - const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) - - if (!finishedAt || finishedAt <= startedAt) { - return null - } - - return finishedAt - startedAt -} - -function resolveResultStepDurationMs(run) { - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - if (!runFinishedAt) { - return null - } - - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - const semanticFinishedAt = ( - toolCalls - .map((item, index) => { - const startedAt = parseFlowTimestamp(item?.created_at) - const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run) - if (!startedAt || !durationMs) { - return 0 - } - return startedAt + durationMs - }) - .filter((value) => value > 0) - .sort((left, right) => right - left)[0] - ) || parseFlowTimestamp(run?.started_at) - - if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) { - return null - } - - return runFinishedAt - semanticFinishedAt -} - -function sanitizeRequest(request) { - if (!request || typeof request !== 'object') return null - - const normalized = { - id: String(request.id || '').trim(), - typeLabel: String(request.typeLabel || request.category || '').trim(), - reason: String(request.reason || request.title || '').trim(), - entity: String(request.entity || '').trim(), - city: String(request.city || request.location || '').trim(), - period: String(request.period || '').trim(), - applyTime: String(request.applyTime || request.occurredAt || '').trim(), - amount: String(request.amount || '').trim(), - node: String(request.node || '').trim(), - approval: String(request.approval || '').trim(), - travel: String(request.travel || '').trim() - } - - return Object.values(normalized).some(Boolean) ? normalized : null -} - -function resolveStatusLabel(status) { - if (status === 'succeeded') return '已完成' - if (status === 'blocked') return '已阻断' - return '失败' -} - -function resolveStatusTone(status) { - if (status === 'succeeded') return 'success' - if (status === 'blocked') return 'warning' - return 'note' -} - -function buildMessageMeta(payload, fileNames = []) { - const items = [] - - if (payload?.selected_agent) { - items.push(`Agent: ${payload.selected_agent}`) - } - - if (payload?.permission_level) { - items.push(`权限: ${payload.permission_level}`) - } - - if (payload?.trace_summary?.tool_count) { - items.push(`工具: ${payload.trace_summary.tool_count}`) - } - - if (payload?.trace_summary?.degraded) { - items.push('已降级') - } - - if (payload?.requires_confirmation) { - items.push('待确认') - } - - if (payload?.run_id) { - items.push(`Run: ${payload.run_id}`) - } - - if (fileNames.length) { - items.push(`附件: ${fileNames.length}`) - } - - return items -} - -function buildStoredMessageMeta(messageJson, attachmentNames = []) { - const payload = messageJson?.orchestrator_payload - if (payload) { - return buildMessageMeta(payload, attachmentNames) - } - - const items = [] - if (messageJson?.status) { - items.push(`状态: ${messageJson.status}`) - } - if (attachmentNames.length) { - items.push(`附件: ${attachmentNames.length}`) - } - return items -} - -function normalizeOcrDocuments(payload) { - const documents = Array.isArray(payload?.documents) ? payload.documents : [] - return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ - filename: item.filename, - summary: item.summary, - text: String(item.text || '').slice(0, 240), - avg_score: Number(item.avg_score || 0), - line_count: Number(item.line_count || 0), - document_type: String(item.document_type || 'other').trim() || 'other', - document_type_label: String(item.document_type_label || '').trim(), - scene_code: String(item.scene_code || 'other').trim() || 'other', - scene_label: String(item.scene_label || '').trim(), - preview_kind: String(item.preview_kind || '').trim(), - preview_data_url: String(item.preview_data_url || '').trim(), - preview_url: String(item.preview_url || '').trim(), - document_fields: Array.isArray(item.document_fields) - ? item.document_fields - .map((field) => ({ - key: String(field?.key || '').trim(), - label: String(field?.label || '').trim(), - value: String(field?.value || '').trim() - })) - .filter((field) => field.key && field.label && field.value) - : [], - warnings: Array.isArray(item.warnings) ? item.warnings : [] - })) -} - -function buildOcrSummary(payload) { - return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) -} - -function buildOcrSummaryFromDocuments(documents) { - return (Array.isArray(documents) ? documents : []) - .slice(0, MAX_OCR_DOCUMENTS) - .map((item) => { - const filename = String(item?.filename || '').trim() - const summary = String(item?.summary || item?.text || '').trim() - if (filename && summary) { - return `${filename}:${summary}` - } - return filename || summary - }) - .filter(Boolean) - .join(';') -} - -function normalizeReviewDocumentFieldKey(label) { - const compact = String(label || '').replace(/\s+/g, '').toLowerCase() - if (!compact) return '' - if ( - ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => - compact.includes(token.toLowerCase()) - ) - ) { - return 'amount' - } - if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { - return 'date' - } - if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { - return 'merchant_name' - } - if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { - return 'invoice_number' - } - if (compact.includes('发票代码')) { - return 'invoice_code' - } - if (compact.includes('车次') || compact.includes('航班')) { - return 'trip_no' - } - if (compact.includes('行程') || compact.includes('路线')) { - return 'route' - } - return compact -} - -function buildOcrDocumentsFromReviewPayload(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { - const fields = Array.isArray(item?.fields) - ? item.fields - .map((field) => { - const label = String(field?.label || '').trim() - const value = String(field?.value || '').trim() - if (!label || !value) { - return null - } - return { - key: normalizeReviewDocumentFieldKey(label), - label, - value - } - }) - .filter(Boolean) - : [] - - return { - filename: String(item?.filename || '').trim(), - summary: String(item?.summary || '').trim(), - text: [ - String(item?.scene_label || '').trim(), - String(item?.summary || '').trim(), - ...fields.map((field) => `${field.label}:${field.value}`) - ] - .filter(Boolean) - .join(' ') - .slice(0, 240), - avg_score: Number(item?.avg_score || 0), - document_type: String(item?.document_type || 'other').trim() || 'other', - document_type_label: resolveDocumentTypeLabel(item?.document_type), - scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), - scene_label: String(item?.scene_label || '').trim(), - document_fields: fields, - warnings: Array.isArray(item?.warnings) ? item.warnings : [] - } - }).filter((item) => item.filename) -} - -function mergeUploadAttachmentNames(existingNames, incomingNames) { - const merged = [] - const seen = new Set() - - for (const value of [...(existingNames || []), ...(incomingNames || [])]) { - const normalized = String(value || '').trim() - if (!normalized || seen.has(normalized)) continue - seen.add(normalized) - merged.push(normalized) - if (merged.length >= MAX_ATTACHMENTS) { - break - } - } - - return merged -} - -function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { - const merged = [] - const seen = new Set() - - for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { - const filename = String(item?.filename || '').trim() - if (!filename || seen.has(filename)) continue - seen.add(filename) - merged.push(item) - if (merged.length >= MAX_OCR_DOCUMENTS) { - break - } - } - - return merged -} - -function inferPreviewKind(file) { - const mediaType = String(file?.type || '').toLowerCase() - const filename = String(file?.name || '').toLowerCase() - if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { - return 'image' - } - if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { - return 'pdf' - } - return 'file' -} - -function buildFilePreviews(files, previewRegistry) { - return files.map((file) => { - const kind = inferPreviewKind(file) - if (!['image', 'pdf'].includes(kind)) { - return { - filename: file.name, - kind - } - } - - const url = URL.createObjectURL(file) - previewRegistry.push(url) - return { - filename: file.name, - kind, - url - } - }) -} - -function resolveDocumentPreview(filePreviews, filename) { - if (!Array.isArray(filePreviews)) return null - const matches = filePreviews.filter((item) => item.filename === filename) - if (!matches.length) { - return null - } - return ( - matches.find((item) => item.kind === 'image' && item.url) || - matches.find((item) => item.url) || - matches[0] - ) -} - -function buildFileIdentity(file) { - return [file?.name, file?.size, file?.lastModified, file?.type].join('__') -} - -function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { - const nextFiles = [] - const seen = new Set() - - for (const file of Array.isArray(existingFiles) ? existingFiles : []) { - const key = buildFileIdentity(file) - if (seen.has(key)) continue - seen.add(key) - nextFiles.push(file) - } - - let duplicateCount = 0 - let overflowCount = 0 - - for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { - const key = buildFileIdentity(file) - if (seen.has(key)) { - duplicateCount += 1 - continue - } - if (nextFiles.length >= limit) { - overflowCount += 1 - continue - } - seen.add(key) - nextFiles.push(file) - } - - return { - files: nextFiles, - duplicateCount, - overflowCount - } -} - -function mergeFilePreviews(existingPreviews, incomingPreviews) { - const result = [] - const seen = new Set() - - for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { - const key = [preview?.filename, preview?.kind].join('__') - if (!preview?.filename || seen.has(key)) continue - seen.add(key) - result.push(preview) - } - - return result -} - -function buildOcrFilePreviews(payload) { - const documents = Array.isArray(payload?.documents) ? payload.documents : [] - return documents - .map((item) => ({ - filename: String(item?.filename || '').trim(), - kind: String(item?.preview_kind || '').trim(), - url: String(item?.preview_url || item?.preview_data_url || '').trim() - })) - .filter((item) => item.filename && item.kind === 'image' && item.url) -} - -function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return documents - .map((item) => ({ - filename: String(item?.filename || '').trim(), - kind: String(item?.preview_kind || '').trim(), - url: String(item?.preview_url || item?.preview_data_url || '').trim() - })) - .filter((item) => item.filename && item.kind === 'image' && item.url) -} - -function buildReviewFilePreviewsFromMessages(messages) { - const previews = [] - for (const message of Array.isArray(messages) ? messages : []) { - previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) - } - return mergeFilePreviews([], previews) -} - -function resolveAttachmentPreviewKind(metadata) { - const explicitKind = String(metadata?.preview_kind || '').trim() - if (explicitKind) { - return explicitKind - } - - const mediaType = String(metadata?.media_type || '').trim().toLowerCase() - if (mediaType.startsWith('image/')) { - return 'image' - } - if (mediaType === 'application/pdf') { - return 'pdf' - } - return '' -} - -function extractReviewAttachmentNames(reviewPayload) { - const documentNames = Array.isArray(reviewPayload?.document_cards) - ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) - : [] - if (documentNames.length) { - return documentNames - } - - const slotMap = buildReviewSlotMap(reviewPayload) - const attachmentValue = String(slotMap.attachments?.value || '').trim() - if (!attachmentValue) { - return [] - } - - return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) -} - -function cloneReviewDocumentDrafts(items) { - return (Array.isArray(items) ? items : []).map((item) => ({ - ...item, - warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], - fields: Array.isArray(item?.fields) - ? item.fields.map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || ''), - source: String(field?.source || 'ocr').trim() || 'ocr' - })) - : [] - })) -} - -function buildReviewDocumentDrafts(reviewPayload) { - return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ - index: Number(item.index || 0), - filename: String(item.filename || '').trim(), - document_type: String(item.document_type || 'other').trim() || 'other', - suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', - scene_label: String(item.scene_label || '').trim(), - summary: String(item.summary || '').trim(), - confidenceLabel: String(item.confidenceLabel || '').trim(), - documentTypeLabel: String(item.documentTypeLabel || '').trim(), - expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), - preview_kind: String(item.preview_kind || '').trim(), - preview_data_url: String(item.preview_data_url || '').trim(), - warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], - fields: Array.isArray(item.fields) - ? item.fields.map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || ''), - source: String(field?.source || 'ocr').trim() || 'ocr' - })) - : [] - })) -} - -function normalizeReviewDocumentComparableValue(item) { - return { - index: Number(item?.index || 0), - filename: String(item?.filename || '').trim(), - scene_label: String(item?.scene_label || '').trim(), - summary: String(item?.summary || '').trim(), - fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || '').trim() - })) - } -} - -function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { - const baseMap = new Map( - cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) - ) - - return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { - const key = `${item.index}:${item.filename}` - const base = baseMap.get(key) - const changes = [] - const nextSceneLabel = String(item.scene_label || '').trim() - const baseSceneLabel = String(base?.scene_label || '').trim() - const nextSummary = String(item.summary || '').trim() - const baseSummary = String(base?.summary || '').trim() - - if (nextSceneLabel !== baseSceneLabel) { - changes.push(`票据场景:${nextSceneLabel || '待补充'}`) - } - - if (nextSummary !== baseSummary) { - changes.push(`识别摘要:${nextSummary || '待补充'}`) - } - - const baseFieldMap = new Map( - (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ - String(field?.label || '').trim(), - String(field?.value || '').trim() - ]) - ) - - for (const field of Array.isArray(item.fields) ? item.fields : []) { - const label = String(field?.label || '').trim() - if (!label) continue - const nextValue = String(field?.value || '').trim() - const baseValue = baseFieldMap.get(label) || '' - if (nextValue !== baseValue) { - changes.push(`${label}:${nextValue || '待补充'}`) - } - } - - if (changes.length) { - lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) - } - - return lines - }, []) -} - -function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { - const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) - if (!lines.length) { - return '' - } - - return `请同步修正逐票据识别结果:\n${lines.join('\n')}` -} - -function buildReviewDocumentCorrectionContext(drafts) { - return cloneReviewDocumentDrafts(drafts).map((item) => ({ - index: item.index, - filename: item.filename, - scene_label: String(item.scene_label || '').trim(), - summary: String(item.summary || '').trim(), - fields: item.fields.map((field) => ({ - label: String(field.label || '').trim(), - value: String(field.value || '').trim() - })) - })) -} - -function buildWelcomeUserContext(user = {}) { - const username = String(user.username || '').trim() - const name = String(user.name || username || '同事').trim() - const grade = String(user.grade || '').trim() - const position = String(user.position || '').trim() - const role = String(user.role || '').trim() - const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] - const isAdmin = - Boolean(user.isAdmin) - || username.toLowerCase() === 'admin' - || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) - || /管理员|系统管理/.test(position) - || /管理员|系统管理/.test(role) - - const now = new Date() - const dateLine = now.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'long' - }) - - let honorific = name - if (isAdmin) { - honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' - } else { - const prefix = [grade, position].filter(Boolean).join(' ') - honorific = prefix ? `${prefix} ${name}`.trim() : name - } - - return { - name, - username, - grade, - position, - role, - isAdmin, - honorific, - dateLine - } -} - -function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ - label: question.length > 20 ? `${question.slice(0, 20)}…` : question, - prompt: question, - icon: 'mdi mdi-comment-question-outline' - })) - } - - if (entrySource === 'detail' && linkedRequest?.id) { - return [ - { - label: '补充当前单据票据', - prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`, - icon: 'mdi mdi-file-plus-outline' - }, - { - label: '解释本单风险', - prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`, - icon: 'mdi mdi-shield-alert-outline' - }, - ...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4) - ] - } - - return EXPENSE_WELCOME_QUICK_ACTIONS -} - -function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - const ctx = buildWelcomeUserContext(user || {}) - const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` - - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - '欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。', - '', - '您可以直接输入问题,或点击下方「猜你想问」快速开始。' - ].join('\n') - } - - if (entrySource === 'detail' && linkedRequest?.id) { - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, - '', - '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' - ].join('\n') - } - - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - '**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销草稿整理、待补项提醒和风险说明。', - '', - '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' - ].join('\n') -} - -function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - const ctx = buildWelcomeUserContext(user || {}) - - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return { - intent: 'welcome', - metricLabel: '今日', - metricValue: ctx.dateLine.split(' ')[0] || '—', - title: '财务知识问答', - summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, - agent: null - } - } - - return { - intent: 'welcome', - metricLabel: '助手状态', - metricValue: '待您吩咐', - title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心', - summary: - entrySource === 'detail' && linkedRequest?.id - ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` - : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, - agent: null - } -} - -function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { - assistantName: ASSISTANT_DISPLAY_NAME, - isWelcome: true, - welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) - }) -} - -function resolveInitialSessionType(conversation) { - const stateJson = conversation?.state_json || conversation?.stateJson || {} - const sessionType = String(stateJson?.session_type || '').trim() - return sessionType || SESSION_TYPE_EXPENSE -} - -function buildInitialInsightFromConversation(conversation) { - const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] - for (let index = rawMessages.length - 1; index >= 0; index -= 1) { - const item = rawMessages[index] - const messageJson = item?.message_json || item?.messageJson || {} - const orchestratorPayload = messageJson?.orchestrator_payload || null - if (!orchestratorPayload) continue - const attachmentNames = Array.isArray(messageJson?.attachment_names) - ? messageJson.attachment_names.filter(Boolean) - : [] - return buildAgentInsight( - orchestratorPayload, - attachmentNames, - buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) - ) - } - return null -} - -function resolveInitialConversationId(conversation) { - return String(conversation?.conversation_id || conversation?.conversationId || '').trim() -} - -function resolveInitialDraftClaimId(conversation) { - return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() -} - -function resolveKnowledgeRankLabel(index) { - return String(index + 1) -} - -function resolveKnowledgeRankTone(index) { - if (index === 0) return 'gold' - if (index === 1) return 'silver' - if (index === 2) return 'bronze' - return 'default' -} - -function parseConversationMessageSequence(message) { - const messageJson = message?.message_json || message?.messageJson || {} - const sequence = Number.parseInt(messageJson?.sequence, 10) - return Number.isFinite(sequence) && sequence > 0 ? sequence : null -} - -function parseConversationMessageTime(message) { - const rawValue = message?.created_at || message?.createdAt || '' - const timestamp = new Date(rawValue).getTime() - return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER -} - -function resolveConversationMessageRolePriority(message) { - return String(message?.role || '').trim() === 'user' ? 0 : 1 -} - -function sortConversationMessages(messages) { - return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { - const leftSequence = parseConversationMessageSequence(left) - const rightSequence = parseConversationMessageSequence(right) - if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { - return leftSequence - rightSequence - } - - const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) - if (timeDiff !== 0) { - return timeDiff - } - - const leftRunId = String(left?.run_id || left?.runId || '').trim() - const rightRunId = String(right?.run_id || right?.runId || '').trim() - if (leftRunId && rightRunId && leftRunId === rightRunId) { - const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) - if (roleDiff !== 0) { - return roleDiff - } - } - - return String(left?.id || '').localeCompare(String(right?.id || '')) - }) -} - -function normalizeInitialConversationMessages(conversation) { - const rawMessages = sortConversationMessages(conversation?.messages) - - return rawMessages.map((item) => { - const messageJson = item?.message_json || item?.messageJson || {} - const attachmentNames = Array.isArray(messageJson?.attachment_names) - ? messageJson.attachment_names.filter(Boolean) - : [] - const orchestratorPayload = messageJson?.orchestrator_payload || null - const result = orchestratorPayload?.result || {} - - return createMessage(item.role, item.content, attachmentNames, { - id: `restored-${item.id || ++messageSeed}`, - time: formatMessageTime(item.created_at || item.createdAt), - meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], - citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], - suggestedActions: - item.role === 'assistant' && Array.isArray(result?.suggested_actions) - ? result.suggested_actions - : [], - queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, - draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, - reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, - riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] - }) - }) -} - -function cloneReviewEditFields(fields) { - const items = Array.isArray(fields) ? fields : [] - return items.map((item) => ({ - key: String(item?.key || '').trim(), - label: String(item?.label || '').trim(), - value: String(item?.value || ''), - placeholder: String(item?.placeholder || ''), - required: Boolean(item?.required), - field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', - group: String(item?.group || 'basic').trim() || 'basic' - })) -} - -function buildReviewFormValues(fields) { - return cloneReviewEditFields(fields).reduce((result, item) => { - if (!item.key) { - return result - } - result[item.key] = String(item.value || '').trim() - return result - }, {}) -} - -function buildReviewCorrectionMessage(fields) { - const lines = ['请按以下核对后的报销信息更新当前识别结果:'] - for (const item of cloneReviewEditFields(fields)) { - if (!item.label || (!item.value && !item.required)) { - continue - } - lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`) - } - return lines.join('\n') -} - -function buildReviewEditFieldMap(fields) { - return cloneReviewEditFields(fields).reduce((result, item) => { - if (!item.key) return result - result[item.key] = item - return result - }, {}) -} - -function createEmptyInlineReviewState() { - return { - occurred_date: '', - amount: '', - scene_label: '', - reason_value: '', - customer_name: '', - location: '', - merchant_name: '', - participants: '', - attachment_names: '', - attachment_count: 0, - pending_attachment_count: 0, - expense_type: '' - } -} - -function buildClientTimeContext() { - const now = new Date() - const locale = - typeof navigator !== 'undefined' && typeof navigator.language === 'string' - ? navigator.language - : 'zh-CN' - - return { - client_now_iso: now.toISOString(), - client_timezone_offset_minutes: now.getTimezoneOffset(), - client_locale: locale - } -} - -function formatDraftApplyTime(date = new Date()) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day} ${hours}:${minutes}` -} - -function formatDateInputValue(date = new Date()) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function buildDraftSavedPayload({ - draftPayload, - reviewPayload, - inlineState, - linkedRequest, - currentUser -}) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - const riskItems = buildReviewRiskItems(reviewPayload) - const missingItems = resolveReviewMissingSlotCards(reviewPayload) - const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) - const amountNumber = parseAmountNumber(inlineState?.amount) - const location = String(inlineState?.location || linkedRequest?.city || '').trim() - const customerName = String(inlineState?.customer_name || '').trim() - const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() - const title = - String(inlineState?.reason_value || '').trim() - || String(inlineState?.scene_label || '').trim() - || String(draftPayload?.title || '').trim() - || `${typeLabel}报销草稿` - const sceneLabel = - String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel - const attachmentSummary = documents.length - ? `${documents.length} 条识别票据 / ${documents.length} 份材料` - : String(inlineState?.attachment_names || '').trim() - ? '1 条识别票据 / 1 份材料' - : '待上传票据' - - return { - claimId: String(draftPayload?.claim_id || '').trim(), - claimNo: String(draftPayload?.claim_no || '').trim(), - status: String(draftPayload?.status || '').trim(), - approvalStage: String(draftPayload?.approval_stage || '').trim(), - person: String(currentUser?.name || '').trim() || '当前用户', - dept: String(currentUser?.role || '').trim() || '待补充部门', - entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', - typeCode, - typeLabel, - detailVariant: typeCode === 'travel' ? 'travel' : 'general', - title, - sceneLabel, - sceneTarget: location || customerName || '待补充', - location, - relatedCustomer: customerName, - occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', - applyTime: formatDraftApplyTime(), - amount: amountNumber === null ? 0 : amountNumber, - secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', - secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', - secondaryStatusTone: documents.length ? 'warning' : 'neutral', - riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), - attachmentSummary, - expenseTableSummary: documents.length - ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` - : '当前尚未上传票据,请在报销页继续补充附件', - note: String(draftPayload?.status || '').trim() === 'submitted' - ? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。' - : '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' - } -} - -function resolveReviewRecognizedSlotCards(reviewPayload) { - return Array.isArray(reviewPayload?.slot_cards) - ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') - : [] -} - -function resolveReviewMissingSlotCards(reviewPayload) { - return Array.isArray(reviewPayload?.slot_cards) - ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') - : [] -} - -function resolveReviewRiskBriefs(reviewPayload) { - return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : [] -} - -function formatConfidenceLabel(value) { - const score = Number(value || 0) - if (!score) return '待补充' - return `${Math.round(score * 100)}%` -} - -function resolveDocumentTypeLabel(type) { - return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other -} - -function resolveExpenseTypeLabel(type, fallbackLabel = '') { - const normalized = String(type || '').trim() - return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other -} - -function buildReviewRecognizedLines(reviewPayload) { - return resolveReviewRecognizedSlotCards(reviewPayload) - .filter((item) => String(item?.value || '').trim()) - .map((item) => `${item.label}:${item.value}`) -} - -function buildReviewSlotMap(reviewPayload) { - return Object.fromEntries( - (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) - ) -} - -function resolveExpenseTypeCode(value) { - const normalized = String(value || '').trim() - if (!normalized) return 'other' - if (EXPENSE_TYPE_LABELS[normalized]) return normalized - const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) - return matched?.[0] || 'other' -} - -function isValidIsoDateString(value) { - const normalized = String(value || '').trim() - if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { - return false - } - - const [yearText, monthText, dayText] = normalized.split('-') - const year = Number(yearText) - const month = Number(monthText) - const day = Number(dayText) - - if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { - return false - } - - const candidate = new Date(Date.UTC(year, month - 1, day)) - return ( - candidate.getUTCFullYear() === year && - candidate.getUTCMonth() === month - 1 && - candidate.getUTCDate() === day - ) -} - -function parseAmountNumber(value) { - const normalized = String(value || '') - .replace(/[,,\s]/g, '') - .replace(/[¥¥]/g, '') - .replace(/元/g, '') - .trim() - - if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { - return null - } - - const amount = Number(normalized) - return Number.isFinite(amount) ? amount : null -} - -function normalizeAmountValue(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return '' - } - return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` -} - -function extractAmountInputValue(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return String(value || '').trim() - } - return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') -} - -function formatAmountDisplay(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return String(value || '').trim() - } - - return new Intl.NumberFormat('zh-CN', { - style: 'currency', - currency: 'CNY', - minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, - maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 - }).format(amount) -} - -function normalizeExpenseQueryStatusGroup(item) { - if (!item || typeof item !== 'object') { - return null - } - - const rawCount = Number(item.count || 0) - return { - key: String(item.key || 'other').trim() || 'other', - label: String(item.label || '其他状态').trim() || '其他状态', - count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 - } -} - -function normalizeExpenseQueryRecord(item) { - if (!item || typeof item !== 'object') { - return null - } - - const amount = Number(item.amount || 0) - const amountValue = Number.isFinite(amount) ? amount : 0 - const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' - const reason = String(item.reason || '').trim() - const documentDate = String(item.document_date || '').trim() - const occurredAt = String(item.occurred_at || '').trim() - - return { - claimId: String(item.claim_id || '').trim(), - claimNo: String(item.claim_no || '').trim() || '未编号', - employeeName: String(item.employee_name || '').trim(), - expenseType: String(item.expense_type || '').trim(), - expenseTypeLabel, - amount: amountValue, - amountDisplay: formatAmountDisplay(amountValue), - status: String(item.status || '').trim(), - statusLabel: String(item.status_label || '处理中').trim() || '处理中', - statusGroup: String(item.status_group || 'other').trim() || 'other', - statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', - approvalStage: String(item.approval_stage || '').trim(), - documentDate, - occurredAt, - reason, - location: String(item.location || '').trim(), - summary: reason || `${expenseTypeLabel}报销`, - dateDisplay: documentDate || occurredAt || '待补充日期' - } -} - -function normalizeExpenseQueryPayload(payload) { - if (!payload || typeof payload !== 'object') { - return null - } - - const resultType = String(payload.result_type || '').trim() - if (resultType && resultType !== 'expense_claim_list') { - return null - } - - const records = (Array.isArray(payload.records) ? payload.records : []) - .map(normalizeExpenseQueryRecord) - .filter(Boolean) - const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) - .map(normalizeExpenseQueryStatusGroup) - .filter(Boolean) - - const rawRecordCount = Number(payload.record_count || 0) - const rawPreviewCount = Number(payload.preview_count || records.length) - const rawOlderRecordCount = Number(payload.older_record_count || 0) - const totalAmount = Number(payload.total_amount || 0) - const rawWindowDays = Number(payload.window_days || 0) - const windowStartDate = String(payload.window_start_date || '').trim() - const windowEndDate = String(payload.window_end_date || '').trim() - - return { - resultType: 'expense_claim_list', - scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', - recentWindowApplied: Boolean(payload.recent_window_applied), - windowDays: - payload.window_days === null || payload.window_days === undefined || payload.window_days === '' - ? null - : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), - windowStartDate: windowStartDate || '', - windowEndDate: windowEndDate || '', - recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, - previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, - olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, - hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), - totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, - statusGroups, - records, - currentPage: 1 - } -} - -function buildExpenseQueryWindowLabel(queryPayload) { - if (!queryPayload) { - return '' - } - - if (queryPayload.windowStartDate && queryPayload.windowEndDate) { - return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` - } - - if (queryPayload.recentWindowApplied && queryPayload.windowDays) { - return `近 ${queryPayload.windowDays} 日内` - } - - return '当前条件下' -} - -function getExpenseQueryTotalPages(queryPayload) { - const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 - return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) -} - -function getExpenseQueryActivePage(queryPayload) { - const totalPages = getExpenseQueryTotalPages(queryPayload) - const rawPage = Number(queryPayload?.currentPage || 1) - if (!Number.isFinite(rawPage)) { - return 1 - } - return Math.min(Math.max(1, Math.round(rawPage)), totalPages) -} - -function getExpenseQueryVisibleRecords(queryPayload) { - const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] - const activePage = getExpenseQueryActivePage(queryPayload) - const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE - return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) -} - -function buildExpenseQueryHint(queryPayload) { - if (!queryPayload) { - return '' - } - - const parts = [] - const windowText = buildExpenseQueryWindowLabel(queryPayload) - - if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { - parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) - } - - if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { - parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) - } - - if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { - parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) - } - - return parts.join('。') -} - -function countReviewPendingItems(reviewPayload) { - return resolveReviewMissingSlotCards(reviewPayload).length -} - -function countReviewRiskItems(reviewPayload) { - return resolveReviewRiskBriefs(reviewPayload).length -} - -function buildReviewHeadline(reviewPayload) { - if (countReviewPendingItems(reviewPayload)) { - return '待补充信息' - } - if (reviewPayload?.can_proceed) { - return '识别结果已整理完成' - } - return '识别结果摘要' -} - -function buildReviewSubline(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - - if (pendingCount) { - return `请先展开查看 ${pendingCount} 项待补充内容,再决定继续处理、修改信息或保存草稿。` - } - if (reviewPayload?.can_proceed) { - return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。' - } - return '已为您整理本轮识别结果,展开后可查看当前识别摘要。' -} - -function buildReviewStateLabel(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) return `待补充 ${pendingCount} 项` - if (reviewPayload?.can_proceed) return '可继续处理' - return '已识别' -} - -function buildReviewStateTone(reviewPayload) { - return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) - ? 'ready' - : 'pending' -} - -function buildReviewDisclosureTitle(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) { - return `当前有 ${pendingCount} 项待补充,点击展开查看` - } - return '当前信息已齐全,可展开查看识别摘要' -} - -function buildReviewDisclosureHint(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) { - return '展开后可查看待补充字段和处理建议' - } - return '展开后可查看本轮已识别的关键信息' -} - -function shouldOpenReviewDisclosure(reviewPayload) { - return !countReviewPendingItems(reviewPayload) -} - -function buildReviewTodoSectionTitle(reviewPayload) { - return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息' -} - -function buildReviewTodoSectionMeta(reviewPayload) { - const count = buildReviewTodoItems(reviewPayload).length - if (resolveReviewMissingSlotCards(reviewPayload).length) { - return count ? `${count} 项` : '待确认' - } - return count ? `${count} 项` : '已齐全' -} - -function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { - if (slotKey === 'customer_name') { - return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' - } - if (slotKey === 'participants') return '缺少同行人员' - if (slotKey === 'attachments') return '票据状态待补充' - if (slotKey === 'amount') return '金额待确认' - if (slotKey === 'time_range') return '发生时间待确认' - if (slotKey === 'reason') return '场景 / 事由待补充' - if (slotKey === 'expense_type') return '报销类型待确认' - if (slotKey === 'location') return '业务地点待补充' - if (slotKey === 'merchant_name') return '酒店/商户待补充' - return '仍有信息待补充' -} - -function buildReviewAlertChips(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() - const chips = [] - - for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { - chips.push({ - key: item.key, - label: buildReviewAlertLabel(item.key, expenseTypeLabel), - tone: item.key === 'attachments' ? 'danger' : 'warning' - }) - } - - if (chips.length < 3) { - for (const risk of resolveReviewRiskBriefs(reviewPayload)) { - if (chips.some((item) => item.label === risk.title)) continue - chips.push({ - key: risk.title, - label: risk.title, - tone: risk.level === 'high' ? 'danger' : 'warning' - }) - if (chips.length >= 3) break - } - } - - if (!chips.length && reviewPayload?.can_proceed) { - chips.push({ - key: 'ready', - label: '当前识别信息已可继续处理', - tone: 'success' - }) - } - - return chips -} - -function buildReviewTodoItems(reviewPayload) { - const missingItems = resolveReviewMissingSlotCards(reviewPayload) - if (missingItems.length) { - return missingItems.map((item) => { - const config = REVIEW_SLOT_CONFIG[item.key] || {} - return { - key: item.key, - icon: config.icon || 'mdi mdi-form-select', - title: config.title || item.label, - hint: item.hint || config.hint || `请补充${item.label}`, - status: config.status || '待补充', - tone: item.key === 'attachments' ? 'danger' : 'warning' - } - }) - } - - return resolveReviewRecognizedSlotCards(reviewPayload) - .filter((item) => String(item?.value || '').trim()) - .slice(0, 3) - .map((item) => { - const config = REVIEW_SLOT_CONFIG[item.key] || {} - return { - key: item.key, - icon: config.icon || 'mdi mdi-check-circle-outline', - title: config.title || item.label, - hint: `已识别:${item.value}`, - status: '已识别', - tone: 'ready' - } - }) -} - -function resolveReviewPrimaryAction(reviewPayload) { - return ( - (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( - (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) - ) || null - ) -} - -function resolveReviewSubmitActions(reviewPayload) { - return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => { - const actionType = String(item?.action_type || '').trim() - return actionType && !['cancel_review', 'edit_review'].includes(actionType) - }) -} - -function resolveReviewEditAction(reviewPayload) { - return ( - (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( - (item) => String(item?.action_type || '') === 'edit_review' - ) || null - ) -} - -function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { - const action = resolveReviewPrimaryAction(reviewPayload) - if (!action) return '确认' - if (action.action_type === 'save_draft') { - return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' - } - if (action.action_type === 'next_step') { - return '继续下一步' - } - if (action.action_type === 'link_to_existing_draft') { - return action.label || '关联到现有草稿' - } - if (action.action_type === 'create_new_claim_from_documents') { - return action.label || '单独建立报销单' - } - return action.label || '确认' -} - -function buildReviewIntentText(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseType = String(slotMap.expense_type?.value || '').trim() - if (expenseType) { - return `报销一笔${expenseType}` - } - return '发起一笔报销' -} - -function buildReviewSceneValue(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim() - const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim() - return inferPresetSceneFromReview(reviewPayload, reason, expenseType) -} - -function matchPresetSceneFromReason(reason) { - const compactReason = String(reason || '').trim().replace(/\s+/g, '') - if (!compactReason) { - return '' - } - if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) { - return '请客户吃饭' - } - if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) { - const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, ''))) - if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) { - return matchedPreset - } - } - if (/出差|差旅/.test(compactReason)) { - return '出差行程' - } - if (/酒店|住宿/.test(compactReason)) { - return '住宿报销' - } - if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) { - return '交通出行' - } - if (/会务|会议|参会|论坛|展会/.test(compactReason)) { - return '会务活动' - } - return '' -} - -function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') { - const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)] - if (fromCode) { - return fromCode - } - - const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '') - if (!compactLabel) { - return '' - } - if (/差旅|出差/.test(compactLabel)) { - return '出差行程' - } - if (/住宿|酒店/.test(compactLabel)) { - return '住宿报销' - } - if (/交通/.test(compactLabel)) { - return '交通出行' - } - if (/招待|餐饮|餐费|伙食/.test(compactLabel)) { - return '请客户吃饭' - } - if (/会务|会议/.test(compactLabel)) { - return '会务活动' - } - return '' -} - -function mapExpenseTypeLabelToPresetScene(expenseType) { - const code = resolveExpenseTypeCode(expenseType) - if (EXPENSE_CODE_TO_PRESET_SCENE[code]) { - return EXPENSE_CODE_TO_PRESET_SCENE[code] - } - - const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '') - if (!compactLabel) { - return '' - } - if (compactLabel.includes('差旅') || compactLabel.includes('出差')) { - return '出差行程' - } - if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) { - return '住宿报销' - } - if (compactLabel.includes('交通')) { - return '交通出行' - } - if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) { - return '请客户吃饭' - } - if (compactLabel.includes('会务') || compactLabel.includes('会议')) { - return '会务活动' - } - return matchPresetSceneFromReason(expenseType) -} - -function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (documents.length) { - const votes = new Map() - for (const document of documents) { - const preset = - mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type) - || mapExpenseTypeLabelToPresetScene(document.suggested_expense_type) - if (!preset) { - continue - } - votes.set(preset, (votes.get(preset) || 0) + 1) - } - if (votes.size) { - return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0] - } - } - - const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : [] - if (claimGroups.length === 1) { - const group = claimGroups[0] - const preset = - mapExpenseTypeLabelToPresetScene(group.expense_type) - || mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type) - if (preset) { - return preset - } - } - - const fromReason = matchPresetSceneFromReason(reasonValue) - if (fromReason) { - return fromReason - } - - const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType) - if (fromExpenseType) { - return fromExpenseType - } - - if (String(reasonValue || '').trim()) { - return REVIEW_SCENE_OTHER_OPTION - } - return '待补充' -} - -function formatReviewSceneDisplayValue(inlineState) { - const scene = String(inlineState?.scene_label || '').trim() - if (!scene || scene === '待补充') { - return '待补充' - } - if (scene === REVIEW_SCENE_OTHER_OPTION) { - const detail = String(inlineState?.reason_value || '').trim() - if (!detail) { - return REVIEW_SCENE_OTHER_OPTION - } - return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}` - } - return scene -} - -function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) { - return inferPresetSceneFromReview(reviewPayload, reason, expenseType) -} - -function buildInlineReviewState(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) - const attachmentNames = String( - editFieldMap.attachment_names?.value || - slotMap.attachments?.value || - (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') - ).trim() - const attachmentCount = Array.isArray(reviewPayload?.document_cards) - ? reviewPayload.document_cards.length - : attachmentNames - ? attachmentNames.split('、').filter(Boolean).length - : 0 - const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() - const reasonValue = String( - editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' - ).trim() - const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) - - return { - occurred_date: String( - editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' - ).trim(), - amount: normalizeAmountValue( - String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() - ), - scene_label: sceneLabel, - reason_value: - sceneLabel === REVIEW_SCENE_OTHER_OPTION - ? reasonValue - : String(slotMap.reason?.raw_value || '').trim() || reasonValue, - customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), - location: String( - editFieldMap.business_location?.value || - editFieldMap.location?.value || - slotMap.location?.normalized_value || - slotMap.location?.value || - '' - ).trim(), - merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), - participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), - attachment_names: attachmentNames, - attachment_count: attachmentCount, - pending_attachment_count: 0, - expense_type: expenseType - } -} - -function buildReviewAttachmentStatus(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (!documents.length) return '未上传' - return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` -} - -function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { - const slotMap = buildReviewSlotMap(reviewPayload) - const slot = slotMap[slotKey] - return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' -} - -function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { - const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0)) - const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0)) - const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount) - const attachmentStatus = - pendingAttachmentCount > 0 - ? existingAttachmentCount > 0 - ? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份` - : `待保存 ${pendingAttachmentCount} 份` - : totalAttachmentCount > 0 - ? `已上传 ${totalAttachmentCount} 份` - : buildReviewAttachmentStatus(reviewPayload) - const cards = [ - { - key: 'occurred_date', - label: '发生时间', - value: String(inlineState.occurred_date || '').trim() || '待补充', - icon: 'mdi mdi-calendar-month-outline', - editor: 'date', - modelKey: 'occurred_date', - placeholder: `例如 ${DATE_INPUT_FORMAT}` - }, - { - key: 'amount', - label: '金额', - value: formatAmountDisplay(inlineState.amount) || '待补充', - icon: 'mdi mdi-cash', - editor: 'amount', - modelKey: 'amount', - placeholder: '例如 200.00' - }, - { - key: 'scene', - label: '场景 / 事由', - value: formatReviewSceneDisplayValue(inlineState), - icon: 'mdi mdi-silverware-fork-knife', - editor: 'select', - modelKey: 'scene_label', - placeholder: '请选择场景' - }, - { - key: 'customer_name', - label: '关联客户', - value: String(inlineState.customer_name || '').trim() || '待补充', - icon: 'mdi mdi-domain', - editor: 'text', - modelKey: 'customer_name', - placeholder: '请输入客户名称' - }, - { - key: 'attachments', - label: '票据状态', - value: attachmentStatus, - icon: 'mdi mdi-file-document-outline', - editor: 'upload', - modelKey: 'attachment_names', - placeholder: '' - } - ] - - if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { - cards.splice(4, 0, { - key: 'location', - label: '业务地点', - value: String(inlineState.location || '').trim() || '待补充', - icon: 'mdi mdi-map-marker-outline', - editor: 'text', - modelKey: 'location', - placeholder: '请输入业务地点' - }) - } - - if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) { - cards.splice(cards.length - 1, 0, { - key: 'merchant_name', - label: '酒店/商户', - value: String(inlineState.merchant_name || '').trim() || '待补充', - icon: 'mdi mdi-storefront-outline', - editor: 'text', - modelKey: 'merchant_name', - placeholder: '请输入酒店或商户名称' - }) - } - - if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) { - cards.splice(cards.length - 1, 0, { - key: 'participants', - label: '同行人员', - value: String(inlineState.participants || '').trim() || '待补充', - icon: 'mdi mdi-account-group-outline', - editor: 'text', - modelKey: 'participants', - placeholder: '例如 客户 2 人,我方 1 人' - }) - } - - return cards -} - -function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { - const slotMap = buildReviewSlotMap(reviewPayload) - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - - return [ - String(inlineState.reason_value || '').trim(), - String(inlineState.scene_label || '').trim(), - String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), - ...documents.map((item) => - [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] - .filter(Boolean) - .join(' ') - ) - ] - .filter(Boolean) - .join(' ') - .toLowerCase() -} - -function resolveReviewCategoryTextScore(text, categoryCode) { - const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] - if (!patterns?.length || !text) { - return 0 - } - return patterns.some((pattern) => pattern.test(text)) - ? { - travel: 0.84, - hotel: 0.82, - transport: 0.8, - meal: 0.76, - meeting: 0.78, - entertainment: 0.88, - office: 0.74, - training: 0.77, - communication: 0.7, - welfare: 0.72 - }[categoryCode] || 0 - : 0 -} - -function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - const matchedScores = documents - .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) - .map((item) => Number(item?.avg_score || 0)) - .filter((score) => Number.isFinite(score) && score > 0) - - if (!matchedScores.length) { - return 0 - } - - return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length -} - -function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { - const normalizedLabel = String(selectedLabel || '').trim() - if (!normalizedLabel) { - return 0 - } - - const selectedCode = resolveExpenseTypeCode(normalizedLabel) - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseSlot = slotMap.expense_type - const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') - let score = 0 - - if (recognizedCode === selectedCode) { - score = Math.max(score, Number(expenseSlot?.confidence || 0)) - } - - score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) - score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) - - if (!score && normalizedLabel) { - score = selectedCode === 'other' ? 0.52 : 0.58 - } - - return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) -} - -function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { - const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) - return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ - ...item, - active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, - confidenceLabel: item.is_other - ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) - : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), - caption: item.is_other - ? selectedLabel && !presetLabels.includes(selectedLabel) - ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` - : '点击选择更多类型' - : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, - groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' - })) -} - -function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { - return formatConfidenceLabel( - resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) - ) -} - -function buildReviewRiskScore(reviewPayload) { - const score = Number(reviewPayload?.risk_score) - if (!Number.isFinite(score) || score <= 0) { - return null - } - return Math.max(0, Math.min(100, Math.round(score))) -} - -function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { - if (slotKey === 'customer_name') { - return expenseTypeLabel === '业务招待费' - ? '业务招待费需补充客户单位名称,以便进行合规校验。' - : '当前仍缺少客户单位名称,建议补充后再提交。' - } - if (slotKey === 'participants') { - return '缺少同行人员信息,建议补充至少 1 名。' - } - if (slotKey === 'attachments') { - return '尚未上传票据附件,当前无法完成票据核对。' - } - if (slotKey === 'amount') { - return '报销金额仍待确认,提交前需补齐金额信息。' - } - if (slotKey === 'time_range') { - return '业务发生时间仍待确认,建议补充准确日期。' - } - if (slotKey === 'reason') { - return '报销事由说明仍不完整,建议补充业务背景。' - } - return '当前仍有识别信息待补充,建议先核对后再处理。' -} - -function buildReviewRiskSummary(reviewPayload) { - if (resolveReviewRiskBriefs(reviewPayload).length) { - return '当前识别到了合规提醒,提交前建议逐项核对。' - } - return '当前版本暂未生成风险评分结果。' -} - -function buildReviewRiskItems(reviewPayload) { - return resolveReviewRiskBriefs(reviewPayload) - .map((brief) => String(brief?.content || '').trim()) - .filter(Boolean) - .slice(0, 4) -} - -function normalizeInlineReviewComparableState(state) { - const source = state && typeof state === 'object' ? state : {} - return { - occurred_date: String(source.occurred_date || '').trim(), - amount: String(source.amount || '').trim(), - scene_label: String(source.scene_label || '').trim(), - reason_value: String(source.reason_value || '').trim(), - customer_name: String(source.customer_name || '').trim(), - location: String(source.location || '').trim(), - merchant_name: String(source.merchant_name || '').trim(), - participants: String(source.participants || '').trim(), - attachment_names: String(source.attachment_names || '').trim(), - pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), - expense_type: String(source.expense_type || '').trim() - } -} - -function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { - const base = normalizeInlineReviewComparableState(baseState) - const next = normalizeInlineReviewComparableState(nextState) - const lines = [] - - if (base.occurred_date !== next.occurred_date) { - lines.push(`发生时间 ${next.occurred_date || '待补充'}`) - } - if (base.amount !== next.amount) { - lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) - } - if (base.scene_label !== next.scene_label) { - lines.push(`场景 ${next.scene_label || '待补充'}`) - } - if (base.customer_name !== next.customer_name) { - lines.push(`关联客户 ${next.customer_name || '待补充'}`) - } - if (base.location !== next.location) { - lines.push(`业务地点 ${next.location || '待补充'}`) - } - if (base.merchant_name !== next.merchant_name) { - lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) - } - if (base.participants !== next.participants) { - lines.push(`同行人员 ${next.participants || '待补充'}`) - } - if (base.expense_type !== next.expense_type) { - lines.push(`报销分类 ${next.expense_type || '待补充'}`) - } - if (base.attachment_names !== next.attachment_names || pendingFiles.length) { - lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) - } - - return lines -} - -function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { - const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) - if (!lines.length) { - return '我已修改识别信息,请按最新内容更新。' - } - return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` -} - -function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { - const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) - const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) - - if (!inlineLines.length && !documentLines.length) { - return '我已修改识别信息,请按最新内容更新。' - } - - const parts = [] - if (inlineLines.length) { - parts.push(inlineLines.join(',')) - } - if (documentLines.length) { - parts.push(`修正了 ${documentLines.length} 张票据识别信息`) - } - - return `我已修改识别信息:${parts.join(';')}。请按最新内容更新。` -} - -function mergeInlineReviewFields(baseFields, inlineState) { - const merged = cloneReviewEditFields(baseFields) - const updateMap = { - expense_type: inlineState.expense_type, - occurred_date: inlineState.occurred_date, - amount: inlineState.amount, - customer_name: inlineState.customer_name, - business_location: inlineState.location, - merchant_name: inlineState.merchant_name, - participants: inlineState.participants, - reason: inlineState.reason_value || inlineState.scene_label, - attachment_names: inlineState.attachment_names - } - - for (const item of merged) { - if (!(item.key in updateMap)) continue - item.value = String(updateMap[item.key] || '').trim() - } - - return merged -} - -function buildReviewRecognitionNotes(reviewPayload) { - const recognized = resolveReviewRecognizedSlotCards(reviewPayload) - const notes = [] - const timeSlot = recognized.find((item) => item.key === 'time_range') - const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] - - if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { - notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) - } - - if (sourceLabels.length) { - notes.push(`本轮主要依据:${sourceLabels.join('、')}`) - } - - const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (documentCards.length) { - notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) - } else { - notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') - } - - return notes -} - -function buildReviewDocumentSummaries(reviewPayload) { - const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return docs.map((item) => { - const fields = Array.isArray(item.fields) ? item.fields : [] - return { - ...item, - documentTypeLabel: resolveDocumentTypeLabel(item.document_type), - expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), - confidenceLabel: formatConfidenceLabel(item.avg_score), - lines: fields - .filter((field) => String(field?.value || '').trim()) - .map((field) => `${field.label}:${field.value}`) - } - }) -} - -function buildReviewDecisionHint(reviewPayload) { - const missingSlots = resolveReviewMissingSlotCards(reviewPayload) - const riskBriefs = resolveReviewRiskBriefs(reviewPayload) - if (reviewPayload?.can_proceed) { - return riskBriefs.length - ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` - : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' - } - if (missingSlots.length) { - return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` - } - return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' -} - -function buildReviewMissingHint(reviewPayload) { - const missingSlots = resolveReviewMissingSlotCards(reviewPayload) - if (!missingSlots.length) { - return '' - } - if (reviewPayload?.can_proceed) { - return '当前关键信息已经齐全,这里无需再补充。' - } - return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' -} - -function buildReviewRiskHint(reviewPayload) { - const riskBriefs = resolveReviewRiskBriefs(reviewPayload) - if (!riskBriefs.length) { - return '' - } - return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。' -} - -function buildReviewActionHint(reviewPayload) { - if (reviewPayload?.can_proceed) { - return '如果识别无误,直接点“下一步”;如果有偏差,先修改识别信息。' - } - return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' -} - -function buildReviewStatusTag(reviewPayload) { - const missingCount = resolveReviewMissingSlotCards(reviewPayload).length - if (reviewPayload?.can_proceed) { - return '可继续处理' - } - if (missingCount > 0) { - return `待补充 ${missingCount} 项` - } - return '待确认' -} - -function buildErrorInsight(error, fileNames = []) { - return { - intent: 'agent', - metricLabel: '运行状态', - metricValue: '失败', - title: '智能体调用失败', - summary: error?.message || '无法连接后端 Orchestrator。', - agent: { - runId: '未生成', - selectedAgent: 'orchestrator', - scenario: '未知', - intent: '未知', - permissionLevel: 'unknown', - routeReason: 'request_failed', - requiresConfirmation: false, - degraded: false, - fileNames, - citations: [], - suggestedActions: [], - queryPayload: null, - draftPayload: null, - reviewPayload: null, - riskFlags: [], - toolCount: 0, - failedToolCount: 0, - selectedCapabilityCodes: [], - filePreviews: [], - statusLabel: '失败', - statusTone: 'note' - } - } -} - -function buildAgentInsight(payload, fileNames = [], filePreviews = []) { - const trace = payload?.trace_summary || {} - const result = payload?.result || {} - const statusLabel = resolveStatusLabel(payload?.status) - - return { - intent: 'agent', - metricLabel: '运行状态', - metricValue: statusLabel, - title: - result?.draft_payload?.title || - `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, - summary: result?.answer || result?.message || '智能体已完成处理。', - agent: { - runId: payload?.run_id || '未生成', - selectedAgent: payload?.selected_agent || 'orchestrator', - scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', - intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', - permissionLevel: payload?.permission_level || 'unknown', - routeReason: payload?.route_reason || 'unknown', - requiresConfirmation: Boolean(payload?.requires_confirmation), - degraded: Boolean(trace?.degraded), - fileNames, - citations: Array.isArray(result?.citations) ? result.citations : [], - suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], - queryPayload: normalizeExpenseQueryPayload(result?.query_payload), - draftPayload: result?.draft_payload || null, - reviewPayload: result?.review_payload || null, - riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], - toolCount: Number(trace?.tool_count || 0), - failedToolCount: Number(trace?.failed_tool_count || 0), - selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) - ? trace.selected_capability_codes - : [], - filePreviews, - statusLabel, - statusTone: resolveStatusTone(payload?.status) - } - } -} - -export default { - name: 'TravelReimbursementCreateView', - components: { - ConfirmDialog - }, - props: { - initialPrompt: { - type: String, - default: '' - }, - initialFiles: { - type: Array, - default: () => [] - }, - initialConversation: { - type: Object, - default: null - }, - entrySource: { - type: String, - default: 'requests' - }, - requestContext: { - type: Object, - default: null - } - }, - emits: ['close', 'draft-saved'], - setup(props, { emit }) { - const router = useRouter() - const { currentUser } = useSystemState() - const { toast } = useToast() - - const fileInputRef = ref(null) - const composerTextareaRef = ref(null) - const fileInputMode = ref('composer') - const messageListRef = ref(null) - const composerDraft = ref('') - const composerDatePickerOpen = ref(false) - const composerDateMode = ref('single') - const composerSingleDate = ref(formatDateInputValue()) - const composerRangeStartDate = ref(formatDateInputValue()) - const composerRangeEndDate = ref(formatDateInputValue()) - const composerBusinessTimeTags = ref([]) - const attachedFiles = ref([]) - const composerFilesExpanded = ref(false) - const submitting = ref(false) - const workbenchVisible = ref(false) - const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) - const initialSessionType = resolveInitialSessionType(props.initialConversation) - const initialSessionState = props.initialConversation - ? buildConversationSessionState(props.initialConversation, initialSessionType) - : buildEmptySessionState(initialSessionType) - const activeSessionType = ref(initialSessionState.sessionType) - const messages = ref(initialSessionState.messages) - const conversationId = ref(initialSessionState.conversationId) - const draftClaimId = ref(initialSessionState.draftClaimId) - const previewRegistry = [] - const restoredDraftPreviewClaims = new Set() - const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) - const sessionSnapshots = ref({ - [SESSION_TYPE_EXPENSE]: null, - [SESSION_TYPE_KNOWLEDGE]: null - }) - - const currentInsight = ref(initialSessionState.currentInsight) - const reviewCancelDialogOpen = ref(false) - const reviewEditDialogOpen = ref(false) - const uploadDecisionDialogOpen = ref(false) - const deleteSessionDialogOpen = ref(false) - const reviewActionBusy = ref(false) - const deleteSessionBusy = ref(false) - const reviewEditFields = ref([]) - const reviewActionMessageId = ref('') - const reviewInlineForm = ref(createEmptyInlineReviewState()) - const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) - const reviewInlineBaseFields = ref([]) - const reviewInlinePendingFiles = ref([]) - const reviewInlineEditorKey = ref('') - const reviewInlineErrors = ref({}) - const reviewOtherCategoryOpen = ref(false) - const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim()) - const reviewDocumentDrafts = ref([]) - const reviewDocumentBaseDrafts = ref([]) - const activeReviewDocumentIndex = ref(0) - const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) - const insightPanelCollapsed = ref(false) - const documentPreviewDialog = ref({ - open: false, - filename: '', - kind: 'file', - url: '' - }) - const sessionSwitchBusy = ref(false) - const flowPanelOpen = ref(false) - const flowRunId = ref('') - const flowStartedAt = ref(0) - const flowFinishedAt = ref(0) - const flowSteps = ref(createFlowSteps()) - const flowRefreshBusy = ref(false) - const flowTick = ref(Date.now()) - let flowTickTimer = 0 - const flowSimulationTimers = [] - const canSubmit = computed( - () => - !submitting.value - && !sessionSwitchBusy.value - && Boolean( - composerDraft.value.trim() - || attachedFiles.value.length - || composerBusinessTimeTags.value.length - ) - ) - const composerCanApplyDateSelection = computed(() => { - if (composerDateMode.value === 'single') { - return Boolean(composerSingleDate.value) - } - return Boolean( - composerRangeStartDate.value - && composerRangeEndDate.value - && composerRangeStartDate.value <= composerRangeEndDate.value - ) - }) - const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) - const completedFlowStepCount = computed( - () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length - ) - const runningFlowStep = computed( - () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null - ) - const flowOverallStatusTone = computed(() => { - if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { - return 'failed' - } - if (runningFlowStep.value) { - return 'running' - } - if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { - return 'completed' - } - return 'pending' - }) - const flowOverallStatusText = computed(() => { - const total = flowSteps.value.length - const completed = completedFlowStepCount.value - if (flowOverallStatusTone.value === 'failed') { - return `异常 ${completed}/${total}` - } - if (flowOverallStatusTone.value === 'completed') { - return `已完成 ${total}/${total}` - } - if (flowOverallStatusTone.value === 'running') { - return `执行中 ${completed}/${total}` - } - return total ? `待执行 0/${total}` : '暂无流程' - }) - const hasInsightPanelContent = computed( - () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' - ) - const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value) - const insightPanelToggleLabel = computed(() => - showInsightPanel.value ? '隐藏详细信息' : '展开详细信息' - ) - const composerPlaceholder = computed(() => { - if (isKnowledgeSession.value) { - return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?' - } - if (props.entrySource === 'detail' && linkedRequest.value?.id) { - return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` - } - return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件生成报销草稿。' - }) - const currentIntentLabel = computed(() => { - if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { - return '热门问题' - } - const labels = isKnowledgeSession.value - ? { - welcome: '热门问题', - agent: '知识回答' - } - : { - welcome: '财务助手', - agent: '处理中' - } - return labels[currentInsight.value.intent] ?? 'AI 处理中' - }) - let knowledgeSessionResetPromise = Promise.resolve() - const canDeleteCurrentSession = computed( - () => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user') - ) - const latestReviewMessage = computed(() => - [...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null - ) - const activeReviewPayload = computed( - () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null - ) - const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) - const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) - const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) - const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) - const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) - const reviewCategoryOptions = computed(() => - buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) - ) - const reviewOtherCategoryOptions = computed(() => - REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ - ...item, - confidenceLabel: formatConfidenceLabel( - resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) - ) - })) - ) - const reviewSelectedOtherCategory = computed(() => { - const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) - return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type - }) - const reviewInlineDirty = computed( - () => - buildInlineReviewChangedLines( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value - ).length > 0 - ) - const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) - const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value)) - const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) - const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) - const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length) - const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) - const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) - const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0) - const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) - const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) - const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) - const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length) - const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) - const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) - const reviewDrawerTitle = computed(() => ( - isReviewDocumentDrawer.value - ? '票据识别结果' - : isReviewRiskDrawer.value - ? '风险提示' - : '报销识别核对' - )) - const reviewDocumentDrawerLabel = computed(() => ( - isReviewDocumentDrawer.value ? '显示核对' : '显示票据' - )) - const reviewDocumentDrawerIcon = computed(() => ( - isReviewDocumentDrawer.value - ? 'mdi mdi-file-document-multiple' - : 'mdi mdi-file-document-multiple-outline' - )) - const reviewRiskDrawerLabel = computed(() => ( - isReviewRiskDrawer.value ? '显示核对' : '显示风险' - )) - const reviewRiskDrawerIcon = computed(() => ( - isReviewRiskDrawer.value - ? 'mdi mdi-shield-alert' - : 'mdi mdi-shield-alert-outline' - )) - const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) - const activeReviewDocumentPreview = computed(() => - activeReviewDocument.value - ? ( - resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) - || ( - activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url - ? { - filename: activeReviewDocument.value.filename, - kind: activeReviewDocument.value.preview_kind, - url: activeReviewDocument.value.preview_data_url - } - : null - ) - ) - : null - ) - const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) - const reviewDocumentDirty = computed(() => { - const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) - const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) - return baseValue !== nextValue - }) - const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) - const hotKnowledgeQuestions = computed(() => HOT_KNOWLEDGE_QUESTIONS) - - const shortcuts = computed(() => [ - { - label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答', - icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline', - action: 'switch_view', - targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE - } - ]) - - function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { - const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType - const restoredMessages = normalizeInitialConversationMessages(conversation) - const initialInsight = buildInitialInsightFromConversation(conversation) - const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages) - - return { - sessionType, - messages: restoredMessages.length - ? restoredMessages - : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], - conversationId: resolveInitialConversationId(conversation), - draftClaimId: resolveInitialDraftClaimId(conversation), - currentInsight: - initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), - reviewFilePreviews: restoredReviewFilePreviews, - composerDraft: '', - attachedFiles: [], - composerFilesExpanded: false, - composerUploadIntent: '', - insightPanelCollapsed: false - } - } - - function buildEmptySessionState(sessionType) { - return { - sessionType, - messages: [ - createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value) - ], - conversationId: '', - draftClaimId: '', - currentInsight: buildWelcomeInsight( - props.entrySource, - linkedRequest.value, - sessionType, - currentUser.value - ), - reviewFilePreviews: [], - composerDraft: '', - attachedFiles: [], - composerFilesExpanded: false, - composerUploadIntent: '', - insightPanelCollapsed: false - } - } - - function resolveCurrentUserId() { - const user = currentUser.value || {} - return String(user.username || user.name || 'anonymous').trim() || 'anonymous' - } - - function captureCurrentSessionState() { - return { - sessionType: activeSessionType.value, - messages: messages.value, - conversationId: conversationId.value, - draftClaimId: draftClaimId.value, - currentInsight: currentInsight.value, - reviewFilePreviews: reviewFilePreviews.value, - composerDraft: composerDraft.value, - attachedFiles: attachedFiles.value, - composerFilesExpanded: composerFilesExpanded.value, - composerUploadIntent: composerUploadIntent.value, - insightPanelCollapsed: insightPanelCollapsed.value - } - } - - function applySessionState(sessionState) { - const nextState = sessionState || buildEmptySessionState(activeSessionType.value) - activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE - messages.value = Array.isArray(nextState.messages) && nextState.messages.length - ? nextState.messages - : [ - createWelcomeAssistantMessage( - props.entrySource, - linkedRequest.value, - activeSessionType.value, - currentUser.value - ) - ] - conversationId.value = String(nextState.conversationId || '').trim() - draftClaimId.value = String(nextState.draftClaimId || '').trim() - currentInsight.value = - nextState.currentInsight - || buildWelcomeInsight( - props.entrySource, - linkedRequest.value, - activeSessionType.value, - currentUser.value - ) - reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] - composerDraft.value = String(nextState.composerDraft || '') - attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] - composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) - composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim() - insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) - uploadDecisionDialogOpen.value = false - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - } - - async function loadLatestSessionState(targetSessionType) { - const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, { - preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE - }) - if (payload?.found && payload.conversation) { - return buildConversationSessionState(payload.conversation, targetSessionType) - } - return buildEmptySessionState(targetSessionType) - } - - function resetKnowledgeSessionSnapshot() { - const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) - sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState - - if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) { - applySessionState(emptyKnowledgeState) - } - } - - function clearKnowledgeSessionOnEntry() { - resetKnowledgeSessionSnapshot() - knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) - .catch((error) => { - console.warn('Failed to clear knowledge session on entry:', error) - }) - .finally(() => { - resetKnowledgeSessionSnapshot() - }) - return knowledgeSessionResetPromise - } - - async function switchSessionType(targetSessionType) { - const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE - if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { - return - } - - sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() - if (sessionSnapshots.value[normalizedTarget]) { - applySessionState(sessionSnapshots.value[normalizedTarget]) - return - } - - sessionSwitchBusy.value = true - try { - const nextState = await loadLatestSessionState(normalizedTarget) - sessionSnapshots.value[normalizedTarget] = nextState - applySessionState(nextState) - } catch (error) { - const emptyState = buildEmptySessionState(normalizedTarget) - sessionSnapshots.value[normalizedTarget] = emptyState - applySessionState(emptyState) - toast(error?.message || '加载会话失败,已为你打开新的会话。') - } finally { - sessionSwitchBusy.value = false - } - } - - sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() - - watch( - () => activeReviewPayload.value, - (payload) => { - rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) - const nextInlineState = buildInlineReviewState(payload) - reviewInlineForm.value = { ...nextInlineState } - reviewInlineBaseForm.value = { ...nextInlineState } - reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) - const nextDocumentDrafts = buildReviewDocumentDrafts(payload) - reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) - reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) - activeReviewDocumentIndex.value = nextDocumentDrafts.length - ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) - : 0 - reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW - reviewInlinePendingFiles.value = [] - reviewInlineEditorKey.value = '' - reviewInlineErrors.value = {} - reviewOtherCategoryOpen.value = false - }, - { immediate: true } - ) - - watch( - () => hasInsightPanelContent.value, - (available) => { - if (!available) { - insightPanelCollapsed.value = false - } - } - ) - - watch( - () => reviewDocumentDrawerAvailable.value, - (available) => { - if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) { - reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW - } - } - ) - - watch( - () => reviewRiskDrawerAvailable.value, - (available) => { - if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) { - reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW - } - } - ) - - watch( - () => composerDraft.value, - () => { - nextTick(adjustComposerTextareaHeight) - } - ) - - watch( - () => [activeSessionType.value, resolveActiveClaimId()], - ([sessionType, claimId]) => { - if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) { - return - } - void restorePersistedDraftAttachmentPreviews(claimId) - }, - { immediate: true } - ) - - onMounted(() => { - document.addEventListener('click', handleComposerDatePickerOutside) - flowTickTimer = window.setInterval(() => { - flowTick.value = Date.now() - }, 250) - nextTick(() => { - workbenchVisible.value = true - }) - void clearKnowledgeSessionOnEntry() - currentInsight.value = - currentInsight.value - || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value) - if (props.initialPrompt?.trim() || props.initialFiles.length) { - const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS) - composerDraft.value = props.initialPrompt.trim() - attachedFiles.value = initialMerge.files - composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS - if (initialMerge.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) - } - submitComposer() - } else { - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - } - }) - - onBeforeUnmount(() => { - document.removeEventListener('click', handleComposerDatePickerOutside) - if (flowTickTimer) { - window.clearInterval(flowTickTimer) - } - clearFlowSimulationTimers() - for (const url of previewRegistry) { - URL.revokeObjectURL(url) - } - }) - - function scrollToBottom() { - if (!messageListRef.value) return - messageListRef.value.scrollTop = messageListRef.value.scrollHeight - } - - function resetCurrentSessionState() { - const emptyState = buildEmptySessionState(activeSessionType.value) - sessionSnapshots.value[activeSessionType.value] = emptyState - applySessionState(emptyState) - clearFlowSimulationTimers() - flowRunId.value = '' - flowStartedAt.value = 0 - flowFinishedAt.value = 0 - flowSteps.value = createFlowSteps() - flowPanelOpen.value = false - } - - function adjustComposerTextareaHeight() { - if (!composerTextareaRef.value) return - - const textarea = composerTextareaRef.value - textarea.style.height = 'auto' - const styles = window.getComputedStyle(textarea) - const lineHeight = Number.parseFloat(styles.lineHeight) || 20 - const verticalPadding = - Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0') - const minHeight = COMPOSER_TEXTAREA_HEIGHT - const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding - const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight)) - - textarea.style.height = `${nextHeight}px` - textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' - } - - function handleComposerInput() { - adjustComposerTextareaHeight() - } - - function handleComposerEnter(event) { - if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { - return - } - submitComposer() - } - - function toggleFlowPanel() { - flowPanelOpen.value = !flowPanelOpen.value - } - - function openFlowPanel() { - flowPanelOpen.value = true - } - - function clearFlowSimulationTimers() { - while (flowSimulationTimers.length) { - const timerId = flowSimulationTimers.pop() - window.clearTimeout(timerId) - window.clearInterval(timerId) - } - } - - function scheduleFlowPanelAutoCollapse(delayMs = 1200) { - const collapseTimer = window.setTimeout(() => { - if (runningFlowStep.value || flowRefreshBusy.value || submitting.value) { - return - } - if (flowSteps.value.length && !flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { - flowPanelOpen.value = false - } - }, delayMs) - flowSimulationTimers.push(collapseTimer) - } - - function resetFlowRun() { - clearFlowSimulationTimers() - flowPanelOpen.value = true - flowRunId.value = '' - flowStartedAt.value = Date.now() - flowFinishedAt.value = 0 - flowSteps.value = createFlowSteps() - } - - function findFlowDefinition(key) { - return FLOW_STEP_FALLBACKS[key] || null - } - - function normalizeFlowStepPatch(key, patch = {}) { - const definition = findFlowDefinition(key) || {} - const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } - return { - title: normalizedPatch.title || definition.title || '智能体工具调用', - tool: normalizedPatch.tool || definition.tool || 'AgentTool', - detail: normalizedPatch.detail || definition.runningText || '', - ...normalizedPatch - } - } - - function createFlowStep(key, patch = {}) { - const normalizedPatch = normalizeFlowStepPatch(key, patch) - return { - key, - index: flowSteps.value.length + 1, - title: normalizedPatch.title, - tool: normalizedPatch.tool, - status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, - detail: normalizedPatch.detail || '', - durationMs: normalizedPatch.durationMs ?? null, - startedAt: normalizedPatch.startedAt || 0, - finishedAt: normalizedPatch.finishedAt || 0, - error: normalizedPatch.error || '' - } - } - - function upsertFlowStep(key, patch) { - const existingStep = flowSteps.value.find((step) => step.key === key) - if (!existingStep) { - flowSteps.value = [...flowSteps.value, createFlowStep(key, patch)] - return - } - const normalizedPatch = normalizeFlowStepPatch(key, patch) - flowSteps.value = flowSteps.value.map((step) => ( - step.key === key ? { ...step, ...normalizedPatch } : step - )) - } - - function startFlowStep(key, patch = {}) { - const normalizedPatch = normalizeFlowStepPatch(key, patch) - upsertFlowStep(key, { - ...normalizedPatch, - status: FLOW_STEP_STATUS_RUNNING, - detail: normalizedPatch.detail, - startedAt: Date.now(), - finishedAt: 0, - durationMs: null, - error: '' - }) - } - - function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { - const now = Date.now() - const definition = findFlowDefinition(key) - const currentStep = flowSteps.value.find((step) => step.key === key) - const startedAt = currentStep?.startedAt || now - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_COMPLETED, - detail: detail || definition?.completedText || '', - startedAt, - finishedAt: now, - durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt, - error: '' - }) - } - - function failFlowStep(key, detail = '', error = '', patch = {}) { - const now = Date.now() - const definition = findFlowDefinition(key) - const currentStep = flowSteps.value.find((step) => step.key === key) - const startedAt = currentStep?.startedAt || now - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_FAILED, - detail: detail || error || '调用失败', - startedAt, - finishedAt: now, - durationMs: now - startedAt, - error: String(error || definition?.title || '').trim() - }) - flowFinishedAt.value = now - } - - function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { - const currentStep = flowSteps.value.find((step) => step.key === key) - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { - return - } - const normalizedDuration = Number(durationMs) - const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 - if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { - if (!hasMeasuredDuration && !currentStep?.startedAt) { - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_COMPLETED, - detail: detail || findFlowDefinition(key)?.completedText || '', - startedAt: 0, - finishedAt: 0, - durationMs: null, - error: '' - }) - return - } - startFlowStep(key, patch) - } - completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) - } - - function failCurrentFlowStep(error) { - clearFlowSimulationTimers() - const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) - failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '') - } - - function startSemanticFlowPreview(rawText, options = {}) { - clearFlowSimulationTimers() - const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value) - const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) - - const completeIntentTimer = window.setTimeout(() => { - const currentStep = flowSteps.value.find((step) => step.key === 'intent') - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { - return - } - completePendingFlowStep('intent', intentPreview, null) - }, 260) - flowSimulationTimers.push(completeIntentTimer) - - const startExtractionTimer = window.setTimeout(() => { - const currentStep = flowSteps.value.find((step) => step.key === 'extraction') - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { - return - } - startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) - - if (extractionMessages.length <= 1) { - return - } - - let index = 1 - const detailTimer = window.setInterval(() => { - const runningStep = flowSteps.value.find((step) => step.key === 'extraction') - if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { - window.clearInterval(detailTimer) - return - } - upsertFlowStep('extraction', { - detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] - }) - index = Math.min(index + 1, extractionMessages.length - 1) - }, 650) - flowSimulationTimers.push(detailTimer) - }, 420) - flowSimulationTimers.push(startExtractionTimer) - } - - function resolveToolCallFlowMeta(toolCall, index) { - const toolType = String(toolCall?.tool_type || '').toLowerCase() - const toolName = String(toolCall?.tool_name || '').toLowerCase() - const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` - if (toolType.includes('rule')) { - return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } - } - if (toolType.includes('mcp')) { - return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } - } - if (toolName.includes('knowledge')) { - return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } - } - if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { - return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } - } - if (toolType.includes('database')) { - return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } - } - if (toolType.includes('llm') || toolName.includes('user_agent')) { - return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } - } - return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } - } - - function summarizeFlowToolCall(toolCall) { - const response = toolCall?.response_json && typeof toolCall.response_json === 'object' - ? toolCall.response_json - : {} - return ( - String(response.message || response.summary || response.result_summary || '').trim() - || String(toolCall?.tool_name || '').trim() - || '工具调用完成' - ) - } - - function mergeFlowRunDetail(run) { - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { - clearFlowSimulationTimers() - const semanticDurations = resolveSemanticPhaseDurations(run) - completePendingFlowStep( - 'intent', - summarizeSemanticIntentDetail(run.semantic_parse), - semanticDurations.intentMs - ) - completePendingFlowStep( - 'extraction', - summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), - semanticDurations.extractionMs - ) - } - - toolCalls.forEach((toolCall, index) => { - const meta = resolveToolCallFlowMeta(toolCall, index) - const failed = String(toolCall?.status || '').toLowerCase() === 'failed' - if (failed) { - failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) - } else { - const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) - completePendingFlowStep( - meta.key, - summarizeFlowToolCall(toolCall), - toolDurationMs, - meta - ) - } - }) - - if (String(run?.status || '').toLowerCase() === 'failed') { - failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) - return - } - } - - function completeFlowResult(payload, run = null) { - const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() - if (!answer && !payload?.result) { - return - } - startFlowStep('result', '正在返回处理结果...') - completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run)) - flowFinishedAt.value = Date.now() - scheduleFlowPanelAutoCollapse() - } - - async function refreshFlowRunDetail() { - if (!flowRunId.value || flowRefreshBusy.value) { - return null - } - flowRefreshBusy.value = true - try { - const run = await fetchAgentRunDetail(flowRunId.value) - mergeFlowRunDetail(run) - return run - } catch (error) { - console.warn('Failed to refresh agent run detail:', error) - return null - } finally { - flowRefreshBusy.value = false - } - } - - function formatFlowStepDuration(step) { - if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { - return formatFlowDuration(flowTick.value - step.startedAt) - } - return formatFlowDuration(step?.durationMs) - } - - function buildComposerBusinessTimeLabel() { - if (composerDateMode.value === 'single') { - return `业务发生时间:${composerSingleDate.value}` - } - if (composerRangeStartDate.value === composerRangeEndDate.value) { - return `业务发生时间:${composerRangeStartDate.value}` - } - return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` - } - - function resolveComposerSubmitText(explicitRawText) { - const draftPart = String(explicitRawText ?? composerDraft.value).trim() - const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',') - if (!tagPart) { - return draftPart - } - if (!draftPart) { - return tagPart - } - return `${tagPart},${draftPart}` - } - - function toggleComposerDatePicker() { - composerDatePickerOpen.value = !composerDatePickerOpen.value - } - - function closeComposerDatePicker() { - composerDatePickerOpen.value = false - } - - function setComposerDateMode(mode) { - composerDateMode.value = mode === 'range' ? 'range' : 'single' - } - - function removeComposerBusinessTimeTag(tagId) { - composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId) - } - - function handleComposerDatePickerOutside(event) { - if (!composerDatePickerOpen.value) { - return - } - if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { - return - } - composerDatePickerOpen.value = false - } - - async function applyComposerDateSelection() { - if (!composerCanApplyDateSelection.value) { - return - } - - composerBusinessTimeTags.value = [ - { - id: `biz-time-${Date.now()}`, - label: buildComposerBusinessTimeLabel() - } - ] - composerDatePickerOpen.value = false - await nextTick() - adjustComposerTextareaHeight() - composerTextareaRef.value?.focus() - } - - function rememberFilePreviews(filePreviews) { - reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) - } - - function trackPreviewObjectUrl(url) { - if (!url || !String(url).startsWith('blob:')) { - return - } - previewRegistry.push(url) - } - - function resolveActiveClaimId() { - return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim() - } - - async function buildPersistedAttachmentPreview(metadata) { - const filename = String(metadata?.file_name || '').trim() - const kind = resolveAttachmentPreviewKind(metadata) - const previewPath = String(metadata?.preview_url || '').trim() - if (!filename || !kind || !previewPath) { - return null - } - - const blob = await fetchExpenseClaimAttachmentAsset(previewPath) - const url = URL.createObjectURL(blob) - trackPreviewObjectUrl(url) - return { - filename, - kind, - url - } - } - - async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) { - const normalizedClaimId = String(claimId || '').trim() - if (!normalizedClaimId || isKnowledgeSession.value) { - return - } - - const force = Boolean(options.force) - if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) { - return - } - - try { - const claim = await fetchExpenseClaimDetail(normalizedClaimId) - const items = Array.isArray(claim?.items) ? claim.items : [] - const previews = [] - - for (const item of items) { - const itemId = String(item?.id || '').trim() - if (!itemId) continue - - let metadata = null - try { - metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId) - } catch { - continue - } - - const filename = String(metadata?.file_name || '').trim() - if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) { - continue - } - - try { - const preview = await buildPersistedAttachmentPreview(metadata) - if (preview) { - previews.push(preview) - } - } catch (error) { - console.warn('Failed to load persisted attachment preview:', error) - } - } - - if (previews.length) { - rememberFilePreviews(previews) - } - restoredDraftPreviewClaims.add(normalizedClaimId) - } catch (error) { - console.warn('Failed to restore persisted draft attachment previews:', error) - } - } - - async function syncComposerFilesToDraft(claimId, files) { - const normalizedClaimId = String(claimId || '').trim() - if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) { - return - } - - const claim = await fetchExpenseClaimDetail(normalizedClaimId) - const items = Array.isArray(claim?.items) ? claim.items : [] - const exactMatchBuckets = new Map() - const placeholderQueue = [] - const usedItemIds = new Set() - - for (const item of items) { - const itemId = String(item?.id || '').trim() - const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim() - if (!itemId) continue - if (invoiceId && !invoiceId.includes('/')) { - placeholderQueue.push(item) - } - if (!invoiceId) continue - const bucket = exactMatchBuckets.get(invoiceId) || [] - bucket.push(item) - exactMatchBuckets.set(invoiceId, bucket) - } - - for (const file of files) { - const exactBucket = exactMatchBuckets.get(file.name) || [] - const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) - const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim())) - const targetItem = nextExactMatch || fallbackMatch - const targetItemId = String(targetItem?.id || '').trim() - if (!targetItemId) { - continue - } - - usedItemIds.add(targetItemId) - await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file) - } - - await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true }) - } - - function replaceMessage(messageId, nextMessage) { - const index = messages.value.findIndex((item) => item.id === messageId) - if (index === -1) { - messages.value.push(nextMessage) - return - } - messages.value.splice(index, 1, nextMessage) - } - - function triggerFileUpload(mode = 'composer') { - if (submitting.value || reviewActionBusy.value) return - fileInputMode.value = mode - fileInputRef.value?.click() - } - - function handleFilesChange(event) { - const files = Array.from(event.target.files ?? []) - - if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { - const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) - const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) - const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) - - if (!remainingSlots && files.length) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) - } else if (mergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) - } - - reviewInlinePendingFiles.value = mergeResult.files - const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] - reviewInlineForm.value = { - ...reviewInlineForm.value, - attachment_names: allAttachmentNames.join('、'), - attachment_count: allAttachmentNames.length, - pending_attachment_count: mergeResult.files.length - } - clearInlineReviewFieldError('attachments') - reviewInlineEditorKey.value = '' - } else { - if (isKnowledgeSession.value) { - toast('财务知识问答暂不支持上传附件。') - fileInputMode.value = 'composer' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - return - } - - const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) - attachedFiles.value = mergeResult.files - if (fileInputMode.value === 'composer-continue' && files.length) { - composerUploadIntent.value = 'continue_existing' - } - if (mergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) - } - if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { - composerFilesExpanded.value = false - } - } - - fileInputMode.value = 'composer' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - } - - function toggleAttachedFilesExpanded() { - composerFilesExpanded.value = !composerFilesExpanded.value - } - - function removeAttachedFile(targetFile) { - const fileKey = buildFileIdentity(targetFile) - attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) - if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { - composerFilesExpanded.value = false - } - if (!attachedFiles.value.length) { - composerUploadIntent.value = '' - } - } - - function clearAttachedFiles() { - attachedFiles.value = [] - composerFilesExpanded.value = false - composerUploadIntent.value = '' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - } - - function closeUploadDecisionDialog() { - if (submitting.value || reviewActionBusy.value) return - uploadDecisionDialogOpen.value = false - } - - async function continueExistingUpload() { - if (submitting.value || reviewActionBusy.value) return - uploadDecisionDialogOpen.value = false - composerUploadIntent.value = 'continue_existing' - await submitComposer({ - uploadDisposition: 'continue_existing', - skipUploadDecisionPrompt: true - }) - } - - async function createNewUploadDocument() { - if (submitting.value || reviewActionBusy.value) return - uploadDecisionDialogOpen.value = false - composerUploadIntent.value = '' - await submitComposer({ - uploadDisposition: 'new_document', - skipUploadDecisionPrompt: true - }) - } - - async function runShortcut(shortcut) { - if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { - await switchSessionType(shortcut.targetSessionType) - return - } - - const prompt = String(shortcut?.prompt || '').trim() - if (!prompt) return - composerDraft.value = prompt - submitComposer() - } - - function toggleInsightPanel() { - if (!hasInsightPanelContent.value) { - return - } - insightPanelCollapsed.value = !insightPanelCollapsed.value - } - - function toggleReviewDocumentDrawer() { - if (!reviewDocumentDrawerAvailable.value) { - return - } - reviewDrawerMode.value = - reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS - ? REVIEW_DRAWER_MODE_REVIEW - : REVIEW_DRAWER_MODE_DOCUMENTS - } - - function toggleReviewRiskDrawer() { - if (!reviewRiskDrawerAvailable.value) { - return - } - reviewDrawerMode.value = - reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK - ? REVIEW_DRAWER_MODE_REVIEW - : REVIEW_DRAWER_MODE_RISK - } - - function setInlineReviewFieldError(key, message) { - reviewInlineErrors.value = { - ...reviewInlineErrors.value, - [key]: String(message || '').trim() - } - } - - function clearInlineReviewFieldError(key) { - if (!reviewInlineErrors.value[key]) { - return - } - - const nextErrors = { ...reviewInlineErrors.value } - delete nextErrors[key] - reviewInlineErrors.value = nextErrors - } - - function openInlineReviewEditor(key) { - if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return - if (key === 'attachments') { - triggerFileUpload('inline-review') - return - } - - if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { - return - } - - if (reviewInlineEditorKey.value === key) { - commitInlineReviewEditor() - return - } - - if (key === 'amount') { - reviewInlineForm.value = { - ...reviewInlineForm.value, - amount: extractAmountInputValue(reviewInlineForm.value.amount) - } - } - - clearInlineReviewFieldError(key) - reviewInlineEditorKey.value = key - if (key !== 'expense_type') { - reviewOtherCategoryOpen.value = false - } - } - - function closeInlineReviewEditor() { - reviewInlineEditorKey.value = '' - reviewOtherCategoryOpen.value = false - } - - function commitInlineReviewEditor() { - const activeEditorKey = reviewInlineEditorKey.value - const nextForm = { - ...reviewInlineForm.value, - occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), - amount: String(reviewInlineForm.value.amount || '').trim(), - customer_name: String(reviewInlineForm.value.customer_name || '').trim(), - location: String(reviewInlineForm.value.location || '').trim(), - merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), - participants: String(reviewInlineForm.value.participants || '').trim(), - scene_label: String(reviewInlineForm.value.scene_label || '').trim(), - reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), - expense_type: String(reviewInlineForm.value.expense_type || '').trim() - } - - if ( - activeEditorKey === 'scene' && - nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION - ) { - nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim() - if (!nextForm.reason_value) { - setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由') - reviewInlineForm.value = nextForm - return false - } - } else if (activeEditorKey === 'scene') { - nextForm.reason_value = nextForm.scene_label - } - - if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { - setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`) - return false - } - - if (activeEditorKey === 'amount' && nextForm.amount) { - const normalizedAmount = normalizeAmountValue(nextForm.amount) - if (!normalizedAmount) { - setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50') - return false - } - nextForm.amount = normalizedAmount - } - - if (activeEditorKey) { - clearInlineReviewFieldError(activeEditorKey) - } - - reviewInlineForm.value = nextForm - reviewInlineEditorKey.value = '' - return true - } - - function selectInlineScene(scene) { - const normalizedScene = String(scene || '').trim() - reviewInlineForm.value = { - ...reviewInlineForm.value, - scene_label: normalizedScene, - reason_value: - normalizedScene === REVIEW_SCENE_OTHER_OPTION - ? '' - : normalizedScene - } - clearInlineReviewFieldError('scene') - if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) { - reviewInlineEditorKey.value = '' - } - } - - function selectReviewCategory(option) { - if (!option) return - if (option.is_other) { - reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value - return - } - - reviewInlineForm.value = { - ...reviewInlineForm.value, - expense_type: option.label - } - reviewOtherCategoryOpen.value = false - } - - function selectReviewOtherCategory(option) { - if (!option) return - reviewInlineForm.value = { - ...reviewInlineForm.value, - expense_type: option.label - } - reviewOtherCategoryOpen.value = false - } - - function queryDraftByClaimNo(claimNo) { - const normalized = String(claimNo || '').trim() - if (!normalized || submitting.value || reviewActionBusy.value) return - submitComposer({ - rawText: `查看报销草稿 ${normalized} 的当前信息`, - userText: `查看草稿 ${normalized}`, - systemGenerated: true - }) - } - - function explainCurrentReviewRisk() { - if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return - submitComposer({ - rawText: '请解释一下当前这笔报销的合规风险和待补充项。', - userText: '查看全部风险项', - systemGenerated: true - }) - } - - function goReviewDocument(direction) { - const total = reviewDocumentCount.value - if (!total) return - const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) - activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) - } - - function openActiveReviewDocumentPreview() { - if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return - documentPreviewDialog.value = { - open: true, - filename: activeReviewDocument.value.filename, - kind: activeReviewDocumentPreview.value.kind, - url: activeReviewDocumentPreview.value.url - } - } - - function closeDocumentPreview() { - documentPreviewDialog.value = { - ...documentPreviewDialog.value, - open: false - } - } - - function requestCloseWorkbench() { - workbenchVisible.value = false - } - - function emitCloseAfterLeave() { - emit('close') - } - - function openExpenseQueryRecord(record) { - const claimId = String(record?.claimId || '').trim() - if (!claimId) { - return - } - - router.push({ - name: 'app-request-detail', - params: { requestId: claimId } - }) - emit('close') - } - - function setExpenseQueryPage(message, page) { - if (!message?.queryPayload) { - return - } - - const totalPages = getExpenseQueryTotalPages(message.queryPayload) - const nextPage = Math.min(Math.max(1, Number(page || 1)), totalPages) - message.queryPayload.currentPage = nextPage - } - - function shiftExpenseQueryPage(message, delta) { - if (!message?.queryPayload) { - return - } - - setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0)) - } - - function openDeleteSessionDialog() { - if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) { - return - } - deleteSessionDialogOpen.value = true - } - - function closeDeleteSessionDialog() { - if (deleteSessionBusy.value) { - return - } - deleteSessionDialogOpen.value = false - } - - async function confirmDeleteCurrentSession() { - if (deleteSessionBusy.value || sessionSwitchBusy.value) { - return - } - - deleteSessionBusy.value = true - try { - if (conversationId.value) { - await deleteConversation(conversationId.value, resolveCurrentUserId()) - } - - resetCurrentSessionState() - deleteSessionDialogOpen.value = false - toast('当前会话已删除。') - } catch (error) { - toast(error?.message || '删除当前会话失败,请稍后重试。') - } finally { - deleteSessionBusy.value = false - } - } - - async function saveInlineReviewChanges() { - if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return - - if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { - return - } - - reviewActionBusy.value = true - try { - const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) - const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ) - await submitComposer({ - rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'), - userText: buildReviewSubmitUserText( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value, - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ), - pendingText: '正在保存修改并刷新右侧核对信息...', - files: reviewInlinePendingFiles.value, - systemGenerated: true, - extraContext: { - review_action: 'edit_review', - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) - } - }) - } finally { - reviewActionBusy.value = false - } - } - - function askHotKnowledgeQuestion(question) { - const normalizedQuestion = String(question || '').trim() - if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { - return - } - - submitComposer({ - rawText: normalizedQuestion, - userText: normalizedQuestion, - pendingText: '正在整理财务知识答案...' - }) - } - - function buildBackendMessage(rawText, fileNames, ocrSummary = '') { - const parts = [] - const normalizedText = String(rawText || '').trim() - - if (normalizedText) { - parts.push(normalizedText) - } else if (fileNames.length) { - parts.push( - isKnowledgeSession.value - ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` - : `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。` - ) - } - - if (fileNames.length) { - parts.push(`附件名称:${fileNames.join('、')}`) - } - - if (ocrSummary) { - parts.push(`OCR摘要:${ocrSummary}`) - } - - if (props.entrySource === 'detail' && linkedRequest.value?.id) { - parts.push(`关联单号:${linkedRequest.value.id}`) - } - - return parts.join('\n') - } - - async function submitComposer(options = {}) { - if (sessionSwitchBusy.value) return null - - const rawText = resolveComposerSubmitText(options.rawText).trim() - const systemGenerated = Boolean(options.systemGenerated) - const resolvedUploadDisposition = - String(options.uploadDisposition || '').trim() || - (composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') - const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) - const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) - const files = fileMergeResult.files - if (fileMergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) - } - if (!rawText && !files.length) return - - const extraContext = options.extraContext && typeof options.extraContext === 'object' - ? { ...options.extraContext } - : {} - const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) - const hasExistingDocumentEvent = - Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 - - if ( - !isKnowledgeSession.value && - files.length && - hasExistingDocumentEvent && - !resolvedUploadDisposition && - !options.skipUploadDecisionPrompt && - !String(extraContext.review_action || '').trim() - ) { - uploadDecisionDialogOpen.value = true - return null - } - - resetFlowRun() - if (rawText) { - startFlowStep('intent', '正在识别业务意图...') - startSemanticFlowPreview(rawText, { attachmentCount: files.length }) - } - - const fileNames = files.map((file) => file.name) - const filePreviews = buildFilePreviews(files, previewRegistry) - rememberFilePreviews(filePreviews) - const userText = - String(options.userText || '').trim() || - rawText || - (isKnowledgeSession.value - ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` - : resolvedUploadDisposition === 'continue_existing' - ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` - : resolvedUploadDisposition === 'new_document' - ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` - : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) - - // 只有在非静默模式下才添加用户消息 - if (!options.skipUserMessage) { - messages.value.push(createMessage('user', userText, fileNames)) - } - - const pendingMessage = createMessage( - 'assistant', - options.pendingText || (isKnowledgeSession.value ? '正在整理财务知识答案...' : '正在识别并更新右侧核对信息...'), - [], - { - meta: ['处理中'] - } - ) - messages.value.push(pendingMessage) - - composerDraft.value = '' - composerBusinessTimeTags.value = [] - clearAttachedFiles() - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - nextTick(adjustComposerTextareaHeight) - - submitting.value = true - nextTick(scrollToBottom) - - let responsePayload = null - - try { - const user = currentUser.value || {} - let ocrPayload = null - let ocrSummary = '' - let ocrDocuments = [] - let ocrFilePreviews = [] - - if (files.length) { - startFlowStep('ocr', `正在识别 ${files.length} 份附件...`) - try { - ocrPayload = await recognizeOcrFiles(files) - ocrSummary = buildOcrSummary(ocrPayload) - ocrDocuments = normalizeOcrDocuments(ocrPayload) - ocrFilePreviews = buildOcrFilePreviews(ocrPayload) - rememberFilePreviews(ocrFilePreviews) - completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`) - } catch (error) { - console.warn('OCR request failed:', error) - completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称') - } - } - - let effectiveFileNames = [...fileNames] - let effectiveOcrDocuments = [...ocrDocuments] - let effectiveOcrSummary = ocrSummary - - if (resolvedUploadDisposition === 'continue_existing') { - extraContext.review_action = 'link_to_existing_draft' - effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames) - effectiveOcrDocuments = mergeUploadOcrDocuments( - buildOcrDocumentsFromReviewPayload(activeReviewPayload.value), - ocrDocuments - ) - effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments) - } else if (resolvedUploadDisposition === 'new_document') { - extraContext.review_action = 'create_new_claim_from_documents' - } - - const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) - const payload = await runOrchestrator( - { - source: 'user_message', - user_id: user.username || user.name || 'anonymous', - conversation_id: conversationId.value || null, - message: backendMessage, - context_json: { - role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], - is_admin: Boolean(user.isAdmin), - name: user.name || '', - role: user.role || '', - position: user.position || '', - grade: user.grade || '', - ...buildClientTimeContext(), - session_type: activeSessionType.value, - entry_source: props.entrySource, - user_input_text: systemGenerated ? '' : rawText, - attachment_names: effectiveFileNames, - attachment_count: effectiveFileNames.length, - draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, - ocr_summary: effectiveOcrSummary, - ocr_documents: effectiveOcrDocuments, - ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), - ...extraContext - } - }, - isKnowledgeSession.value - ? { - timeoutMs: 18000, - timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。' - } - : {} - ) - responsePayload = payload - flowRunId.value = String(payload?.run_id || '').trim() - let flowRunDetail = null - if (flowRunId.value) { - flowRunDetail = await refreshFlowRunDetail() - } - - conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value - draftClaimId.value = - isKnowledgeSession.value - ? '' - : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value - - const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() - if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { - try { - await syncComposerFilesToDraft(resolvedDraftClaimId, files) - } catch (error) { - console.warn('Failed to persist composer attachments to draft claim:', error) - toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。') - } - } - - replaceMessage( - pendingMessage.id, - createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { - meta: buildMessageMeta(payload, effectiveFileNames), - citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [], - suggestedActions: Array.isArray(payload?.result?.suggested_actions) - ? payload.result.suggested_actions - : [], - queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), - draftPayload: payload?.result?.draft_payload || null, - reviewPayload: payload?.result?.review_payload || null, - riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] - }) - ) - currentInsight.value = buildAgentInsight( - payload, - effectiveFileNames, - mergeFilePreviews(filePreviews, ocrFilePreviews) - ) - completeFlowResult(payload, flowRunDetail) - } catch (error) { - clearFlowSimulationTimers() - failCurrentFlowStep(error) - replaceMessage( - pendingMessage.id, - createMessage( - 'assistant', - error?.message || '无法连接后端 Orchestrator,请稍后重试。', - [], - { - meta: ['调用失败'] - } - ) - ) - currentInsight.value = buildErrorInsight(error, fileNames) - } finally { - submitting.value = false - composerUploadIntent.value = '' - nextTick(scrollToBottom) - } - - return responsePayload - } - - function openCancelReviewDialog(message) { - reviewActionMessageId.value = String(message?.id || '') - reviewCancelDialogOpen.value = true - } - - function closeCancelReviewDialog() { - if (reviewActionBusy.value) return - reviewCancelDialogOpen.value = false - reviewActionMessageId.value = '' - } - - function confirmCancelReview() { - if (reviewActionBusy.value) return - reviewCancelDialogOpen.value = false - emit('close') - } - - function openEditReviewDialog(message) { - reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields) - reviewActionMessageId.value = String(message?.id || '') - reviewEditDialogOpen.value = true - } - - function closeEditReviewDialog() { - if (reviewActionBusy.value) return - reviewEditDialogOpen.value = false - reviewEditFields.value = [] - reviewActionMessageId.value = '' - } - - async function applyEditedReview() { - if (reviewActionBusy.value) return - - reviewActionBusy.value = true - try { - const fields = cloneReviewEditFields(reviewEditFields.value) - await submitComposer({ - rawText: buildReviewCorrectionMessage(fields), - userText: '我已修改识别信息,请按最新内容更新。', - pendingText: '正在根据修改内容重新识别...', - systemGenerated: true, - extraContext: { - review_action: 'edit_review', - review_form_values: buildReviewFormValues(fields) - } - }) - } finally { - reviewActionBusy.value = false - } - closeEditReviewDialog() - } - - async function handleReviewAction(message, action) { - const actionType = String(action?.action_type || '').trim() - if (!actionType || reviewActionBusy.value) return - - if (actionType === 'cancel_review') { - openCancelReviewDialog(message) - return - } - - if (actionType === 'edit_review') { - openEditReviewDialog(message) - return - } - - if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { - return - } - - if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { - return - } - - if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { - await handleSaveDraftDirectly(message, actionType) - return - } - - reviewActionBusy.value = true - try { - const baseFields = reviewInlineBaseFields.value.length - ? reviewInlineBaseFields.value - : cloneReviewEditFields(message?.reviewPayload?.edit_fields) - const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) - const reviewChangedUserText = reviewHasUnsavedChanges.value - ? buildReviewSubmitUserText( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value, - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ) - : '' - const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ) - const payload = await submitComposer({ - rawText: [ - reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', - reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', - '我已核对右侧识别结果,请进入下一步。' - ] - .filter(Boolean) - .join('\n'), - userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', - files: reviewInlinePendingFiles.value, - pendingText: '正在进入下一步...', - systemGenerated: true, - extraContext: { - review_action: actionType, - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) - } - }) - - if (payload?.result?.draft_payload?.status === 'submitted') { - emit( - 'draft-saved', - buildDraftSavedPayload({ - draftPayload: payload.result.draft_payload, - reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, - inlineState: reviewInlineForm.value, - linkedRequest: linkedRequest.value, - currentUser: currentUser.value - }) - ) - } - } finally { - reviewActionBusy.value = false - } - } - - async function handleSaveDraftDirectly(message, actionType = 'save_draft') { - reviewActionBusy.value = true - let savingMessage = null - - const actionConfig = { - save_draft: { - rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', - pendingText: '正在保存当前草稿...', - helperText: '正在保存草稿...', - successMeta: '草稿已保存', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成' - } - }, - link_to_existing_draft: { - rawText: '请把当前上传的票据合并到现有报销草稿中。', - pendingText: '正在关联到现有草稿...', - helperText: '正在关联现有草稿...', - successMeta: '已关联草稿', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿' - } - }, - create_new_claim_from_documents: { - rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。', - pendingText: '正在建立新的报销草稿...', - helperText: '正在建立新报销草稿...', - successMeta: '新草稿已建立', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿' - } - } - }[actionType] || { - rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', - pendingText: '正在保存当前草稿...', - helperText: '正在保存草稿...', - successMeta: '草稿已保存', - successMessage: () => '草稿保存完成' - } - - try { - const baseFields = reviewInlineBaseFields.value.length - ? reviewInlineBaseFields.value - : cloneReviewEditFields(message?.reviewPayload?.edit_fields) - const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) - - savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] }) - messages.value.push(savingMessage) - nextTick(scrollToBottom) - - const payload = await submitComposer({ - rawText: actionConfig.rawText, - userText: '', - skipUserMessage: true, - files: reviewInlinePendingFiles.value, - pendingText: actionConfig.pendingText, - systemGenerated: true, - extraContext: { - review_action: actionType, - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) - } - }) - - const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) - if (tempIndex !== -1) { - messages.value.splice(tempIndex, 1) - } - - if (payload?.result?.draft_payload?.claim_no) { - messages.value.push( - createMessage('assistant', actionConfig.successMessage(payload), [], { - meta: [actionConfig.successMeta] - }) - ) - - emit( - 'draft-saved', - buildDraftSavedPayload({ - draftPayload: payload.result.draft_payload, - reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, - inlineState: reviewInlineForm.value, - linkedRequest: linkedRequest.value, - currentUser: currentUser.value - }) - ) - } else { - messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] })) - } - - nextTick(scrollToBottom) - } catch (error) { - if (savingMessage) { - const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) - if (tempIndex !== -1) { - messages.value.splice(tempIndex, 1) - } - } - messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] })) - nextTick(scrollToBottom) - } finally { - reviewActionBusy.value = false - } - } - - return { - emit, - ASSISTANT_DISPLAY_NAME, - aiAvatar, - userAvatar, - fileInputRef, - composerTextareaRef, - messageListRef, - composerDraft, - composerDatePickerOpen, - composerDateMode, - composerSingleDate, - composerRangeStartDate, - composerRangeEndDate, - composerBusinessTimeTags, - composerCanApplyDateSelection, - toggleComposerDatePicker, - closeComposerDatePicker, - setComposerDateMode, - removeComposerBusinessTimeTag, - flowPanelOpen, - flowSteps, - flowRunId, - flowRefreshBusy, - flowOverallStatusTone, - flowOverallStatusText, - attachedFiles, - composerFilesExpanded, - visibleAttachedFiles, - hiddenAttachedFileCount, - submitting, - sessionSwitchBusy, - messages, - currentInsight, - linkedRequest, - canSubmit, - activeSessionType, - isKnowledgeSession, - hotKnowledgeQuestions, - hasInsightPanelContent, - showInsightPanel, - insightPanelToggleLabel, - composerPlaceholder, - currentIntentLabel, - canDeleteCurrentSession, - latestReviewMessage, - activeReviewPayload, - activeReviewFilePreviews, - reviewDrawerMode, - isReviewDocumentDrawer, - isReviewRiskDrawer, - reviewDrawerTitle, - reviewDocumentDrawerAvailable, - reviewRiskDrawerAvailable, - reviewDocumentDrawerLabel, - reviewDocumentDrawerIcon, - reviewRiskDrawerLabel, - reviewRiskDrawerIcon, - activeReviewDocument, - activeReviewDocumentIndex, - activeReviewDocumentPreview, - canPreviewActiveReviewDocument, - reviewIntentText, - reviewFactCards, - reviewCategoryOptions, - reviewOtherCategoryOptions, - reviewSelectedOtherCategory, - reviewInlineDirty, - reviewInlineForm, - reviewInlineEditorKey, - reviewInlineErrors, - reviewOtherCategoryOpen, - reviewInlinePendingFiles, - DATE_INPUT_FORMAT, - REVIEW_SCENE_OTHER_OPTION, - REVIEW_SCENE_OPTIONS, - REVIEW_OTHER_CATEGORY_OPTIONS, - workbenchVisible, - reviewPanelConfidence, - reviewRiskScore, - reviewRiskSummary, - reviewRiskItems, - reviewRiskEmpty, - reviewRiskActionAvailable, - recognizedNarratives, - reviewRecognitionNotes, - reviewDocumentSummaries, - reviewDocumentCount, - reviewDocumentDirty, - reviewHasUnsavedChanges, - reviewCancelDialogOpen, - reviewEditDialogOpen, - uploadDecisionDialogOpen, - deleteSessionDialogOpen, - reviewActionBusy, - deleteSessionBusy, - reviewEditFields, - documentPreviewDialog, - shortcuts, - resolveReviewMissingSlotCards, - resolveReviewRiskBriefs, - buildReviewHeadline, - buildReviewSubline, - buildReviewStateLabel, - buildReviewStateTone, - buildReviewDisclosureTitle, - buildReviewDisclosureHint, - shouldOpenReviewDisclosure, - buildReviewTodoSectionTitle, - buildReviewTodoSectionMeta, - buildReviewAlertChips, - buildReviewTodoItems, - resolveReviewSubmitActions, - resolveReviewPrimaryAction, - resolveReviewEditAction, - buildReviewPrimaryButtonLabel, - buildReviewDecisionHint, - buildReviewMissingHint, - buildReviewRiskHint, - buildReviewActionHint, - buildReviewStatusTag, - renderMarkdown, - buildExpenseQueryWindowLabel, - buildExpenseQueryHint, - getExpenseQueryActivePage, - getExpenseQueryTotalPages, - getExpenseQueryVisibleRecords, - resolveDocumentPreview, - triggerFileUpload, - applyComposerDateSelection, - handleFilesChange, - handleComposerInput, - handleComposerEnter, - runShortcut, - runWelcomeQuickAction: runShortcut, - askHotKnowledgeQuestion, - resolveKnowledgeRankLabel, - resolveKnowledgeRankTone, - toggleFlowPanel, - openFlowPanel, - refreshFlowRunDetail, - formatFlowStepDuration, - toggleInsightPanel, - toggleReviewDocumentDrawer, - toggleReviewRiskDrawer, - toggleAttachedFilesExpanded, - removeAttachedFile, - clearAttachedFiles, - requestCloseWorkbench, - emitCloseAfterLeave, - openExpenseQueryRecord, - setExpenseQueryPage, - shiftExpenseQueryPage, - openDeleteSessionDialog, - closeDeleteSessionDialog, - confirmDeleteCurrentSession, - closeUploadDecisionDialog, - continueExistingUpload, - createNewUploadDocument, - openInlineReviewEditor, - closeInlineReviewEditor, - commitInlineReviewEditor, - clearInlineReviewFieldError, - selectInlineScene, - selectReviewCategory, - selectReviewOtherCategory, - queryDraftByClaimNo, - explainCurrentReviewRisk, - goReviewDocument, - openActiveReviewDocumentPreview, - closeDocumentPreview, - saveInlineReviewChanges, - submitComposer, - handleReviewAction, - handleSaveDraftDirectly, - closeCancelReviewDialog, - confirmCancelReview, - closeEditReviewDialog, - applyEditedReview - } - } -} +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useRouter } from 'vue-router' + +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' +import { recognizeOcrFiles } from '../../services/ocr.js' +import { fetchAgentRunDetail } from '../../services/agentAssets.js' +import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' +import { renderMarkdown } from '../../utils/markdown.js' +import { + fetchExpenseClaimAttachmentAsset, + fetchExpenseClaimDetail, + fetchExpenseClaimItemAttachmentMeta, + uploadExpenseClaimItemAttachment +} from '../../services/reimbursements.js' + +const aiAvatar = '/assets/header.png' +const userAvatar = '/assets/person.png' + +const SOURCE_LABELS = { + workbench: '来自个人工作台', + topbar: '来自发起报销', + detail: '来自智能录入', + upload: '来自附件上传', + requests: '来自报销列表' +} + +const SCENARIO_LABELS = { + expense: '报销', + accounts_receivable: '应收', + accounts_payable: '应付', + knowledge: '知识', + unknown: '通用' +} + +const INTENT_LABELS = { + query: '查询', + explain: '解释', + compare: '对比', + risk_check: '风险检查', + draft: '草稿生成', + operate: '动作请求' +} + +const DOCUMENT_TYPE_LABELS = { + travel_ticket: '行程单/机票/车票', + flight_itinerary: '机票/航班行程单', + train_ticket: '火车/高铁票', + hotel_invoice: '酒店住宿票据', + taxi_receipt: '出租车/网约车票据', + parking_toll_receipt: '停车/通行费票据', + transport_receipt: '交通出行票据', + meal_receipt: '餐饮票据', + office_invoice: '办公用品票据', + meeting_invoice: '会议/会务票据', + training_invoice: '培训票据', + vat_invoice: '增值税发票', + receipt: '一般收据/凭证', + other: '其他单据' +} + +const EXPENSE_TYPE_LABELS = { + travel: '差旅费', + hotel: '住宿费', + transport: '交通费', + meal: '伙食费', + meeting: '会务费', + entertainment: '业务招待费', + office: '办公费', + training: '培训费', + communication: '通讯费', + welfare: '福利费', + other: '其他费用' +} + +const REVIEW_SLOT_CONFIG = { + expense_type: { + title: '报销分类', + hint: '请选择本次报销分类', + status: '待确认', + icon: 'mdi mdi-shape-outline' + }, + customer_name: { + title: '关联客户', + hint: '请补充客户单位全称', + status: '待补充', + icon: 'mdi mdi-domain' + }, + time_range: { + title: '发生时间', + hint: '请按 YYYY-MM-DD 补充业务发生日期', + status: '待补充', + icon: 'mdi mdi-calendar-month-outline' + }, + location: { + title: '业务地点', + hint: '请补充业务发生地点', + status: '待补充', + icon: 'mdi mdi-map-marker-outline' + }, + merchant_name: { + title: '酒店/商户', + hint: '请补充酒店或商户名称', + status: '待补充', + icon: 'mdi mdi-storefront-outline' + }, + amount: { + title: '金额', + hint: '请补充本次费用金额', + status: '待补充', + icon: 'mdi mdi-cash' + }, + reason: { + title: '场景 / 事由', + hint: '请补充本次费用场景或事由', + status: '待补充', + icon: 'mdi mdi-text-box-outline' + }, + participants: { + title: '同行人员', + hint: '请至少填写 1 名同行人员', + status: '待补充', + icon: 'mdi mdi-account-group-outline' + }, + attachments: { + title: '票据状态', + hint: '请上传发票/收据等票据附件', + status: '未上传', + icon: 'mdi mdi-paperclip' + } +} + +const REVIEW_FALLBACK_GROUP_CODES = [ + 'other', + 'travel', + 'transport', + 'hotel', + 'meal', + 'meeting', + 'entertainment', + 'office', + 'training', + 'communication', + 'welfare' +] + +const REVIEW_CATEGORY_PRESET_OPTIONS = [ + { key: 'travel', label: '差旅费' }, + { key: 'transport', label: '交通费' }, + { key: 'hotel', label: '住宿费' }, + { key: 'meal', label: '餐费' }, + { key: 'entertainment', label: '业务招待费' }, + { key: 'other_trigger', label: '其他类型', is_other: true } +] + +const REVIEW_OTHER_CATEGORY_OPTIONS = [ + { key: 'meeting', label: '会务费' }, + { key: 'office', label: '办公费' }, + { key: 'training', label: '培训费' }, + { key: 'communication', label: '通讯费' }, + { key: 'welfare', label: '福利费' }, + { key: 'other', label: '其他费用' } +] + +const REVIEW_SCENE_OTHER_OPTION = '其他场景' +const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION] +const EXPENSE_CODE_TO_PRESET_SCENE = { + travel: '出差行程', + hotel: '住宿报销', + transport: '交通出行', + meeting: '会务活动', + entertainment: '请客户吃饭', + meal: '请客户吃饭' +} +const DATE_INPUT_FORMAT = 'YYYY-MM-DD' +const MAX_ATTACHMENTS = 10 +const MAX_OCR_DOCUMENTS = 10 +const VISIBLE_ATTACHMENT_CHIPS = 2 +const COMPOSER_TEXTAREA_HEIGHT = 36 +const COMPOSER_MAX_ROWS = 5 +const EXPENSE_QUERY_PAGE_SIZE = 5 +const SESSION_TYPE_EXPENSE = 'expense' +const SESSION_TYPE_KNOWLEDGE = 'knowledge' +const REVIEW_DRAWER_MODE_REVIEW = 'review' +const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' +const REVIEW_DRAWER_MODE_RISK = 'risk' +const REVIEW_DRAWER_MODE_FLOW = 'flow' +const FLOW_STEP_STATUS_PENDING = 'pending' +const FLOW_STEP_STATUS_RUNNING = 'running' +const FLOW_STEP_STATUS_COMPLETED = 'completed' +const FLOW_STEP_STATUS_FAILED = 'failed' +const FLOW_STEP_FALLBACKS = { + intent: { + title: '意图识别', + tool: 'IntentRecognizer', + runningText: '正在识别业务意图...', + completedText: '意图识别完成' + }, + extraction: { + title: '信息提取', + tool: 'SemanticExtractor', + runningText: '正在提取时间、金额、费用类型和待补项...', + completedText: '信息提取完成' + }, + ocr: { + title: '票据/OCR识别', + tool: 'OCRService', + runningText: '正在识别票据附件...', + completedText: '票据识别完成' + }, + agent: { + title: '智能体编排', + tool: 'UserAgent', + runningText: '正在调用财务智能体...', + completedText: '智能体处理完成' + }, + result: { + title: '生成结果', + tool: 'ResultGenerator', + runningText: '正在生成解释与草稿...', + completedText: '结果已生成' + } +} +const ASSISTANT_DISPLAY_NAME = '财务助手' + +const EXPENSE_WELCOME_QUICK_ACTIONS = [ + { + label: '发起差旅报销', + prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', + icon: 'mdi mdi-bag-suitcase-outline' + }, + { + label: '招待费报销', + prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', + icon: 'mdi mdi-food-fork-drink' + }, + { + label: '交通费报销', + prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。', + icon: 'mdi mdi-car-outline' + }, + { + label: '上传票据识别', + prompt: '我已准备好票据,请帮我识别并生成报销草稿。', + icon: 'mdi mdi-file-upload-outline' + }, + { + label: '查询近期报销', + prompt: '帮我查询近10天的报销记录和金额汇总。', + icon: 'mdi mdi-chart-timeline-variant' + }, + { + label: '解释报销风险', + prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。', + icon: 'mdi mdi-shield-alert-outline' + } +] + +const HOT_KNOWLEDGE_QUESTIONS = [ + '差旅住宿标准按什么规则执行?', + '酒店超标后如何申请例外报销?', + '招待费报销需要哪些凭证?', + '发票抬头不一致还能报销吗?', + '电子发票验真失败怎么处理?', + '借款多久内需要冲销?', + '预算不足还能先提交报销吗?', + '会议费和招待费如何区分?', + '跨部门项目费用应该怎么归集?', + '员工退票手续费是否可以报销?' +] +const CATEGORY_CONFIDENCE_KEYWORDS = { + travel: [/出差|差旅|行程|机票|火车|高铁|航班/], + hotel: [/住宿|酒店|宾馆|民宿/], + transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/], + meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], + meeting: [/会务|会议|论坛|展会|参会|会场/], + entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], + office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], + training: [/培训|授课|讲师|课程|签到|讲义/], + communication: [/通讯|电话|流量|话费|宽带|网络/], + welfare: [/福利|体检|团建|节日|慰问|关怀/] +} +const FLOW_MISSING_SLOT_LABELS = { + expense_type: '报销类型', + customer_name: '客户名称', + time_range: '发生时间', + location: '地点', + merchant_name: '酒店/商户', + amount: '金额', + reason: '事由说明', + participants: '参与人员', + attachments: '票据附件' +} +const FLOW_INTENT_KEYWORDS = { + draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'], + query: ['查询', '查一下', '多少', '明细', '统计'], + risk_check: ['风险', '异常', '重复', '超标'], + explain: ['为什么', '依据', '规则', '怎么'] +} + +let messageSeed = 0 + +function nowTime() { + return new Date().toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) +} + +function createMessage(role, text, attachments = [], extras = {}) { + messageSeed += 1 + return { + id: `msg-${messageSeed}`, + role, + text, + attachments, + time: nowTime(), + meta: [], + citations: [], + suggestedActions: [], + queryPayload: null, + draftPayload: null, + reviewPayload: null, + riskFlags: [], + ...extras + } +} + +function formatMessageTime(value) { + if (!value) { + return nowTime() + } + + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return nowTime() + } + + return parsed.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) +} + +function createFlowSteps(options = {}) { + const keys = [] + if (options.includeIntent) { + keys.push('intent') + } + if (options.includeOcr) { + keys.push('ocr') + } + if (options.includeExtraction) { + keys.push('extraction') + } + if (options.includeAgent) { + keys.push('agent') + } + if (options.includeResult) { + keys.push('result') + } + + return keys.map((key, index) => { + const definition = FLOW_STEP_FALLBACKS[key] || {} + return { + key, + index: index + 1, + title: definition.title || '智能体工具调用', + tool: definition.tool || 'AgentTool', + status: FLOW_STEP_STATUS_PENDING, + detail: '', + durationMs: null, + startedAt: 0, + finishedAt: 0, + error: '' + } + }) +} + +function formatSemanticEntityValue(entity) { + const normalizedValue = String(entity?.normalized_value || '').trim() + const rawValue = String(entity?.value || '').trim() + const entityType = String(entity?.type || '').trim() + + if (entityType === 'amount') { + const numericValue = Number(normalizedValue || rawValue) + if (Number.isFinite(numericValue) && numericValue > 0) { + return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + return rawValue || normalizedValue +} + +function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { + if (!semanticParse || typeof semanticParse !== 'object') { + return FLOW_STEP_FALLBACKS.extraction.completedText + } + + const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] + const entityMap = new Map() + for (const item of entities) { + const entityType = String(item?.type || '').trim() + if (!entityType || entityMap.has(entityType)) continue + entityMap.set(entityType, item) + } + + const extractedParts = [] + const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' + ? semanticParse.time_range_json + : {} + const startDate = String(timeRange.start_date || '').trim() + const endDate = String(timeRange.end_date || '').trim() + if (startDate) { + extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) + } + + const amountEntity = entityMap.get('amount') + if (amountEntity) { + const amountValue = formatSemanticEntityValue(amountEntity) + if (amountValue) { + extractedParts.push(`金额 ${amountValue}`) + } + } + + const expenseTypeEntity = entityMap.get('expense_type') + if (expenseTypeEntity) { + const expenseTypeLabel = resolveExpenseTypeLabel( + String(expenseTypeEntity?.normalized_value || '').trim(), + String(expenseTypeEntity?.value || '').trim() + ) + if (expenseTypeLabel) { + extractedParts.push(`费用类型 ${expenseTypeLabel}`) + } + } + + const customerEntity = entityMap.get('customer') + if (customerEntity) { + const customerValue = formatSemanticEntityValue(customerEntity) + if (customerValue) { + extractedParts.push(`客户 ${customerValue}`) + } + } + + const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] + const missingLabels = missingSlots + .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) + .filter(Boolean) + + if (extractedParts.length && missingLabels.length) { + return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` + } + if (extractedParts.length) { + return `已提取${extractedParts.join('、')}` + } + if (missingLabels.length) { + return `已完成信息提取;待补充 ${missingLabels.join('、')}` + } + return FLOW_STEP_FALLBACKS.extraction.completedText +} + +function summarizeSemanticIntentDetail(semanticParse) { + if (!semanticParse || typeof semanticParse !== 'object') { + return FLOW_STEP_FALLBACKS.intent.completedText + } + + const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用' + const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理' + return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}` +} + +function extractLocalFlowCandidates(rawText) { + const text = String(rawText || '').trim() + const compact = text.replace(/\s+/g, '') + + let time = '' + const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (explicitTimeMatch?.[1]) { + time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else { + const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (dateMatch?.[1]) { + time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else if (/今天|今日/.test(compact)) { + time = '今天' + } else if (/昨天|昨日/.test(compact)) { + time = '昨天' + } else if (/前天/.test(compact)) { + time = '前天' + } + } + + let amount = '' + const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/) + if (amountMatch?.[1]) { + const numericValue = Number(amountMatch[1]) + if (Number.isFinite(numericValue)) { + amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + let event = '' + let expenseType = '' + if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) { + event = '请客户吃饭' + expenseType = '业务招待费' + } else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) { + event = '出差行程' + expenseType = '差旅费' + } else if (/打车|网约车|出租车|车费|停车/.test(compact)) { + event = '交通出行' + expenseType = '交通费' + } else if (/住宿|酒店|宾馆/.test(compact)) { + event = '住宿报销' + expenseType = '住宿费' + } else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) { + event = '餐饮用餐' + expenseType = '餐费' + } + + return { + time, + amount, + event, + expenseType + } +} + +function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return '初步识别为财务知识问答,正在准备检索范围' + } + + const text = String(rawText || '').trim() + const compact = text.replace(/\s+/g, '') + const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) => + keywords.some((keyword) => compact.includes(keyword)) + )?.[0] || 'draft' + const intentLabel = INTENT_LABELS[intentKey] || '处理' + return `初步识别为报销场景,准备进入${intentLabel}` +} + +function buildLocalExtractionProgressMessages(rawText, options = {}) { + const candidates = extractLocalFlowCandidates(rawText) + const messages = [] + + messages.push('正在提取发生时间...') + messages.push( + candidates.time + ? `发现发生时间 ${candidates.time},继续提取金额...` + : '暂未定位到明确时间,继续提取金额...' + ) + messages.push( + candidates.amount + ? `发现金额 ${candidates.amount},继续识别事件类型...` + : '暂未定位到明确金额,继续识别事件类型...' + ) + + if (candidates.event || candidates.expenseType) { + const eventParts = [candidates.event, candidates.expenseType].filter(Boolean) + messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`) + } else { + messages.push('正在识别事件类型和费用分类...') + } + + const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件' + messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`) + + return messages +} + +function formatFlowDuration(ms) { + const numericValue = Number(ms) + if (!Number.isFinite(numericValue) || numericValue < 0) { + return '--' + } + if (numericValue < 100) { + return '<0.1s' + } + if (numericValue < 1000) { + return `${(numericValue / 1000).toFixed(1)}s` + } + if (numericValue < 10000) { + return `${(numericValue / 1000).toFixed(1)}s` + } + return `${Math.round(numericValue / 1000)}s` +} + +function parseFlowTimestamp(value) { + const timestamp = new Date(value || '').getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function resolveSemanticPhaseDurations(run) { + const runStart = parseFlowTimestamp(run?.started_at) + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + const firstToolStartedAt = toolCalls + .map((item) => parseFlowTimestamp(item?.created_at)) + .filter((value) => value > 0) + .sort((left, right) => left - right)[0] || 0 + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const semanticFinishedAt = firstToolStartedAt || runFinishedAt + + if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { + return { intentMs: null, extractionMs: null } + } + + const totalMs = semanticFinishedAt - runStart + const intentMs = Math.max(120, Math.round(totalMs * 0.35)) + const extractionMs = Math.max(160, totalMs - intentMs) + return { + intentMs, + extractionMs + } +} + +function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { + const explicitDuration = Number(toolCall?.duration_ms) + if (Number.isFinite(explicitDuration) && explicitDuration > 0) { + return explicitDuration + } + + const startedAt = parseFlowTimestamp(toolCall?.created_at) + if (!startedAt) { + return null + } + + const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) + + if (!finishedAt || finishedAt <= startedAt) { + return null + } + + return finishedAt - startedAt +} + +function resolveResultStepDurationMs(run) { + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + if (!runFinishedAt) { + return null + } + + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + const semanticFinishedAt = ( + toolCalls + .map((item, index) => { + const startedAt = parseFlowTimestamp(item?.created_at) + const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run) + if (!startedAt || !durationMs) { + return 0 + } + return startedAt + durationMs + }) + .filter((value) => value > 0) + .sort((left, right) => right - left)[0] + ) || parseFlowTimestamp(run?.started_at) + + if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) { + return null + } + + return runFinishedAt - semanticFinishedAt +} + +function sanitizeRequest(request) { + if (!request || typeof request !== 'object') return null + + const normalized = { + id: String(request.id || '').trim(), + typeLabel: String(request.typeLabel || request.category || '').trim(), + reason: String(request.reason || request.title || '').trim(), + entity: String(request.entity || '').trim(), + city: String(request.city || request.location || '').trim(), + period: String(request.period || '').trim(), + applyTime: String(request.applyTime || request.occurredAt || '').trim(), + amount: String(request.amount || '').trim(), + node: String(request.node || '').trim(), + approval: String(request.approval || '').trim(), + travel: String(request.travel || '').trim() + } + + return Object.values(normalized).some(Boolean) ? normalized : null +} + +function resolveStatusLabel(status) { + if (status === 'succeeded') return '已完成' + if (status === 'blocked') return '已阻断' + return '失败' +} + +function resolveStatusTone(status) { + if (status === 'succeeded') return 'success' + if (status === 'blocked') return 'warning' + return 'note' +} + +function buildMessageMeta(payload, fileNames = []) { + const items = [] + + if (payload?.selected_agent) { + items.push(`Agent: ${payload.selected_agent}`) + } + + if (payload?.permission_level) { + items.push(`权限: ${payload.permission_level}`) + } + + if (payload?.trace_summary?.tool_count) { + items.push(`工具: ${payload.trace_summary.tool_count}`) + } + + if (payload?.trace_summary?.degraded) { + items.push('已降级') + } + + if (payload?.requires_confirmation) { + items.push('待确认') + } + + if (payload?.run_id) { + items.push(`Run: ${payload.run_id}`) + } + + if (fileNames.length) { + items.push(`附件: ${fileNames.length}`) + } + + return items +} + +function buildStoredMessageMeta(messageJson, attachmentNames = []) { + const payload = messageJson?.orchestrator_payload + if (payload) { + return buildMessageMeta(payload, attachmentNames) + } + + const items = [] + if (messageJson?.status) { + items.push(`状态: ${messageJson.status}`) + } + if (attachmentNames.length) { + items.push(`附件: ${attachmentNames.length}`) + } + return items +} + +function normalizeOcrDocuments(payload) { + const documents = Array.isArray(payload?.documents) ? payload.documents : [] + return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ + filename: item.filename, + summary: item.summary, + text: String(item.text || '').slice(0, 240), + avg_score: Number(item.avg_score || 0), + line_count: Number(item.line_count || 0), + document_type: String(item.document_type || 'other').trim() || 'other', + document_type_label: String(item.document_type_label || '').trim(), + scene_code: String(item.scene_code || 'other').trim() || 'other', + scene_label: String(item.scene_label || '').trim(), + preview_kind: String(item.preview_kind || '').trim(), + preview_data_url: String(item.preview_data_url || '').trim(), + preview_url: String(item.preview_url || '').trim(), + document_fields: Array.isArray(item.document_fields) + ? item.document_fields + .map((field) => ({ + key: String(field?.key || '').trim(), + label: String(field?.label || '').trim(), + value: String(field?.value || '').trim() + })) + .filter((field) => field.key && field.label && field.value) + : [], + warnings: Array.isArray(item.warnings) ? item.warnings : [] + })) +} + +function buildOcrSummary(payload) { + return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) +} + +function buildOcrSummaryFromDocuments(documents) { + return (Array.isArray(documents) ? documents : []) + .slice(0, MAX_OCR_DOCUMENTS) + .map((item) => { + const filename = String(item?.filename || '').trim() + const summary = String(item?.summary || item?.text || '').trim() + if (filename && summary) { + return `${filename}:${summary}` + } + return filename || summary + }) + .filter(Boolean) + .join(';') +} + +function normalizeReviewDocumentFieldKey(label) { + const compact = String(label || '').replace(/\s+/g, '').toLowerCase() + if (!compact) return '' + if ( + ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => + compact.includes(token.toLowerCase()) + ) + ) { + return 'amount' + } + if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { + return 'date' + } + if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { + return 'merchant_name' + } + if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { + return 'invoice_number' + } + if (compact.includes('发票代码')) { + return 'invoice_code' + } + if (compact.includes('车次') || compact.includes('航班')) { + return 'trip_no' + } + if (compact.includes('行程') || compact.includes('路线')) { + return 'route' + } + return compact +} + +function buildOcrDocumentsFromReviewPayload(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { + const fields = Array.isArray(item?.fields) + ? item.fields + .map((field) => { + const label = String(field?.label || '').trim() + const value = String(field?.value || '').trim() + if (!label || !value) { + return null + } + return { + key: normalizeReviewDocumentFieldKey(label), + label, + value + } + }) + .filter(Boolean) + : [] + + return { + filename: String(item?.filename || '').trim(), + summary: String(item?.summary || '').trim(), + text: [ + String(item?.scene_label || '').trim(), + String(item?.summary || '').trim(), + ...fields.map((field) => `${field.label}:${field.value}`) + ] + .filter(Boolean) + .join(' ') + .slice(0, 240), + avg_score: Number(item?.avg_score || 0), + document_type: String(item?.document_type || 'other').trim() || 'other', + document_type_label: resolveDocumentTypeLabel(item?.document_type), + scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), + scene_label: String(item?.scene_label || '').trim(), + document_fields: fields, + warnings: Array.isArray(item?.warnings) ? item.warnings : [] + } + }).filter((item) => item.filename) +} + +function mergeUploadAttachmentNames(existingNames, incomingNames) { + const merged = [] + const seen = new Set() + + for (const value of [...(existingNames || []), ...(incomingNames || [])]) { + const normalized = String(value || '').trim() + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + merged.push(normalized) + if (merged.length >= MAX_ATTACHMENTS) { + break + } + } + + return merged +} + +function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { + const merged = [] + const seen = new Set() + + for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { + const filename = String(item?.filename || '').trim() + if (!filename || seen.has(filename)) continue + seen.add(filename) + merged.push(item) + if (merged.length >= MAX_OCR_DOCUMENTS) { + break + } + } + + return merged +} + +function inferPreviewKind(file) { + const mediaType = String(file?.type || '').toLowerCase() + const filename = String(file?.name || '').toLowerCase() + if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { + return 'image' + } + if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { + return 'pdf' + } + return 'file' +} + +function buildFilePreviews(files, previewRegistry) { + return files.map((file) => { + const kind = inferPreviewKind(file) + if (!['image', 'pdf'].includes(kind)) { + return { + filename: file.name, + kind + } + } + + const url = URL.createObjectURL(file) + previewRegistry.push(url) + return { + filename: file.name, + kind, + url + } + }) +} + +function resolveDocumentPreview(filePreviews, filename) { + if (!Array.isArray(filePreviews)) return null + const matches = filePreviews.filter((item) => item.filename === filename) + if (!matches.length) { + return null + } + return ( + matches.find((item) => item.kind === 'image' && item.url) || + matches.find((item) => item.url) || + matches[0] + ) +} + +function buildFileIdentity(file) { + return [file?.name, file?.size, file?.lastModified, file?.type].join('__') +} + +function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { + const nextFiles = [] + const seen = new Set() + + for (const file of Array.isArray(existingFiles) ? existingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) continue + seen.add(key) + nextFiles.push(file) + } + + let duplicateCount = 0 + let overflowCount = 0 + + for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) { + duplicateCount += 1 + continue + } + if (nextFiles.length >= limit) { + overflowCount += 1 + continue + } + seen.add(key) + nextFiles.push(file) + } + + return { + files: nextFiles, + duplicateCount, + overflowCount + } +} + +function mergeFilePreviews(existingPreviews, incomingPreviews) { + const result = [] + const seen = new Set() + + for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { + const key = [preview?.filename, preview?.kind].join('__') + if (!preview?.filename || seen.has(key)) continue + seen.add(key) + result.push(preview) + } + + return result +} + +function buildOcrFilePreviews(payload) { + const documents = Array.isArray(payload?.documents) ? payload.documents : [] + return documents + .map((item) => ({ + filename: String(item?.filename || '').trim(), + kind: String(item?.preview_kind || '').trim(), + url: String(item?.preview_url || item?.preview_data_url || '').trim() + })) + .filter((item) => item.filename && item.kind === 'image' && item.url) +} + +function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return documents + .map((item) => ({ + filename: String(item?.filename || '').trim(), + kind: String(item?.preview_kind || '').trim(), + url: String(item?.preview_url || item?.preview_data_url || '').trim() + })) + .filter((item) => item.filename && item.kind === 'image' && item.url) +} + +function buildReviewFilePreviewsFromMessages(messages) { + const previews = [] + for (const message of Array.isArray(messages) ? messages : []) { + previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) + } + return mergeFilePreviews([], previews) +} + +function resolveAttachmentPreviewKind(metadata) { + const explicitKind = String(metadata?.preview_kind || '').trim() + if (explicitKind) { + return explicitKind + } + + const mediaType = String(metadata?.media_type || '').trim().toLowerCase() + if (mediaType.startsWith('image/')) { + return 'image' + } + if (mediaType === 'application/pdf') { + return 'pdf' + } + return '' +} + +function extractReviewAttachmentNames(reviewPayload) { + const documentNames = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) + : [] + if (documentNames.length) { + return documentNames + } + + const slotMap = buildReviewSlotMap(reviewPayload) + const attachmentValue = String(slotMap.attachments?.value || '').trim() + if (!attachmentValue) { + return [] + } + + return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) +} + +function cloneReviewDocumentDrafts(items) { + return (Array.isArray(items) ? items : []).map((item) => ({ + ...item, + warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item?.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +function buildReviewDocumentDrafts(reviewPayload) { + return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ + index: Number(item.index || 0), + filename: String(item.filename || '').trim(), + document_type: String(item.document_type || 'other').trim() || 'other', + suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + confidenceLabel: String(item.confidenceLabel || '').trim(), + documentTypeLabel: String(item.documentTypeLabel || '').trim(), + expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), + preview_kind: String(item.preview_kind || '').trim(), + preview_data_url: String(item.preview_data_url || '').trim(), + warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +function normalizeReviewDocumentComparableValue(item) { + return { + index: Number(item?.index || 0), + filename: String(item?.filename || '').trim(), + scene_label: String(item?.scene_label || '').trim(), + summary: String(item?.summary || '').trim(), + fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || '').trim() + })) + } +} + +function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { + const baseMap = new Map( + cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) + ) + + return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { + const key = `${item.index}:${item.filename}` + const base = baseMap.get(key) + const changes = [] + const nextSceneLabel = String(item.scene_label || '').trim() + const baseSceneLabel = String(base?.scene_label || '').trim() + const nextSummary = String(item.summary || '').trim() + const baseSummary = String(base?.summary || '').trim() + + if (nextSceneLabel !== baseSceneLabel) { + changes.push(`票据场景:${nextSceneLabel || '待补充'}`) + } + + if (nextSummary !== baseSummary) { + changes.push(`识别摘要:${nextSummary || '待补充'}`) + } + + const baseFieldMap = new Map( + (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ + String(field?.label || '').trim(), + String(field?.value || '').trim() + ]) + ) + + for (const field of Array.isArray(item.fields) ? item.fields : []) { + const label = String(field?.label || '').trim() + if (!label) continue + const nextValue = String(field?.value || '').trim() + const baseValue = baseFieldMap.get(label) || '' + if (nextValue !== baseValue) { + changes.push(`${label}:${nextValue || '待补充'}`) + } + } + + if (changes.length) { + lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) + } + + return lines + }, []) +} + +function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { + const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + if (!lines.length) { + return '' + } + + return `请同步修正逐票据识别结果:\n${lines.join('\n')}` +} + +function buildReviewDocumentCorrectionContext(drafts) { + return cloneReviewDocumentDrafts(drafts).map((item) => ({ + index: item.index, + filename: item.filename, + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + fields: item.fields.map((field) => ({ + label: String(field.label || '').trim(), + value: String(field.value || '').trim() + })) + })) +} + +function buildWelcomeUserContext(user = {}) { + const username = String(user.username || '').trim() + const name = String(user.name || username || '同事').trim() + const grade = String(user.grade || '').trim() + const position = String(user.position || '').trim() + const role = String(user.role || '').trim() + const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] + const isAdmin = + Boolean(user.isAdmin) + || username.toLowerCase() === 'admin' + || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) + || /管理员|系统管理/.test(position) + || /管理员|系统管理/.test(role) + + const now = new Date() + const dateLine = now.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }) + + let honorific = name + if (isAdmin) { + honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' + } else { + const prefix = [grade, position].filter(Boolean).join(' ') + honorific = prefix ? `${prefix} ${name}`.trim() : name + } + + return { + name, + username, + grade, + position, + role, + isAdmin, + honorific, + dateLine + } +} + +function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ + label: question.length > 20 ? `${question.slice(0, 20)}…` : question, + prompt: question, + icon: 'mdi mdi-comment-question-outline' + })) + } + + if (entrySource === 'detail' && linkedRequest?.id) { + return [ + { + label: '补充当前单据票据', + prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`, + icon: 'mdi mdi-file-plus-outline' + }, + { + label: '解释本单风险', + prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`, + icon: 'mdi mdi-shield-alert-outline' + }, + ...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4) + ] + } + + return EXPENSE_WELCOME_QUICK_ACTIONS +} + +function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + const ctx = buildWelcomeUserContext(user || {}) + const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` + + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + '欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。', + '', + '您可以直接输入问题,或点击下方「猜你想问」快速开始。' + ].join('\n') + } + + if (entrySource === 'detail' && linkedRequest?.id) { + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, + '', + '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' + ].join('\n') + } + + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + '**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销草稿整理、待补项提醒和风险说明。', + '', + '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' + ].join('\n') +} + +function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + const ctx = buildWelcomeUserContext(user || {}) + + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return { + intent: 'welcome', + metricLabel: '今日', + metricValue: ctx.dateLine.split(' ')[0] || '—', + title: '财务知识问答', + summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, + agent: null + } + } + + return { + intent: 'welcome', + metricLabel: '助手状态', + metricValue: '待您吩咐', + title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心', + summary: + entrySource === 'detail' && linkedRequest?.id + ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` + : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, + agent: null + } +} + +function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { + assistantName: ASSISTANT_DISPLAY_NAME, + isWelcome: true, + welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) + }) +} + +function resolveInitialSessionType(conversation) { + const stateJson = conversation?.state_json || conversation?.stateJson || {} + const sessionType = String(stateJson?.session_type || '').trim() + return sessionType || SESSION_TYPE_EXPENSE +} + +function buildInitialInsightFromConversation(conversation) { + const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] + for (let index = rawMessages.length - 1; index >= 0; index -= 1) { + const item = rawMessages[index] + const messageJson = item?.message_json || item?.messageJson || {} + const orchestratorPayload = messageJson?.orchestrator_payload || null + if (!orchestratorPayload) continue + const attachmentNames = Array.isArray(messageJson?.attachment_names) + ? messageJson.attachment_names.filter(Boolean) + : [] + return buildAgentInsight( + orchestratorPayload, + attachmentNames, + buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) + ) + } + return null +} + +function resolveInitialConversationId(conversation) { + return String(conversation?.conversation_id || conversation?.conversationId || '').trim() +} + +function resolveInitialDraftClaimId(conversation) { + return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() +} + +function resolveKnowledgeRankLabel(index) { + return String(index + 1) +} + +function resolveKnowledgeRankTone(index) { + if (index === 0) return 'gold' + if (index === 1) return 'silver' + if (index === 2) return 'bronze' + return 'default' +} + +function parseConversationMessageSequence(message) { + const messageJson = message?.message_json || message?.messageJson || {} + const sequence = Number.parseInt(messageJson?.sequence, 10) + return Number.isFinite(sequence) && sequence > 0 ? sequence : null +} + +function parseConversationMessageTime(message) { + const rawValue = message?.created_at || message?.createdAt || '' + const timestamp = new Date(rawValue).getTime() + return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER +} + +function resolveConversationMessageRolePriority(message) { + return String(message?.role || '').trim() === 'user' ? 0 : 1 +} + +function sortConversationMessages(messages) { + return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { + const leftSequence = parseConversationMessageSequence(left) + const rightSequence = parseConversationMessageSequence(right) + if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { + return leftSequence - rightSequence + } + + const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) + if (timeDiff !== 0) { + return timeDiff + } + + const leftRunId = String(left?.run_id || left?.runId || '').trim() + const rightRunId = String(right?.run_id || right?.runId || '').trim() + if (leftRunId && rightRunId && leftRunId === rightRunId) { + const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) + if (roleDiff !== 0) { + return roleDiff + } + } + + return String(left?.id || '').localeCompare(String(right?.id || '')) + }) +} + +function normalizeInitialConversationMessages(conversation) { + const rawMessages = sortConversationMessages(conversation?.messages) + + return rawMessages.map((item) => { + const messageJson = item?.message_json || item?.messageJson || {} + const attachmentNames = Array.isArray(messageJson?.attachment_names) + ? messageJson.attachment_names.filter(Boolean) + : [] + const orchestratorPayload = messageJson?.orchestrator_payload || null + const result = orchestratorPayload?.result || {} + + return createMessage(item.role, item.content, attachmentNames, { + id: `restored-${item.id || ++messageSeed}`, + time: formatMessageTime(item.created_at || item.createdAt), + meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], + citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], + suggestedActions: + item.role === 'assistant' && Array.isArray(result?.suggested_actions) + ? result.suggested_actions + : [], + queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, + draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, + reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, + riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] + }) + }) +} + +function cloneReviewEditFields(fields) { + const items = Array.isArray(fields) ? fields : [] + return items.map((item) => ({ + key: String(item?.key || '').trim(), + label: String(item?.label || '').trim(), + value: String(item?.value || ''), + placeholder: String(item?.placeholder || ''), + required: Boolean(item?.required), + field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', + group: String(item?.group || 'basic').trim() || 'basic' + })) +} + +function buildReviewFormValues(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) { + return result + } + result[item.key] = String(item.value || '').trim() + return result + }, {}) +} + +function buildReviewCorrectionMessage(fields) { + const lines = ['请按以下核对后的报销信息更新当前识别结果:'] + for (const item of cloneReviewEditFields(fields)) { + if (!item.label || (!item.value && !item.required)) { + continue + } + lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`) + } + return lines.join('\n') +} + +function buildReviewEditFieldMap(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) return result + result[item.key] = item + return result + }, {}) +} + +function createEmptyInlineReviewState() { + return { + occurred_date: '', + amount: '', + scene_label: '', + reason_value: '', + customer_name: '', + location: '', + merchant_name: '', + participants: '', + attachment_names: '', + attachment_count: 0, + pending_attachment_count: 0, + expense_type: '' + } +} + +function buildClientTimeContext() { + const now = new Date() + const locale = + typeof navigator !== 'undefined' && typeof navigator.language === 'string' + ? navigator.language + : 'zh-CN' + + return { + client_now_iso: now.toISOString(), + client_timezone_offset_minutes: now.getTimezoneOffset(), + client_locale: locale + } +} + +function formatDraftApplyTime(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +function formatDateInputValue(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function buildDraftSavedPayload({ + draftPayload, + reviewPayload, + inlineState, + linkedRequest, + currentUser +}) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const riskItems = buildReviewRiskItems(reviewPayload) + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) + const amountNumber = parseAmountNumber(inlineState?.amount) + const location = String(inlineState?.location || linkedRequest?.city || '').trim() + const customerName = String(inlineState?.customer_name || '').trim() + const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() + const title = + String(inlineState?.reason_value || '').trim() + || String(inlineState?.scene_label || '').trim() + || String(draftPayload?.title || '').trim() + || `${typeLabel}报销草稿` + const sceneLabel = + String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel + const attachmentSummary = documents.length + ? `${documents.length} 条识别票据 / ${documents.length} 份材料` + : String(inlineState?.attachment_names || '').trim() + ? '1 条识别票据 / 1 份材料' + : '待上传票据' + + return { + claimId: String(draftPayload?.claim_id || '').trim(), + claimNo: String(draftPayload?.claim_no || '').trim(), + status: String(draftPayload?.status || '').trim(), + approvalStage: String(draftPayload?.approval_stage || '').trim(), + person: String(currentUser?.name || '').trim() || '当前用户', + dept: String(currentUser?.role || '').trim() || '待补充部门', + entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', + typeCode, + typeLabel, + detailVariant: typeCode === 'travel' ? 'travel' : 'general', + title, + sceneLabel, + sceneTarget: location || customerName || '待补充', + location, + relatedCustomer: customerName, + occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', + applyTime: formatDraftApplyTime(), + amount: amountNumber === null ? 0 : amountNumber, + secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', + secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', + secondaryStatusTone: documents.length ? 'warning' : 'neutral', + riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), + attachmentSummary, + expenseTableSummary: documents.length + ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` + : '当前尚未上传票据,请在报销页继续补充附件', + note: String(draftPayload?.status || '').trim() === 'submitted' + ? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。' + : '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' + } +} + +function resolveReviewRecognizedSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') + : [] +} + +function resolveReviewMissingSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') + : [] +} + +function resolveReviewRiskBriefs(reviewPayload) { + return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : [] +} + +function formatConfidenceLabel(value) { + const score = Number(value || 0) + if (!score) return '待补充' + return `${Math.round(score * 100)}%` +} + +function resolveDocumentTypeLabel(type) { + return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other +} + +function resolveExpenseTypeLabel(type, fallbackLabel = '') { + const normalized = String(type || '').trim() + return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other +} + +function buildReviewRecognizedLines(reviewPayload) { + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .map((item) => `${item.label}:${item.value}`) +} + +function buildReviewSlotMap(reviewPayload) { + return Object.fromEntries( + (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) + ) +} + +function resolveExpenseTypeCode(value) { + const normalized = String(value || '').trim() + if (!normalized) return 'other' + if (EXPENSE_TYPE_LABELS[normalized]) return normalized + const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) + return matched?.[0] || 'other' +} + +function isValidIsoDateString(value) { + const normalized = String(value || '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return false + } + + const [yearText, monthText, dayText] = normalized.split('-') + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return false + } + + const candidate = new Date(Date.UTC(year, month - 1, day)) + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ) +} + +function parseAmountNumber(value) { + const normalized = String(value || '') + .replace(/[,,\s]/g, '') + .replace(/[¥¥]/g, '') + .replace(/元/g, '') + .trim() + + if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { + return null + } + + const amount = Number(normalized) + return Number.isFinite(amount) ? amount : null +} + +function normalizeAmountValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return '' + } + return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` +} + +function extractAmountInputValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') +} + +function formatAmountDisplay(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, + maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }).format(amount) +} + +function normalizeExpenseQueryStatusGroup(item) { + if (!item || typeof item !== 'object') { + return null + } + + const rawCount = Number(item.count || 0) + return { + key: String(item.key || 'other').trim() || 'other', + label: String(item.label || '其他状态').trim() || '其他状态', + count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 + } +} + +function normalizeExpenseQueryRecord(item) { + if (!item || typeof item !== 'object') { + return null + } + + const amount = Number(item.amount || 0) + const amountValue = Number.isFinite(amount) ? amount : 0 + const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' + const reason = String(item.reason || '').trim() + const documentDate = String(item.document_date || '').trim() + const occurredAt = String(item.occurred_at || '').trim() + + return { + claimId: String(item.claim_id || '').trim(), + claimNo: String(item.claim_no || '').trim() || '未编号', + employeeName: String(item.employee_name || '').trim(), + expenseType: String(item.expense_type || '').trim(), + expenseTypeLabel, + amount: amountValue, + amountDisplay: formatAmountDisplay(amountValue), + status: String(item.status || '').trim(), + statusLabel: String(item.status_label || '处理中').trim() || '处理中', + statusGroup: String(item.status_group || 'other').trim() || 'other', + statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', + approvalStage: String(item.approval_stage || '').trim(), + documentDate, + occurredAt, + reason, + location: String(item.location || '').trim(), + summary: reason || `${expenseTypeLabel}报销`, + dateDisplay: documentDate || occurredAt || '待补充日期' + } +} + +function normalizeExpenseQueryPayload(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const resultType = String(payload.result_type || '').trim() + if (resultType && resultType !== 'expense_claim_list') { + return null + } + + const records = (Array.isArray(payload.records) ? payload.records : []) + .map(normalizeExpenseQueryRecord) + .filter(Boolean) + const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) + .map(normalizeExpenseQueryStatusGroup) + .filter(Boolean) + + const rawRecordCount = Number(payload.record_count || 0) + const rawPreviewCount = Number(payload.preview_count || records.length) + const rawOlderRecordCount = Number(payload.older_record_count || 0) + const totalAmount = Number(payload.total_amount || 0) + const rawWindowDays = Number(payload.window_days || 0) + const windowStartDate = String(payload.window_start_date || '').trim() + const windowEndDate = String(payload.window_end_date || '').trim() + + return { + resultType: 'expense_claim_list', + scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', + recentWindowApplied: Boolean(payload.recent_window_applied), + windowDays: + payload.window_days === null || payload.window_days === undefined || payload.window_days === '' + ? null + : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), + windowStartDate: windowStartDate || '', + windowEndDate: windowEndDate || '', + recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, + previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, + olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, + hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), + totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, + statusGroups, + records, + currentPage: 1 + } +} + +function buildExpenseQueryWindowLabel(queryPayload) { + if (!queryPayload) { + return '' + } + + if (queryPayload.windowStartDate && queryPayload.windowEndDate) { + return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` + } + + if (queryPayload.recentWindowApplied && queryPayload.windowDays) { + return `近 ${queryPayload.windowDays} 日内` + } + + return '当前条件下' +} + +function getExpenseQueryTotalPages(queryPayload) { + const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 + return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) +} + +function getExpenseQueryActivePage(queryPayload) { + const totalPages = getExpenseQueryTotalPages(queryPayload) + const rawPage = Number(queryPayload?.currentPage || 1) + if (!Number.isFinite(rawPage)) { + return 1 + } + return Math.min(Math.max(1, Math.round(rawPage)), totalPages) +} + +function getExpenseQueryVisibleRecords(queryPayload) { + const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] + const activePage = getExpenseQueryActivePage(queryPayload) + const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE + return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) +} + +function buildExpenseQueryHint(queryPayload) { + if (!queryPayload) { + return '' + } + + const parts = [] + const windowText = buildExpenseQueryWindowLabel(queryPayload) + + if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { + parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) + } + + if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { + parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) + } + + if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { + parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) + } + + return parts.join('。') +} + +function countReviewPendingItems(reviewPayload) { + return resolveReviewMissingSlotCards(reviewPayload).length +} + +function countReviewRiskItems(reviewPayload) { + return resolveReviewRiskBriefs(reviewPayload).length +} + +function buildReviewHeadline(reviewPayload) { + if (countReviewPendingItems(reviewPayload)) { + return '待补充信息' + } + if (reviewPayload?.can_proceed) { + return '识别结果已整理完成' + } + return '识别结果摘要' +} + +function buildReviewSubline(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + + if (pendingCount) { + return `请先展开查看 ${pendingCount} 项待补充内容,再决定继续处理、修改信息或保存草稿。` + } + if (reviewPayload?.can_proceed) { + return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。' + } + return '已为您整理本轮识别结果,展开后可查看当前识别摘要。' +} + +function buildReviewStateLabel(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) return `待补充 ${pendingCount} 项` + if (reviewPayload?.can_proceed) return '可继续处理' + return '已识别' +} + +function buildReviewStateTone(reviewPayload) { + return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) + ? 'ready' + : 'pending' +} + +function buildReviewDisclosureTitle(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) { + return `当前有 ${pendingCount} 项待补充,点击展开查看` + } + return '当前信息已齐全,可展开查看识别摘要' +} + +function buildReviewDisclosureHint(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) { + return '展开后可查看待补充字段和处理建议' + } + return '展开后可查看本轮已识别的关键信息' +} + +function shouldOpenReviewDisclosure(reviewPayload) { + return !countReviewPendingItems(reviewPayload) +} + +function buildReviewTodoSectionTitle(reviewPayload) { + return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息' +} + +function buildReviewTodoSectionMeta(reviewPayload) { + const count = buildReviewTodoItems(reviewPayload).length + if (resolveReviewMissingSlotCards(reviewPayload).length) { + return count ? `${count} 项` : '待确认' + } + return count ? `${count} 项` : '已齐全' +} + +function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' + } + if (slotKey === 'participants') return '缺少同行人员' + if (slotKey === 'attachments') return '票据状态待补充' + if (slotKey === 'amount') return '金额待确认' + if (slotKey === 'time_range') return '发生时间待确认' + if (slotKey === 'reason') return '场景 / 事由待补充' + if (slotKey === 'expense_type') return '报销类型待确认' + if (slotKey === 'location') return '业务地点待补充' + if (slotKey === 'merchant_name') return '酒店/商户待补充' + return '仍有信息待补充' +} + +function buildReviewAlertChips(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() + const chips = [] + + for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { + chips.push({ + key: item.key, + label: buildReviewAlertLabel(item.key, expenseTypeLabel), + tone: item.key === 'attachments' ? 'danger' : 'warning' + }) + } + + if (chips.length < 3) { + for (const risk of resolveReviewRiskBriefs(reviewPayload)) { + if (chips.some((item) => item.label === risk.title)) continue + chips.push({ + key: risk.title, + label: risk.title, + tone: risk.level === 'high' ? 'danger' : 'warning' + }) + if (chips.length >= 3) break + } + } + + if (!chips.length && reviewPayload?.can_proceed) { + chips.push({ + key: 'ready', + label: '当前识别信息已可继续处理', + tone: 'success' + }) + } + + return chips +} + +function buildReviewTodoItems(reviewPayload) { + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + if (missingItems.length) { + return missingItems.map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-form-select', + title: config.title || item.label, + hint: item.hint || config.hint || `请补充${item.label}`, + status: config.status || '待补充', + tone: item.key === 'attachments' ? 'danger' : 'warning' + } + }) + } + + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .slice(0, 3) + .map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-check-circle-outline', + title: config.title || item.label, + hint: `已识别:${item.value}`, + status: '已识别', + tone: 'ready' + } + }) +} + +function resolveReviewPrimaryAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) + ) || null + ) +} + +function resolveReviewSubmitActions(reviewPayload) { + return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => { + const actionType = String(item?.action_type || '').trim() + return actionType && !['cancel_review', 'edit_review'].includes(actionType) + }) +} + +function resolveReviewEditAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => String(item?.action_type || '') === 'edit_review' + ) || null + ) +} + +function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { + const action = resolveReviewPrimaryAction(reviewPayload) + if (!action) return '确认' + if (action.action_type === 'save_draft') { + return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' + } + if (action.action_type === 'next_step') { + return '继续下一步' + } + if (action.action_type === 'link_to_existing_draft') { + return action.label || '关联到现有草稿' + } + if (action.action_type === 'create_new_claim_from_documents') { + return action.label || '单独建立报销单' + } + return action.label || '确认' +} + +function buildReviewIntentText(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseType = String(slotMap.expense_type?.value || '').trim() + if (expenseType) { + return `报销一笔${expenseType}` + } + return '发起一笔报销' +} + +function buildReviewSceneValue(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim() + const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim() + return inferPresetSceneFromReview(reviewPayload, reason, expenseType) +} + +function matchPresetSceneFromReason(reason) { + const compactReason = String(reason || '').trim().replace(/\s+/g, '') + if (!compactReason) { + return '' + } + if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) { + return '请客户吃饭' + } + if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) { + const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, ''))) + if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) { + return matchedPreset + } + } + if (/出差|差旅/.test(compactReason)) { + return '出差行程' + } + if (/酒店|住宿/.test(compactReason)) { + return '住宿报销' + } + if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) { + return '交通出行' + } + if (/会务|会议|参会|论坛|展会/.test(compactReason)) { + return '会务活动' + } + return '' +} + +function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') { + const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)] + if (fromCode) { + return fromCode + } + + const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '') + if (!compactLabel) { + return '' + } + if (/差旅|出差/.test(compactLabel)) { + return '出差行程' + } + if (/住宿|酒店/.test(compactLabel)) { + return '住宿报销' + } + if (/交通/.test(compactLabel)) { + return '交通出行' + } + if (/招待|餐饮|餐费|伙食/.test(compactLabel)) { + return '请客户吃饭' + } + if (/会务|会议/.test(compactLabel)) { + return '会务活动' + } + return '' +} + +function mapExpenseTypeLabelToPresetScene(expenseType) { + const code = resolveExpenseTypeCode(expenseType) + if (EXPENSE_CODE_TO_PRESET_SCENE[code]) { + return EXPENSE_CODE_TO_PRESET_SCENE[code] + } + + const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '') + if (!compactLabel) { + return '' + } + if (compactLabel.includes('差旅') || compactLabel.includes('出差')) { + return '出差行程' + } + if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) { + return '住宿报销' + } + if (compactLabel.includes('交通')) { + return '交通出行' + } + if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) { + return '请客户吃饭' + } + if (compactLabel.includes('会务') || compactLabel.includes('会议')) { + return '会务活动' + } + return matchPresetSceneFromReason(expenseType) +} + +function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (documents.length) { + const votes = new Map() + for (const document of documents) { + const preset = + mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type) + || mapExpenseTypeLabelToPresetScene(document.suggested_expense_type) + if (!preset) { + continue + } + votes.set(preset, (votes.get(preset) || 0) + 1) + } + if (votes.size) { + return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0] + } + } + + const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : [] + if (claimGroups.length === 1) { + const group = claimGroups[0] + const preset = + mapExpenseTypeLabelToPresetScene(group.expense_type) + || mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type) + if (preset) { + return preset + } + } + + const fromReason = matchPresetSceneFromReason(reasonValue) + if (fromReason) { + return fromReason + } + + const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType) + if (fromExpenseType) { + return fromExpenseType + } + + if (String(reasonValue || '').trim()) { + return REVIEW_SCENE_OTHER_OPTION + } + return '待补充' +} + +function formatReviewSceneDisplayValue(inlineState) { + const scene = String(inlineState?.scene_label || '').trim() + if (!scene || scene === '待补充') { + return '待补充' + } + if (scene === REVIEW_SCENE_OTHER_OPTION) { + const detail = String(inlineState?.reason_value || '').trim() + if (!detail) { + return REVIEW_SCENE_OTHER_OPTION + } + return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}` + } + return scene +} + +function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) { + return inferPresetSceneFromReview(reviewPayload, reason, expenseType) +} + +function buildInlineReviewState(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) + const attachmentNames = String( + editFieldMap.attachment_names?.value || + slotMap.attachments?.value || + (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') + ).trim() + const attachmentCount = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.length + : attachmentNames + ? attachmentNames.split('、').filter(Boolean).length + : 0 + const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() + const reasonValue = String( + editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' + ).trim() + const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) + + return { + occurred_date: String( + editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' + ).trim(), + amount: normalizeAmountValue( + String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() + ), + scene_label: sceneLabel, + reason_value: + sceneLabel === REVIEW_SCENE_OTHER_OPTION + ? reasonValue + : String(slotMap.reason?.raw_value || '').trim() || reasonValue, + customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), + location: String( + editFieldMap.business_location?.value || + editFieldMap.location?.value || + slotMap.location?.normalized_value || + slotMap.location?.value || + '' + ).trim(), + merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), + participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), + attachment_names: attachmentNames, + attachment_count: attachmentCount, + pending_attachment_count: 0, + expense_type: expenseType + } +} + +function buildReviewAttachmentStatus(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (!documents.length) return '未上传' + return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` +} + +function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { + const slotMap = buildReviewSlotMap(reviewPayload) + const slot = slotMap[slotKey] + return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' +} + +function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0)) + const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0)) + const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount) + const attachmentStatus = + pendingAttachmentCount > 0 + ? existingAttachmentCount > 0 + ? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份` + : `待保存 ${pendingAttachmentCount} 份` + : totalAttachmentCount > 0 + ? `已上传 ${totalAttachmentCount} 份` + : buildReviewAttachmentStatus(reviewPayload) + const cards = [ + { + key: 'occurred_date', + label: '发生时间', + value: String(inlineState.occurred_date || '').trim() || '待补充', + icon: 'mdi mdi-calendar-month-outline', + editor: 'date', + modelKey: 'occurred_date', + placeholder: `例如 ${DATE_INPUT_FORMAT}` + }, + { + key: 'amount', + label: '金额', + value: formatAmountDisplay(inlineState.amount) || '待补充', + icon: 'mdi mdi-cash', + editor: 'amount', + modelKey: 'amount', + placeholder: '例如 200.00' + }, + { + key: 'scene', + label: '场景 / 事由', + value: formatReviewSceneDisplayValue(inlineState), + icon: 'mdi mdi-silverware-fork-knife', + editor: 'select', + modelKey: 'scene_label', + placeholder: '请选择场景' + }, + { + key: 'customer_name', + label: '关联客户', + value: String(inlineState.customer_name || '').trim() || '待补充', + icon: 'mdi mdi-domain', + editor: 'text', + modelKey: 'customer_name', + placeholder: '请输入客户名称' + }, + { + key: 'attachments', + label: '票据状态', + value: attachmentStatus, + icon: 'mdi mdi-file-document-outline', + editor: 'upload', + modelKey: 'attachment_names', + placeholder: '' + } + ] + + if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { + cards.splice(4, 0, { + key: 'location', + label: '业务地点', + value: String(inlineState.location || '').trim() || '待补充', + icon: 'mdi mdi-map-marker-outline', + editor: 'text', + modelKey: 'location', + placeholder: '请输入业务地点' + }) + } + + if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) { + cards.splice(cards.length - 1, 0, { + key: 'merchant_name', + label: '酒店/商户', + value: String(inlineState.merchant_name || '').trim() || '待补充', + icon: 'mdi mdi-storefront-outline', + editor: 'text', + modelKey: 'merchant_name', + placeholder: '请输入酒店或商户名称' + }) + } + + if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) { + cards.splice(cards.length - 1, 0, { + key: 'participants', + label: '同行人员', + value: String(inlineState.participants || '').trim() || '待补充', + icon: 'mdi mdi-account-group-outline', + editor: 'text', + modelKey: 'participants', + placeholder: '例如 客户 2 人,我方 1 人' + }) + } + + return cards +} + +function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const slotMap = buildReviewSlotMap(reviewPayload) + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + + return [ + String(inlineState.reason_value || '').trim(), + String(inlineState.scene_label || '').trim(), + String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), + ...documents.map((item) => + [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] + .filter(Boolean) + .join(' ') + ) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} + +function resolveReviewCategoryTextScore(text, categoryCode) { + const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] + if (!patterns?.length || !text) { + return 0 + } + return patterns.some((pattern) => pattern.test(text)) + ? { + travel: 0.84, + hotel: 0.82, + transport: 0.8, + meal: 0.76, + meeting: 0.78, + entertainment: 0.88, + office: 0.74, + training: 0.77, + communication: 0.7, + welfare: 0.72 + }[categoryCode] || 0 + : 0 +} + +function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const matchedScores = documents + .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) + .map((item) => Number(item?.avg_score || 0)) + .filter((score) => Number.isFinite(score) && score > 0) + + if (!matchedScores.length) { + return 0 + } + + return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length +} + +function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { + const normalizedLabel = String(selectedLabel || '').trim() + if (!normalizedLabel) { + return 0 + } + + const selectedCode = resolveExpenseTypeCode(normalizedLabel) + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseSlot = slotMap.expense_type + const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') + let score = 0 + + if (recognizedCode === selectedCode) { + score = Math.max(score, Number(expenseSlot?.confidence || 0)) + } + + score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) + score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) + + if (!score && normalizedLabel) { + score = selectedCode === 'other' ? 0.52 : 0.58 + } + + return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) +} + +function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ + ...item, + active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, + confidenceLabel: item.is_other + ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) + : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), + caption: item.is_other + ? selectedLabel && !presetLabels.includes(selectedLabel) + ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` + : '点击选择更多类型' + : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, + groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' + })) +} + +function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { + return formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) + ) +} + +function buildReviewRiskScore(reviewPayload) { + const score = Number(reviewPayload?.risk_score) + if (!Number.isFinite(score) || score <= 0) { + return null + } + return Math.max(0, Math.min(100, Math.round(score))) +} + +function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' + ? '业务招待费需补充客户单位名称,以便进行合规校验。' + : '当前仍缺少客户单位名称,建议补充后再提交。' + } + if (slotKey === 'participants') { + return '缺少同行人员信息,建议补充至少 1 名。' + } + if (slotKey === 'attachments') { + return '尚未上传票据附件,当前无法完成票据核对。' + } + if (slotKey === 'amount') { + return '报销金额仍待确认,提交前需补齐金额信息。' + } + if (slotKey === 'time_range') { + return '业务发生时间仍待确认,建议补充准确日期。' + } + if (slotKey === 'reason') { + return '报销事由说明仍不完整,建议补充业务背景。' + } + return '当前仍有识别信息待补充,建议先核对后再处理。' +} + +function buildReviewRiskSummary(reviewPayload) { + if (resolveReviewRiskBriefs(reviewPayload).length) { + return '当前识别到了合规提醒,提交前建议逐项核对。' + } + return '当前版本暂未生成风险评分结果。' +} + +function buildReviewRiskItems(reviewPayload) { + return resolveReviewRiskBriefs(reviewPayload) + .map((brief) => String(brief?.content || '').trim()) + .filter(Boolean) + .slice(0, 4) +} + +function normalizeInlineReviewComparableState(state) { + const source = state && typeof state === 'object' ? state : {} + return { + occurred_date: String(source.occurred_date || '').trim(), + amount: String(source.amount || '').trim(), + scene_label: String(source.scene_label || '').trim(), + reason_value: String(source.reason_value || '').trim(), + customer_name: String(source.customer_name || '').trim(), + location: String(source.location || '').trim(), + merchant_name: String(source.merchant_name || '').trim(), + participants: String(source.participants || '').trim(), + attachment_names: String(source.attachment_names || '').trim(), + pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), + expense_type: String(source.expense_type || '').trim() + } +} + +function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { + const base = normalizeInlineReviewComparableState(baseState) + const next = normalizeInlineReviewComparableState(nextState) + const lines = [] + + if (base.occurred_date !== next.occurred_date) { + lines.push(`发生时间 ${next.occurred_date || '待补充'}`) + } + if (base.amount !== next.amount) { + lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) + } + if (base.scene_label !== next.scene_label) { + lines.push(`场景 ${next.scene_label || '待补充'}`) + } + if (base.customer_name !== next.customer_name) { + lines.push(`关联客户 ${next.customer_name || '待补充'}`) + } + if (base.location !== next.location) { + lines.push(`业务地点 ${next.location || '待补充'}`) + } + if (base.merchant_name !== next.merchant_name) { + lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) + } + if (base.participants !== next.participants) { + lines.push(`同行人员 ${next.participants || '待补充'}`) + } + if (base.expense_type !== next.expense_type) { + lines.push(`报销分类 ${next.expense_type || '待补充'}`) + } + if (base.attachment_names !== next.attachment_names || pendingFiles.length) { + lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) + } + + return lines +} + +function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { + const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + if (!lines.length) { + return '我已修改识别信息,请按最新内容更新。' + } + return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` +} + +function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { + const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + + if (!inlineLines.length && !documentLines.length) { + return '我已修改识别信息,请按最新内容更新。' + } + + const parts = [] + if (inlineLines.length) { + parts.push(inlineLines.join(',')) + } + if (documentLines.length) { + parts.push(`修正了 ${documentLines.length} 张票据识别信息`) + } + + return `我已修改识别信息:${parts.join(';')}。请按最新内容更新。` +} + +function mergeInlineReviewFields(baseFields, inlineState) { + const merged = cloneReviewEditFields(baseFields) + const updateMap = { + expense_type: inlineState.expense_type, + occurred_date: inlineState.occurred_date, + amount: inlineState.amount, + customer_name: inlineState.customer_name, + business_location: inlineState.location, + merchant_name: inlineState.merchant_name, + participants: inlineState.participants, + reason: inlineState.reason_value || inlineState.scene_label, + attachment_names: inlineState.attachment_names + } + + for (const item of merged) { + if (!(item.key in updateMap)) continue + item.value = String(updateMap[item.key] || '').trim() + } + + return merged +} + +function buildReviewRecognitionNotes(reviewPayload) { + const recognized = resolveReviewRecognizedSlotCards(reviewPayload) + const notes = [] + const timeSlot = recognized.find((item) => item.key === 'time_range') + const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] + + if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { + notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) + } + + if (sourceLabels.length) { + notes.push(`本轮主要依据:${sourceLabels.join('、')}`) + } + + const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (documentCards.length) { + notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) + } else { + notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') + } + + return notes +} + +function buildReviewDocumentSummaries(reviewPayload) { + const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return docs.map((item) => { + const fields = Array.isArray(item.fields) ? item.fields : [] + return { + ...item, + documentTypeLabel: resolveDocumentTypeLabel(item.document_type), + expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), + confidenceLabel: formatConfidenceLabel(item.avg_score), + lines: fields + .filter((field) => String(field?.value || '').trim()) + .map((field) => `${field.label}:${field.value}`) + } + }) +} + +function buildReviewDecisionHint(reviewPayload) { + const missingSlots = resolveReviewMissingSlotCards(reviewPayload) + const riskBriefs = resolveReviewRiskBriefs(reviewPayload) + if (reviewPayload?.can_proceed) { + return riskBriefs.length + ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` + : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' + } + if (missingSlots.length) { + return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` + } + return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' +} + +function buildReviewMissingHint(reviewPayload) { + const missingSlots = resolveReviewMissingSlotCards(reviewPayload) + if (!missingSlots.length) { + return '' + } + if (reviewPayload?.can_proceed) { + return '当前关键信息已经齐全,这里无需再补充。' + } + return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' +} + +function buildReviewRiskHint(reviewPayload) { + const riskBriefs = resolveReviewRiskBriefs(reviewPayload) + if (!riskBriefs.length) { + return '' + } + return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。' +} + +function buildReviewActionHint(reviewPayload) { + if (reviewPayload?.can_proceed) { + return '如果识别无误,直接点“下一步”;如果有偏差,先修改识别信息。' + } + return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' +} + +function buildReviewStatusTag(reviewPayload) { + const missingCount = resolveReviewMissingSlotCards(reviewPayload).length + if (reviewPayload?.can_proceed) { + return '可继续处理' + } + if (missingCount > 0) { + return `待补充 ${missingCount} 项` + } + return '待确认' +} + +function buildErrorInsight(error, fileNames = []) { + return { + intent: 'agent', + metricLabel: '运行状态', + metricValue: '失败', + title: '智能体调用失败', + summary: error?.message || '无法连接后端 Orchestrator。', + agent: { + runId: '未生成', + selectedAgent: 'orchestrator', + scenario: '未知', + intent: '未知', + permissionLevel: 'unknown', + routeReason: 'request_failed', + requiresConfirmation: false, + degraded: false, + fileNames, + citations: [], + suggestedActions: [], + queryPayload: null, + draftPayload: null, + reviewPayload: null, + riskFlags: [], + toolCount: 0, + failedToolCount: 0, + selectedCapabilityCodes: [], + filePreviews: [], + statusLabel: '失败', + statusTone: 'note' + } + } +} + +function buildAgentInsight(payload, fileNames = [], filePreviews = []) { + const trace = payload?.trace_summary || {} + const result = payload?.result || {} + const statusLabel = resolveStatusLabel(payload?.status) + + return { + intent: 'agent', + metricLabel: '运行状态', + metricValue: statusLabel, + title: + result?.draft_payload?.title || + `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, + summary: result?.answer || result?.message || '智能体已完成处理。', + agent: { + runId: payload?.run_id || '未生成', + selectedAgent: payload?.selected_agent || 'orchestrator', + scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', + intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', + permissionLevel: payload?.permission_level || 'unknown', + routeReason: payload?.route_reason || 'unknown', + requiresConfirmation: Boolean(payload?.requires_confirmation), + degraded: Boolean(trace?.degraded), + fileNames, + citations: Array.isArray(result?.citations) ? result.citations : [], + suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], + queryPayload: normalizeExpenseQueryPayload(result?.query_payload), + draftPayload: result?.draft_payload || null, + reviewPayload: result?.review_payload || null, + riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], + toolCount: Number(trace?.tool_count || 0), + failedToolCount: Number(trace?.failed_tool_count || 0), + selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) + ? trace.selected_capability_codes + : [], + filePreviews, + statusLabel, + statusTone: resolveStatusTone(payload?.status) + } + } +} + +export default { + name: 'TravelReimbursementCreateView', + components: { + ConfirmDialog + }, + props: { + initialPrompt: { + type: String, + default: '' + }, + initialFiles: { + type: Array, + default: () => [] + }, + initialConversation: { + type: Object, + default: null + }, + entrySource: { + type: String, + default: 'requests' + }, + requestContext: { + type: Object, + default: null + } + }, + emits: ['close', 'draft-saved'], + setup(props, { emit }) { + const router = useRouter() + const { currentUser } = useSystemState() + const { toast } = useToast() + + const fileInputRef = ref(null) + const composerTextareaRef = ref(null) + const fileInputMode = ref('composer') + const messageListRef = ref(null) + const composerDraft = ref('') + const composerDatePickerOpen = ref(false) + const composerDateMode = ref('single') + const composerSingleDate = ref(formatDateInputValue()) + const composerRangeStartDate = ref(formatDateInputValue()) + const composerRangeEndDate = ref(formatDateInputValue()) + const composerBusinessTimeTags = ref([]) + const attachedFiles = ref([]) + const composerFilesExpanded = ref(false) + const submitting = ref(false) + const workbenchVisible = ref(false) + const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) + const initialSessionType = resolveInitialSessionType(props.initialConversation) + const initialSessionState = props.initialConversation + ? buildConversationSessionState(props.initialConversation, initialSessionType) + : buildEmptySessionState(initialSessionType) + const activeSessionType = ref(initialSessionState.sessionType) + const messages = ref(initialSessionState.messages) + const conversationId = ref(initialSessionState.conversationId) + const draftClaimId = ref(initialSessionState.draftClaimId) + const previewRegistry = [] + const restoredDraftPreviewClaims = new Set() + const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) + const sessionSnapshots = ref({ + [SESSION_TYPE_EXPENSE]: null, + [SESSION_TYPE_KNOWLEDGE]: null + }) + + const currentInsight = ref(initialSessionState.currentInsight) + const reviewCancelDialogOpen = ref(false) + const reviewEditDialogOpen = ref(false) + const uploadDecisionDialogOpen = ref(false) + const deleteSessionDialogOpen = ref(false) + const reviewActionBusy = ref(false) + const deleteSessionBusy = ref(false) + const reviewEditFields = ref([]) + const reviewActionMessageId = ref('') + const reviewInlineForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseFields = ref([]) + const reviewInlinePendingFiles = ref([]) + const reviewInlineEditorKey = ref('') + const reviewInlineErrors = ref({}) + const reviewOtherCategoryOpen = ref(false) + const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim()) + const reviewDocumentDrafts = ref([]) + const reviewDocumentBaseDrafts = ref([]) + const activeReviewDocumentIndex = ref(0) + const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) + const insightPanelCollapsed = ref(false) + const documentPreviewDialog = ref({ + open: false, + filename: '', + kind: 'file', + url: '' + }) + const sessionSwitchBusy = ref(false) + const flowRunId = ref('') + const flowStartedAt = ref(0) + const flowFinishedAt = ref(0) + const flowSteps = ref(createFlowSteps()) + const flowRefreshBusy = ref(false) + const flowTick = ref(Date.now()) + let flowTickTimer = 0 + const flowSimulationTimers = [] + const canSubmit = computed( + () => + !submitting.value + && !sessionSwitchBusy.value + && Boolean( + composerDraft.value.trim() + || attachedFiles.value.length + || composerBusinessTimeTags.value.length + ) + ) + const composerCanApplyDateSelection = computed(() => { + if (composerDateMode.value === 'single') { + return Boolean(composerSingleDate.value) + } + return Boolean( + composerRangeStartDate.value + && composerRangeEndDate.value + && composerRangeStartDate.value <= composerRangeEndDate.value + ) + }) + const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) + const completedFlowStepCount = computed( + () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length + ) + const runningFlowStep = computed( + () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null + ) + const flowOverallStatusTone = computed(() => { + if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { + return 'failed' + } + if (runningFlowStep.value) { + return 'running' + } + if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { + return 'completed' + } + return 'pending' + }) + const flowOverallStatusText = computed(() => { + const total = flowSteps.value.length + const completed = completedFlowStepCount.value + if (flowOverallStatusTone.value === 'failed') { + return `异常 ${completed}/${total}` + } + if (flowOverallStatusTone.value === 'completed') { + return `已完成 ${total}/${total}` + } + if (flowOverallStatusTone.value === 'running') { + return `执行中 ${completed}/${total}` + } + return total ? `待执行 0/${total}` : '暂无流程' + }) + const flowTotalDurationText = computed(() => { + if (!flowStartedAt.value) { + return '--' + } + + const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0) + if (finishedAt > flowStartedAt.value) { + return formatFlowDuration(finishedAt - flowStartedAt.value) + } + + const measuredDuration = flowSteps.value.reduce((total, step) => { + const duration = Number(step.durationMs) + return total + (Number.isFinite(duration) && duration > 0 ? duration : 0) + }, 0) + return measuredDuration ? formatFlowDuration(measuredDuration) : '--' + }) + const hasInsightPanelContent = computed( + () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0 + ) + const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value) + const insightPanelToggleLabel = computed(() => + showInsightPanel.value ? '隐藏详细信息' : '展开详细信息' + ) + const composerPlaceholder = computed(() => { + if (isKnowledgeSession.value) { + return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?' + } + if (props.entrySource === 'detail' && linkedRequest.value?.id) { + return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` + } + return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件生成报销草稿。' + }) + const currentIntentLabel = computed(() => { + if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { + return '热门问题' + } + const labels = isKnowledgeSession.value + ? { + welcome: '热门问题', + agent: '知识回答' + } + : { + welcome: '财务助手', + agent: '处理中' + } + return labels[currentInsight.value.intent] ?? 'AI 处理中' + }) + let knowledgeSessionResetPromise = Promise.resolve() + const canDeleteCurrentSession = computed( + () => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user') + ) + const latestReviewMessage = computed(() => + [...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null + ) + const activeReviewPayload = computed( + () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null + ) + const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) + const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) + const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) + const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) + const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) + const reviewCategoryOptions = computed(() => + buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) + ) + const reviewOtherCategoryOptions = computed(() => + REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ + ...item, + confidenceLabel: formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) + ) + })) + ) + const reviewSelectedOtherCategory = computed(() => { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type + }) + const reviewInlineDirty = computed( + () => + buildInlineReviewChangedLines( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value + ).length > 0 + ) + const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) + const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value)) + const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) + const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) + const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length) + const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) + const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) + const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0) + const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) + const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) + const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) + const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) + const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length) + const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) + const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) + const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) + const reviewDrawerTitle = computed(() => ( + isReviewDocumentDrawer.value + ? '票据识别结果' + : isReviewRiskDrawer.value + ? '风险提示' + : isReviewFlowDrawer.value + ? '调用流程' + : '报销识别核对' + )) + const reviewDocumentDrawerLabel = computed(() => ( + isReviewDocumentDrawer.value ? '显示核对' : '显示票据' + )) + const reviewDocumentDrawerIcon = computed(() => ( + isReviewDocumentDrawer.value + ? 'mdi mdi-file-document-multiple' + : 'mdi mdi-file-document-multiple-outline' + )) + const reviewRiskDrawerLabel = computed(() => ( + isReviewRiskDrawer.value ? '显示核对' : '显示风险' + )) + const reviewRiskDrawerIcon = computed(() => ( + isReviewRiskDrawer.value + ? 'mdi mdi-shield-alert' + : 'mdi mdi-shield-alert-outline' + )) + const reviewFlowDrawerLabel = computed(() => ( + isReviewFlowDrawer.value ? '显示核对' : '显示流程' + )) + const reviewFlowDrawerIcon = computed(() => ( + isReviewFlowDrawer.value + ? 'mdi mdi-timeline-clock' + : 'mdi mdi-timeline-clock-outline' + )) + const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) + const activeReviewDocumentPreview = computed(() => + activeReviewDocument.value + ? ( + resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) + || ( + activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url + ? { + filename: activeReviewDocument.value.filename, + kind: activeReviewDocument.value.preview_kind, + url: activeReviewDocument.value.preview_data_url + } + : null + ) + ) + : null + ) + const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) + const reviewDocumentDirty = computed(() => { + const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) + const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) + return baseValue !== nextValue + }) + const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) + const hotKnowledgeQuestions = computed(() => HOT_KNOWLEDGE_QUESTIONS) + + const shortcuts = computed(() => [ + { + label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答', + icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline', + action: 'switch_view', + targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE + } + ]) + + function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { + const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType + const restoredMessages = normalizeInitialConversationMessages(conversation) + const initialInsight = buildInitialInsightFromConversation(conversation) + const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages) + + return { + sessionType, + messages: restoredMessages.length + ? restoredMessages + : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], + conversationId: resolveInitialConversationId(conversation), + draftClaimId: resolveInitialDraftClaimId(conversation), + currentInsight: + initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), + reviewFilePreviews: restoredReviewFilePreviews, + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + composerUploadIntent: '', + insightPanelCollapsed: false + } + } + + function buildEmptySessionState(sessionType) { + return { + sessionType, + messages: [ + createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value) + ], + conversationId: '', + draftClaimId: '', + currentInsight: buildWelcomeInsight( + props.entrySource, + linkedRequest.value, + sessionType, + currentUser.value + ), + reviewFilePreviews: [], + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + composerUploadIntent: '', + insightPanelCollapsed: false + } + } + + function resolveCurrentUserId() { + const user = currentUser.value || {} + return String(user.username || user.name || 'anonymous').trim() || 'anonymous' + } + + function captureCurrentSessionState() { + return { + sessionType: activeSessionType.value, + messages: messages.value, + conversationId: conversationId.value, + draftClaimId: draftClaimId.value, + currentInsight: currentInsight.value, + reviewFilePreviews: reviewFilePreviews.value, + composerDraft: composerDraft.value, + attachedFiles: attachedFiles.value, + composerFilesExpanded: composerFilesExpanded.value, + composerUploadIntent: composerUploadIntent.value, + insightPanelCollapsed: insightPanelCollapsed.value + } + } + + function applySessionState(sessionState) { + const nextState = sessionState || buildEmptySessionState(activeSessionType.value) + activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE + messages.value = Array.isArray(nextState.messages) && nextState.messages.length + ? nextState.messages + : [ + createWelcomeAssistantMessage( + props.entrySource, + linkedRequest.value, + activeSessionType.value, + currentUser.value + ) + ] + conversationId.value = String(nextState.conversationId || '').trim() + draftClaimId.value = String(nextState.draftClaimId || '').trim() + currentInsight.value = + nextState.currentInsight + || buildWelcomeInsight( + props.entrySource, + linkedRequest.value, + activeSessionType.value, + currentUser.value + ) + reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] + composerDraft.value = String(nextState.composerDraft || '') + attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] + composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) + composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim() + insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) + uploadDecisionDialogOpen.value = false + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + + async function loadLatestSessionState(targetSessionType) { + const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, { + preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE + }) + if (payload?.found && payload.conversation) { + return buildConversationSessionState(payload.conversation, targetSessionType) + } + return buildEmptySessionState(targetSessionType) + } + + function resetKnowledgeSessionSnapshot() { + const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) + sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState + + if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) { + applySessionState(emptyKnowledgeState) + } + } + + function clearKnowledgeSessionOnEntry() { + resetKnowledgeSessionSnapshot() + knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) + .catch((error) => { + console.warn('Failed to clear knowledge session on entry:', error) + }) + .finally(() => { + resetKnowledgeSessionSnapshot() + }) + return knowledgeSessionResetPromise + } + + async function switchSessionType(targetSessionType) { + const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE + if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { + return + } + + sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() + if (sessionSnapshots.value[normalizedTarget]) { + applySessionState(sessionSnapshots.value[normalizedTarget]) + return + } + + sessionSwitchBusy.value = true + try { + const nextState = await loadLatestSessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = nextState + applySessionState(nextState) + } catch (error) { + const emptyState = buildEmptySessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = emptyState + applySessionState(emptyState) + toast(error?.message || '加载会话失败,已为你打开新的会话。') + } finally { + sessionSwitchBusy.value = false + } + } + + sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() + + watch( + () => activeReviewPayload.value, + (payload) => { + rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) + const nextInlineState = buildInlineReviewState(payload) + reviewInlineForm.value = { ...nextInlineState } + reviewInlineBaseForm.value = { ...nextInlineState } + reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) + const nextDocumentDrafts = buildReviewDocumentDrafts(payload) + reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + activeReviewDocumentIndex.value = nextDocumentDrafts.length + ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) + : 0 + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + reviewInlinePendingFiles.value = [] + reviewInlineEditorKey.value = '' + reviewInlineErrors.value = {} + reviewOtherCategoryOpen.value = false + }, + { immediate: true } + ) + + watch( + () => hasInsightPanelContent.value, + (available) => { + if (!available) { + insightPanelCollapsed.value = false + } + } + ) + + watch( + () => reviewDocumentDrawerAvailable.value, + (available) => { + if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + ) + + watch( + () => reviewRiskDrawerAvailable.value, + (available) => { + if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + ) + + watch( + () => reviewFlowDrawerAvailable.value, + (available) => { + if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + ) + + watch( + () => composerDraft.value, + () => { + nextTick(adjustComposerTextareaHeight) + } + ) + + watch( + () => [activeSessionType.value, resolveActiveClaimId()], + ([sessionType, claimId]) => { + if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) { + return + } + void restorePersistedDraftAttachmentPreviews(claimId) + }, + { immediate: true } + ) + + onMounted(() => { + document.addEventListener('click', handleComposerDatePickerOutside) + flowTickTimer = window.setInterval(() => { + flowTick.value = Date.now() + }, 250) + nextTick(() => { + workbenchVisible.value = true + }) + void clearKnowledgeSessionOnEntry() + currentInsight.value = + currentInsight.value + || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value) + if (props.initialPrompt?.trim() || props.initialFiles.length) { + const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS) + composerDraft.value = props.initialPrompt.trim() + attachedFiles.value = initialMerge.files + composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS + if (initialMerge.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + submitComposer() + } else { + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + }) + + onBeforeUnmount(() => { + document.removeEventListener('click', handleComposerDatePickerOutside) + if (flowTickTimer) { + window.clearInterval(flowTickTimer) + } + clearFlowSimulationTimers() + for (const url of previewRegistry) { + URL.revokeObjectURL(url) + } + }) + + function scrollToBottom() { + if (!messageListRef.value) return + messageListRef.value.scrollTop = messageListRef.value.scrollHeight + } + + function resetCurrentSessionState() { + const emptyState = buildEmptySessionState(activeSessionType.value) + sessionSnapshots.value[activeSessionType.value] = emptyState + applySessionState(emptyState) + clearFlowSimulationTimers() + flowRunId.value = '' + flowStartedAt.value = 0 + flowFinishedAt.value = 0 + flowSteps.value = createFlowSteps() + } + + function adjustComposerTextareaHeight() { + if (!composerTextareaRef.value) return + + const textarea = composerTextareaRef.value + textarea.style.height = 'auto' + const styles = window.getComputedStyle(textarea) + const lineHeight = Number.parseFloat(styles.lineHeight) || 20 + const verticalPadding = + Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0') + const minHeight = COMPOSER_TEXTAREA_HEIGHT + const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding + const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight)) + + textarea.style.height = `${nextHeight}px` + textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' + } + + function handleComposerInput() { + adjustComposerTextareaHeight() + } + + function handleComposerEnter(event) { + if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { + return + } + submitComposer() + } + + function clearFlowSimulationTimers() { + while (flowSimulationTimers.length) { + const timerId = flowSimulationTimers.pop() + window.clearTimeout(timerId) + window.clearInterval(timerId) + } + } + + function resetFlowRun(options = {}) { + clearFlowSimulationTimers() + flowRunId.value = '' + flowStartedAt.value = Date.now() + flowFinishedAt.value = 0 + reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW + insightPanelCollapsed.value = false + const hasText = Boolean(String(options.rawText || '').trim()) + const attachmentCount = Number(options.attachmentCount || 0) + flowSteps.value = createFlowSteps({ + includeIntent: hasText, + includeOcr: attachmentCount > 0, + includeExtraction: hasText || attachmentCount > 0, + includeAgent: true, + includeResult: true + }) + } + + function findFlowDefinition(key) { + return FLOW_STEP_FALLBACKS[key] || null + } + + function normalizeFlowStepPatch(key, patch = {}) { + const definition = findFlowDefinition(key) || {} + const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } + return { + title: normalizedPatch.title || definition.title || '智能体工具调用', + tool: normalizedPatch.tool || definition.tool || 'AgentTool', + detail: normalizedPatch.detail || definition.runningText || '', + ...normalizedPatch + } + } + + function createFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + return { + key, + index: flowSteps.value.length + 1, + title: normalizedPatch.title, + tool: normalizedPatch.tool, + status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, + detail: normalizedPatch.detail || '', + durationMs: normalizedPatch.durationMs ?? null, + startedAt: normalizedPatch.startedAt || 0, + finishedAt: normalizedPatch.finishedAt || 0, + error: normalizedPatch.error || '' + } + } + + function normalizeFlowStepIndexes(steps) { + return steps.map((step, index) => ({ ...step, index: index + 1 })) + } + + function upsertFlowStep(key, patch) { + const existingStep = flowSteps.value.find((step) => step.key === key) + if (!existingStep) { + const nextStep = createFlowStep(key, patch) + const resultIndex = flowSteps.value.findIndex((step) => step.key === 'result') + if (resultIndex !== -1 && key !== 'result') { + const nextSteps = [...flowSteps.value] + nextSteps.splice(resultIndex, 0, nextStep) + flowSteps.value = normalizeFlowStepIndexes(nextSteps) + return + } + flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep]) + return + } + const normalizedPatch = normalizeFlowStepPatch(key, patch) + flowSteps.value = flowSteps.value.map((step) => ( + step.key === key ? { ...step, ...normalizedPatch } : step + )) + } + + function startFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + upsertFlowStep(key, { + ...normalizedPatch, + status: FLOW_STEP_STATUS_RUNNING, + detail: normalizedPatch.detail, + startedAt: Date.now(), + finishedAt: 0, + durationMs: null, + error: '' + }) + } + + function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const startedAt = currentStep?.startedAt || now + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || definition?.completedText || '', + startedAt, + finishedAt: now, + durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt, + error: '' + }) + } + + function failFlowStep(key, detail = '', error = '', patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const startedAt = currentStep?.startedAt || now + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_FAILED, + detail: detail || error || '调用失败', + startedAt, + finishedAt: now, + durationMs: now - startedAt, + error: String(error || definition?.title || '').trim() + }) + flowFinishedAt.value = now + } + + function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { + const currentStep = flowSteps.value.find((step) => step.key === key) + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { + return + } + const normalizedDuration = Number(durationMs) + const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 + if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { + if (!hasMeasuredDuration && !currentStep?.startedAt) { + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || findFlowDefinition(key)?.completedText || '', + startedAt: 0, + finishedAt: 0, + durationMs: null, + error: '' + }) + return + } + startFlowStep(key, patch) + } + completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) + } + + function failCurrentFlowStep(error) { + clearFlowSimulationTimers() + const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) + failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '') + } + + function startSemanticFlowPreview(rawText, options = {}) { + clearFlowSimulationTimers() + const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value) + const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) + + const completeIntentTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'intent') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + completePendingFlowStep('intent', intentPreview, null) + }, 260) + flowSimulationTimers.push(completeIntentTimer) + + const startExtractionTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'extraction') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) + + if (extractionMessages.length <= 1) { + return + } + + let index = 1 + const detailTimer = window.setInterval(() => { + const runningStep = flowSteps.value.find((step) => step.key === 'extraction') + if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { + window.clearInterval(detailTimer) + return + } + upsertFlowStep('extraction', { + detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] + }) + index = Math.min(index + 1, extractionMessages.length - 1) + }, 650) + flowSimulationTimers.push(detailTimer) + }, 420) + flowSimulationTimers.push(startExtractionTimer) + } + + function resolveToolCallFlowMeta(toolCall, index) { + const toolType = String(toolCall?.tool_type || '').toLowerCase() + const toolName = String(toolCall?.tool_name || '').toLowerCase() + const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` + if (toolType.includes('rule')) { + return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } + } + if (toolType.includes('mcp')) { + return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } + } + if (toolName.includes('knowledge')) { + return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } + } + if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { + return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } + } + if (toolType.includes('database')) { + return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } + } + if (toolType.includes('llm') || toolName.includes('user_agent')) { + return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } + } + return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } + } + + function summarizeFlowToolCall(toolCall) { + const response = toolCall?.response_json && typeof toolCall.response_json === 'object' + ? toolCall.response_json + : {} + return ( + String(response.message || response.summary || response.result_summary || '').trim() + || String(toolCall?.tool_name || '').trim() + || '工具调用完成' + ) + } + + function mergeFlowRunDetail(run) { + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { + clearFlowSimulationTimers() + const semanticDurations = resolveSemanticPhaseDurations(run) + completePendingFlowStep( + 'intent', + summarizeSemanticIntentDetail(run.semantic_parse), + semanticDurations.intentMs + ) + completePendingFlowStep( + 'extraction', + summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), + semanticDurations.extractionMs + ) + } + + if (flowSteps.value.some((step) => step.key === 'agent')) { + completePendingFlowStep( + 'agent', + toolCalls.length ? `已完成 ${toolCalls.length} 个工具调用` : FLOW_STEP_FALLBACKS.agent.completedText + ) + } + + toolCalls.forEach((toolCall, index) => { + const meta = resolveToolCallFlowMeta(toolCall, index) + const failed = String(toolCall?.status || '').toLowerCase() === 'failed' + if (failed) { + failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) + } else { + const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) + completePendingFlowStep( + meta.key, + summarizeFlowToolCall(toolCall), + toolDurationMs, + meta + ) + } + }) + + if (String(run?.status || '').toLowerCase() === 'failed') { + failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) + return + } + } + + function completeFlowResult(payload, run = null) { + const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() + if (!answer && !payload?.result) { + return + } + flowSteps.value + .filter((step) => step.key !== 'result' && ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) + .forEach((step) => { + completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })) + }) + startFlowStep('result', '正在返回处理结果...') + completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run)) + flowFinishedAt.value = Date.now() + if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + + async function refreshFlowRunDetail() { + if (!flowRunId.value || flowRefreshBusy.value) { + return null + } + flowRefreshBusy.value = true + try { + const run = await fetchAgentRunDetail(flowRunId.value) + mergeFlowRunDetail(run) + return run + } catch (error) { + console.warn('Failed to refresh agent run detail:', error) + return null + } finally { + flowRefreshBusy.value = false + } + } + + function formatFlowStepDuration(step) { + if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { + return formatFlowDuration(flowTick.value - step.startedAt) + } + return formatFlowDuration(step?.durationMs) + } + + function resolveFlowStepStatusLabel(step) { + const status = String(step?.status || '').trim() + if (status === FLOW_STEP_STATUS_COMPLETED) { + return '完成' + } + if (status === FLOW_STEP_STATUS_RUNNING) { + return '执行中' + } + if (status === FLOW_STEP_STATUS_FAILED) { + return '异常' + } + return '待执行' + } + + function resolveFlowStepDetail(step) { + const detail = String(step?.detail || '').trim() + if (detail) { + return detail + } + const definition = findFlowDefinition(step?.key) + if (step?.status === FLOW_STEP_STATUS_COMPLETED) { + return definition?.completedText || '步骤已完成' + } + if (step?.status === FLOW_STEP_STATUS_RUNNING) { + return definition?.runningText || '正在执行当前步骤...' + } + if (step?.status === FLOW_STEP_STATUS_FAILED) { + return step?.error || '步骤执行异常' + } + return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...' + } + + function buildComposerBusinessTimeLabel() { + if (composerDateMode.value === 'single') { + return `业务发生时间:${composerSingleDate.value}` + } + if (composerRangeStartDate.value === composerRangeEndDate.value) { + return `业务发生时间:${composerRangeStartDate.value}` + } + return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` + } + + function resolveComposerSubmitText(explicitRawText) { + const draftPart = String(explicitRawText ?? composerDraft.value).trim() + const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',') + if (!tagPart) { + return draftPart + } + if (!draftPart) { + return tagPart + } + return `${tagPart},${draftPart}` + } + + function toggleComposerDatePicker() { + composerDatePickerOpen.value = !composerDatePickerOpen.value + } + + function closeComposerDatePicker() { + composerDatePickerOpen.value = false + } + + function setComposerDateMode(mode) { + composerDateMode.value = mode === 'range' ? 'range' : 'single' + } + + function removeComposerBusinessTimeTag(tagId) { + composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId) + } + + function handleComposerDatePickerOutside(event) { + if (!composerDatePickerOpen.value) { + return + } + if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { + return + } + composerDatePickerOpen.value = false + } + + async function applyComposerDateSelection() { + if (!composerCanApplyDateSelection.value) { + return + } + + composerBusinessTimeTags.value = [ + { + id: `biz-time-${Date.now()}`, + label: buildComposerBusinessTimeLabel() + } + ] + composerDatePickerOpen.value = false + await nextTick() + adjustComposerTextareaHeight() + composerTextareaRef.value?.focus() + } + + function rememberFilePreviews(filePreviews) { + reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) + } + + function trackPreviewObjectUrl(url) { + if (!url || !String(url).startsWith('blob:')) { + return + } + previewRegistry.push(url) + } + + function resolveActiveClaimId() { + return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim() + } + + async function buildPersistedAttachmentPreview(metadata) { + const filename = String(metadata?.file_name || '').trim() + const kind = resolveAttachmentPreviewKind(metadata) + const previewPath = String(metadata?.preview_url || '').trim() + if (!filename || !kind || !previewPath) { + return null + } + + const blob = await fetchExpenseClaimAttachmentAsset(previewPath) + const url = URL.createObjectURL(blob) + trackPreviewObjectUrl(url) + return { + filename, + kind, + url + } + } + + async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) { + const normalizedClaimId = String(claimId || '').trim() + if (!normalizedClaimId || isKnowledgeSession.value) { + return + } + + const force = Boolean(options.force) + if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) { + return + } + + try { + const claim = await fetchExpenseClaimDetail(normalizedClaimId) + const items = Array.isArray(claim?.items) ? claim.items : [] + const previews = [] + + for (const item of items) { + const itemId = String(item?.id || '').trim() + if (!itemId) continue + + let metadata = null + try { + metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId) + } catch { + continue + } + + const filename = String(metadata?.file_name || '').trim() + if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) { + continue + } + + try { + const preview = await buildPersistedAttachmentPreview(metadata) + if (preview) { + previews.push(preview) + } + } catch (error) { + console.warn('Failed to load persisted attachment preview:', error) + } + } + + if (previews.length) { + rememberFilePreviews(previews) + } + restoredDraftPreviewClaims.add(normalizedClaimId) + } catch (error) { + console.warn('Failed to restore persisted draft attachment previews:', error) + } + } + + async function syncComposerFilesToDraft(claimId, files) { + const normalizedClaimId = String(claimId || '').trim() + if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) { + return + } + + const claim = await fetchExpenseClaimDetail(normalizedClaimId) + const items = Array.isArray(claim?.items) ? claim.items : [] + const exactMatchBuckets = new Map() + const placeholderQueue = [] + const usedItemIds = new Set() + + for (const item of items) { + const itemId = String(item?.id || '').trim() + const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim() + if (!itemId) continue + if (invoiceId && !invoiceId.includes('/')) { + placeholderQueue.push(item) + } + if (!invoiceId) continue + const bucket = exactMatchBuckets.get(invoiceId) || [] + bucket.push(item) + exactMatchBuckets.set(invoiceId, bucket) + } + + for (const file of files) { + const exactBucket = exactMatchBuckets.get(file.name) || [] + const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) + const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim())) + const targetItem = nextExactMatch || fallbackMatch + const targetItemId = String(targetItem?.id || '').trim() + if (!targetItemId) { + continue + } + + usedItemIds.add(targetItemId) + await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file) + } + + await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true }) + } + + function replaceMessage(messageId, nextMessage) { + const index = messages.value.findIndex((item) => item.id === messageId) + if (index === -1) { + messages.value.push(nextMessage) + return + } + messages.value.splice(index, 1, nextMessage) + } + + function triggerFileUpload(mode = 'composer') { + if (submitting.value || reviewActionBusy.value) return + fileInputMode.value = mode + fileInputRef.value?.click() + } + + function handleFilesChange(event) { + const files = Array.from(event.target.files ?? []) + + if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { + const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) + const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) + const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) + + if (!remainingSlots && files.length) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) + } else if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) + } + + reviewInlinePendingFiles.value = mergeResult.files + const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] + reviewInlineForm.value = { + ...reviewInlineForm.value, + attachment_names: allAttachmentNames.join('、'), + attachment_count: allAttachmentNames.length, + pending_attachment_count: mergeResult.files.length + } + clearInlineReviewFieldError('attachments') + reviewInlineEditorKey.value = '' + } else { + if (isKnowledgeSession.value) { + toast('财务知识问答暂不支持上传附件。') + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + return + } + + const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) + attachedFiles.value = mergeResult.files + if (fileInputMode.value === 'composer-continue' && files.length) { + composerUploadIntent.value = 'continue_existing' + } + if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } + } + + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + function toggleAttachedFilesExpanded() { + composerFilesExpanded.value = !composerFilesExpanded.value + } + + function removeAttachedFile(targetFile) { + const fileKey = buildFileIdentity(targetFile) + attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } + if (!attachedFiles.value.length) { + composerUploadIntent.value = '' + } + } + + function clearAttachedFiles() { + attachedFiles.value = [] + composerFilesExpanded.value = false + composerUploadIntent.value = '' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + function closeUploadDecisionDialog() { + if (submitting.value || reviewActionBusy.value) return + uploadDecisionDialogOpen.value = false + } + + async function continueExistingUpload() { + if (submitting.value || reviewActionBusy.value) return + uploadDecisionDialogOpen.value = false + composerUploadIntent.value = 'continue_existing' + await submitComposer({ + uploadDisposition: 'continue_existing', + skipUploadDecisionPrompt: true + }) + } + + async function createNewUploadDocument() { + if (submitting.value || reviewActionBusy.value) return + uploadDecisionDialogOpen.value = false + composerUploadIntent.value = '' + await submitComposer({ + uploadDisposition: 'new_document', + skipUploadDecisionPrompt: true + }) + } + + async function runShortcut(shortcut) { + if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { + await switchSessionType(shortcut.targetSessionType) + return + } + + const prompt = String(shortcut?.prompt || '').trim() + if (!prompt) return + composerDraft.value = prompt + submitComposer() + } + + function toggleInsightPanel() { + if (!hasInsightPanelContent.value) { + return + } + insightPanelCollapsed.value = !insightPanelCollapsed.value + } + + function toggleReviewDocumentDrawer() { + if (!reviewDocumentDrawerAvailable.value) { + return + } + reviewDrawerMode.value = + reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS + ? REVIEW_DRAWER_MODE_REVIEW + : REVIEW_DRAWER_MODE_DOCUMENTS + } + + function toggleReviewRiskDrawer() { + if (!reviewRiskDrawerAvailable.value) { + return + } + reviewDrawerMode.value = + reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK + ? REVIEW_DRAWER_MODE_REVIEW + : REVIEW_DRAWER_MODE_RISK + } + + function toggleReviewFlowDrawer() { + if (!reviewFlowDrawerAvailable.value) { + return + } + reviewDrawerMode.value = + reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW + ? REVIEW_DRAWER_MODE_REVIEW + : REVIEW_DRAWER_MODE_FLOW + } + + function setInlineReviewFieldError(key, message) { + reviewInlineErrors.value = { + ...reviewInlineErrors.value, + [key]: String(message || '').trim() + } + } + + function clearInlineReviewFieldError(key) { + if (!reviewInlineErrors.value[key]) { + return + } + + const nextErrors = { ...reviewInlineErrors.value } + delete nextErrors[key] + reviewInlineErrors.value = nextErrors + } + + function openInlineReviewEditor(key) { + if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return + if (key === 'attachments') { + triggerFileUpload('inline-review') + return + } + + if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { + return + } + + if (reviewInlineEditorKey.value === key) { + commitInlineReviewEditor() + return + } + + if (key === 'amount') { + reviewInlineForm.value = { + ...reviewInlineForm.value, + amount: extractAmountInputValue(reviewInlineForm.value.amount) + } + } + + clearInlineReviewFieldError(key) + reviewInlineEditorKey.value = key + if (key !== 'expense_type') { + reviewOtherCategoryOpen.value = false + } + } + + function closeInlineReviewEditor() { + reviewInlineEditorKey.value = '' + reviewOtherCategoryOpen.value = false + } + + function commitInlineReviewEditor() { + const activeEditorKey = reviewInlineEditorKey.value + const nextForm = { + ...reviewInlineForm.value, + occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), + amount: String(reviewInlineForm.value.amount || '').trim(), + customer_name: String(reviewInlineForm.value.customer_name || '').trim(), + location: String(reviewInlineForm.value.location || '').trim(), + merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), + participants: String(reviewInlineForm.value.participants || '').trim(), + scene_label: String(reviewInlineForm.value.scene_label || '').trim(), + reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), + expense_type: String(reviewInlineForm.value.expense_type || '').trim() + } + + if ( + activeEditorKey === 'scene' && + nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION + ) { + nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim() + if (!nextForm.reason_value) { + setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由') + reviewInlineForm.value = nextForm + return false + } + } else if (activeEditorKey === 'scene') { + nextForm.reason_value = nextForm.scene_label + } + + if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { + setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`) + return false + } + + if (activeEditorKey === 'amount' && nextForm.amount) { + const normalizedAmount = normalizeAmountValue(nextForm.amount) + if (!normalizedAmount) { + setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50') + return false + } + nextForm.amount = normalizedAmount + } + + if (activeEditorKey) { + clearInlineReviewFieldError(activeEditorKey) + } + + reviewInlineForm.value = nextForm + reviewInlineEditorKey.value = '' + return true + } + + function selectInlineScene(scene) { + const normalizedScene = String(scene || '').trim() + reviewInlineForm.value = { + ...reviewInlineForm.value, + scene_label: normalizedScene, + reason_value: + normalizedScene === REVIEW_SCENE_OTHER_OPTION + ? '' + : normalizedScene + } + clearInlineReviewFieldError('scene') + if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) { + reviewInlineEditorKey.value = '' + } + } + + function selectReviewCategory(option) { + if (!option) return + if (option.is_other) { + reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value + return + } + + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function selectReviewOtherCategory(option) { + if (!option) return + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function queryDraftByClaimNo(claimNo) { + const normalized = String(claimNo || '').trim() + if (!normalized || submitting.value || reviewActionBusy.value) return + submitComposer({ + rawText: `查看报销草稿 ${normalized} 的当前信息`, + userText: `查看草稿 ${normalized}`, + systemGenerated: true + }) + } + + function explainCurrentReviewRisk() { + if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return + submitComposer({ + rawText: '请解释一下当前这笔报销的合规风险和待补充项。', + userText: '查看全部风险项', + systemGenerated: true + }) + } + + function goReviewDocument(direction) { + const total = reviewDocumentCount.value + if (!total) return + const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) + activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) + } + + function openActiveReviewDocumentPreview() { + if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return + documentPreviewDialog.value = { + open: true, + filename: activeReviewDocument.value.filename, + kind: activeReviewDocumentPreview.value.kind, + url: activeReviewDocumentPreview.value.url + } + } + + function closeDocumentPreview() { + documentPreviewDialog.value = { + ...documentPreviewDialog.value, + open: false + } + } + + function requestCloseWorkbench() { + workbenchVisible.value = false + } + + function emitCloseAfterLeave() { + emit('close') + } + + function openExpenseQueryRecord(record) { + const claimId = String(record?.claimId || '').trim() + if (!claimId) { + return + } + + router.push({ + name: 'app-request-detail', + params: { requestId: claimId } + }) + emit('close') + } + + function setExpenseQueryPage(message, page) { + if (!message?.queryPayload) { + return + } + + const totalPages = getExpenseQueryTotalPages(message.queryPayload) + const nextPage = Math.min(Math.max(1, Number(page || 1)), totalPages) + message.queryPayload.currentPage = nextPage + } + + function shiftExpenseQueryPage(message, delta) { + if (!message?.queryPayload) { + return + } + + setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0)) + } + + function openDeleteSessionDialog() { + if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) { + return + } + deleteSessionDialogOpen.value = true + } + + function closeDeleteSessionDialog() { + if (deleteSessionBusy.value) { + return + } + deleteSessionDialogOpen.value = false + } + + async function confirmDeleteCurrentSession() { + if (deleteSessionBusy.value || sessionSwitchBusy.value) { + return + } + + deleteSessionBusy.value = true + try { + if (conversationId.value) { + await deleteConversation(conversationId.value, resolveCurrentUserId()) + } + + resetCurrentSessionState() + deleteSessionDialogOpen.value = false + toast('当前会话已删除。') + } catch (error) { + toast(error?.message || '删除当前会话失败,请稍后重试。') + } finally { + deleteSessionBusy.value = false + } + } + + async function saveInlineReviewChanges() { + if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return + + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + + reviewActionBusy.value = true + try { + const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) + const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) + await submitComposer({ + rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'), + userText: buildReviewSubmitUserText( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ), + pendingText: '正在保存修改并刷新右侧核对信息...', + files: reviewInlinePendingFiles.value, + systemGenerated: true, + extraContext: { + review_action: 'edit_review', + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + } + }) + } finally { + reviewActionBusy.value = false + } + } + + function askHotKnowledgeQuestion(question) { + const normalizedQuestion = String(question || '').trim() + if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { + return + } + + submitComposer({ + rawText: normalizedQuestion, + userText: normalizedQuestion, + pendingText: '正在整理财务知识答案...' + }) + } + + function buildBackendMessage(rawText, fileNames, ocrSummary = '') { + const parts = [] + const normalizedText = String(rawText || '').trim() + + if (normalizedText) { + parts.push(normalizedText) + } else if (fileNames.length) { + parts.push( + isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` + : `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。` + ) + } + + if (fileNames.length) { + parts.push(`附件名称:${fileNames.join('、')}`) + } + + if (ocrSummary) { + parts.push(`OCR摘要:${ocrSummary}`) + } + + if (props.entrySource === 'detail' && linkedRequest.value?.id) { + parts.push(`关联单号:${linkedRequest.value.id}`) + } + + return parts.join('\n') + } + + async function submitComposer(options = {}) { + if (sessionSwitchBusy.value) return null + + const rawText = resolveComposerSubmitText(options.rawText).trim() + const systemGenerated = Boolean(options.systemGenerated) + const resolvedUploadDisposition = + String(options.uploadDisposition || '').trim() || + (composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') + const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) + const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) + const files = fileMergeResult.files + if (fileMergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + if (!rawText && !files.length) return + + const extraContext = options.extraContext && typeof options.extraContext === 'object' + ? { ...options.extraContext } + : {} + const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) + const hasExistingDocumentEvent = + Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 + + if ( + !isKnowledgeSession.value && + files.length && + hasExistingDocumentEvent && + !resolvedUploadDisposition && + !options.skipUploadDecisionPrompt && + !String(extraContext.review_action || '').trim() + ) { + uploadDecisionDialogOpen.value = true + return null + } + + resetFlowRun({ rawText, attachmentCount: files.length }) + if (rawText) { + startFlowStep('intent', '正在识别业务意图...') + startSemanticFlowPreview(rawText, { attachmentCount: files.length }) + } + + const fileNames = files.map((file) => file.name) + const filePreviews = buildFilePreviews(files, previewRegistry) + rememberFilePreviews(filePreviews) + const userText = + String(options.userText || '').trim() || + rawText || + (isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` + : resolvedUploadDisposition === 'continue_existing' + ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` + : resolvedUploadDisposition === 'new_document' + ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` + : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) + + // 只有在非静默模式下才添加用户消息 + if (!options.skipUserMessage) { + messages.value.push(createMessage('user', userText, fileNames)) + } + + const pendingMessage = createMessage( + 'assistant', + options.pendingText || (isKnowledgeSession.value ? '正在整理财务知识答案...' : '正在识别并更新右侧核对信息...'), + [], + { + meta: ['处理中'] + } + ) + messages.value.push(pendingMessage) + + composerDraft.value = '' + composerBusinessTimeTags.value = [] + clearAttachedFiles() + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + nextTick(adjustComposerTextareaHeight) + + submitting.value = true + nextTick(scrollToBottom) + + let responsePayload = null + + try { + const user = currentUser.value || {} + let ocrPayload = null + let ocrSummary = '' + let ocrDocuments = [] + let ocrFilePreviews = [] + + if (files.length) { + startFlowStep('ocr', `正在识别 ${files.length} 份附件...`) + try { + ocrPayload = await recognizeOcrFiles(files) + ocrSummary = buildOcrSummary(ocrPayload) + ocrDocuments = normalizeOcrDocuments(ocrPayload) + ocrFilePreviews = buildOcrFilePreviews(ocrPayload) + rememberFilePreviews(ocrFilePreviews) + completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`) + } catch (error) { + console.warn('OCR request failed:', error) + completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称') + } + } + + let effectiveFileNames = [...fileNames] + let effectiveOcrDocuments = [...ocrDocuments] + let effectiveOcrSummary = ocrSummary + + if (resolvedUploadDisposition === 'continue_existing') { + extraContext.review_action = 'link_to_existing_draft' + effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames) + effectiveOcrDocuments = mergeUploadOcrDocuments( + buildOcrDocumentsFromReviewPayload(activeReviewPayload.value), + ocrDocuments + ) + effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments) + } else if (resolvedUploadDisposition === 'new_document') { + extraContext.review_action = 'create_new_claim_from_documents' + } + + const runningExtractionStep = flowSteps.value.find( + (step) => step.key === 'extraction' && step.status === FLOW_STEP_STATUS_RUNNING + ) + if (runningExtractionStep) { + completeFlowStep( + 'extraction', + runningExtractionStep.detail || FLOW_STEP_FALLBACKS.extraction.completedText + ) + } + startFlowStep('agent', FLOW_STEP_FALLBACKS.agent.runningText) + + const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) + const payload = await runOrchestrator( + { + source: 'user_message', + user_id: user.username || user.name || 'anonymous', + conversation_id: conversationId.value || null, + message: backendMessage, + context_json: { + role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], + is_admin: Boolean(user.isAdmin), + name: user.name || '', + role: user.role || '', + position: user.position || '', + grade: user.grade || '', + ...buildClientTimeContext(), + session_type: activeSessionType.value, + entry_source: props.entrySource, + user_input_text: systemGenerated ? '' : rawText, + attachment_names: effectiveFileNames, + attachment_count: effectiveFileNames.length, + draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, + ocr_summary: effectiveOcrSummary, + ocr_documents: effectiveOcrDocuments, + ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), + ...extraContext + } + }, + isKnowledgeSession.value + ? { + timeoutMs: 18000, + timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。' + } + : {} + ) + responsePayload = payload + flowRunId.value = String(payload?.run_id || '').trim() + let flowRunDetail = null + if (flowRunId.value) { + flowRunDetail = await refreshFlowRunDetail() + } + + conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value + draftClaimId.value = + isKnowledgeSession.value + ? '' + : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value + + const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() + if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { + try { + await syncComposerFilesToDraft(resolvedDraftClaimId, files) + } catch (error) { + console.warn('Failed to persist composer attachments to draft claim:', error) + toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。') + } + } + + replaceMessage( + pendingMessage.id, + createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { + meta: buildMessageMeta(payload, effectiveFileNames), + citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [], + suggestedActions: Array.isArray(payload?.result?.suggested_actions) + ? payload.result.suggested_actions + : [], + queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), + draftPayload: payload?.result?.draft_payload || null, + reviewPayload: payload?.result?.review_payload || null, + riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] + }) + ) + currentInsight.value = buildAgentInsight( + payload, + effectiveFileNames, + mergeFilePreviews(filePreviews, ocrFilePreviews) + ) + completeFlowResult(payload, flowRunDetail) + } catch (error) { + clearFlowSimulationTimers() + failCurrentFlowStep(error) + replaceMessage( + pendingMessage.id, + createMessage( + 'assistant', + error?.message || '无法连接后端 Orchestrator,请稍后重试。', + [], + { + meta: ['调用失败'] + } + ) + ) + currentInsight.value = buildErrorInsight(error, fileNames) + } finally { + submitting.value = false + composerUploadIntent.value = '' + nextTick(scrollToBottom) + } + + return responsePayload + } + + function openCancelReviewDialog(message) { + reviewActionMessageId.value = String(message?.id || '') + reviewCancelDialogOpen.value = true + } + + function closeCancelReviewDialog() { + if (reviewActionBusy.value) return + reviewCancelDialogOpen.value = false + reviewActionMessageId.value = '' + } + + function confirmCancelReview() { + if (reviewActionBusy.value) return + reviewCancelDialogOpen.value = false + emit('close') + } + + function openEditReviewDialog(message) { + reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields) + reviewActionMessageId.value = String(message?.id || '') + reviewEditDialogOpen.value = true + } + + function closeEditReviewDialog() { + if (reviewActionBusy.value) return + reviewEditDialogOpen.value = false + reviewEditFields.value = [] + reviewActionMessageId.value = '' + } + + async function applyEditedReview() { + if (reviewActionBusy.value) return + + reviewActionBusy.value = true + try { + const fields = cloneReviewEditFields(reviewEditFields.value) + await submitComposer({ + rawText: buildReviewCorrectionMessage(fields), + userText: '我已修改识别信息,请按最新内容更新。', + pendingText: '正在根据修改内容重新识别...', + systemGenerated: true, + extraContext: { + review_action: 'edit_review', + review_form_values: buildReviewFormValues(fields) + } + }) + } finally { + reviewActionBusy.value = false + } + closeEditReviewDialog() + } + + async function handleReviewAction(message, action) { + const actionType = String(action?.action_type || '').trim() + if (!actionType || reviewActionBusy.value) return + + if (actionType === 'cancel_review') { + openCancelReviewDialog(message) + return + } + + if (actionType === 'edit_review') { + openEditReviewDialog(message) + return + } + + if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { + return + } + + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + + if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { + await handleSaveDraftDirectly(message, actionType) + return + } + + reviewActionBusy.value = true + try { + const baseFields = reviewInlineBaseFields.value.length + ? reviewInlineBaseFields.value + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) + const reviewChangedUserText = reviewHasUnsavedChanges.value + ? buildReviewSubmitUserText( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) + : '' + const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) + const payload = await submitComposer({ + rawText: [ + reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', + reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', + '我已核对右侧识别结果,请进入下一步。' + ] + .filter(Boolean) + .join('\n'), + userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', + files: reviewInlinePendingFiles.value, + pendingText: '正在进入下一步...', + systemGenerated: true, + extraContext: { + review_action: actionType, + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + } + }) + + if (payload?.result?.draft_payload?.status === 'submitted') { + emit( + 'draft-saved', + buildDraftSavedPayload({ + draftPayload: payload.result.draft_payload, + reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, + inlineState: reviewInlineForm.value, + linkedRequest: linkedRequest.value, + currentUser: currentUser.value + }) + ) + } + } finally { + reviewActionBusy.value = false + } + } + + async function handleSaveDraftDirectly(message, actionType = 'save_draft') { + reviewActionBusy.value = true + let savingMessage = null + + const actionConfig = { + save_draft: { + rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', + pendingText: '正在保存当前草稿...', + helperText: '正在保存草稿...', + successMeta: '草稿已保存', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成' + } + }, + link_to_existing_draft: { + rawText: '请把当前上传的票据合并到现有报销草稿中。', + pendingText: '正在关联到现有草稿...', + helperText: '正在关联现有草稿...', + successMeta: '已关联草稿', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿' + } + }, + create_new_claim_from_documents: { + rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。', + pendingText: '正在建立新的报销草稿...', + helperText: '正在建立新报销草稿...', + successMeta: '新草稿已建立', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿' + } + } + }[actionType] || { + rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', + pendingText: '正在保存当前草稿...', + helperText: '正在保存草稿...', + successMeta: '草稿已保存', + successMessage: () => '草稿保存完成' + } + + try { + const baseFields = reviewInlineBaseFields.value.length + ? reviewInlineBaseFields.value + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) + + savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] }) + messages.value.push(savingMessage) + nextTick(scrollToBottom) + + const payload = await submitComposer({ + rawText: actionConfig.rawText, + userText: '', + skipUserMessage: true, + files: reviewInlinePendingFiles.value, + pendingText: actionConfig.pendingText, + systemGenerated: true, + extraContext: { + review_action: actionType, + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + } + }) + + const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) + if (tempIndex !== -1) { + messages.value.splice(tempIndex, 1) + } + + if (payload?.result?.draft_payload?.claim_no) { + messages.value.push( + createMessage('assistant', actionConfig.successMessage(payload), [], { + meta: [actionConfig.successMeta] + }) + ) + + emit( + 'draft-saved', + buildDraftSavedPayload({ + draftPayload: payload.result.draft_payload, + reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, + inlineState: reviewInlineForm.value, + linkedRequest: linkedRequest.value, + currentUser: currentUser.value + }) + ) + } else { + messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] })) + } + + nextTick(scrollToBottom) + } catch (error) { + if (savingMessage) { + const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) + if (tempIndex !== -1) { + messages.value.splice(tempIndex, 1) + } + } + messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] })) + nextTick(scrollToBottom) + } finally { + reviewActionBusy.value = false + } + } + + return { + emit, + ASSISTANT_DISPLAY_NAME, + aiAvatar, + userAvatar, + fileInputRef, + composerTextareaRef, + messageListRef, + composerDraft, + composerDatePickerOpen, + composerDateMode, + composerSingleDate, + composerRangeStartDate, + composerRangeEndDate, + composerBusinessTimeTags, + composerCanApplyDateSelection, + toggleComposerDatePicker, + closeComposerDatePicker, + setComposerDateMode, + removeComposerBusinessTimeTag, + flowSteps, + flowRunId, + flowRefreshBusy, + completedFlowStepCount, + flowOverallStatusTone, + flowOverallStatusText, + flowTotalDurationText, + attachedFiles, + composerFilesExpanded, + visibleAttachedFiles, + hiddenAttachedFileCount, + submitting, + sessionSwitchBusy, + messages, + currentInsight, + linkedRequest, + canSubmit, + activeSessionType, + isKnowledgeSession, + hotKnowledgeQuestions, + hasInsightPanelContent, + showInsightPanel, + insightPanelToggleLabel, + composerPlaceholder, + currentIntentLabel, + canDeleteCurrentSession, + latestReviewMessage, + activeReviewPayload, + activeReviewFilePreviews, + reviewDrawerMode, + isReviewDocumentDrawer, + isReviewRiskDrawer, + isReviewFlowDrawer, + reviewDrawerTitle, + reviewDocumentDrawerAvailable, + reviewRiskDrawerAvailable, + reviewFlowDrawerAvailable, + reviewDocumentDrawerLabel, + reviewDocumentDrawerIcon, + reviewRiskDrawerLabel, + reviewRiskDrawerIcon, + reviewFlowDrawerLabel, + reviewFlowDrawerIcon, + activeReviewDocument, + activeReviewDocumentIndex, + activeReviewDocumentPreview, + canPreviewActiveReviewDocument, + reviewIntentText, + reviewFactCards, + reviewCategoryOptions, + reviewOtherCategoryOptions, + reviewSelectedOtherCategory, + reviewInlineDirty, + reviewInlineForm, + reviewInlineEditorKey, + reviewInlineErrors, + reviewOtherCategoryOpen, + reviewInlinePendingFiles, + DATE_INPUT_FORMAT, + REVIEW_SCENE_OTHER_OPTION, + REVIEW_SCENE_OPTIONS, + REVIEW_OTHER_CATEGORY_OPTIONS, + workbenchVisible, + reviewPanelConfidence, + reviewRiskScore, + reviewRiskSummary, + reviewRiskItems, + reviewRiskEmpty, + reviewRiskActionAvailable, + recognizedNarratives, + reviewRecognitionNotes, + reviewDocumentSummaries, + reviewDocumentCount, + reviewDocumentDirty, + reviewHasUnsavedChanges, + reviewCancelDialogOpen, + reviewEditDialogOpen, + uploadDecisionDialogOpen, + deleteSessionDialogOpen, + reviewActionBusy, + deleteSessionBusy, + reviewEditFields, + documentPreviewDialog, + shortcuts, + resolveReviewMissingSlotCards, + resolveReviewRiskBriefs, + buildReviewHeadline, + buildReviewSubline, + buildReviewStateLabel, + buildReviewStateTone, + buildReviewDisclosureTitle, + buildReviewDisclosureHint, + shouldOpenReviewDisclosure, + buildReviewTodoSectionTitle, + buildReviewTodoSectionMeta, + buildReviewAlertChips, + buildReviewTodoItems, + resolveReviewSubmitActions, + resolveReviewPrimaryAction, + resolveReviewEditAction, + buildReviewPrimaryButtonLabel, + buildReviewDecisionHint, + buildReviewMissingHint, + buildReviewRiskHint, + buildReviewActionHint, + buildReviewStatusTag, + renderMarkdown, + buildExpenseQueryWindowLabel, + buildExpenseQueryHint, + getExpenseQueryActivePage, + getExpenseQueryTotalPages, + getExpenseQueryVisibleRecords, + resolveDocumentPreview, + triggerFileUpload, + applyComposerDateSelection, + handleFilesChange, + handleComposerInput, + handleComposerEnter, + runShortcut, + runWelcomeQuickAction: runShortcut, + askHotKnowledgeQuestion, + resolveKnowledgeRankLabel, + resolveKnowledgeRankTone, + refreshFlowRunDetail, + formatFlowStepDuration, + resolveFlowStepStatusLabel, + resolveFlowStepDetail, + toggleInsightPanel, + toggleReviewDocumentDrawer, + toggleReviewRiskDrawer, + toggleReviewFlowDrawer, + toggleAttachedFilesExpanded, + removeAttachedFile, + clearAttachedFiles, + requestCloseWorkbench, + emitCloseAfterLeave, + openExpenseQueryRecord, + setExpenseQueryPage, + shiftExpenseQueryPage, + openDeleteSessionDialog, + closeDeleteSessionDialog, + confirmDeleteCurrentSession, + closeUploadDecisionDialog, + continueExistingUpload, + createNewUploadDocument, + openInlineReviewEditor, + closeInlineReviewEditor, + commitInlineReviewEditor, + clearInlineReviewFieldError, + selectInlineScene, + selectReviewCategory, + selectReviewOtherCategory, + queryDraftByClaimNo, + explainCurrentReviewRisk, + goReviewDocument, + openActiveReviewDocumentPreview, + closeDocumentPreview, + saveInlineReviewChanges, + submitComposer, + handleReviewAction, + handleSaveDraftDirectly, + closeCancelReviewDialog, + confirmCancelReview, + closeEditReviewDialog, + applyEditedReview + } + } +}