diff --git a/docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md b/docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md new file mode 100644 index 0000000..dc3ea6b --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-code-deduplication-refactor.md @@ -0,0 +1,410 @@ +# X-Financial Duplicate Code Refactor Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans for multi-task execution. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce duplicated business logic, renderer helpers, protocol constants, and test fixtures without changing existing behavior. + +**Architecture:** Start with low-risk pure helpers, then move toward business contract consolidation. Each round must add or preserve regression tests before production code changes, and backend validation must run inside `x-financial-main`. + +**Tech Stack:** Vue 3, Vite, Node test runner, FastAPI, SQLAlchemy, pytest, Docker Compose. + +--- + +## Scope + +This document records the duplicate-code audit from 2026-06-23 and defines a staged cleanup path. The first implementation slice is intentionally small: extract shared frontend conversation rendering helpers used by `markdown.js` and `aiConversationHtmlRenderer.js`. + +The following areas are in scope: + +- Frontend AI markdown / HTML trusted block normalization. +- Frontend reimbursement review panel model duplication. +- Workbench AI composer / attachment strip duplication. +- Backend application gate, fact extraction, amount/date/location parsing. +- Backend platform risk context helper duplication. +- Cross-layer status, expense type, document type, and risk-level taxonomy drift. +- Test helper duplication for DB sessions, FastAPI client overrides, and OCR fakes. + +The following areas are out of scope for the first implementation slice: + +- Changing application submission behavior. +- Changing reimbursement association decision flow. +- Changing API response shapes. +- Editing unrelated notification top bar changes already present in the worktree. + +## Findings + +### P0: Frontend Trusted HTML And Conversation Text Helpers + +`web/src/utils/markdown.js` and `web/src/utils/aiConversationHtmlRenderer.js` both implement: + +- `ALLOWED_COLON_HEADING_TITLES` +- `BUSINESS_FIELD_LABELS` +- `TRUSTED_HTML_ALLOWED_TAGS` +- `TRUSTED_HTML_ALLOWED_ATTRS` +- `splitColonHeadingLine` +- `normalizeBusinessFieldLine` +- `hasOnlyTrustedHtmlTags` +- `sanitizeTrustedHtmlBlock` +- `extractTrustedHtmlBlocks` +- `restoreTrustedHtmlBlocks` + +Plan: + +- [x] Create `web/src/utils/conversationTrustedHtml.js`. +- [x] Move trusted HTML sanitizing and business-line normalization into the helper. +- [x] Keep renderer-specific output differences in each renderer. +- [x] Verify both markdown and AI conversation renderers still preserve valid trusted document cards and reject unsafe trusted HTML. + +Expected benefit: + +- One XSS whitelist. +- One business field normalization rule. +- Less drift between AI workbench and reimbursement assistant rendering. + +### P0: Reimbursement Review Panel Model Duplication + +`web/src/views/scripts/travelReimbursementCreateReviewModel.js` and `web/src/views/scripts/travelReimbursementReviewPanelModel.js` duplicate review scope, fact cards, risk item mapping, risk conversation text, and message cleanup. + +Plan: + +- [x] Choose `travelReimbursementReviewPanelModel.js` as the shared model. +- [x] Convert create-view imports to the shared model, or make the create-view module a thin compatibility re-export. +- [x] Add behavior tests for exported review helpers before deleting duplicate code. + +Expected benefit: + +- One risk item mapping. +- One review fact card model. +- Lower risk when changing reimbursement review copy or drawer behavior. + +### P0: Workbench AI Composer And File Strip Template Duplication + +`web/src/components/business/PersonalWorkbenchAiMode.template.html` keeps two near-identical composer forms and two near-identical selected-file strips for the welcome and inline conversation states. `web/src/components/business/workbench-ai/WorkbenchAiComposer.vue` and `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue` already exist, but the main template still duplicates the markup. + +Plan: + +- [x] Reuse `WorkbenchAiComposer.vue` for both welcome and inline composer surfaces. +- [x] Reuse `WorkbenchAiFileStrip.vue` for both welcome and inline selected-file strips. +- [x] Keep the parent runtime API stable by passing a proxied runtime object into shared components. +- [x] Preserve OCR state display in the shared file-strip component. +- [x] Keep input focus behavior by exposing an explicit assistant input ref setter. + +Expected benefit: + +- One composer markup surface. +- One selected-file/OCR badge markup surface. +- Lower maintenance cost when upload, date picker, lock state, or send-button behavior changes. + +### P1: Application Gate And Fact Extraction + +Backend application flow repeats checks across `user_agent_application.py`, `steward_planner.py`, `orchestrator.py`, `ontology_detection.py`, and `ontology_extraction.py`. + +Plan: + +- [ ] Extract application intent / context gate helpers. +- [ ] Extract application fact resolver for date, location, reason, amount, transport, and expense type. +- [ ] Route UserAgent, StewardPlanner, and Orchestrator through the same helpers. +- [ ] Preserve existing application vs reimbursement stage boundaries. + +Expected benefit: + +- Fewer mismatches between button actions and text-input actions. +- Less chance of direct submit re-entering slow `/orchestrator/run` paths. +- More consistent missing-field prompts. + +### P1: Backend Parsing And Risk Context Utilities + +Several backend modules repeat city extraction, document field lookup, item-id dedupe, Decimal conversion, endpoint normalization, and JSON error parsing. + +Plan: + +- [ ] Extract platform risk context helpers for item-id and document field utilities. +- [ ] Reuse existing amount utilities before adding new regex parsing. +- [ ] Share model connectivity URL/header/error helpers between RAG runtime and connectivity checks. +- [ ] Cache sorted travel-policy city names per policy snapshot. + +Expected benefit: + +- Less CPU churn in repeated risk/OCR loops. +- Fewer provider connectivity behavior differences. +- Easier review of platform-risk regressions. + +### P1: Cross-Layer Taxonomy And Protocol Constants + +Status labels, expense types, document types, risk levels, API paths, and snake_case/camelCase mappings are repeated across backend schemas, frontend services, and tests. + +Plan: + +- [ ] Establish read-only contract baseline from OpenAPI export. +- [ ] Export status / approval-stage registry to frontend constants. +- [ ] Consolidate expense type, document type, and risk-level taxonomy. +- [ ] Move high-churn API path and payload builders into shared frontend test helpers. + +Expected benefit: + +- Fewer display inconsistencies. +- Easier API evolution. +- Less brittle source-string tests. + +### P2: Test Fixture Duplication + +`server/tests` repeatedly defines `build_session`, `build_session_factory`, `override_db`, `build_client`, and OCR fake functions. + +Plan: + +- [ ] Add backend test fixtures in `server/tests/conftest.py`. +- [ ] Move OCR fake builders into a small test helper. +- [ ] Migrate tests in batches, one behavior area at a time. + +Expected benefit: + +- Less boilerplate in large test files. +- Easier targeted regression coverage before service refactors. + +## First Slice Execution Plan + +### Task 1: Lock Shared Renderer Helper Behavior + +**Files:** + +- Create: `web/tests/conversation-trusted-html.test.mjs` +- Create: `web/src/utils/conversationTrustedHtml.js` +- Modify: `web/src/utils/markdown.js` +- Modify: `web/src/utils/aiConversationHtmlRenderer.js` + +Steps: + +- [x] Add a failing Node test that imports `conversationTrustedHtml.js`. +- [x] Assert valid trusted document-card HTML is preserved through placeholder extraction and restore. +- [x] Assert unsafe tags, event handlers, and non-document hrefs are rejected. +- [x] Assert colon headings and business field lines normalize outside fenced code blocks. +- [x] Run the new test and confirm it fails because the helper does not exist. +- [x] Implement the helper with pure functions only. +- [x] Refactor both renderers to use the helper. +- [x] Run targeted renderer tests. +- [x] Run `npm --prefix web run build`. + +Validation commands: + +```bash +node --test web/tests/conversation-trusted-html.test.mjs +node --test web/tests/ai-conversation-html-renderer.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs +npm --prefix web run build +``` + +## Third Slice Execution Plan + +### Task 3: Reuse Workbench AI Composer Components + +**Files:** + +- Create: `web/tests/workbench-ai-composer-components.test.mjs` +- Modify: `web/src/components/business/PersonalWorkbenchAiMode.vue` +- Modify: `web/src/components/business/PersonalWorkbenchAiMode.template.html` +- Modify: `web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue` +- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js` + +Steps: + +- [x] Add a failing Node test that expects the main template to use `WorkbenchAiComposer` and `WorkbenchAiFileStrip`. +- [x] Assert the shared file strip preserves OCR state badges. +- [x] Assert the runtime exposes an input ref setter for the shared composer. +- [x] Run the new test and confirm it fails on the duplicated template. +- [x] Pass a proxied runtime object from `PersonalWorkbenchAiMode.vue` into shared components. +- [x] Replace duplicate composer and file-strip markup in the external template. +- [x] Add OCR badge markup to `WorkbenchAiFileStrip.vue`. +- [x] Run targeted workbench AI tests. +- [x] Run `npm --prefix web run build`. + +Validation commands: + +```bash +node --test web/tests/workbench-ai-composer-components.test.mjs +node --test web/tests/workbench-ai-composer-components.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-mode-expense-scene-action.test.mjs +npm --prefix web run build +``` + +## Remaining Execution Plan + +The remaining work is intentionally split into bounded slices. Each slice extracts shared code without changing user-facing flow, API response shape, or submission semantics. + +### Task 4: Frontend Application Gate Helpers + +**Files:** + +- Create: `web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js` +- Create: `web/tests/workbench-ai-application-gate-model.test.mjs` +- Modify: `web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js` +- Modify: `web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js` + +Steps: + +- [x] Add a failing Node test for reimbursement creation intent, submit/save text action resolution, and orphan preview detection. +- [x] Move pure gate predicates into `workbenchAiApplicationGateModel.js`. +- [x] Replace inline copies in personal workbench and application-preview flow. +- [x] Run targeted workbench AI application tests. + +Validation commands: + +```bash +node --test web/tests/workbench-ai-application-gate-model.test.mjs +node --test web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-action-router.test.mjs +``` + +### Task 5: Backend Application Fact Resolver + +**Files:** + +- Create: `server/src/app/services/application_fact_resolver.py` +- Create: `server/tests/test_application_fact_resolver.py` +- Modify: `server/src/app/services/steward_planner.py` + +Steps: + +- [x] Add failing pytest coverage for time, location, reason, transport, and task-type inference. +- [x] Extract pure resolver helpers that preserve existing planner output. +- [x] Route `StewardPlannerService` extraction wrappers through the resolver. +- [x] Run targeted planner/fact tests inside the active app container. + +Validation commands: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_planner.py +``` + +### Task 6: Backend Platform Risk Context Utilities + +**Files:** + +- Modify: `server/src/app/services/expense_claim_platform_context_tools.py` +- Create: `server/tests/test_expense_claim_platform_context_tools.py` +- Modify: `server/src/app/services/expense_claim_platform_route_risk.py` +- Modify: `server/src/app/services/expense_claim_platform_risk.py` + +Steps: + +- [x] Add failing pytest coverage for context city extraction, item-id dedupe, and text-value dedupe helpers. +- [x] Add pure helper functions in `expense_claim_platform_context_tools.py`. +- [x] Route route-risk and platform-risk consumers through the shared helpers. +- [x] Run targeted platform-risk tests inside the active app container. + +Validation commands: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_expense_claim_platform_context_tools.py server/tests/test_expense_claim_platform_risk_stage.py +``` + +### Task 7: Frontend Protocol Constants And Test Helpers + +**Files:** + +- Create: `web/src/constants/documentProtocol.js` +- Create: `web/tests/helpers/sourceSurface.mjs` +- Create: `web/tests/document-protocol-constants.test.mjs` +- Modify: `web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js` +- Migrate one high-churn source-surface test to the helper. + +Steps: + +- [x] Add failing Node tests for status labels, document type constants, and reusable source-surface loading. +- [x] Move duplicated status labels into `documentProtocol.js`. +- [x] Reuse constants in application-preview model and request/document-center model code. +- [x] Migrate one source-surface test helper to reduce brittle boilerplate. + +Validation commands: + +```bash +node --test web/tests/document-protocol-constants.test.mjs web/tests/workbench-ai-mode-switch.test.mjs +``` + +### Task 8: Backend Test Fixture Consolidation + +**Files:** + +- Create: `server/src/app/test_helpers/db.py` +- Create: `server/src/app/test_helpers/__init__.py` +- Create: `server/tests/test_db_test_helpers.py` +- Modify: `server/tests/test_expense_claim_platform_risk_stage.py` + +Steps: + +- [x] Identify an existing small test file with duplicated session/client/OCR helpers. +- [x] Add shared DB helper while preserving its current behavior. +- [x] Migrate one test file only. +- [x] Run the migrated test plus adjacent coverage inside the active app container. + +Validation commands: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_db_test_helpers.py server/tests/test_expense_claim_platform_risk_stage.py +``` + +### Task 9: Steward Planner Module Split + +**Files:** + +- Create: `server/src/app/services/steward_planner_shared.py` +- Create: `server/src/app/services/steward_planner_fallback.py` +- Create: `server/src/app/services/steward_planner_extraction.py` +- Modify: `server/src/app/services/steward_planner.py` + +Steps: + +- [x] Move shared constants and `PlannedTaskDraft` into a shared planner module. +- [x] Move off-topic, pending-flow, and rule-fallback planning into `steward_planner_fallback.py`. +- [x] Move task extraction, ontology normalization, attachment grouping, and summary helpers into `steward_planner_extraction.py`. +- [x] Keep `steward_planner.py` as the service orchestration entrypoint. +- [x] Run planner/fact resolver tests inside the active app container. + +Validation commands: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_planner.py +``` + +### Task 10: App Shell Dynamic Business Chunk Loading + +**Files:** + +- Create: `web/src/views/scripts/appShellAsyncViews.js` +- Create: `web/src/components/shared/AppViewLoadingState.vue` +- Create: `web/src/components/shared/AppModalLoadingState.vue` +- Modify: `web/src/views/AppShellRouteView.vue` +- Modify: `web/src/components/layout/SidebarRail.vue` +- Modify: `web/src/components/layout/AiSidebarRail.vue` +- Modify: `web/tests/app-shell-route-loading.test.mjs` +- Modify: `web/tests/ai-sidebar-rail-mode.test.mjs` + +Steps: + +- [x] Keep top-level shell routes eager so login/setup/app layout does not blank during route navigation. +- [x] Move heavy business views behind `defineAsyncComponent` loaders. +- [x] Add an in-workarea loading state for slow business chunks. +- [x] Add a modal loading state for the smart reimbursement assistant chunk. +- [x] Preload likely next views on sidebar hover/focus and during browser idle time. +- [x] Preserve existing Vite manual vendor chunks, including `vendor-echarts`. +- [x] Run targeted frontend tests and production build. + +Validation commands: + +```bash +node --test web/tests/app-shell-route-loading.test.mjs web/tests/ai-sidebar-rail-mode.test.mjs web/tests/sidebar-document-unread-dot.test.mjs web/tests/workbench-ai-mode-switch.test.mjs web/tests/workbench-ai-reimbursement-association-gate.test.mjs web/tests/travel-reimbursement-review-drawer-switch.test.mjs web/tests/documents-center-status-filter.test.mjs +npm --prefix web run build +``` + +Result: + +- App shell entry chunk after split: `index-vWyUfHfm.js` 223.75 kB, gzip 74.11 kB. +- Large `vendor-echarts` chunk remains isolated at 598.67 kB, gzip 204.84 kB; it is no longer part of the app-shell entry chunk. +- Build still reports the existing Rollup `#__PURE__` annotation warnings from Element Plus / VueUse. + +## Guardrails + +- Do not touch unrelated dirty files. +- Do not change renderer output intentionally in this slice. +- Do not move backend logic until frontend helper extraction is green. +- Backend tests must run through Docker: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q +``` diff --git a/server/src/app/services/application_fact_resolver.py b/server/src/app/services/application_fact_resolver.py new file mode 100644 index 0000000..0654c4b --- /dev/null +++ b/server/src/app/services/application_fact_resolver.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import re +from datetime import date, timedelta + + +CITY_NAMES = ( + "北京", + "上海", + "广州", + "深圳", + "杭州", + "南京", + "苏州", + "成都", + "重庆", + "天津", + "武汉", + "西安", + "长沙", + "郑州", + "青岛", + "厦门", + "福州", + "合肥", + "济南", + "沈阳", + "大连", + "宁波", + "无锡", +) + +MONTH_DAY_PATTERN = re.compile(r"(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?") +ISO_DATE_PATTERN = re.compile( + r"(?P\d{4})[-/年](?P\d{1,2})[-/月](?P\d{1,2})(?:日)?" +) + + +class ApplicationFactResolver: + @staticmethod + def infer_expense_type(segment: str, task_type: str) -> str: + compact = re.sub(r"\s+", "", segment) + if re.search(r"招待|接待|餐饮|宴请|客户吃饭|业务餐", compact): + return "entertainment" + if re.search(r"出差|差旅|住宿|酒店|机票|航班|高铁|火车", compact): + return "travel" + if re.search(r"交通|出租车|的士|网约车|打车|地铁|公交", compact): + return "transport" if task_type == "reimbursement" else "travel" + return "travel" if task_type == "expense_application" else "other" + + @staticmethod + def extract_time_range(segment: str, base_date: date) -> str: + compact = re.sub(r"\s+", "", segment) + if "昨天" in compact: + return (base_date - timedelta(days=1)).isoformat() + if "前天" in compact: + return (base_date - timedelta(days=2)).isoformat() + if "明天" in compact: + return (base_date + timedelta(days=1)).isoformat() + if "后天" in compact: + return (base_date + timedelta(days=2)).isoformat() + + iso_match = ISO_DATE_PATTERN.search(compact) + if iso_match: + return ApplicationFactResolver.safe_date( + int(iso_match.group("year")), + int(iso_match.group("month")), + int(iso_match.group("day")), + ) + + month_day = MONTH_DAY_PATTERN.search(compact) + if month_day: + return ApplicationFactResolver.safe_date( + base_date.year, + int(month_day.group("month")), + int(month_day.group("day")), + ) + return "" + + @staticmethod + def safe_date(year: int, month: int, day: int) -> str: + try: + return date(year, month, day).isoformat() + except ValueError: + return "" + + @staticmethod + def extract_location(segment: str) -> str: + compact = re.sub(r"\s+", "", segment) + for prefix in ("去", "到", "赴", "前往"): + match = re.search(fr"{prefix}({'|'.join(CITY_NAMES)})", compact) + if match: + return match.group(1) + for city in CITY_NAMES: + if city in compact: + return city + return "" + + @staticmethod + def extract_reason(segment: str, task_type: str) -> str: + cleaned = re.sub(r"\s+", "", segment).strip(",,。;; ") + if task_type == "expense_application": + match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned) + if match: + return strip_trailing_connectors(match.group(0)) + reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",,。;;的费用") + return strip_trailing_connectors(reason) or cleaned + cleaned = re.sub(r"^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销", "", cleaned) + if not cleaned or cleaned in {"费用", "报销单", "报销流程"}: + return "" + cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned) + return cleaned.strip(",,。;; ") + + @staticmethod + def extract_transport_mode(segment: str) -> str: + compact = re.sub(r"\s+", "", segment) + if re.search(r"高铁|动车|火车", compact): + return "train" + if re.search(r"飞机|机票|航班", compact): + return "flight" + if re.search(r"出租车|的士|网约车|打车", compact): + return "taxi" + if "交通" in compact: + return "other" + return "" + + +def strip_trailing_connectors(value: str) -> str: + cleaned = str(value or "").strip(",,。;; ") + return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",,。;; ") + + +def resolve_application_facts(segment: str, task_type: str, base_date: date) -> dict[str, str]: + fields = { + "expense_type": ApplicationFactResolver.infer_expense_type(segment, task_type), + "time_range": ApplicationFactResolver.extract_time_range(segment, base_date), + "location": ApplicationFactResolver.extract_location(segment), + "reason": ApplicationFactResolver.extract_reason(segment, task_type), + "transport_mode": ApplicationFactResolver.extract_transport_mode(segment), + } + return {key: value for key, value in fields.items() if value} diff --git a/server/src/app/services/expense_claim_platform_context_tools.py b/server/src/app/services/expense_claim_platform_context_tools.py index c1411e4..0ef3654 100644 --- a/server/src/app/services/expense_claim_platform_context_tools.py +++ b/server/src/app/services/expense_claim_platform_context_tools.py @@ -6,6 +6,18 @@ from typing import Any from app.services.expense_rule_runtime import RuntimeTravelPolicy +def unique_text_values(values: list[Any]) -> list[str]: + normalized_values: list[str] = [] + seen: set[str] = set() + for value in list(values or []): + normalized = str(value or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + normalized_values.append(normalized) + return normalized_values + + def count_values(values: list[str]) -> dict[str, int]: counts: dict[str, int] = {} for value in values: @@ -51,21 +63,35 @@ def collect_attachment_cities( ) -> list[str]: cities: list[str] = [] for context in contexts: - document_info = context.get("document_info") or {} - parts = [ - str(context.get("ocr_summary") or ""), - str(context.get("ocr_text") or ""), - str(context.get("item").item_location if context.get("item") is not None else ""), - ] - for field in list(document_info.get("fields") or []): - if isinstance(field, dict): - parts.append(str(field.get("value") or "")) - for city in extract_known_cities_from_text(" ".join(parts), policy): + for city in collect_context_cities(context, policy): if city not in cities: cities.append(city) return cities +def collect_context_cities( + context: dict[str, Any], + policy: RuntimeTravelPolicy, + *, + include_item_reason: bool = False, +) -> list[str]: + if not isinstance(context, dict): + return [] + document_info = context.get("document_info") or {} + item = context.get("item") + parts = [ + str(context.get("ocr_summary") or ""), + str(context.get("ocr_text") or ""), + str(getattr(item, "item_location", "") or ""), + ] + if include_item_reason: + parts.append(str(getattr(item, "item_reason", "") or "")) + for field in list(document_info.get("fields") or []): + if isinstance(field, dict): + parts.append(str(field.get("value") or "")) + return extract_known_cities_from_text(" ".join(parts), policy) + + def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]: normalized = str(text or "").strip() if not normalized: @@ -77,6 +103,11 @@ def extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> li return cities +def extract_first_known_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str: + cities = extract_known_cities_from_text(text, policy) + return cities[0] if cities else "" + + def resolve_first_document_field_value( document_info: dict[str, Any], *, @@ -95,3 +126,15 @@ def resolve_first_document_field_value( if field_key in normalized_keys or any(token in label for token in labels): return value return "" + + +def collect_context_item_ids(contexts: list[dict[str, Any]]) -> list[str]: + item_ids: list[str] = [] + seen: set[str] = set() + for context in list(contexts or []): + item = context.get("item") if isinstance(context, dict) else None + item_id = str(getattr(item, "id", "") or "").strip() + if item_id and item_id not in seen: + seen.add(item_id) + item_ids.append(item_id) + return item_ids diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index a4c9f0a..5b1aa53 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -13,6 +13,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.budget import BudgetService from app.services.expense_claim_platform_context_tools import ( collect_attachment_cities, + collect_context_item_ids, collect_invoice_keys_from_contexts, collect_invoice_keys_from_document_info, count_values, @@ -768,15 +769,7 @@ class ExpenseClaimPlatformRiskMixin: @staticmethod def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]: - item_ids: list[str] = [] - seen: set[str] = set() - for context in list(contexts or []): - item = context.get("item") if isinstance(context, dict) else None - item_id = str(getattr(item, "id", "") or "").strip() - if item_id and item_id not in seen: - seen.add(item_id) - item_ids.append(item_id) - return item_ids + return collect_context_item_ids(contexts) @staticmethod def _with_related_item_ids(flag: dict[str, Any], item_ids: list[str]) -> dict[str, Any]: diff --git a/server/src/app/services/expense_claim_platform_route_risk.py b/server/src/app/services/expense_claim_platform_route_risk.py index 475e640..aa52a0e 100644 --- a/server/src/app/services/expense_claim_platform_route_risk.py +++ b/server/src/app/services/expense_claim_platform_route_risk.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import Any from app.models.financial_record import ExpenseClaim +from app.services.expense_claim_platform_context_tools import ( + collect_context_cities, + collect_context_item_ids, + extract_first_known_city_from_text, + resolve_first_document_field_value, + unique_text_values, +) from app.services.expense_rule_runtime import RuntimeTravelPolicy @@ -13,16 +20,16 @@ def resolve_multi_city_related_item_ids( ) -> tuple[list[str], list[str]]: segments = _collect_travel_route_segments(contexts, policy) if not segments: - return _context_item_ids(contexts), [] + return collect_context_item_ids(contexts), [] first_origin = str(segments[0].get("origin") or "").strip() first_destination = str(segments[0].get("destination") or "").strip() expected_destination = _resolve_expected_travel_city(claim, contexts, policy) - baseline_cities = _unique_text_values( + baseline_cities = unique_text_values( [first_origin, expected_destination or first_destination] ) - destination_cities = _unique_text_values( + destination_cities = unique_text_values( [str(segment.get("destination") or "") for segment in segments] ) extra_cities = [ @@ -31,7 +38,7 @@ def resolve_multi_city_related_item_ids( if city and city not in set(baseline_cities) ] if not extra_cities: - route_cities = _unique_text_values( + route_cities = unique_text_values( [ city for segment in segments @@ -86,7 +93,7 @@ def _resolve_expected_travel_city( contexts: list[dict[str, Any]], policy: RuntimeTravelPolicy, ) -> str: - claim_city = _extract_first_known_city(str(claim.location or ""), policy) + claim_city = extract_first_known_city_from_text(str(claim.location or ""), policy) if claim_city: return claim_city @@ -96,7 +103,7 @@ def _resolve_expected_travel_city( scene_code = str(document_info.get("scene_code") or "").strip().lower() if document_type != "hotel_invoice" and scene_code != "hotel": continue - for city in _extract_context_cities(context, policy): + for city in collect_context_cities(context, policy, include_item_reason=True): return city return "" @@ -107,7 +114,7 @@ def _extract_route_segment( ) -> tuple[str, str] | None: document_info = context.get("document_info") or {} item = context.get("item") - route_value = _resolve_document_field_value( + route_value = resolve_first_document_field_value( document_info, keys={"route", "route_cities", "routecities", "travel_route", "trip_route"}, labels={"路线", "行程", "起讫", "起终", "始发", "到达"}, @@ -130,8 +137,8 @@ def _extract_route_segment( segment.strip() for segment in normalized.split(separator, 1) ] - origin = _extract_first_known_city(origin_text, policy) - destination = _extract_first_known_city(destination_text, policy) + origin = extract_first_known_city_from_text(origin_text, policy) + destination = extract_first_known_city_from_text(destination_text, policy) if origin and destination and origin != destination: return origin, destination return None @@ -154,91 +161,11 @@ def _is_long_distance_context( ) -def _extract_context_cities( - context: dict[str, Any], - policy: RuntimeTravelPolicy, -) -> list[str]: - document_info = context.get("document_info") or {} - item = context.get("item") - parts = [ - str(context.get("ocr_summary") or ""), - str(context.get("ocr_text") or ""), - str(getattr(item, "item_location", "") or ""), - str(getattr(item, "item_reason", "") or ""), - ] - for field in list(document_info.get("fields") or []): - if isinstance(field, dict): - parts.append(str(field.get("value") or "")) - return _extract_known_cities_from_text(" ".join(parts), policy) - - -def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]: - normalized = str(text or "").strip() - if not normalized: - return [] - cities: list[str] = [] - for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True): - if city in normalized and city not in cities: - cities.append(city) - return cities - - -def _extract_first_known_city(text: str, policy: RuntimeTravelPolicy) -> str: - cities = _extract_known_cities_from_text(text, policy) - return cities[0] if cities else "" - - -def _resolve_document_field_value( - document_info: dict[str, Any], - *, - keys: set[str], - labels: set[str], -) -> str: - normalized_keys = {key.replace("_", "").lower() for key in keys} - for field in list(document_info.get("fields") or []): - if not isinstance(field, dict): - continue - field_key = str(field.get("key") or "").strip().lower().replace("_", "") - label = str(field.get("label") or "").replace(" ", "") - value = str(field.get("value") or "").strip() - if not value: - continue - if field_key in normalized_keys or any(token in label for token in labels): - return value - return "" - - def _route_segment_item_ids(segments: list[dict[str, Any]]) -> list[str]: - item_ids: list[str] = [] - seen: set[str] = set() - for segment in list(segments or []): - item = segment.get("item") if isinstance(segment, dict) else None - item_id = str(getattr(item, "id", "") or "").strip() - if item_id and item_id not in seen: - seen.add(item_id) - item_ids.append(item_id) - return item_ids - - -def _context_item_ids(contexts: list[dict[str, Any]]) -> list[str]: - item_ids: list[str] = [] - seen: set[str] = set() - for context in list(contexts or []): - item = context.get("item") if isinstance(context, dict) else None - item_id = str(getattr(item, "id", "") or "").strip() - if item_id and item_id not in seen: - seen.add(item_id) - item_ids.append(item_id) - return item_ids - - -def _unique_text_values(values: list[str]) -> list[str]: - normalized_values: list[str] = [] - seen: set[str] = set() - for value in list(values or []): - normalized = str(value or "").strip() - if not normalized or normalized in seen: - continue - seen.add(normalized) - normalized_values.append(normalized) - return normalized_values + return collect_context_item_ids( + [ + {"item": segment.get("item")} + for segment in list(segments or []) + if isinstance(segment, dict) + ] + ) diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index cea7345..10aa1fb 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -1,1164 +1,14 @@ from __future__ import annotations -import re -import uuid -from dataclasses import dataclass -from datetime import UTC, date, datetime, timedelta from typing import Any -from app.schemas.steward import ( - StewardAttachmentGroup, - StewardAttachmentInput, - StewardCandidateFlow, - StewardConfirmationAction, - StewardPendingFlowConfirmation, - StewardPlanRequest, - StewardPlanResponse, - StewardTask, - StewardThinkingEvent, -) -from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS -from app.services.ontology_field_registry import normalize_ontology_form_values +from app.schemas.steward import StewardPlanRequest, StewardPlanResponse +from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_model_plan_builder import StewardModelPlanBuilder from app.services.steward_off_topic_agent import StewardOffTopicAgent - - -CITY_NAMES = ( - "北京", - "上海", - "广州", - "深圳", - "杭州", - "南京", - "苏州", - "成都", - "重庆", - "天津", - "武汉", - "西安", - "长沙", - "郑州", - "青岛", - "厦门", - "福州", - "合肥", - "济南", - "沈阳", - "大连", - "宁波", - "无锡", -) - -# 业务信号关键词:用于判定输入是否与小财管家支持的财务事项相关。 -# 只要清洗后的消息命中其中任意一个关键词,就视为业务相关;否则进入 off_topic 拦截。 -STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = ( - # 动作词 - "申请", "报销", "草稿", "提交", "审批", "保存", "发起", "创建", "核对", "归集", - # 差旅场景 - "出差", "差旅", "费用", "交通", "住宿", "招待", "酒店", "机票", "航班", "高铁", - "动车", "火车", "出租车", "的士", "网约车", "打车", "地铁", "公交", "用餐", "餐饮", "宴请", - # 票据/凭证 - "票据", "发票", "凭证", "行程单", "付款截图", "付款", "小票", "收据", - # 业务对象 - "客户", "项目", "拜访", "会议", "培训", "部署", "实施", "支撑", "支持", "协助", - "调研", "驻场", "上线", "验收", "审核", - # 时间信号 - "昨天", "前天", "明天", "后天", "下周", "下月", "近期", "月底", "今天", "上周", "上月", - # 金额/数量("天"用于"出差3天"等表达) - "金额", "元", "块", "万", "千", "天", - # 复用城市名信号 - *CITY_NAMES, -) - - -# 业务无关输入的场景分类 -STEWARD_OFF_TOPIC_SCENARIO_GREETING = "greeting" -STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS = "meaningless" -STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS = "off_business" - - -# 问候词:用于将"你好"等礼貌问候单独归类为 greeting 场景 -STEWARD_GREETING_KEYWORDS: tuple[str, ...] = ( - "你好", "您好", "hi", "hello", "hey", "嗨", "哈喽", - "早上好", "上午好", "中午好", "下午好", "晚上好", "早安", "晚安", - "您好呀", "你好呀", "在吗", "在么", "在不在", -) - -APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,,。;;])[^,,。;;]*?(?:申请|出差申请|差旅申请)[^,,。;;]*") -REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,,。;;!??!\n]+)") -MONTH_DAY_PATTERN = re.compile(r"(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?") -ISO_DATE_PATTERN = re.compile(r"(?P\d{4})[-/年](?P\d{1,2})[-/月](?P\d{1,2})(?:日)?") - -BUSINESS_FIELD_LABELS = { - "expense_type": "费用类型", - "time_range": "时间", - "location": "地点", - "reason": "事由", - "amount": "金额", - "transport_mode": "出行方式", - "attachments": "附件/凭证", - "customer_name": "客户或项目对象", - "merchant_name": "商户/开票方", - "department_name": "所属部门", - "employee_name": "申请人", - "employee_no": "员工编号", -} - -EXPENSE_TYPE_LABELS = { - "travel": "差旅", - "transport": "交通费", - "entertainment": "业务招待费", - "office": "办公用品", - "meeting": "会议费", - "training": "培训费", - "other": "其他费用", -} - -TRANSPORT_MODE_LABELS = { - "train": "火车/高铁", - "flight": "飞机", - "taxi": "出租车/网约车", - "subway": "地铁", - "other": "其他交通方式", -} - - -@dataclass(frozen=True) -class PlannedTaskDraft: - task_type: str - segment: str - index: int - - -class StewardPlannerFallbackMixin: - def _should_use_model_intent_recognition( - self, - message: str, - base_date: date, - request: StewardPlanRequest, - ) -> bool: - if self._looks_like_ambiguous_travel_flow(message, base_date, request): - return False - return self._has_multiple_financial_demands(message) - - @staticmethod - def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool: - """判断输入是否与小财管家支持的财务事项完全无关(向后兼容包装)。 - - 判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件。 - 实际判定逻辑由 _classify_irrelevant_input 负责,命中任何场景即视为业务无关。 - """ - return StewardPlannerService._classify_irrelevant_input(message, request) is not None - - @staticmethod - def _classify_irrelevant_input(message: str, request: StewardPlanRequest) -> str | None: - """把业务无关输入细分为三个场景,便于给出更贴切的引导。 - - 返回值: - - "greeting":礼貌问候("你好"等),无业务关键词 - - "meaningless":完全无意义内容(纯数字、纯标点、单字符重复、纯字母数字乱码) - - "off_business":有意义但与财务无关(问天气、聊生活等) - - None:消息与业务相关,无需走 off_topic 路径 - """ - if request.attachments: - return None - compact = re.sub(r"\s+", "", message) - if not compact: - return None - if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS): - return None - - if StewardPlannerService._looks_like_greeting(compact): - return STEWARD_OFF_TOPIC_SCENARIO_GREETING - if StewardPlannerService._looks_like_meaningless(compact): - return STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS - return STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS - - @staticmethod - def _looks_like_greeting(compact_message: str) -> bool: - """判断消息是否只是礼貌问候(无其他有意义内容)。""" - normalized = compact_message.lower() - for keyword in STEWARD_GREETING_KEYWORDS: - if normalized == keyword.lower() or normalized.startswith(keyword.lower()): - # 整句只是问候词(允许少量标点) - tail = normalized[len(keyword.lower()):] - if not tail or re.fullmatch(r"[!!。.??,,~\s]+", tail): - return True - return False - - @staticmethod - def _looks_like_meaningless(compact_message: str) -> bool: - """判断消息是否完全没有语义价值(纯数字、纯标点、单字符重复等)。""" - if re.fullmatch(r"\d+", compact_message): - return True - # 纯标点 - if re.fullmatch(r"[\W_]+", compact_message): - return True - # 单字符重复(例如 "啊啊啊啊啊") - if len(compact_message) >= 2 and len(set(compact_message)) == 1: - return True - # 短字母数字组合但没有任何业务意义,例如 "abc"、"test123" - # 注意:必须排除已经被关键词命中的情况(前面的判定已保证不命中关键词) - if re.fullmatch(r"[a-zA-Z0-9]+", compact_message) and len(compact_message) <= 12: - return True - return False - - def _build_off_topic_plan( - self, - request: StewardPlanRequest, - *, - scenario: str, - ) -> StewardPlanResponse: - """业务无关输入的兜底计划:根据场景给出对应引导,off_business 场景可由 LLM 增强。""" - base_summary = self._default_off_topic_summary(scenario) - thinking_event = self._build_off_topic_thinking_event(scenario) - suggested_prompts = self._off_topic_suggested_prompts(scenario) - model_call_traces: list[dict[str, Any]] = [] - - # 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。 - if ( - scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS - and self.off_topic_agent is not None - ): - try: - llm_result = self.off_topic_agent.generate(request, scenario=scenario) - if llm_result is not None and llm_result.response_text: - base_summary = llm_result.response_text - model_call_traces = llm_result.model_call_traces - except Exception: - # 失败时静默回退到规则模板 - pass - - return StewardPlanResponse( - plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", - plan_status="off_topic", - planning_source="rule_fallback", - next_action="none", - summary=base_summary, - thinking_events=[thinking_event], - tasks=[], - attachment_groups=[], - confirmation_groups=[], - candidate_flows=[], - suggested_prompts=suggested_prompts, - model_call_traces=model_call_traces, - ) - - @staticmethod - def _default_off_topic_summary(scenario: str) -> str: - """off_topic 场景的默认引导文案;LLM 不可用时使用。""" - if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: - return ( - "### 您好主人,很高兴为您服务\n\n" - "请问您今天要办理什么业务?目前小财管家能帮您整理" - "**费用申请**和**费用报销**这两类事项。\n\n" - "要不您换种说法告诉我:" - ) - if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: - return ( - "### 抱歉主人,这句话我暂时帮不上忙\n\n" - "我看了您刚才说的这句话,里面聊的不是财务事项。" - "小财管家目前只能帮您整理**费用申请**和**费用报销**这两类业务。\n\n" - "要不您换种说法告诉我:" - ) - # meaningless - return ( - "### 这句话我暂时没识别到财务事项\n\n" - "很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n" - "要不您换种说法告诉我:" - ) - - @staticmethod - def _build_off_topic_thinking_event(scenario: str) -> StewardThinkingEvent: - """off_topic 场景下向用户展示的思考过程摘要。""" - if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: - return StewardThinkingEvent( - event_id="intent_agent_off_topic_greeting", - stage="off_topic", - title="先回应主人的问候", - content="主人向我打了个招呼,我先礼貌回应一下,再引导他/她说出具体想办什么业务。", - ) - if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: - return StewardThinkingEvent( - event_id="intent_agent_off_topic_non_business", - stage="off_topic", - title="这句话不在服务范围内", - content="我看了您刚才说的这句话,里面聊的不是财务事项。小财管家目前只能帮您整理费用申请和费用报销。", - ) - return StewardThinkingEvent( - event_id="intent_agent_off_topic_meaningless", - stage="off_topic", - title="未识别到财务事项", - content=( - "我仔细看了看您刚才说的这句话,里面好像没有出现" - "费用申请、报销、出差、交通、招待这些财务关键词。" - ), - ) - - @staticmethod - def _off_topic_suggested_prompts(scenario: str) -> list[str]: - """off_topic 场景下展示给用户的推荐话术。""" - if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: - return [ - "我想要申请明天去北京出差3天,支撑客户现场实施", - "我要报销昨天的交通费", - "我上周出差去上海的费用需要报销", - ] - if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: - return [ - "我想要申请明天去北京出差3天,支撑客户现场实施", - "我要报销昨天的交通费", - "我需要整理上周出差的发票", - ] - # meaningless - return [ - "我想要申请明天去北京出差3天,支撑客户现场实施", - "我要报销昨天的交通费", - "我上周出差去上海的费用需要报销", - ] - - def _build_rule_fallback_plan( - self, - request: StewardPlanRequest, - *, - base_date: date, - model_call_traces: list[dict[str, Any]] | None = None, - fallback_reason: str = "", - ) -> StewardPlanResponse: - message = self._clean_text(request.message) - if self._looks_like_ambiguous_travel_flow(message, base_date, request): - return self._build_pending_flow_fallback_plan( - request, - base_date=base_date, - model_call_traces=model_call_traces, - fallback_reason=fallback_reason, - ) - task_drafts = self._extract_task_drafts(message) - tasks = [self._build_task(draft, base_date, request) for draft in task_drafts] - if not tasks: - tasks = [self._build_fallback_task(message, base_date, request)] - - attachment_groups = self._build_attachment_groups(request.attachments, tasks) - confirmation_groups = self._build_confirmation_actions(tasks, attachment_groups) - thinking_events = self._build_thinking_events(tasks, attachment_groups, request.attachments) - if fallback_reason: - thinking_events.insert( - 0, - StewardThinkingEvent( - event_id="intent_agent_rule_fallback", - stage="rule_fallback", - title="意图识别智能体进入兜底模式", - content=fallback_reason, - ), - ) - plan_id = f"steward_plan_{uuid.uuid4().hex[:12]}" - - return StewardPlanResponse( - plan_id=plan_id, - plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", - planning_source="rule_fallback", - next_action="confirm_task" if confirmation_groups else "delegate_task", - summary=self._build_summary(tasks, attachment_groups), - thinking_events=thinking_events, - tasks=tasks, - attachment_groups=attachment_groups, - confirmation_groups=confirmation_groups, - model_call_traces=model_call_traces or [], - ) - - def _build_pending_flow_fallback_plan( - self, - request: StewardPlanRequest, - *, - base_date: date, - model_call_traces: list[dict[str, Any]] | None = None, - fallback_reason: str = "", - planning_source: str = "rule_fallback", - ) -> StewardPlanResponse: - candidates = self._build_rule_candidate_flows(request, base_date) - gate = self._resolve_required_application_gate(request, "travel") - pending_reason = self._build_pending_flow_reason(gate) - pending = StewardPendingFlowConfirmation( - status="pending", - source_message=request.message, - reason=pending_reason, - candidate_flows=candidates, - ) - thinking_events = [] - if fallback_reason: - thinking_events.append( - StewardThinkingEvent( - event_id="intent_agent_rule_fallback", - stage="rule_fallback", - title="意图识别智能体进入兜底模式", - content=fallback_reason, - ) - ) - thinking_events.append( - StewardThinkingEvent( - event_id="intent_pending_flow_confirmation", - stage="flow_confirmation", - title="需要确认流程方向", - content=pending_reason, - ) - ) - return StewardPlanResponse( - plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", - plan_status="needs_flow_confirmation", - planning_source=planning_source, # type: ignore[arg-type] - next_action="confirm_flow", - summary=self._build_pending_flow_summary(gate), - thinking_events=thinking_events, - pending_flow_confirmation=pending, - candidate_flows=candidates, - model_call_traces=model_call_traces or [], - ) - - def _build_rule_candidate_flows( - self, - request: StewardPlanRequest, - base_date: date, - ) -> list[StewardCandidateFlow]: - application_fields = self._extract_ontology_fields( - request.message, - "expense_application", - base_date, - request, - ) - reimbursement_fields = self._extract_ontology_fields( - request.message, - "reimbursement", - base_date, - request, - ) - gate = self._resolve_required_application_gate(request, "travel") - if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: - return [ - StewardCandidateFlow( - flow_id="travel_application", - label="先发起出差申请", - confidence=0.86, - reason="已先查询你名下可关联的差旅申请单,暂未查到可关联单据,因此应先申请单据。", - ontology_fields=application_fields, - missing_fields=self._resolve_missing_fields("expense_application", application_fields), - ) - ] - reimbursement_label = "发起费用报销" - reimbursement_reason = "用户描述的也可能是已发生出差事项,需要进入报销材料整理。" - if gate.get("checked"): - candidate_count = int(gate.get("candidate_count") or 0) - reimbursement_label = "关联已有申请单并发起报销" - reimbursement_reason = f"已先查到 {candidate_count} 个可关联申请单,选择后会先请你关联具体单据。" - return [ - StewardCandidateFlow( - flow_id="travel_application", - label="补办出差申请", - confidence=0.52, - reason="用户描述了出差时间、地点和事由,但没有明确说要报销。", - ontology_fields=application_fields, - missing_fields=self._resolve_missing_fields("expense_application", application_fields), - ), - StewardCandidateFlow( - flow_id="travel_reimbursement", - label=reimbursement_label, - confidence=0.48, - reason=reimbursement_reason, - ontology_fields=reimbursement_fields, - missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields), - ), - ] - - @staticmethod - def _resolve_required_application_gate( - request: StewardPlanRequest, - expense_type: str, - ) -> dict[str, Any]: - context = request.context_json if isinstance(request.context_json, dict) else {} - gates = context.get("required_application_gate") - if not isinstance(gates, dict): - return {} - gate = gates.get(expense_type) - if not isinstance(gate, dict) or not gate.get("checked"): - return {} - try: - candidate_count = max(0, int(gate.get("candidate_count") or 0)) - except (TypeError, ValueError): - candidate_count = 0 - return { - **gate, - "candidate_count": candidate_count, - "checked": True, - } - - @staticmethod - def _build_pending_flow_reason(gate: dict[str, Any]) -> str: - if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: - return "我已经先查询你名下可关联的差旅申请单,未查到可关联单据,所以当前应先申请单据。" - if gate.get("checked"): - candidate_count = int(gate.get("candidate_count") or 0) - return f"我已经先查询你名下的差旅申请单,查到 {candidate_count} 个可关联申请单,需要你确认是否关联单据后发起报销。" - return "当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。" - - @staticmethod - def _build_pending_flow_summary(gate: dict[str, Any]) -> str: - if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: - return "我已先查询可关联申请单,暂未查到可关联单据;这次应先申请单据,再进入后续报销。" - if gate.get("checked"): - candidate_count = int(gate.get("candidate_count") or 0) - return ( - f"我已先查询可关联申请单,查到 {candidate_count} 个可关联申请单;" - "你可以选择关联已有申请单发起报销,或改为补办新的出差申请。" - ) - return ( - "我识别到这是一次出差事项,但还不能确定你要做的是" - "**补办出差申请**还是**发起费用报销**。请先选择一个方向。" - ) - - def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]: - drafts: list[PlannedTaskDraft] = [] - first_reimbursement = self._find_first_reimbursement_index(message) - application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message - if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source): - drafts.append( - PlannedTaskDraft( - task_type="expense_application", - segment=application_source.strip(",,。;; "), - index=len(drafts) + 1, - ) - ) - - for match in REIMBURSEMENT_PATTERN.finditer(message): - segment = f"报销{match.group(1)}" - drafts.append( - PlannedTaskDraft( - task_type="reimbursement", - segment=segment.strip(",,。;; "), - index=len(drafts) + 1, - ) - ) - - return drafts - - -class StewardPlannerExtractionMixin: - def _has_multiple_financial_demands(self, message: str) -> bool: - task_drafts = self._extract_task_drafts(message) - if len(task_drafts) > 1: - return True - - compact = re.sub(r"\s+", "", message) - if not compact: - return False - - application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact) - reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0 - if application_signal and reimbursement_signal: - return True - - connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact) - repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1 - return bool(connector_signal and repeated_reimbursement_signal) - - @staticmethod - def _find_first_reimbursement_index(message: str) -> int: - candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")] - positives = [item for item in candidates if item >= 0] - return min(positives) if positives else -1 - - @staticmethod - def _looks_like_application(text: str) -> bool: - compact = re.sub(r"\s+", "", text) - return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact)) - - @staticmethod - def _looks_like_future_travel_application(text: str) -> bool: - compact = re.sub(r"\s+", "", text) - if not compact or "报销" in compact: - return False - business_signal = re.search( - r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", - compact, - ) - route_signal = re.search( - fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})", - compact, - ) - time_signal = re.search( - r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|" - r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天", - compact, - ) - planned_route_signal = re.search( - r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|" - r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)", - compact, - ) - return bool((business_signal or route_signal) and (time_signal or planned_route_signal)) - - def _looks_like_ambiguous_travel_flow( - self, - text: str, - base_date: date, - request: StewardPlanRequest, - ) -> bool: - compact = re.sub(r"\s+", "", text) - if not compact or request.attachments: - return False - if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact): - return False - if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact): - return False - if not self._extract_time_range(compact, base_date): - return False - if not self._extract_location(compact): - return False - return not self._is_future_or_current_time_range(compact, base_date) - - def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool: - normalized = self._extract_time_range(segment, base_date) - if not normalized: - return False - try: - parsed = date.fromisoformat(normalized) - except ValueError: - return False - return parsed >= base_date - - def _build_task( - self, - draft: PlannedTaskDraft, - base_date: date, - request: StewardPlanRequest, - ) -> StewardTask: - fields = self._extract_ontology_fields(draft.segment, draft.task_type, base_date, request) - missing_fields = self._resolve_missing_fields(draft.task_type, fields) - task_id = f"task_{'app' if draft.task_type == 'expense_application' else 'reim'}_{draft.index:03d}" - assigned_agent = ( - "application_assistant" - if draft.task_type == "expense_application" - else "reimbursement_assistant" - ) - title_prefix = "费用申请" if draft.task_type == "expense_application" else "费用报销" - title = self._build_task_title(title_prefix, fields, draft.index) - return StewardTask( - task_id=task_id, - task_type=draft.task_type, # type: ignore[arg-type] - assigned_agent=assigned_agent, # type: ignore[arg-type] - title=title, - summary=self._build_task_summary(draft.segment, fields), - status="needs_confirmation", - confidence=self._resolve_task_confidence(draft.segment, fields, draft.task_type), - ontology_fields=fields, - missing_fields=missing_fields, - confirmation_required=True, - ) - - def _build_fallback_task( - self, - message: str, - base_date: date, - request: StewardPlanRequest, - ) -> StewardTask: - task_type = "reimbursement" if "报销" in message or request.attachments else "expense_application" - draft = PlannedTaskDraft(task_type=task_type, segment=message, index=1) - task = self._build_task(draft, base_date, request) - return task.model_copy(update={"confidence": min(task.confidence, 0.58)}) - - def _extract_ontology_fields( - self, - segment: str, - task_type: str, - base_date: date, - request: StewardPlanRequest, - ) -> dict[str, str]: - normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values")) - fields: dict[str, str] = { - key: value - for key, value in normalized_context.items() - if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip() - } - expense_type = self._infer_expense_type(segment, task_type) - if expense_type and not fields.get("expense_type"): - fields["expense_type"] = expense_type - time_range = self._extract_time_range(segment, base_date) - if time_range and not fields.get("time_range"): - fields["time_range"] = time_range - location = self._extract_location(segment) - if location and not fields.get("location"): - fields["location"] = location - reason = self._extract_reason(segment, task_type) - if reason and not fields.get("reason"): - fields["reason"] = reason - transport_mode = self._extract_transport_mode(segment) - if transport_mode and not fields.get("transport_mode"): - fields["transport_mode"] = transport_mode - if request.attachments: - fields["attachments"] = "、".join(item.name for item in request.attachments if item.name) - - return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value} - - @staticmethod - def _infer_expense_type(segment: str, task_type: str) -> str: - compact = re.sub(r"\s+", "", segment) - if re.search(r"招待|接待|餐饮|宴请|客户吃饭|业务餐", compact): - return "entertainment" - if re.search(r"出差|差旅|住宿|酒店|机票|航班|高铁|火车", compact): - return "travel" - if re.search(r"交通|出租车|的士|网约车|打车|地铁|公交", compact): - return "transport" if task_type == "reimbursement" else "travel" - return "travel" if task_type == "expense_application" else "other" - - def _extract_time_range(self, segment: str, base_date: date) -> str: - compact = re.sub(r"\s+", "", segment) - if "昨天" in compact: - return (base_date - timedelta(days=1)).isoformat() - if "前天" in compact: - return (base_date - timedelta(days=2)).isoformat() - if "明天" in compact: - return (base_date + timedelta(days=1)).isoformat() - if "后天" in compact: - return (base_date + timedelta(days=2)).isoformat() - - iso_match = ISO_DATE_PATTERN.search(compact) - if iso_match: - return self._safe_date( - int(iso_match.group("year")), - int(iso_match.group("month")), - int(iso_match.group("day")), - ) - - month_day = MONTH_DAY_PATTERN.search(compact) - if month_day: - return self._safe_date( - base_date.year, - int(month_day.group("month")), - int(month_day.group("day")), - ) - return "" - - @staticmethod - def _safe_date(year: int, month: int, day: int) -> str: - try: - return date(year, month, day).isoformat() - except ValueError: - return "" - - @staticmethod - def _extract_location(segment: str) -> str: - compact = re.sub(r"\s+", "", segment) - for prefix in ("去", "到", "赴", "前往"): - match = re.search(fr"{prefix}({'|'.join(CITY_NAMES)})", compact) - if match: - return match.group(1) - for city in CITY_NAMES: - if city in compact: - return city - return "" - - @staticmethod - def _extract_reason(segment: str, task_type: str) -> str: - cleaned = re.sub(r"\s+", "", segment).strip(",,。;; ") - if task_type == "expense_application": - match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned) - if match: - return StewardPlannerService._strip_trailing_connectors(match.group(0)) - reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",,。;;的费用") - return StewardPlannerService._strip_trailing_connectors(reason) or cleaned - cleaned = re.sub(r"^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销", "", cleaned) - if not cleaned or cleaned in {"费用", "报销单", "报销流程"}: - return "" - cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned) - return cleaned.strip(",,。;; ") - - @staticmethod - def _strip_trailing_connectors(value: str) -> str: - cleaned = str(value or "").strip(",,。;; ") - return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",,。;; ") - - @staticmethod - def _extract_transport_mode(segment: str) -> str: - compact = re.sub(r"\s+", "", segment) - if re.search(r"高铁|动车|火车", compact): - return "train" - if re.search(r"飞机|机票|航班", compact): - return "flight" - if re.search(r"出租车|的士|网约车|打车", compact): - return "taxi" - if "交通" in compact: - return "other" - return "" - - @staticmethod - def _resolve_missing_fields(task_type: str, fields: dict[str, str]) -> list[str]: - required = ["expense_type", "time_range", "reason"] - if task_type == "expense_application": - required.append("location") - if fields.get("expense_type") in {"travel", "transport"}: - required.append("transport_mode") - return [key for key in required if not str(fields.get(key) or "").strip()] - - @staticmethod - def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float: - compact = re.sub(r"\s+", "", segment) - if task_type == "expense_application": - intent_score = 1.0 if ( - "申请" in compact or StewardPlannerService._looks_like_future_travel_application(compact) - ) else 0.45 - else: - intent_score = 1.0 if "报销" in compact else 0.45 - time_score = 1.0 if fields.get("time_range") else 0.0 - location_score = 1.0 if fields.get("location") else 0.2 - scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35 - confidence = min(1.0, 0.35 * intent_score + 0.25 * time_score + 0.2 * location_score + 0.2 * scene_score) - return round(max(0.45, confidence), 2) - - def _build_attachment_groups( - self, - attachments: list[StewardAttachmentInput], - tasks: list[StewardTask], - ) -> list[StewardAttachmentGroup]: - if not attachments: - return [] - - classified = [(item, self._classify_attachment(item)) for item in attachments if item.name] - travel_related = [item.name for item, scene in classified if scene in {"travel", "transport"}] - excluded = [item.name for item, scene in classified if scene not in {"travel", "transport"}] - target_task = self._resolve_attachment_target_task(tasks) - - groups: list[StewardAttachmentGroup] = [] - if travel_related: - confidence = 0.72 + min(0.18, len(travel_related) * 0.04) - groups.append( - StewardAttachmentGroup( - group_id="ag_travel_001", - target_task_id=target_task.task_id if target_task else None, - scene="travel", - scene_label="差旅相关费用", - attachment_names=travel_related, - excluded_attachment_names=excluded, - confidence=round(confidence, 2), - rationale="附件名称或 OCR 摘要中包含差旅、交通、住宿、火车、机票等线索。", - confirmation_required=True, - ) - ) - elif excluded: - groups.append( - StewardAttachmentGroup( - group_id="ag_other_001", - target_task_id=None, - scene="other", - scene_label="待人工确认费用", - attachment_names=excluded, - excluded_attachment_names=[], - confidence=0.5, - rationale="当前附件缺少可稳定归属到申请或报销任务的差旅线索。", - confirmation_required=True, - ) - ) - return groups - - @staticmethod - def _resolve_attachment_target_task(tasks: list[StewardTask]) -> StewardTask | None: - reimbursement_tasks = [item for item in tasks if item.task_type == "reimbursement"] - for task in reimbursement_tasks: - if task.ontology_fields.get("expense_type") == "travel": - return task - return reimbursement_tasks[0] if reimbursement_tasks else None - - @staticmethod - def _classify_attachment(attachment: StewardAttachmentInput) -> str: - text = " ".join( - [ - attachment.name, - attachment.media_type, - attachment.ocr_summary, - " ".join(f"{key}:{value}" for key, value in attachment.ocr_fields.items()), - ] - ) - compact = re.sub(r"\s+", "", text).lower() - if re.search(r"招待|接待|餐饮|宴请|客户|meal|entertainment", compact): - return "entertainment" - if re.search(r"酒店|住宿|差旅|出差|高铁|火车|动车|机票|航班|train|flight|hotel|travel", compact): - return "travel" - if re.search(r"出租车|的士|网约车|打车|交通|taxi|transport", compact): - return "transport" - return "other" - - def _build_confirmation_actions( - self, - tasks: list[StewardTask], - attachment_groups: list[StewardAttachmentGroup], - ) -> list[StewardConfirmationAction]: - actions: list[StewardConfirmationAction] = [] - for task in tasks: - if task.task_type == "expense_application": - action_type = "confirm_create_application" - label = "确认创建申请单" - else: - action_type = "confirm_create_reimbursement_draft" - label = "确认创建报销草稿" - actions.append( - StewardConfirmationAction( - confirmation_id=f"confirm_{task.task_id}", - action_type=action_type, - label=label, - description=f"确认后把“{task.title}”交给{self._agent_label(task.assigned_agent)}继续核对。", - target_task_id=task.task_id, - payload={ - "task_id": task.task_id, - "task_type": task.task_type, - "assigned_agent": task.assigned_agent, - "ontology_fields": task.ontology_fields, - }, - ) - ) - - for group in attachment_groups: - actions.append( - StewardConfirmationAction( - confirmation_id=f"confirm_{group.group_id}", - action_type="confirm_attachment_group", - label="确认附件归集", - description=f"确认后将 {len(group.attachment_names)} 份附件按“{group.scene_label}”归集。", - target_task_id=group.target_task_id, - attachment_group_id=group.group_id, - payload={ - "attachment_group_id": group.group_id, - "target_task_id": group.target_task_id, - "attachment_names": group.attachment_names, - "excluded_attachment_names": group.excluded_attachment_names, - }, - ) - ) - return actions - - @staticmethod - def _agent_label(assigned_agent: str) -> str: - return "申请助手" if assigned_agent == "application_assistant" else "报销助手" - - def _build_thinking_events( - self, - tasks: list[StewardTask], - attachment_groups: list[StewardAttachmentGroup], - attachments: list[StewardAttachmentInput], - ) -> list[StewardThinkingEvent]: - application_count = sum(1 for item in tasks if item.task_type == "expense_application") - reimbursement_count = sum(1 for item in tasks if item.task_type == "reimbursement") - task_intent_summary = self._summarize_task_intents(tasks) - ontology_summary = self._summarize_ontology_coverage(tasks) - delegation_summary = self._summarize_delegation_targets(tasks) - events = [ - StewardThinkingEvent( - event_id="intent_agent_entry", - stage="intent_agent", - title="意图识别智能体接管", - content=( - f"检测到复合财务话术,当前不是单一助手会话;" - f"已进入小财管家编排模式,候选任务共 {len(tasks)} 个。" - ), - ), - StewardThinkingEvent( - event_id="intent_task_split", - stage="task_split", - title=f"拆分申请 {application_count} 个、报销 {reimbursement_count} 个", - content=task_intent_summary, - ), - StewardThinkingEvent( - event_id="intent_ontology_mapping", - stage="ontology_mapping", - title="核对业务要素", - content=ontology_summary, - ), - ] - gap_event = self._build_business_gap_thinking_event(tasks) - if gap_event: - events.append(gap_event) - if attachments: - events.append( - StewardThinkingEvent( - event_id="intent_attachment_correlation", - stage="attachment_correlation", - title="关联附件与任务线索", - content=self._summarize_attachment_correlation(attachment_groups, len(attachments)), - ) - ) - events.append( - StewardThinkingEvent( - event_id="intent_delegation_gate", - stage="delegation_gate", - title="生成确认点并准备分派", - content=f"{delegation_summary} 创建单据、生成草稿、绑定附件和提交审批都会等待用户确认。", - ) - ) - return events - - @staticmethod - def _summarize_task_intents(tasks: list[StewardTask]) -> str: - if not tasks: - return "当前输入尚未形成稳定任务,先保留为待确认财务事项。" - parts = [] - for task in tasks: - task_label = "申请" if task.task_type == "expense_application" else "报销" - fields = task.ontology_fields - anchors = [] - if fields.get("time_range"): - anchors.append(fields["time_range"]) - if fields.get("location"): - anchors.append(fields["location"]) - if fields.get("expense_type"): - anchors.append(StewardPlannerService._format_business_field_value("expense_type", fields["expense_type"])) - anchor_text = "、".join(anchors) if anchors else "待补充关键字段" - parts.append(f"{task_label}:{task.title}({anchor_text})") - return ";".join(parts) - - @staticmethod - def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str: - mapped_labels = [] - missing_labels = [] - for task in tasks: - mapped_labels.extend(StewardPlannerService._business_field_label(key) for key in task.ontology_fields.keys()) - missing_labels.extend(StewardPlannerService._business_field_label(key) for key in task.missing_fields) - mapped = "、".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素" - missing = ";还缺少:" + "、".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else "" - return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。" - - @staticmethod - def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None: - gap_lines = [] - for task in tasks: - if not task.missing_fields: - continue - missing_labels = [ - StewardPlannerService._business_field_label(key) - for key in task.missing_fields - if key - ] - if not missing_labels: - continue - if task.task_type == "expense_application" and "transport_mode" in task.missing_fields: - gap_lines.append( - ( - f"{task.title}已识别到{StewardPlannerService._summarize_known_business_points(task)}," - "但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。" - ) - ) - else: - gap_lines.append( - ( - f"{task.title}还缺少{'、'.join(dict.fromkeys(missing_labels))}," - "需要在对应步骤里继续向用户确认,不能直接执行入库或提交。" - ) - ) - if not gap_lines: - return None - return StewardThinkingEvent( - event_id="intent_business_gap_check", - stage="business_gap_check", - title="判断待补充信息", - content=";".join(gap_lines), - ) - - @staticmethod - def _summarize_known_business_points(task: StewardTask) -> str: - parts = [] - for key in ("time_range", "location", "reason", "expense_type"): - value = str(task.ontology_fields.get(key) or "").strip() - if value: - parts.append( - f"{StewardPlannerService._business_field_label(key)}为" - f"{StewardPlannerService._format_business_field_value(key, value)}" - ) - return "、".join(parts) or "部分业务要素" - - @staticmethod - def _business_field_label(key: str) -> str: - return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip()) - - @staticmethod - def _format_business_field_value(key: str, value: str) -> str: - cleaned = str(value or "").strip() - if key == "expense_type": - return EXPENSE_TYPE_LABELS.get(cleaned, cleaned) - if key == "transport_mode": - return TRANSPORT_MODE_LABELS.get(cleaned, cleaned) - return cleaned - - @staticmethod - def _summarize_attachment_correlation( - attachment_groups: list[StewardAttachmentGroup], - total_attachment_count: int, - ) -> str: - grouped_names = [] - excluded_names = [] - for group in attachment_groups: - grouped_names.extend(group.attachment_names) - excluded_names.extend(group.excluded_attachment_names) - grouped_text = "、".join(grouped_names) if grouped_names else "暂无可稳定归集附件" - excluded_text = ";排除或单独确认:" + "、".join(excluded_names) if excluded_names else "" - return f"已核对 {total_attachment_count} 份附件,建议归集:{grouped_text}{excluded_text}。" - - @staticmethod - def _summarize_delegation_targets(tasks: list[StewardTask]) -> str: - application_count = sum(1 for item in tasks if item.assigned_agent == "application_assistant") - reimbursement_count = sum(1 for item in tasks if item.assigned_agent == "reimbursement_assistant") - parts = [] - if application_count: - parts.append(f"{application_count} 个申请任务交给申请助手") - if reimbursement_count: - parts.append(f"{reimbursement_count} 个报销任务交给报销助手") - return ";".join(parts) + "。" if parts else "尚无可分派任务。" - - @staticmethod - def _build_summary(tasks: list[StewardTask], attachment_groups: list[StewardAttachmentGroup]) -> str: - parts = [f"我识别到 {len(tasks)} 个待处理任务"] - if attachment_groups: - grouped = sum(len(item.attachment_names) for item in attachment_groups) - parts.append(f"并形成 {grouped} 份附件的归集建议") - parts.append(",请确认后我再分派给对应助手执行。") - return "".join(parts) - - @staticmethod - def _build_task_title(prefix: str, fields: dict[str, str], index: int) -> str: - location = fields.get("location", "") - time_range = fields.get("time_range", "") - expense_type = fields.get("expense_type", "") - subject = location or {"travel": "差旅", "transport": "交通", "entertainment": "招待"}.get(expense_type, "") - if subject and time_range: - return f"{prefix} {time_range} {subject}" - if subject: - return f"{prefix} {subject}" - return f"{prefix} {index}" - - @staticmethod - def _build_task_summary(segment: str, fields: dict[str, str]) -> str: - field_parts = [] - for key, label in ( - ("time_range", "时间"), - ("location", "地点"), - ("expense_type", "费用类型"), - ("reason", "事由"), - ("transport_mode", "交通方式"), - ): - value = fields.get(key) - if value: - field_parts.append(f"{label}:{value}") - return ";".join(field_parts) or segment - - @staticmethod - def _resolve_base_date(client_now_iso: str | None, context_json: dict[str, Any]) -> date: - raw_value = client_now_iso or str(context_json.get("client_now_iso") or "").strip() - if raw_value: - try: - parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) - return parsed.date() - except ValueError: - pass - return datetime.now(UTC).date() - - @staticmethod - def _clean_text(value: Any) -> str: - return re.sub(r"\s+", " ", str(value or "")).strip() +from app.services.steward_planner_extraction import StewardPlannerExtractionMixin +from app.services.steward_planner_fallback import StewardPlannerFallbackMixin class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractionMixin): @@ -1223,4 +73,3 @@ class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractio model_call_traces=model_call_traces, fallback_reason=fallback_reason, ) - diff --git a/server/src/app/services/steward_planner_extraction.py b/server/src/app/services/steward_planner_extraction.py new file mode 100644 index 0000000..e0120a2 --- /dev/null +++ b/server/src/app/services/steward_planner_extraction.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import re +from datetime import UTC, date, datetime +from typing import Any + +from app.schemas.steward import ( + StewardAttachmentGroup, + StewardAttachmentInput, + StewardConfirmationAction, + StewardPlanRequest, + StewardTask, + StewardThinkingEvent, +) +from app.services.application_fact_resolver import ApplicationFactResolver +from app.services.ontology_field_registry import normalize_ontology_form_values +from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS +from app.services.steward_planner_shared import ( + BUSINESS_FIELD_LABELS, + CITY_NAMES, + EXPENSE_TYPE_LABELS, + PlannedTaskDraft, + REIMBURSEMENT_PATTERN, + TRANSPORT_MODE_LABELS, +) + + +class StewardPlannerExtractionMixin: + def _has_multiple_financial_demands(self, message: str) -> bool: + task_drafts = self._extract_task_drafts(message) + if len(task_drafts) > 1: + return True + + compact = re.sub(r"\s+", "", message) + if not compact: + return False + + application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact) + reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0 + if application_signal and reimbursement_signal: + return True + + connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact) + repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1 + return bool(connector_signal and repeated_reimbursement_signal) + + @staticmethod + def _find_first_reimbursement_index(message: str) -> int: + candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")] + positives = [item for item in candidates if item >= 0] + return min(positives) if positives else -1 + + @staticmethod + def _looks_like_application(text: str) -> bool: + compact = re.sub(r"\s+", "", text) + return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact)) + + @staticmethod + def _looks_like_future_travel_application(text: str) -> bool: + compact = re.sub(r"\s+", "", text) + if not compact or "报销" in compact: + return False + business_signal = re.search( + r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", + compact, + ) + route_signal = re.search( + fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})", + compact, + ) + time_signal = re.search( + r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|" + r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天", + compact, + ) + planned_route_signal = re.search( + r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|" + r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)", + compact, + ) + return bool((business_signal or route_signal) and (time_signal or planned_route_signal)) + + def _looks_like_ambiguous_travel_flow( + self, + text: str, + base_date: date, + request: StewardPlanRequest, + ) -> bool: + compact = re.sub(r"\s+", "", text) + if not compact or request.attachments: + return False + if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact): + return False + if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact): + return False + if not self._extract_time_range(compact, base_date): + return False + if not self._extract_location(compact): + return False + return not self._is_future_or_current_time_range(compact, base_date) + + def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool: + normalized = self._extract_time_range(segment, base_date) + if not normalized: + return False + try: + parsed = date.fromisoformat(normalized) + except ValueError: + return False + return parsed >= base_date + + def _build_task( + self, + draft: PlannedTaskDraft, + base_date: date, + request: StewardPlanRequest, + ) -> StewardTask: + fields = self._extract_ontology_fields(draft.segment, draft.task_type, base_date, request) + missing_fields = self._resolve_missing_fields(draft.task_type, fields) + task_id = f"task_{'app' if draft.task_type == 'expense_application' else 'reim'}_{draft.index:03d}" + assigned_agent = ( + "application_assistant" + if draft.task_type == "expense_application" + else "reimbursement_assistant" + ) + title_prefix = "费用申请" if draft.task_type == "expense_application" else "费用报销" + title = self._build_task_title(title_prefix, fields, draft.index) + return StewardTask( + task_id=task_id, + task_type=draft.task_type, # type: ignore[arg-type] + assigned_agent=assigned_agent, # type: ignore[arg-type] + title=title, + summary=self._build_task_summary(draft.segment, fields), + status="needs_confirmation", + confidence=self._resolve_task_confidence(draft.segment, fields, draft.task_type), + ontology_fields=fields, + missing_fields=missing_fields, + confirmation_required=True, + ) + + def _build_fallback_task( + self, + message: str, + base_date: date, + request: StewardPlanRequest, + ) -> StewardTask: + task_type = "reimbursement" if "报销" in message or request.attachments else "expense_application" + draft = PlannedTaskDraft(task_type=task_type, segment=message, index=1) + task = self._build_task(draft, base_date, request) + return task.model_copy(update={"confidence": min(task.confidence, 0.58)}) + + def _extract_ontology_fields( + self, + segment: str, + task_type: str, + base_date: date, + request: StewardPlanRequest, + ) -> dict[str, str]: + normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values")) + fields: dict[str, str] = { + key: value + for key, value in normalized_context.items() + if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip() + } + expense_type = self._infer_expense_type(segment, task_type) + if expense_type and not fields.get("expense_type"): + fields["expense_type"] = expense_type + time_range = self._extract_time_range(segment, base_date) + if time_range and not fields.get("time_range"): + fields["time_range"] = time_range + location = self._extract_location(segment) + if location and not fields.get("location"): + fields["location"] = location + reason = self._extract_reason(segment, task_type) + if reason and not fields.get("reason"): + fields["reason"] = reason + transport_mode = self._extract_transport_mode(segment) + if transport_mode and not fields.get("transport_mode"): + fields["transport_mode"] = transport_mode + if request.attachments: + fields["attachments"] = "、".join(item.name for item in request.attachments if item.name) + + return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value} + + @staticmethod + def _infer_expense_type(segment: str, task_type: str) -> str: + return ApplicationFactResolver.infer_expense_type(segment, task_type) + + def _extract_time_range(self, segment: str, base_date: date) -> str: + return ApplicationFactResolver.extract_time_range(segment, base_date) + + @staticmethod + def _safe_date(year: int, month: int, day: int) -> str: + return ApplicationFactResolver.safe_date(year, month, day) + + @staticmethod + def _extract_location(segment: str) -> str: + return ApplicationFactResolver.extract_location(segment) + + @staticmethod + def _extract_reason(segment: str, task_type: str) -> str: + return ApplicationFactResolver.extract_reason(segment, task_type) + + @staticmethod + def _extract_transport_mode(segment: str) -> str: + return ApplicationFactResolver.extract_transport_mode(segment) + + @staticmethod + def _resolve_missing_fields(task_type: str, fields: dict[str, str]) -> list[str]: + required = ["expense_type", "time_range", "reason"] + if task_type == "expense_application": + required.append("location") + if fields.get("expense_type") in {"travel", "transport"}: + required.append("transport_mode") + return [key for key in required if not str(fields.get(key) or "").strip()] + + @staticmethod + def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float: + compact = re.sub(r"\s+", "", segment) + if task_type == "expense_application": + intent_score = 1.0 if ( + "申请" in compact or StewardPlannerExtractionMixin._looks_like_future_travel_application(compact) + ) else 0.45 + else: + intent_score = 1.0 if "报销" in compact else 0.45 + time_score = 1.0 if fields.get("time_range") else 0.0 + location_score = 1.0 if fields.get("location") else 0.2 + scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35 + confidence = min(1.0, 0.35 * intent_score + 0.25 * time_score + 0.2 * location_score + 0.2 * scene_score) + return round(max(0.45, confidence), 2) + + def _build_attachment_groups( + self, + attachments: list[StewardAttachmentInput], + tasks: list[StewardTask], + ) -> list[StewardAttachmentGroup]: + if not attachments: + return [] + + classified = [(item, self._classify_attachment(item)) for item in attachments if item.name] + travel_related = [item.name for item, scene in classified if scene in {"travel", "transport"}] + excluded = [item.name for item, scene in classified if scene not in {"travel", "transport"}] + target_task = self._resolve_attachment_target_task(tasks) + + groups: list[StewardAttachmentGroup] = [] + if travel_related: + confidence = 0.72 + min(0.18, len(travel_related) * 0.04) + groups.append( + StewardAttachmentGroup( + group_id="ag_travel_001", + target_task_id=target_task.task_id if target_task else None, + scene="travel", + scene_label="差旅相关费用", + attachment_names=travel_related, + excluded_attachment_names=excluded, + confidence=round(confidence, 2), + rationale="附件名称或 OCR 摘要中包含差旅、交通、住宿、火车、机票等线索。", + confirmation_required=True, + ) + ) + elif excluded: + groups.append( + StewardAttachmentGroup( + group_id="ag_other_001", + target_task_id=None, + scene="other", + scene_label="待人工确认费用", + attachment_names=excluded, + excluded_attachment_names=[], + confidence=0.5, + rationale="当前附件缺少可稳定归属到申请或报销任务的差旅线索。", + confirmation_required=True, + ) + ) + return groups + + @staticmethod + def _resolve_attachment_target_task(tasks: list[StewardTask]) -> StewardTask | None: + reimbursement_tasks = [item for item in tasks if item.task_type == "reimbursement"] + for task in reimbursement_tasks: + if task.ontology_fields.get("expense_type") == "travel": + return task + return reimbursement_tasks[0] if reimbursement_tasks else None + + @staticmethod + def _classify_attachment(attachment: StewardAttachmentInput) -> str: + text = " ".join( + [ + attachment.name, + attachment.media_type, + attachment.ocr_summary, + " ".join(f"{key}:{value}" for key, value in attachment.ocr_fields.items()), + ] + ) + compact = re.sub(r"\s+", "", text).lower() + if re.search(r"招待|接待|餐饮|宴请|客户|meal|entertainment", compact): + return "entertainment" + if re.search(r"酒店|住宿|差旅|出差|高铁|火车|动车|机票|航班|train|flight|hotel|travel", compact): + return "travel" + if re.search(r"出租车|的士|网约车|打车|交通|taxi|transport", compact): + return "transport" + return "other" + + def _build_confirmation_actions( + self, + tasks: list[StewardTask], + attachment_groups: list[StewardAttachmentGroup], + ) -> list[StewardConfirmationAction]: + actions: list[StewardConfirmationAction] = [] + for task in tasks: + if task.task_type == "expense_application": + action_type = "confirm_create_application" + label = "确认创建申请单" + else: + action_type = "confirm_create_reimbursement_draft" + label = "确认创建报销草稿" + actions.append( + StewardConfirmationAction( + confirmation_id=f"confirm_{task.task_id}", + action_type=action_type, + label=label, + description=f"确认后把“{task.title}”交给{self._agent_label(task.assigned_agent)}继续核对。", + target_task_id=task.task_id, + payload={ + "task_id": task.task_id, + "task_type": task.task_type, + "assigned_agent": task.assigned_agent, + "ontology_fields": task.ontology_fields, + }, + ) + ) + + for group in attachment_groups: + actions.append( + StewardConfirmationAction( + confirmation_id=f"confirm_{group.group_id}", + action_type="confirm_attachment_group", + label="确认附件归集", + description=f"确认后将 {len(group.attachment_names)} 份附件按“{group.scene_label}”归集。", + target_task_id=group.target_task_id, + attachment_group_id=group.group_id, + payload={ + "attachment_group_id": group.group_id, + "target_task_id": group.target_task_id, + "attachment_names": group.attachment_names, + "excluded_attachment_names": group.excluded_attachment_names, + }, + ) + ) + return actions + + @staticmethod + def _agent_label(assigned_agent: str) -> str: + return "申请助手" if assigned_agent == "application_assistant" else "报销助手" + + def _build_thinking_events( + self, + tasks: list[StewardTask], + attachment_groups: list[StewardAttachmentGroup], + attachments: list[StewardAttachmentInput], + ) -> list[StewardThinkingEvent]: + application_count = sum(1 for item in tasks if item.task_type == "expense_application") + reimbursement_count = sum(1 for item in tasks if item.task_type == "reimbursement") + task_intent_summary = self._summarize_task_intents(tasks) + ontology_summary = self._summarize_ontology_coverage(tasks) + delegation_summary = self._summarize_delegation_targets(tasks) + events = [ + StewardThinkingEvent( + event_id="intent_agent_entry", + stage="intent_agent", + title="意图识别智能体接管", + content=( + f"检测到复合财务话术,当前不是单一助手会话;" + f"已进入小财管家编排模式,候选任务共 {len(tasks)} 个。" + ), + ), + StewardThinkingEvent( + event_id="intent_task_split", + stage="task_split", + title=f"拆分申请 {application_count} 个、报销 {reimbursement_count} 个", + content=task_intent_summary, + ), + StewardThinkingEvent( + event_id="intent_ontology_mapping", + stage="ontology_mapping", + title="核对业务要素", + content=ontology_summary, + ), + ] + gap_event = self._build_business_gap_thinking_event(tasks) + if gap_event: + events.append(gap_event) + if attachments: + events.append( + StewardThinkingEvent( + event_id="intent_attachment_correlation", + stage="attachment_correlation", + title="关联附件与任务线索", + content=self._summarize_attachment_correlation(attachment_groups, len(attachments)), + ) + ) + events.append( + StewardThinkingEvent( + event_id="intent_delegation_gate", + stage="delegation_gate", + title="生成确认点并准备分派", + content=f"{delegation_summary} 创建单据、生成草稿、绑定附件和提交审批都会等待用户确认。", + ) + ) + return events + + @staticmethod + def _summarize_task_intents(tasks: list[StewardTask]) -> str: + if not tasks: + return "当前输入尚未形成稳定任务,先保留为待确认财务事项。" + parts = [] + for task in tasks: + task_label = "申请" if task.task_type == "expense_application" else "报销" + fields = task.ontology_fields + anchors = [] + if fields.get("time_range"): + anchors.append(fields["time_range"]) + if fields.get("location"): + anchors.append(fields["location"]) + if fields.get("expense_type"): + anchors.append(StewardPlannerExtractionMixin._format_business_field_value("expense_type", fields["expense_type"])) + anchor_text = "、".join(anchors) if anchors else "待补充关键字段" + parts.append(f"{task_label}:{task.title}({anchor_text})") + return ";".join(parts) + + @staticmethod + def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str: + mapped_labels = [] + missing_labels = [] + for task in tasks: + mapped_labels.extend(StewardPlannerExtractionMixin._business_field_label(key) for key in task.ontology_fields.keys()) + missing_labels.extend(StewardPlannerExtractionMixin._business_field_label(key) for key in task.missing_fields) + mapped = "、".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素" + missing = ";还缺少:" + "、".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else "" + return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。" + + @staticmethod + def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None: + gap_lines = [] + for task in tasks: + if not task.missing_fields: + continue + missing_labels = [ + StewardPlannerExtractionMixin._business_field_label(key) + for key in task.missing_fields + if key + ] + if not missing_labels: + continue + if task.task_type == "expense_application" and "transport_mode" in task.missing_fields: + gap_lines.append( + ( + f"{task.title}已识别到{StewardPlannerExtractionMixin._summarize_known_business_points(task)}," + "但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。" + ) + ) + else: + gap_lines.append( + ( + f"{task.title}还缺少{'、'.join(dict.fromkeys(missing_labels))}," + "需要在对应步骤里继续向用户确认,不能直接执行入库或提交。" + ) + ) + if not gap_lines: + return None + return StewardThinkingEvent( + event_id="intent_business_gap_check", + stage="business_gap_check", + title="判断待补充信息", + content=";".join(gap_lines), + ) + + @staticmethod + def _summarize_known_business_points(task: StewardTask) -> str: + parts = [] + for key in ("time_range", "location", "reason", "expense_type"): + value = str(task.ontology_fields.get(key) or "").strip() + if value: + parts.append( + f"{StewardPlannerExtractionMixin._business_field_label(key)}为" + f"{StewardPlannerExtractionMixin._format_business_field_value(key, value)}" + ) + return "、".join(parts) or "部分业务要素" + + @staticmethod + def _business_field_label(key: str) -> str: + return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip()) + + @staticmethod + def _format_business_field_value(key: str, value: str) -> str: + cleaned = str(value or "").strip() + if key == "expense_type": + return EXPENSE_TYPE_LABELS.get(cleaned, cleaned) + if key == "transport_mode": + return TRANSPORT_MODE_LABELS.get(cleaned, cleaned) + return cleaned + + @staticmethod + def _summarize_attachment_correlation( + attachment_groups: list[StewardAttachmentGroup], + total_attachment_count: int, + ) -> str: + grouped_names = [] + excluded_names = [] + for group in attachment_groups: + grouped_names.extend(group.attachment_names) + excluded_names.extend(group.excluded_attachment_names) + grouped_text = "、".join(grouped_names) if grouped_names else "暂无可稳定归集附件" + excluded_text = ";排除或单独确认:" + "、".join(excluded_names) if excluded_names else "" + return f"已核对 {total_attachment_count} 份附件,建议归集:{grouped_text}{excluded_text}。" + + @staticmethod + def _summarize_delegation_targets(tasks: list[StewardTask]) -> str: + application_count = sum(1 for item in tasks if item.assigned_agent == "application_assistant") + reimbursement_count = sum(1 for item in tasks if item.assigned_agent == "reimbursement_assistant") + parts = [] + if application_count: + parts.append(f"{application_count} 个申请任务交给申请助手") + if reimbursement_count: + parts.append(f"{reimbursement_count} 个报销任务交给报销助手") + return ";".join(parts) + "。" if parts else "尚无可分派任务。" + + @staticmethod + def _build_summary(tasks: list[StewardTask], attachment_groups: list[StewardAttachmentGroup]) -> str: + parts = [f"我识别到 {len(tasks)} 个待处理任务"] + if attachment_groups: + grouped = sum(len(item.attachment_names) for item in attachment_groups) + parts.append(f"并形成 {grouped} 份附件的归集建议") + parts.append(",请确认后我再分派给对应助手执行。") + return "".join(parts) + + @staticmethod + def _build_task_title(prefix: str, fields: dict[str, str], index: int) -> str: + location = fields.get("location", "") + time_range = fields.get("time_range", "") + expense_type = fields.get("expense_type", "") + subject = location or {"travel": "差旅", "transport": "交通", "entertainment": "招待"}.get(expense_type, "") + if subject and time_range: + return f"{prefix} {time_range} {subject}" + if subject: + return f"{prefix} {subject}" + return f"{prefix} {index}" + + @staticmethod + def _build_task_summary(segment: str, fields: dict[str, str]) -> str: + field_parts = [] + for key, label in ( + ("time_range", "时间"), + ("location", "地点"), + ("expense_type", "费用类型"), + ("reason", "事由"), + ("transport_mode", "交通方式"), + ): + value = fields.get(key) + if value: + field_parts.append(f"{label}:{value}") + return ";".join(field_parts) or segment + + @staticmethod + def _resolve_base_date(client_now_iso: str | None, context_json: dict[str, Any]) -> date: + raw_value = client_now_iso or str(context_json.get("client_now_iso") or "").strip() + if raw_value: + try: + parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + return parsed.date() + except ValueError: + pass + return datetime.now(UTC).date() + + @staticmethod + def _clean_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + diff --git a/server/src/app/services/steward_planner_fallback.py b/server/src/app/services/steward_planner_fallback.py new file mode 100644 index 0000000..6b29302 --- /dev/null +++ b/server/src/app/services/steward_planner_fallback.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import re +import uuid +from datetime import date +from typing import Any + +from app.schemas.steward import ( + StewardCandidateFlow, + StewardConfirmationAction, + StewardPendingFlowConfirmation, + StewardPlanRequest, + StewardPlanResponse, + StewardTask, + StewardThinkingEvent, +) +from app.services.steward_planner_shared import ( + APPLICATION_SPLIT_PATTERN, + BUSINESS_FIELD_LABELS, + PlannedTaskDraft, + REIMBURSEMENT_PATTERN, + STEWARD_BUSINESS_SIGNAL_KEYWORDS, + STEWARD_GREETING_KEYWORDS, + STEWARD_OFF_TOPIC_SCENARIO_GREETING, + STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS, + STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS, +) + + +class StewardPlannerFallbackMixin: + def _should_use_model_intent_recognition( + self, + message: str, + base_date: date, + request: StewardPlanRequest, + ) -> bool: + if self._looks_like_ambiguous_travel_flow(message, base_date, request): + return False + return self._has_multiple_financial_demands(message) + + @staticmethod + def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool: + """判断输入是否与小财管家支持的财务事项完全无关(向后兼容包装)。 + + 判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件。 + 实际判定逻辑由 _classify_irrelevant_input 负责,命中任何场景即视为业务无关。 + """ + return StewardPlannerFallbackMixin._classify_irrelevant_input(message, request) is not None + + @staticmethod + def _classify_irrelevant_input(message: str, request: StewardPlanRequest) -> str | None: + """把业务无关输入细分为三个场景,便于给出更贴切的引导。 + + 返回值: + - "greeting":礼貌问候("你好"等),无业务关键词 + - "meaningless":完全无意义内容(纯数字、纯标点、单字符重复、纯字母数字乱码) + - "off_business":有意义但与财务无关(问天气、聊生活等) + - None:消息与业务相关,无需走 off_topic 路径 + """ + if request.attachments: + return None + compact = re.sub(r"\s+", "", message) + if not compact: + return None + if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS): + return None + + if StewardPlannerFallbackMixin._looks_like_greeting(compact): + return STEWARD_OFF_TOPIC_SCENARIO_GREETING + if StewardPlannerFallbackMixin._looks_like_meaningless(compact): + return STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS + return STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS + + @staticmethod + def _looks_like_greeting(compact_message: str) -> bool: + """判断消息是否只是礼貌问候(无其他有意义内容)。""" + normalized = compact_message.lower() + for keyword in STEWARD_GREETING_KEYWORDS: + if normalized == keyword.lower() or normalized.startswith(keyword.lower()): + # 整句只是问候词(允许少量标点) + tail = normalized[len(keyword.lower()):] + if not tail or re.fullmatch(r"[!!。.??,,~\s]+", tail): + return True + return False + + @staticmethod + def _looks_like_meaningless(compact_message: str) -> bool: + """判断消息是否完全没有语义价值(纯数字、纯标点、单字符重复等)。""" + if re.fullmatch(r"\d+", compact_message): + return True + # 纯标点 + if re.fullmatch(r"[\W_]+", compact_message): + return True + # 单字符重复(例如 "啊啊啊啊啊") + if len(compact_message) >= 2 and len(set(compact_message)) == 1: + return True + # 短字母数字组合但没有任何业务意义,例如 "abc"、"test123" + # 注意:必须排除已经被关键词命中的情况(前面的判定已保证不命中关键词) + if re.fullmatch(r"[a-zA-Z0-9]+", compact_message) and len(compact_message) <= 12: + return True + return False + + def _build_off_topic_plan( + self, + request: StewardPlanRequest, + *, + scenario: str, + ) -> StewardPlanResponse: + """业务无关输入的兜底计划:根据场景给出对应引导,off_business 场景可由 LLM 增强。""" + base_summary = self._default_off_topic_summary(scenario) + thinking_event = self._build_off_topic_thinking_event(scenario) + suggested_prompts = self._off_topic_suggested_prompts(scenario) + model_call_traces: list[dict[str, Any]] = [] + + # 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。 + if ( + scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS + and self.off_topic_agent is not None + ): + try: + llm_result = self.off_topic_agent.generate(request, scenario=scenario) + if llm_result is not None and llm_result.response_text: + base_summary = llm_result.response_text + model_call_traces = llm_result.model_call_traces + except Exception: + # 失败时静默回退到规则模板 + pass + + return StewardPlanResponse( + plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", + plan_status="off_topic", + planning_source="rule_fallback", + next_action="none", + summary=base_summary, + thinking_events=[thinking_event], + tasks=[], + attachment_groups=[], + confirmation_groups=[], + candidate_flows=[], + suggested_prompts=suggested_prompts, + model_call_traces=model_call_traces, + ) + + @staticmethod + def _default_off_topic_summary(scenario: str) -> str: + """off_topic 场景的默认引导文案;LLM 不可用时使用。""" + if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: + return ( + "### 您好主人,很高兴为您服务\n\n" + "请问您今天要办理什么业务?目前小财管家能帮您整理" + "**费用申请**和**费用报销**这两类事项。\n\n" + "要不您换种说法告诉我:" + ) + if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: + return ( + "### 抱歉主人,这句话我暂时帮不上忙\n\n" + "我看了您刚才说的这句话,里面聊的不是财务事项。" + "小财管家目前只能帮您整理**费用申请**和**费用报销**这两类业务。\n\n" + "要不您换种说法告诉我:" + ) + # meaningless + return ( + "### 这句话我暂时没识别到财务事项\n\n" + "很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n" + "要不您换种说法告诉我:" + ) + + @staticmethod + def _build_off_topic_thinking_event(scenario: str) -> StewardThinkingEvent: + """off_topic 场景下向用户展示的思考过程摘要。""" + if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: + return StewardThinkingEvent( + event_id="intent_agent_off_topic_greeting", + stage="off_topic", + title="先回应主人的问候", + content="主人向我打了个招呼,我先礼貌回应一下,再引导他/她说出具体想办什么业务。", + ) + if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: + return StewardThinkingEvent( + event_id="intent_agent_off_topic_non_business", + stage="off_topic", + title="这句话不在服务范围内", + content="我看了您刚才说的这句话,里面聊的不是财务事项。小财管家目前只能帮您整理费用申请和费用报销。", + ) + return StewardThinkingEvent( + event_id="intent_agent_off_topic_meaningless", + stage="off_topic", + title="未识别到财务事项", + content=( + "我仔细看了看您刚才说的这句话,里面好像没有出现" + "费用申请、报销、出差、交通、招待这些财务关键词。" + ), + ) + + @staticmethod + def _off_topic_suggested_prompts(scenario: str) -> list[str]: + """off_topic 场景下展示给用户的推荐话术。""" + if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING: + return [ + "我想要申请明天去北京出差3天,支撑客户现场实施", + "我要报销昨天的交通费", + "我上周出差去上海的费用需要报销", + ] + if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS: + return [ + "我想要申请明天去北京出差3天,支撑客户现场实施", + "我要报销昨天的交通费", + "我需要整理上周出差的发票", + ] + # meaningless + return [ + "我想要申请明天去北京出差3天,支撑客户现场实施", + "我要报销昨天的交通费", + "我上周出差去上海的费用需要报销", + ] + + def _build_rule_fallback_plan( + self, + request: StewardPlanRequest, + *, + base_date: date, + model_call_traces: list[dict[str, Any]] | None = None, + fallback_reason: str = "", + ) -> StewardPlanResponse: + message = self._clean_text(request.message) + if self._looks_like_ambiguous_travel_flow(message, base_date, request): + return self._build_pending_flow_fallback_plan( + request, + base_date=base_date, + model_call_traces=model_call_traces, + fallback_reason=fallback_reason, + ) + task_drafts = self._extract_task_drafts(message) + tasks = [self._build_task(draft, base_date, request) for draft in task_drafts] + if not tasks: + tasks = [self._build_fallback_task(message, base_date, request)] + + attachment_groups = self._build_attachment_groups(request.attachments, tasks) + confirmation_groups = self._build_confirmation_actions(tasks, attachment_groups) + thinking_events = self._build_thinking_events(tasks, attachment_groups, request.attachments) + if fallback_reason: + thinking_events.insert( + 0, + StewardThinkingEvent( + event_id="intent_agent_rule_fallback", + stage="rule_fallback", + title="意图识别智能体进入兜底模式", + content=fallback_reason, + ), + ) + plan_id = f"steward_plan_{uuid.uuid4().hex[:12]}" + + return StewardPlanResponse( + plan_id=plan_id, + plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate", + planning_source="rule_fallback", + next_action="confirm_task" if confirmation_groups else "delegate_task", + summary=self._build_summary(tasks, attachment_groups), + thinking_events=thinking_events, + tasks=tasks, + attachment_groups=attachment_groups, + confirmation_groups=confirmation_groups, + model_call_traces=model_call_traces or [], + ) + + def _build_pending_flow_fallback_plan( + self, + request: StewardPlanRequest, + *, + base_date: date, + model_call_traces: list[dict[str, Any]] | None = None, + fallback_reason: str = "", + planning_source: str = "rule_fallback", + ) -> StewardPlanResponse: + candidates = self._build_rule_candidate_flows(request, base_date) + gate = self._resolve_required_application_gate(request, "travel") + pending_reason = self._build_pending_flow_reason(gate) + pending = StewardPendingFlowConfirmation( + status="pending", + source_message=request.message, + reason=pending_reason, + candidate_flows=candidates, + ) + thinking_events = [] + if fallback_reason: + thinking_events.append( + StewardThinkingEvent( + event_id="intent_agent_rule_fallback", + stage="rule_fallback", + title="意图识别智能体进入兜底模式", + content=fallback_reason, + ) + ) + thinking_events.append( + StewardThinkingEvent( + event_id="intent_pending_flow_confirmation", + stage="flow_confirmation", + title="需要确认流程方向", + content=pending_reason, + ) + ) + return StewardPlanResponse( + plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", + plan_status="needs_flow_confirmation", + planning_source=planning_source, # type: ignore[arg-type] + next_action="confirm_flow", + summary=self._build_pending_flow_summary(gate), + thinking_events=thinking_events, + pending_flow_confirmation=pending, + candidate_flows=candidates, + model_call_traces=model_call_traces or [], + ) + + def _build_rule_candidate_flows( + self, + request: StewardPlanRequest, + base_date: date, + ) -> list[StewardCandidateFlow]: + application_fields = self._extract_ontology_fields( + request.message, + "expense_application", + base_date, + request, + ) + reimbursement_fields = self._extract_ontology_fields( + request.message, + "reimbursement", + base_date, + request, + ) + gate = self._resolve_required_application_gate(request, "travel") + if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: + return [ + StewardCandidateFlow( + flow_id="travel_application", + label="先发起出差申请", + confidence=0.86, + reason="已先查询你名下可关联的差旅申请单,暂未查到可关联单据,因此应先申请单据。", + ontology_fields=application_fields, + missing_fields=self._resolve_missing_fields("expense_application", application_fields), + ) + ] + reimbursement_label = "发起费用报销" + reimbursement_reason = "用户描述的也可能是已发生出差事项,需要进入报销材料整理。" + if gate.get("checked"): + candidate_count = int(gate.get("candidate_count") or 0) + reimbursement_label = "关联已有申请单并发起报销" + reimbursement_reason = f"已先查到 {candidate_count} 个可关联申请单,选择后会先请你关联具体单据。" + return [ + StewardCandidateFlow( + flow_id="travel_application", + label="补办出差申请", + confidence=0.52, + reason="用户描述了出差时间、地点和事由,但没有明确说要报销。", + ontology_fields=application_fields, + missing_fields=self._resolve_missing_fields("expense_application", application_fields), + ), + StewardCandidateFlow( + flow_id="travel_reimbursement", + label=reimbursement_label, + confidence=0.48, + reason=reimbursement_reason, + ontology_fields=reimbursement_fields, + missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields), + ), + ] + + @staticmethod + def _resolve_required_application_gate( + request: StewardPlanRequest, + expense_type: str, + ) -> dict[str, Any]: + context = request.context_json if isinstance(request.context_json, dict) else {} + gates = context.get("required_application_gate") + if not isinstance(gates, dict): + return {} + gate = gates.get(expense_type) + if not isinstance(gate, dict) or not gate.get("checked"): + return {} + try: + candidate_count = max(0, int(gate.get("candidate_count") or 0)) + except (TypeError, ValueError): + candidate_count = 0 + return { + **gate, + "candidate_count": candidate_count, + "checked": True, + } + + @staticmethod + def _build_pending_flow_reason(gate: dict[str, Any]) -> str: + if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: + return "我已经先查询你名下可关联的差旅申请单,未查到可关联单据,所以当前应先申请单据。" + if gate.get("checked"): + candidate_count = int(gate.get("candidate_count") or 0) + return f"我已经先查询你名下的差旅申请单,查到 {candidate_count} 个可关联申请单,需要你确认是否关联单据后发起报销。" + return "当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。" + + @staticmethod + def _build_pending_flow_summary(gate: dict[str, Any]) -> str: + if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0: + return "我已先查询可关联申请单,暂未查到可关联单据;这次应先申请单据,再进入后续报销。" + if gate.get("checked"): + candidate_count = int(gate.get("candidate_count") or 0) + return ( + f"我已先查询可关联申请单,查到 {candidate_count} 个可关联申请单;" + "你可以选择关联已有申请单发起报销,或改为补办新的出差申请。" + ) + return ( + "我识别到这是一次出差事项,但还不能确定你要做的是" + "**补办出差申请**还是**发起费用报销**。请先选择一个方向。" + ) + + def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]: + drafts: list[PlannedTaskDraft] = [] + first_reimbursement = self._find_first_reimbursement_index(message) + application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message + if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source): + drafts.append( + PlannedTaskDraft( + task_type="expense_application", + segment=application_source.strip(",,。;; "), + index=len(drafts) + 1, + ) + ) + + for match in REIMBURSEMENT_PATTERN.finditer(message): + segment = f"报销{match.group(1)}" + drafts.append( + PlannedTaskDraft( + task_type="reimbursement", + segment=segment.strip(",,。;; "), + index=len(drafts) + 1, + ) + ) + + return drafts + diff --git a/server/src/app/services/steward_planner_shared.py b/server/src/app/services/steward_planner_shared.py new file mode 100644 index 0000000..3a28d86 --- /dev/null +++ b/server/src/app/services/steward_planner_shared.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +CITY_NAMES = ( + "北京", + "上海", + "广州", + "深圳", + "杭州", + "南京", + "苏州", + "成都", + "重庆", + "天津", + "武汉", + "西安", + "长沙", + "郑州", + "青岛", + "厦门", + "福州", + "合肥", + "济南", + "沈阳", + "大连", + "宁波", + "无锡", +) + +# 业务信号关键词:用于判定输入是否与小财管家支持的财务事项相关。 +# 只要清洗后的消息命中其中任意一个关键词,就视为业务相关;否则进入 off_topic 拦截。 +STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = ( + # 动作词 + "申请", "报销", "草稿", "提交", "审批", "保存", "发起", "创建", "核对", "归集", + # 差旅场景 + "出差", "差旅", "费用", "交通", "住宿", "招待", "酒店", "机票", "航班", "高铁", + "动车", "火车", "出租车", "的士", "网约车", "打车", "地铁", "公交", "用餐", "餐饮", "宴请", + # 票据/凭证 + "票据", "发票", "凭证", "行程单", "付款截图", "付款", "小票", "收据", + # 业务对象 + "客户", "项目", "拜访", "会议", "培训", "部署", "实施", "支撑", "支持", "协助", + "调研", "驻场", "上线", "验收", "审核", + # 时间信号 + "昨天", "前天", "明天", "后天", "下周", "下月", "近期", "月底", "今天", "上周", "上月", + # 金额/数量("天"用于"出差3天"等表达) + "金额", "元", "块", "万", "千", "天", + # 复用城市名信号 + *CITY_NAMES, +) + + +# 业务无关输入的场景分类 +STEWARD_OFF_TOPIC_SCENARIO_GREETING = "greeting" +STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS = "meaningless" +STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS = "off_business" + + +# 问候词:用于将"你好"等礼貌问候单独归类为 greeting 场景 +STEWARD_GREETING_KEYWORDS: tuple[str, ...] = ( + "你好", "您好", "hi", "hello", "hey", "嗨", "哈喽", + "早上好", "上午好", "中午好", "下午好", "晚上好", "早安", "晚安", + "您好呀", "你好呀", "在吗", "在么", "在不在", +) + +APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,,。;;])[^,,。;;]*?(?:申请|出差申请|差旅申请)[^,,。;;]*") +REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,,。;;!??!\n]+)") + +BUSINESS_FIELD_LABELS = { + "expense_type": "费用类型", + "time_range": "时间", + "location": "地点", + "reason": "事由", + "amount": "金额", + "transport_mode": "出行方式", + "attachments": "附件/凭证", + "customer_name": "客户或项目对象", + "merchant_name": "商户/开票方", + "department_name": "所属部门", + "employee_name": "申请人", + "employee_no": "员工编号", +} + +EXPENSE_TYPE_LABELS = { + "travel": "差旅", + "transport": "交通费", + "entertainment": "业务招待费", + "office": "办公用品", + "meeting": "会议费", + "training": "培训费", + "other": "其他费用", +} + +TRANSPORT_MODE_LABELS = { + "train": "火车/高铁", + "flight": "飞机", + "taxi": "出租车/网约车", + "subway": "地铁", + "other": "其他交通方式", +} + + +@dataclass(frozen=True) +class PlannedTaskDraft: + task_type: str + segment: str + index: int + + diff --git a/server/src/app/test_helpers/__init__.py b/server/src/app/test_helpers/__init__.py new file mode 100644 index 0000000..332af65 --- /dev/null +++ b/server/src/app/test_helpers/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for backend tests.""" diff --git a/server/src/app/test_helpers/db.py b/server/src/app/test_helpers/db.py new file mode 100644 index 0000000..588349c --- /dev/null +++ b/server/src/app/test_helpers/db.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base + + +def build_in_memory_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def build_in_memory_session() -> Session: + return build_in_memory_session_factory()() diff --git a/server/tests/test_application_fact_resolver.py b/server/tests/test_application_fact_resolver.py new file mode 100644 index 0000000..4248ea7 --- /dev/null +++ b/server/tests/test_application_fact_resolver.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import date + +from app.services.application_fact_resolver import ( + ApplicationFactResolver, + resolve_application_facts, +) + + +def test_application_fact_resolver_extracts_travel_application_fields() -> None: + facts = resolve_application_facts( + "明天去上海出差3天,辅助国网仿生产环境部署,高铁往返", + "expense_application", + date(2026, 6, 23), + ) + + assert facts["expense_type"] == "travel" + assert facts["time_range"] == "2026-06-24" + assert facts["location"] == "上海" + assert facts["reason"] == "辅助国网仿生产环境部署,高铁往返" + assert facts["transport_mode"] == "train" + + +def test_application_fact_resolver_preserves_reimbursement_transport_semantics() -> None: + facts = resolve_application_facts( + "报销昨天去北京客户现场沟通产生的出租车费用", + "reimbursement", + date(2026, 6, 23), + ) + + assert facts["expense_type"] == "transport" + assert facts["time_range"] == "2026-06-22" + assert facts["location"] == "北京" + assert facts["reason"] == "去北京客户现场沟通产生的出租车费用" + assert facts["transport_mode"] == "taxi" + + +def test_application_fact_resolver_keeps_static_wrapper_api() -> None: + assert ApplicationFactResolver.infer_expense_type("打车去客户现场", "expense_application") == "travel" + assert ApplicationFactResolver.infer_expense_type("打车去客户现场", "reimbursement") == "transport" + assert ApplicationFactResolver.extract_time_range("2026-07-01 去深圳", date(2026, 6, 23)) == "2026-07-01" diff --git a/server/tests/test_db_test_helpers.py b/server/tests/test_db_test_helpers.py new file mode 100644 index 0000000..61b55ef --- /dev/null +++ b/server/tests/test_db_test_helpers.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from app.models.financial_record import ExpenseClaim +from app.test_helpers.db import build_in_memory_session + + +def test_build_in_memory_session_creates_isolated_sqlite_schema() -> None: + with build_in_memory_session() as db: + db.add( + ExpenseClaim( + claim_no="CLM-HELPER-001", + employee_name="张三", + department_name="研发部", + expense_type="travel", + reason="测试共享内存数据库夹具", + location="上海", + amount=Decimal("10.00"), + occurred_at=datetime(2026, 6, 23, tzinfo=UTC), + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + ) + db.commit() + + assert db.query(ExpenseClaim).count() == 1 diff --git a/server/tests/test_expense_claim_platform_context_tools.py b/server/tests/test_expense_claim_platform_context_tools.py new file mode 100644 index 0000000..d94ac7a --- /dev/null +++ b/server/tests/test_expense_claim_platform_context_tools.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from app.services.expense_claim_platform_context_tools import ( + collect_context_cities, + collect_context_item_ids, + extract_first_known_city_from_text, + unique_text_values, +) +from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog + + +def test_unique_text_values_trims_and_preserves_order() -> None: + assert unique_text_values([" 上海 ", "", "上海", "北京", None, "北京"]) == [ + "上海", + "北京", + ] + + +def test_collect_context_item_ids_skips_empty_and_dedupes() -> None: + contexts = [ + {"item": SimpleNamespace(id="item-1")}, + {"item": SimpleNamespace(id="item-1")}, + {"item": SimpleNamespace(id=" item-2 ")}, + {"item": SimpleNamespace(id="")}, + {}, + ] + + assert collect_context_item_ids(contexts) == ["item-1", "item-2"] + + +def test_collect_context_cities_can_include_item_reason() -> None: + policy = build_default_expense_rule_catalog().travel_policy + assert policy is not None + context = { + "ocr_summary": "火车票;武汉-上海", + "ocr_text": "", + "document_info": { + "fields": [ + {"key": "route", "label": "路线", "value": "上海-深圳"}, + ] + }, + "item": SimpleNamespace(item_location="", item_reason="深圳-上海"), + } + + assert set(collect_context_cities(context, policy, include_item_reason=True)) == { + "武汉", + "上海", + "深圳", + } + assert extract_first_known_city_from_text("预计前往北京客户现场", policy) == "北京" diff --git a/server/tests/test_expense_claim_platform_risk_stage.py b/server/tests/test_expense_claim_platform_risk_stage.py index 334fffa..d251e9b 100644 --- a/server/tests/test_expense_claim_platform_risk_stage.py +++ b/server/tests/test_expense_claim_platform_risk_stage.py @@ -5,13 +5,10 @@ from datetime import UTC, date, datetime from decimal import Decimal from typing import Any -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker -from sqlalchemy.pool import StaticPool +from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType -from app.db.base import Base from app.models.agent_asset import AgentAsset from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager @@ -19,17 +16,10 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claims import ExpenseClaimService from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY +from app.test_helpers.db import build_in_memory_session -def build_session() -> Session: - engine = create_engine( - "sqlite+pysqlite:///:memory:", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) - Base.metadata.create_all(bind=engine) - session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) - return session_factory() +build_session = build_in_memory_session def _build_rule_payload( diff --git a/web/src/assets/styles/components/ai-sidebar-rail.css b/web/src/assets/styles/components/ai-sidebar-rail.css index c577030..d5a41c9 100644 --- a/web/src/assets/styles/components/ai-sidebar-rail.css +++ b/web/src/assets/styles/components/ai-sidebar-rail.css @@ -148,10 +148,10 @@ .ai-quick-btn { min-height: 48px; display: grid; - grid-template-columns: 28px minmax(0, 1fr); + grid-template-columns: 32px minmax(0, 1fr); align-items: center; - gap: 10px; - padding: 0 4px; + gap: 12px; + padding: 7px 10px; color: #111827; font-size: 14px; font-weight: 780; @@ -160,13 +160,21 @@ box-shadow: none; } -.ai-quick-btn i { - width: 28px; - display: inline-flex; - justify-content: center; +.ai-quick-icon { + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; color: #536277; - font-size: 18px; - line-height: 1; +} + +.ai-sidebar-tabler-icon { + width: 20px; + height: 20px; + display: block; + overflow: visible; + flex: none; + stroke-width: 1.85; } .ai-quick-btn.primary { @@ -175,17 +183,10 @@ box-shadow: none; } -.ai-quick-btn.active { - color: #173d78; - background: rgba(45, 114, 217, 0.055); - border-color: rgba(45, 114, 217, 0.12); -} - -.ai-quick-btn.primary i { +.ai-quick-btn.primary .ai-quick-icon { color: var(--ai-rail-amber); } -.ai-nav-btn:hover, .ai-recent-item:hover, .ai-user-action:hover { background: rgba(255, 255, 255, 0.78); @@ -194,7 +195,10 @@ transform: translateY(-1px); } -.ai-quick-btn:hover { +.ai-quick-btn:hover, +.ai-quick-btn.active, +.ai-nav-btn:hover, +.ai-nav-btn.active { color: #0f172a; background: rgba(15, 23, 42, 0.035); border-color: transparent; @@ -202,11 +206,14 @@ transform: translateX(2px); } -.ai-quick-btn:hover i { +.ai-quick-btn:hover .ai-quick-icon, +.ai-quick-btn.active .ai-quick-icon, +.ai-nav-btn:hover .ai-nav-icon, +.ai-nav-btn.active .ai-nav-icon { color: var(--ai-rail-accent); } -.ai-quick-btn.primary:hover i { +.ai-quick-btn.primary:hover .ai-quick-icon { color: var(--ai-rail-amber); } @@ -215,10 +222,10 @@ min-height: 48px; height: 48px; display: grid; - grid-template-columns: 28px minmax(0, 1fr) 28px; + grid-template-columns: 32px minmax(0, 1fr) 28px; align-items: center; - gap: 4px; - padding: 0 6px 0 4px; + gap: 8px; + padding: 0 6px 0 10px; border: 1px solid rgba(45, 114, 217, 0.14); border-radius: 12px; background: rgba(255, 255, 255, 0.7); @@ -308,41 +315,6 @@ box-shadow: none; } -.ai-nav-btn::before { - content: ""; - position: absolute; - left: 0; - width: 3px; - height: 22px; - border-radius: 999px; - background: transparent; - transition: - background 180ms var(--ease), - opacity 180ms var(--ease); - opacity: 0; -} - -.ai-nav-btn.active { - border-color: rgba(45, 114, 217, 0.13); - background: - linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)), - var(--ai-rail-panel); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.78), - 0 8px 18px rgba(45, 114, 217, 0.045); - color: #173d78; -} - -.ai-nav-btn.active::before { - background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green)); - opacity: 1; -} - -.ai-nav-btn:not(.active):hover::before { - background: rgba(45, 114, 217, 0.36); - opacity: 1; -} - .ai-nav-icon { width: 32px; height: 32px; @@ -357,17 +329,9 @@ box-shadow 180ms var(--ease); } -.ai-nav-btn.active .ai-nav-icon { - background: - linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)), - rgba(255, 255, 255, 0.52); - color: var(--ai-rail-accent); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); -} - -.ai-nav-icon i { - font-size: 18px; - line-height: 1; +.ai-nav-icon .ai-sidebar-tabler-icon { + width: 19px; + height: 19px; } .ai-nav-copy { @@ -386,10 +350,6 @@ white-space: nowrap; } -.ai-nav-btn.active .ai-nav-copy strong { - font-weight: 820; -} - .ai-recent-desc { min-width: 0; overflow: hidden; @@ -621,8 +581,7 @@ box-shadow: none; } -.ai-rail.rail-collapsed .ai-nav-list::before, -.ai-rail.rail-collapsed .ai-nav-btn::before { +.ai-rail.rail-collapsed .ai-nav-list::before { display: none; } @@ -636,12 +595,6 @@ padding: 8px; } -.ai-rail.rail-collapsed .ai-nav-btn.active { - grid-column: auto; - min-height: 44px; - grid-template-columns: 1fr; -} - .ai-rail.rail-collapsed .ai-quick-btn span, .ai-rail.rail-collapsed .ai-conversation-search, .ai-rail.rail-collapsed .ai-brand-copy, @@ -653,7 +606,7 @@ display: none; } -.ai-rail.rail-collapsed .ai-quick-btn i, +.ai-rail.rail-collapsed .ai-quick-icon, .ai-rail.rail-collapsed .ai-brand-logo, .ai-rail.rail-collapsed .ai-nav-icon, .ai-rail.rail-collapsed .ai-user-avatar { diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index c729bdf..7b5ca57 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -475,42 +475,30 @@ top: calc(100% + 12px); right: 0; z-index: 60; - width: 380px; + width: 428px; max-width: calc(100vw - 24px); - max-height: min(520px, calc(100vh - 96px)); + max-height: min(560px, calc(100vh - 68px)); display: flex; flex-direction: column; gap: 0; overflow: hidden; - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 12px; + border: 1px solid #dbe4ef; + border-radius: 10px; background: #ffffff; box-shadow: - 0 16px 36px rgba(0, 0, 0, 0.08), - 0 4px 12px rgba(0, 0, 0, 0.03), - 0 0 1px rgba(0, 0, 0, 0.1); + 0 18px 42px rgba(15, 23, 42, 0.13), + 0 4px 12px rgba(15, 23, 42, 0.06); overscroll-behavior-y: contain; } -.notification-popover::before { - content: ""; - display: block; - height: 3px; - background: linear-gradient( - 90deg, - var(--theme-primary-active) 0%, - var(--theme-primary-light-3, #7eb3d4) 100% - ); -} - .notification-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 12px 14px 10px; - border-bottom: 1px solid #edf2f7; - background: #fafbfd; + padding: 14px 16px 12px; + border-bottom: 1px solid #e6edf6; + background: #ffffff; } .notification-head-brand { @@ -522,14 +510,14 @@ } .notification-head-icon { - width: 32px; - height: 32px; + width: 34px; + height: 34px; flex: 0 0 auto; display: grid; place-items: center; - border: 1px solid var(--theme-primary-light-6); + border: 1px solid #dbeafe; border-radius: 4px; - background: #fff; + background: #f8fbff; color: var(--theme-primary-active); font-size: 17px; } @@ -561,22 +549,25 @@ } .notification-clear-btn { - height: 28px; + height: 30px; padding: 0 10px; - border: 0; + border: 1px solid transparent; border-radius: 4px; background: transparent; - color: var(--theme-primary-active); + color: #475569; font-size: 12px; font-weight: 750; white-space: nowrap; transition: background 160ms var(--ease), + border-color 160ms var(--ease), color 160ms var(--ease); } .notification-clear-btn:hover:not(:disabled) { - background: var(--theme-primary-light-9); + border-color: #bfdbfe; + background: #eff6ff; + color: var(--theme-primary-active); } .notification-clear-btn:disabled { @@ -585,8 +576,8 @@ } .notification-close-btn { - width: 28px; - height: 28px; + width: 30px; + height: 30px; display: inline-grid; place-items: center; border: 0; @@ -608,15 +599,15 @@ display: flex; align-items: stretch; gap: 0; - padding: 0 14px; - border-bottom: 1px solid #edf2f7; - background: #fff; + padding: 0 16px; + border-bottom: 1px solid #e6edf6; + background: #f8fafc; } .notification-tabs button { position: relative; flex: 1 1 0; - height: 38px; + height: 40px; display: inline-flex; align-items: center; justify-content: center; @@ -666,10 +657,10 @@ display: flex; flex-direction: column; min-height: 0; - max-height: min(336px, calc(100vh - 226px)); + max-height: min(420px, calc(100vh - 166px)); overflow-x: hidden; overflow-y: auto; - padding: 4px 0 12px; + padding: 10px 0 14px; scrollbar-width: thin; scrollbar-color: #cbd5e1 #f8fafc; overscroll-behavior-y: contain; @@ -691,179 +682,151 @@ .notification-row { display: grid; - grid-template-columns: 34px minmax(0, 1fr) 16px; - align-items: center; - gap: 12px; - min-height: 68px; - padding: 12px 16px; + grid-template-columns: 52px minmax(0, 1fr); + align-items: start; + gap: 16px; + min-height: 104px; + padding: 20px 22px; border: 0; border-radius: 0; background: #ffffff; flex-shrink: 0; + cursor: pointer; text-align: left; transition: background 180ms var(--ease), - border-color 180ms var(--ease); + transform 180ms var(--ease); } .notification-row + .notification-row { - border-top: 1px solid #f1f5f9; + border-top: 1px solid #f4f6fb; } .notification-row.unread { - background: #f8fafc; - position: relative; -} - -.notification-row.unread::before { - content: ''; - position: absolute; - left: 0; - top: 16px; - bottom: 16px; - width: 3px; - border-radius: 0 4px 4px 0; - background: var(--theme-primary-active); + background: #ffffff; } .notification-row:hover { - background: #f1f5f9; + background: #f8fafc; } .notification-row.unread:hover { - background: #f1f5f9; + background: #f8fafc; } -.notification-type-icon { - width: 34px; - height: 34px; +.notification-avatar { + position: relative; + width: 52px; + height: 52px; display: grid; place-items: center; - border: 1px solid rgba(0,0,0,0.04); - border-radius: 8px; - background: #ffffff; - color: var(--theme-primary-active); - font-size: 16px; - box-shadow: 0 1.5px 4px rgba(0,0,0,0.03); + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 18px; + font-weight: 800; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); } -.notification-type-icon.danger { +.notification-avatar.danger { border-color: #fecaca; - background: #fff5f5; - color: #dc2626; + background: #fff1f2; + color: #be123c; } -.notification-type-icon.warning { +.notification-avatar.warning { border-color: #fde68a; background: #fffbeb; - color: #d97706; + color: #b45309; } -.notification-type-icon.success { +.notification-avatar.success { border-color: #bbf7d0; background: #f0fdf4; - color: #16a34a; + color: #15803d; } -.notification-type-icon.info { +.notification-avatar.info { border-color: #bfdbfe; background: #eff6ff; color: #2563eb; } -.notification-copy { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-top: 1px; - padding-bottom: 1px; +.notification-avatar-label { + line-height: 1; } -.notification-title-line { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.notification-copy strong { - min-width: 0; - color: #0f172a; - font-size: 13.5px; - font-weight: 750; - line-height: 1.3; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.notification-title-line b { - flex: 0 0 auto; +.notification-avatar-badge { + position: absolute; + top: -2px; + right: -2px; min-width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; - padding: 0 5px; - border-radius: 4px; - background: #dc2626; - color: #fff; + padding: 0 4px; + border: 2px solid #ffffff; + border-radius: 999px; + background: #ef4444; + color: #ffffff; font-size: 10px; font-weight: 800; line-height: 1; } -.notification-copy small { - color: #64748b; - font-size: 12px; - line-height: 1.4; +.notification-row-content { + min-width: 0; + display: grid; + gap: 8px; + padding-top: 2px; +} + +.notification-row-top { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.notification-row-title { + min-width: 0; + color: #111827; + font-size: 15px; + font-weight: 800; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notification-row.unread .notification-row-title { + color: #0f172a; +} + +.notification-preview { + max-width: 100%; + color: #8a94a6; + font-size: 13.5px; + line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; - margin-bottom: 2px; } -.notification-meta { - min-width: 0; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - margin-top: 2px; -} - -.notification-meta em, -.notification-meta time { - min-width: 0; - overflow: hidden; - color: #94a3b8; - font-size: 11px; - font-style: normal; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.notification-meta em { - display: none; -} - -.notification-meta time { +.notification-time { flex: 0 0 auto; -} - -.notification-row-arrow { - color: #cbd5e1; - font-size: 18px; - transition: color 160ms var(--ease), transform 160ms var(--ease); -} - -.notification-row:hover .notification-row-arrow { - color: var(--theme-primary-active); - transform: translateX(2px); + color: #b4bcc8; + font-size: 13px; + font-style: normal; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.3; + white-space: nowrap; } .notification-empty { @@ -1573,7 +1536,24 @@ } .notification-row { - padding: 9px 12px; + grid-template-columns: 44px minmax(0, 1fr); + gap: 12px; + min-height: 90px; + padding: 16px 14px; + } + + .notification-avatar { + width: 44px; + height: 44px; + font-size: 16px; + } + + .notification-row-top { + gap: 8px; + } + + .notification-time { + font-size: 12px; } .company-switcher { @@ -1641,7 +1621,7 @@ } .notification-head-icon, - .notification-type-icon { + .notification-avatar { width: 30px; height: 30px; } @@ -1653,10 +1633,26 @@ .notification-row { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; + min-height: 82px; + padding: 13px 10px; } - .notification-row-arrow { - display: none; + .notification-avatar { + font-size: 13px; + } + + .notification-avatar-badge { + min-width: 16px; + height: 16px; + font-size: 9px; + } + + .notification-row-title { + font-size: 13.5px; + } + + .notification-preview { + font-size: 12.5px; } .topbar.detail-mode { diff --git a/web/src/components/business/PersonalWorkbenchAiMode.template.html b/web/src/components/business/PersonalWorkbenchAiMode.template.html index 7a86e7a..a81a5af 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.template.html +++ b/web/src/components/business/PersonalWorkbenchAiMode.template.html @@ -28,183 +28,11 @@

您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务

-
-
-
- - {{ workbenchDateTagLabel }} - -
- - -
- -
-
-
- - - -
- - -
- -
-
- {{ displayModelName }} - -
- -
-
-
- -
-
- - - {{ file.name }} - {{ file.typeLabel }} - - - - - {{ file.ocrState.label }} - - - -
-
+ +

快速开始

@@ -565,183 +393,12 @@
-
-
- - - {{ file.name }} - {{ file.typeLabel }} - - - - - {{ file.ocrState.label }} - - - -
-
- -
-
-
- - {{ workbenchDateTagLabel }} - -
- - -
- -
-
-
- - - -
- - -
- -
-
- {{ displayModelName }} - -
- -
-
-
+ +

小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。

diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index b810d3d..53b6427 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -1,7 +1,10 @@ diff --git a/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue b/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue index d958d08..f307ec0 100644 --- a/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue +++ b/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue @@ -12,6 +12,17 @@ {{ file.name }} {{ file.typeLabel }} + + + + + {{ file.ocrState.label }} + @@ -60,10 +76,26 @@ class="ai-nav-btn" :class="{ active: activeView === item.id }" :aria-current="activeView === item.id ? 'page' : undefined" + @mouseenter="emit('prefetch-view', item.id)" + @focus="emit('prefetch-view', item.id)" @click="emit('navigate', item.id)" > {{ item.displayLabel }} @@ -155,7 +187,7 @@ const props = defineProps({ conversationHistory: { type: Array, default: () => [] } }) -const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout']) +const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view']) const conversationSearchOpen = ref(false) const conversationSearchQuery = ref('') const conversationSearchInputRef = ref(null) @@ -164,16 +196,78 @@ const editingConversationTitle = ref('') const editingTitleInputRef = ref(null) let recentClickTimer = null +const tablerIconPaths = { + plus: [ + 'M12 5l0 14', + 'M5 12l14 0' + ], + search: [ + 'M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0', + 'M21 21l-6 -6' + ], + fileText: [ + 'M14 3v4a1 1 0 0 0 1 1h4', + 'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2', + 'M9 9l1 0', + 'M9 13l6 0', + 'M9 17l6 0' + ], + folder: [ + 'M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2' + ], + book2: [ + 'M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12', + 'M19 16h-12a2 2 0 0 0 -2 2', + 'M9 8h6' + ], + chartLine: [ + 'M4 19l16 0', + 'M4 15l4 -6l4 2l4 -5l4 4' + ], + chartDonut: [ + 'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-6.8a1 1 0 0 0 -1 -1', + 'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5' + ], + shieldCheck: [ + 'M11.46 20.846a12 12 0 0 1 -7.46 -10.846v-4l8 -3l8 3v4c0 1.122 -.154 2.203 -.441 3.226', + 'M15 19l2 2l4 -4' + ], + robot: [ + 'M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2', + 'M9 11l.01 0', + 'M15 11l.01 0', + 'M9 15h6', + 'M12 7v-4' + ], + users: [ + 'M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', + 'M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2', + 'M16 3.13a4 4 0 0 1 0 7.75', + 'M21 21v-2a4 4 0 0 0 -3 -3.85' + ], + sliders: [ + 'M4 6h16', + 'M4 12h10', + 'M4 18h14', + 'M8 6v.01', + 'M14 12v.01', + 'M18 18v.01' + ], + circle: [ + 'M12 12m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0' + ] +} + const quickActions = [ { label: '新建对话', - icon: 'mdi mdi-plus', + iconPaths: tablerIconPaths.plus, event: 'new-chat', primary: true }, { label: '查询对话', - icon: 'mdi mdi-magnify', + iconPaths: tablerIconPaths.search, event: 'search' } ] @@ -181,15 +275,15 @@ const quickActions = [ const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控') const sidebarMeta = { - overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' }, - documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' }, - receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' }, - budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' }, - policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' }, - audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' }, - digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' }, - employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' }, - settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' } + overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine }, + documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText }, + receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder }, + budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut }, + policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 }, + audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck }, + digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot }, + employees: { label: '员工管理', iconPaths: tablerIconPaths.users }, + settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders } } const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser))) @@ -200,7 +294,7 @@ const businessNavItems = computed(() => .map((item) => ({ ...item, displayLabel: sidebarMeta[item.id]?.label ?? item.label, - aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline' + aiIconPaths: sidebarMeta[item.id]?.iconPaths ?? tablerIconPaths.circle })) ) diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 61b75eb..3efc784 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -47,6 +47,8 @@ class="nav-btn" :class="{ active: activeView === item.id }" type="button" + @mouseenter="emit('prefetch-view', item.id)" + @focus="emit('prefetch-view', item.id)" @click="emit('navigate', item.id)" > @@ -100,7 +102,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse']) +const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse', 'prefetch-view']) const sidebarMeta = { overview: { label: '分析看板' }, diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index 3a30cca..31fc4f8 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -153,10 +153,10 @@ @@ -512,7 +504,8 @@ const { isNotificationHidden, isNotificationRead, loadNotificationStates, - markNotificationStateRead + markNotificationStateRead, + markNotificationStatesRead } = useTopBarNotificationStates() const notificationTab = ref('unread') @@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) { ].filter(Boolean).join(' · ') || '单据中心有新的单据状态' } +function resolveNotificationAvatarLabel(item) { + const raw = String( + item?.avatarLabel + || item?.initiatorName + || item?.applicantName + || item?.employeeName + || item?.category + || item?.title + || '通' + ).trim() + + if (!raw) { + return '通' + } + + return raw.replace(/\s+/g, '').slice(0, 1).toUpperCase() +} + +function resolveDocumentNotificationAvatarLabel(row) { + return resolveNotificationAvatarLabel({ + avatarLabel: + row?.initiatorName + || row?.applicantName + || row?.employeeName + || row?.sourceLabel + || row?.documentTypeLabel + || row?.title + }) +} + function resolveWorkbenchNotificationId(item, index) { return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`) } @@ -580,15 +603,15 @@ const documentNotificationItems = computed(() => return { id, - kind: 'document', - title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`, - description: resolveDocumentNotificationDescription(row), - time: row.updatedAt || row.createdAt, - timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt), - category: row.sourceLabel || '单据中心', - tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }), - unread, - icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline', + kind: 'document', + title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`, + description: resolveDocumentNotificationDescription(row), + avatarLabel: resolveDocumentNotificationAvatarLabel(row), + time: row.updatedAt || row.createdAt, + timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt), + category: row.sourceLabel || '单据中心', + tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }), + unread, badge: unread ? '新' : '', target: { type: 'document', @@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => ( id, kind: 'workbench', category: item.category || '个人工作台', + avatarLabel: resolveNotificationAvatarLabel(item), time: notificationTime, timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due), - unread: Boolean(item.unread) && !readNotificationIds.value.has(id), - icon: item.icon || resolveNotificationIcon(item) + unread: Boolean(item.unread) && !readNotificationIds.value.has(id) } }).filter(Boolean) : [] @@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) = const activeNotifications = computed(() => ( notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value )) +const notificationBulkActionLabel = computed(() => ( + notificationTab.value === 'unread' ? '全部已读' : '删除已读' +)) +const notificationBulkActionDisabled = computed(() => ( + notificationTab.value === 'unread' + ? unreadNotifications.value.length === 0 + : readNotifications.value.length === 0 +)) const topbarNotificationCount = computed(() => { const count = unreadNotifications.value.length return count > 0 ? Math.min(count, 99) : 0 @@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() { }, props.activeView === 'workbench' ? 1200 : 6000) } -function resolveNotificationIcon(item) { - if (item?.icon) { - return item.icon - } - - if (item?.tone === 'danger') { - return 'mdi mdi-alert-circle-outline' - } - - if (item?.tone === 'warning') { - return 'mdi mdi-alert-outline' - } - - if (item?.tone === 'success') { - return 'mdi mdi-check-circle-outline' - } - - return 'mdi mdi-bell-outline' -} - function markNotificationRead(item) { if (!item?.id || !item.unread) { return @@ -691,8 +702,8 @@ function markNotificationRead(item) { void markNotificationStateRead(item) } -function clearAllNotifications() { - const currentItems = notificationItems.value +function markUnreadNotificationsRead() { + const currentItems = unreadNotifications.value if (!currentItems.length) { return } @@ -705,8 +716,29 @@ function clearAllNotifications() { markDocumentInboxRowsRead(documentRows) } + void markNotificationStatesRead(currentItems) +} + +function deleteReadNotifications() { + const currentItems = readNotifications.value + if (!currentItems.length) { + return + } + void hideNotificationStates(currentItems) - notificationTab.value = 'unread' +} + +function handleNotificationBulkAction() { + if (notificationBulkActionDisabled.value) { + return + } + + if (notificationTab.value === 'unread') { + markUnreadNotificationsRead() + return + } + + deleteReadNotifications() } function openNotification(item) { diff --git a/web/src/components/shared/AppModalLoadingState.vue b/web/src/components/shared/AppModalLoadingState.vue new file mode 100644 index 0000000..f63d896 --- /dev/null +++ b/web/src/components/shared/AppModalLoadingState.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/src/components/shared/AppViewLoadingState.vue b/web/src/components/shared/AppViewLoadingState.vue new file mode 100644 index 0000000..15ecd24 --- /dev/null +++ b/web/src/components/shared/AppViewLoadingState.vue @@ -0,0 +1,104 @@ + + + diff --git a/web/src/composables/requests/requestShared.js b/web/src/composables/requests/requestShared.js index 8ff01ba..8792b17 100644 --- a/web/src/composables/requests/requestShared.js +++ b/web/src/composables/requests/requestShared.js @@ -1,5 +1,11 @@ import { isApplicationDocumentNo } from '../../utils/documentClassification.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js' +import { + DOCUMENT_TYPE_APPLICATION, + DOCUMENT_TYPE_EXPENSE_APPLICATION, + DOCUMENT_TYPE_REIMBURSEMENT, + resolveDocumentTypeLabel +} from '../../constants/documentProtocol.js' const EXPENSE_TYPE_LABELS = { travel: '差旅费', @@ -49,8 +55,6 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([ 'hotel_ticket', 'ride_ticket' ]) -const DOCUMENT_TYPE_APPLICATION = 'application' -const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement' const RELATED_APPLICATION_STEP_LABEL = '关联单据' const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态' const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档' @@ -179,14 +183,14 @@ function resolveDocumentTypeMeta(claim, typeCode) { const normalizedType = String(typeCode || '').trim() const isApplication = explicitType === DOCUMENT_TYPE_APPLICATION - || explicitType === 'expense_application' + || explicitType === DOCUMENT_TYPE_EXPENSE_APPLICATION || isApplicationDocumentNo(claimNo) || normalizedType === 'application' || normalizedType.endsWith('_application') return isApplication - ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' } - : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' } + ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) } + : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) } } function normalizeExpenseType(typeCode) { diff --git a/web/src/composables/useTopBarNotificationStates.js b/web/src/composables/useTopBarNotificationStates.js index 2dff26b..1789e00 100644 --- a/web/src/composables/useTopBarNotificationStates.js +++ b/web/src/composables/useTopBarNotificationStates.js @@ -139,7 +139,14 @@ export function useTopBarNotificationStates() { } function markNotificationStateRead(item) { - return syncNotificationPatches([buildPatch(item, { read: true })]) + return markNotificationStatesRead([item]) + } + + function markNotificationStatesRead(items) { + const patches = (Array.isArray(items) ? items : []) + .map((item) => buildPatch(item, { read: true, hidden: false })) + .filter(Boolean) + return syncNotificationPatches(patches) } function hideNotificationStates(items) { @@ -158,6 +165,7 @@ export function useTopBarNotificationStates() { isNotificationHidden, isNotificationRead, loadNotificationStates, - markNotificationStateRead + markNotificationStateRead, + markNotificationStatesRead } } diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js index 1793aca..d7da320 100644 --- a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js +++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js @@ -24,8 +24,7 @@ import { } from './workbenchAiComposerModel.js' import { createWorkbenchAiMessageRuntime, - formatMessageTime, - normalizeInlineAttachmentOcrDetails + formatMessageTime } from './workbenchAiMessageModel.js' import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js' import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js' @@ -34,8 +33,10 @@ import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js' import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js' import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js' import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js' +import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js' import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js' import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js' +import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js' const AI_SEARCH_CONVERSATION_ID = 'ai-search' const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 @@ -174,6 +175,23 @@ export function usePersonalWorkbenchAiMode(props, emit) { ...card, ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index]) }))) + const { + hasInlineAttachmentOcrDetails, + hasInlineThinking, + isInlineAttachmentOcrExpanded, + isInlineThinkingExpanded, + resolveInlineAttachmentOcrDocuments, + resolveInlineAttachmentOcrFileCount, + resolveInlineThinkingEvents, + toggleInlineAttachmentOcrDetails, + toggleInlineThinking + } = useWorkbenchAiMessageExpansion({ + attachmentOcrExpandedMessageIds, + inlineConversationAutoScrollPinned, + scrollInlineConversationToBottom, + thinkingCollapsedMessageIds, + thinkingExpandedMessageIds + }) const applicationFlow = useWorkbenchAiApplicationPreviewFlow({ activateInlineConversation, @@ -324,6 +342,10 @@ export function usePersonalWorkbenchAiMode(props, emit) { }) } + function setAssistantInputRef(element) { + assistantInputRef.value = element + } + function isInlineConversationNearBottom() { const el = conversationScrollRef.value if (!el) { @@ -499,78 +521,6 @@ export function usePersonalWorkbenchAiMode(props, emit) { return renderAiConversationHtml(content) } - function resolveInlineThinkingEvents(message) { - return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : [] - } - - function hasInlineThinking(message) { - return resolveInlineThinkingEvents(message).length > 0 - } - - function isInlineThinkingExpanded(message) { - if (!message?.id) { - return Boolean(message?.pending) - } - if (thinkingCollapsedMessageIds.value.has(message.id)) { - return false - } - return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id)) - } - - function toggleInlineThinking(message) { - if (!message?.id) { - return - } - const nextExpandedIds = new Set(thinkingExpandedMessageIds.value) - const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value) - if (isInlineThinkingExpanded(message)) { - nextExpandedIds.delete(message.id) - nextCollapsedIds.add(message.id) - } else { - nextCollapsedIds.delete(message.id) - nextExpandedIds.add(message.id) - } - thinkingExpandedMessageIds.value = nextExpandedIds - thinkingCollapsedMessageIds.value = nextCollapsedIds - } - - function hasInlineAttachmentOcrDetails(message = {}) { - const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null) - return Boolean(details?.documents?.length || details?.fileNames?.length) - } - - function resolveInlineAttachmentOcrDocuments(message = {}) { - return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || [] - } - - function resolveInlineAttachmentOcrFileCount(message = {}) { - const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null) - return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0) - } - - function isInlineAttachmentOcrExpanded(message = {}) { - return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id)) - } - - function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) { - if (!message?.id || !hasInlineAttachmentOcrDetails(message)) { - return - } - const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value) - const shouldExpand = forceExpanded === null - ? !nextExpandedIds.has(message.id) - : Boolean(forceExpanded) - if (shouldExpand) { - nextExpandedIds.add(message.id) - } else { - nextExpandedIds.delete(message.id) - } - attachmentOcrExpandedMessageIds.value = nextExpandedIds - nextTick(() => { - scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) - }) - } - function buildInlinePromptText(rawPrompt, files = []) { const prompt = buildWorkbenchPromptText(rawPrompt) if (prompt) { @@ -579,20 +529,6 @@ export function usePersonalWorkbenchAiMode(props, emit) { return files.length ? '请帮我处理已上传的附件。' : '' } - function isReimbursementCreationIntent(prompt = '') { - const compact = String(prompt || '').replace(/\s+/g, '') - if (!compact || !/报销|报账/.test(compact)) { - return false - } - if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) { - return false - } - return ( - /^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) || - /^(报销|报账)(一下|一笔|单|流程)?$/.test(compact) - ) - } - function handleAiAnswerMarkdownClick(event) { const target = event?.target const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') @@ -800,6 +736,7 @@ export function usePersonalWorkbenchAiMode(props, emit) { scrollInlineConversationToTop, selectedFileCards, sending, + setAssistantInputRef, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js index 8b5a16f..fedc616 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js @@ -24,10 +24,15 @@ import { buildInlineApplicationSubmitPrecheckPayload, buildInlineApplicationSubmitThinkingEvents, completeInlineThinkingEvents, - extractInlineApplicationDraftPayload, - resolveInlineApplicationPreviewActionFromText + extractInlineApplicationDraftPayload } from './workbenchAiApplicationPreviewModel.js' import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js' +import { + isOrphanInlineApplicationPreviewMessage, + resolveInlineApplicationPreviewTextAction, + resolveLatestApplicationPreviewMessage, + resolveLatestOrphanApplicationPreviewMessage +} from './workbenchAiApplicationGateModel.js' function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) { const fields = normalizeApplicationPreview(applicationPreview).fields || {} @@ -197,23 +202,12 @@ export function useWorkbenchAiApplicationPreviewFlow({ ].join('\n\n') } - function resolveLatestApplicationPreviewMessage() { - return [...conversationMessages.value] - .reverse() - .find((message) => message.role === 'assistant' && message.applicationPreview) + function resolveLatestInlineApplicationPreviewMessage() { + return resolveLatestApplicationPreviewMessage(conversationMessages.value) } - function isOrphanInlineApplicationPreviewMessage(message = {}) { - if (message?.applicationPreview || message?.role !== 'assistant') { - return false - } - return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || '')) - } - - function resolveLatestOrphanApplicationPreviewMessage() { - return [...conversationMessages.value] - .reverse() - .find((message) => isOrphanInlineApplicationPreviewMessage(message)) + function resolveLatestOrphanInlineApplicationPreviewMessage() { + return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value) } function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) { @@ -310,7 +304,7 @@ export function useWorkbenchAiApplicationPreviewFlow({ } async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) { - const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage() + const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage() if (!targetMessage?.applicationPreview) { toast('当前没有可提交的申请表。') return false @@ -446,12 +440,12 @@ export function useWorkbenchAiApplicationPreviewFlow({ toast('请等待费用测算完成后再继续操作。') return true } - const actionType = resolveInlineApplicationPreviewActionFromText(prompt) + const actionType = resolveInlineApplicationPreviewTextAction(prompt) if (!actionType) { return false } - if (!resolveLatestApplicationPreviewMessage()) { - const orphanPreviewMessage = resolveLatestOrphanApplicationPreviewMessage() + if (!resolveLatestInlineApplicationPreviewMessage()) { + const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage() if (!orphanPreviewMessage) { return false } diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiMessageExpansion.js b/web/src/composables/workbenchAiMode/useWorkbenchAiMessageExpansion.js new file mode 100644 index 0000000..3fce495 --- /dev/null +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiMessageExpansion.js @@ -0,0 +1,95 @@ +import { nextTick } from 'vue' + +import { normalizeInlineAttachmentOcrDetails } from './workbenchAiMessageModel.js' + +export function useWorkbenchAiMessageExpansion({ + attachmentOcrExpandedMessageIds, + inlineConversationAutoScrollPinned, + scrollInlineConversationToBottom, + thinkingCollapsedMessageIds, + thinkingExpandedMessageIds +}) { + function resolveInlineThinkingEvents(message) { + return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : [] + } + + function hasInlineThinking(message) { + return resolveInlineThinkingEvents(message).length > 0 + } + + function isInlineThinkingExpanded(message) { + if (!message?.id) { + return Boolean(message?.pending) + } + if (thinkingCollapsedMessageIds.value.has(message.id)) { + return false + } + return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id)) + } + + function toggleInlineThinking(message) { + if (!message?.id) { + return + } + const nextExpandedIds = new Set(thinkingExpandedMessageIds.value) + const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value) + if (isInlineThinkingExpanded(message)) { + nextExpandedIds.delete(message.id) + nextCollapsedIds.add(message.id) + } else { + nextCollapsedIds.delete(message.id) + nextExpandedIds.add(message.id) + } + thinkingExpandedMessageIds.value = nextExpandedIds + thinkingCollapsedMessageIds.value = nextCollapsedIds + } + + function hasInlineAttachmentOcrDetails(message = {}) { + const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null) + return Boolean(details?.documents?.length || details?.fileNames?.length) + } + + function resolveInlineAttachmentOcrDocuments(message = {}) { + return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || [] + } + + function resolveInlineAttachmentOcrFileCount(message = {}) { + const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null) + return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0) + } + + function isInlineAttachmentOcrExpanded(message = {}) { + return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id)) + } + + function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) { + if (!message?.id || !hasInlineAttachmentOcrDetails(message)) { + return + } + const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value) + const shouldExpand = forceExpanded === null + ? !nextExpandedIds.has(message.id) + : Boolean(forceExpanded) + if (shouldExpand) { + nextExpandedIds.add(message.id) + } else { + nextExpandedIds.delete(message.id) + } + attachmentOcrExpandedMessageIds.value = nextExpandedIds + nextTick(() => { + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + }) + } + + return { + hasInlineAttachmentOcrDetails, + hasInlineThinking, + isInlineAttachmentOcrExpanded, + isInlineThinkingExpanded, + resolveInlineAttachmentOcrDocuments, + resolveInlineAttachmentOcrFileCount, + resolveInlineThinkingEvents, + toggleInlineAttachmentOcrDetails, + toggleInlineThinking + } +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js new file mode 100644 index 0000000..8da6298 --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js @@ -0,0 +1,51 @@ +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT +} from '../../services/aiApplicationPreviewActions.js' + +export function isReimbursementCreationIntent(prompt = '') { + const compact = String(prompt || '').replace(/\s+/g, '') + if (!compact || !/报销|报账/.test(compact)) { + return false + } + if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) { + return false + } + return ( + /^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) || + /^(报销|报账)(一下|一笔|单|流程)?$/.test(compact) + ) +} + +export function resolveInlineApplicationPreviewTextAction(text = '') { + const normalized = String(text || '').replace(/\s+/g, '').trim() + if (!normalized) { + return '' + } + if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) { + return AI_APPLICATION_ACTION_SAVE_DRAFT + } + if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) { + return AI_APPLICATION_ACTION_SUBMIT + } + return '' +} + +export function resolveLatestApplicationPreviewMessage(messages = []) { + return [...(Array.isArray(messages) ? messages : [])] + .reverse() + .find((message) => message?.role === 'assistant' && message.applicationPreview) || null +} + +export function isOrphanInlineApplicationPreviewMessage(message = {}) { + if (message?.applicationPreview || message?.role !== 'assistant') { + return false + } + return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || '')) +} + +export function resolveLatestOrphanApplicationPreviewMessage(messages = []) { + return [...(Array.isArray(messages) ? messages : [])] + .reverse() + .find((message) => isOrphanInlineApplicationPreviewMessage(message)) || null +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js index f44b323..4759c00 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js @@ -13,19 +13,8 @@ import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT } from '../../services/aiApplicationPreviewActions.js' - -const INLINE_APPLICATION_STATUS_LABELS = { - draft: '草稿', - submitted: '审批中', - pending: '待处理', - approved: '已审批', - completed: '已完成', - archived: '已归档', - returned: '已退回', - rejected: '已驳回', - pending_payment: '待付款', - paid: '已付款' -} +import { INLINE_APPLICATION_STATUS_LABELS } from '../../constants/documentProtocol.js' +import { resolveInlineApplicationPreviewTextAction } from './workbenchAiApplicationGateModel.js' function normalizeInlineApplicationResultTableCell(value, fallback = '-') { const text = String(value || '') @@ -201,17 +190,7 @@ export function buildInlineApplicationDetailAction(draftPayload = {}) { } export function resolveInlineApplicationPreviewActionFromText(text = '') { - const normalized = String(text || '').replace(/\s+/g, '').trim() - if (!normalized) { - return '' - } - if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) { - return AI_APPLICATION_ACTION_SAVE_DRAFT - } - if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) { - return AI_APPLICATION_ACTION_SUBMIT - } - return '' + return resolveInlineApplicationPreviewTextAction(text) } export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') { diff --git a/web/src/constants/documentProtocol.js b/web/src/constants/documentProtocol.js new file mode 100644 index 0000000..936a9d8 --- /dev/null +++ b/web/src/constants/documentProtocol.js @@ -0,0 +1,28 @@ +export const DOCUMENT_TYPE_ALL = 'all' +export const DOCUMENT_TYPE_APPLICATION = 'application' +export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement' +export const DOCUMENT_TYPE_EXPENSE_APPLICATION = 'expense_application' + +export const DOCUMENT_TYPE_LABELS = { + [DOCUMENT_TYPE_APPLICATION]: '申请单', + [DOCUMENT_TYPE_EXPENSE_APPLICATION]: '申请单', + [DOCUMENT_TYPE_REIMBURSEMENT]: '报销单' +} + +export const INLINE_APPLICATION_STATUS_LABELS = { + draft: '草稿', + submitted: '审批中', + pending: '待处理', + approved: '已审批', + completed: '已完成', + archived: '已归档', + returned: '已退回', + rejected: '已驳回', + pending_payment: '待付款', + paid: '已付款' +} + +export function resolveDocumentTypeLabel(typeCode, fallback = '报销单') { + const normalized = String(typeCode || '').trim().toLowerCase() + return DOCUMENT_TYPE_LABELS[normalized] || fallback +} diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js index 27b3439..339caf3 100644 --- a/web/src/utils/aiConversationHtmlRenderer.js +++ b/web/src/utils/aiConversationHtmlRenderer.js @@ -1,49 +1,14 @@ import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js' import { parseTableRow, renderTable } from './aiConversationTableRenderer.js' - -const ALLOWED_COLON_HEADING_TITLES = new Set([ - '基础信息识别结果', - '报销测算参考', - '补充信息' -]) - -const BUSINESS_FIELD_LABELS = new Set([ - '时间', - '地点', - '事由', - '金额', - '费用类型', - '报销类型', - '商户', - '商户/开票方', - '客户', - '客户/项目对象', - '附件', - '附件/凭证', - '出行方式' -]) +import { + DOCUMENT_DETAIL_HREF_PREFIX, + extractTrustedHtmlBlocks, + normalizeConversationText, + restoreTrustedHtmlBlocks +} from './conversationTrustedHtml.js' const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' -const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' -const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g -const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' -const TRUSTED_HTML_ALLOWED_TAGS = new Set([ - 'section', - 'article', - 'header', - 'footer', - 'div', - 'span', - 'strong', - 'a' -]) -const TRUSTED_HTML_ALLOWED_ATTRS = new Set([ - 'aria-label', - 'class', - 'data-ai-action', - 'href' -]) function escapeHtml(value = '') { return String(value) @@ -146,150 +111,6 @@ function renderInlineHtml(value = '') { return html } -function splitColonHeadingLine(line) { - const rawLine = String(line || '') - const trimmed = rawLine.trim() - if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) { - return [rawLine] - } - - const chineseColonIndex = trimmed.indexOf(':') - const asciiColonIndex = trimmed.indexOf(':') - const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0) - if (!colonIndexes.length) { - return [rawLine] - } - - const colonIndex = Math.min(...colonIndexes) - const title = trimmed.slice(0, colonIndex) - const body = trimmed.slice(colonIndex + 1).trim() - if (!ALLOWED_COLON_HEADING_TITLES.has(title)) { - return [rawLine] - } - return body ? [`### ${title}`, '', body] : [`### ${title}`] -} - -function normalizeBusinessFieldLine(line) { - const rawLine = String(line || '') - const trimmed = rawLine.trim() - if ( - !trimmed || - trimmed.startsWith('|') || - /^[-*+]\s/.test(trimmed) || - /^#{1,6}\s/.test(trimmed) - ) { - return rawLine - } - - const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u) - if (!match) { - return rawLine - } - const label = match[1].trim() - const value = match[2].trim() - if (!BUSINESS_FIELD_LABELS.has(label) || !value) { - return rawLine - } - return `- **${label}**:${value}` -} - -function normalizeConversationText(text = '') { - const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n') - const normalizedLines = [] - let inFence = false - - lines.forEach((line) => { - if (/^\s*(```|~~~)/.test(line)) { - inFence = !inFence - normalizedLines.push(line) - return - } - if (inFence) { - normalizedLines.push(line) - return - } - - const nextLines = splitColonHeadingLine(line) - if (nextLines[0]?.startsWith('### ') && normalizedLines.length) { - const previousLine = normalizedLines[normalizedLines.length - 1] - if (String(previousLine || '').trim()) { - normalizedLines.push('') - } - } - normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine))) - }) - - return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim() -} - -function hasOnlyTrustedHtmlTags(html = '') { - const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi - let match = tagPattern.exec(html) - while (match) { - const tagName = String(match[1] || '').toLowerCase() - if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) { - return false - } - const attrText = String(match[2] || '') - const attrPattern = /\s([:@\w-]+)\s*=/g - let attrMatch = attrPattern.exec(attrText) - while (attrMatch) { - const attrName = String(attrMatch[1] || '').toLowerCase() - if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) { - return false - } - attrMatch = attrPattern.exec(attrText) - } - match = tagPattern.exec(html) - } - return true -} - -function sanitizeTrustedHtmlBlock(html = '') { - const value = String(html || '').trim() - if (!value || !value.includes('class="ai-document-card-list"')) { - return '' - } - if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) { - return '' - } - if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) { - return '' - } - if (!hasOnlyTrustedHtmlTags(value)) { - return '' - } - const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim()) - if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) { - return '' - } - return value -} - -function extractTrustedHtmlBlocks(text = '') { - const trustedHtmlBlocks = [] - const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => { - const sanitizedHtml = sanitizeTrustedHtmlBlock(html) - if (!sanitizedHtml) { - return '' - } - const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}` - trustedHtmlBlocks.push(sanitizedHtml) - return `\n\n${placeholder}\n\n` - }) - return { content, trustedHtmlBlocks } -} - -function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) { - return trustedHtmlBlocks.reduce((nextHtml, block, index) => { - const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}` - const paragraphPattern = new RegExp(`

${placeholder}

`, 'g') - return nextHtml - .replace(paragraphPattern, block) - .replaceAll(placeholder, block) - }, html) -} - function isFenceLine(line = '') { return /^\s*(```|~~~)/.test(String(line || '')) } @@ -501,7 +322,7 @@ export function renderAiConversationHtml(content = '') { } const extracted = extractTrustedHtmlBlocks(content) - const normalized = normalizeConversationText(extracted.content) + const normalized = normalizeConversationText(extracted.content, { trim: true }) if (!normalized) { return '' } @@ -628,6 +449,7 @@ export function renderAiConversationHtml(content = '') { return restoreTrustedHtmlBlocks( `
${blocks.filter(Boolean).join('')}
`, - extracted.trustedHtmlBlocks + extracted.trustedHtmlBlocks, + { paragraphClass: 'ai-html-paragraph' } ) } diff --git a/web/src/utils/conversationTrustedHtml.js b/web/src/utils/conversationTrustedHtml.js new file mode 100644 index 0000000..8530302 --- /dev/null +++ b/web/src/utils/conversationTrustedHtml.js @@ -0,0 +1,191 @@ +export const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' + +const ALLOWED_COLON_HEADING_TITLES = new Set([ + '基础信息识别结果', + '报销测算参考', + '补充信息' +]) + +const BUSINESS_FIELD_LABELS = new Set([ + '时间', + '地点', + '事由', + '金额', + '费用类型', + '报销类型', + '商户', + '商户/开票方', + '客户', + '客户/项目对象', + '附件', + '附件/凭证', + '出行方式' +]) + +const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g +const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' +const TRUSTED_HTML_ALLOWED_TAGS = new Set([ + 'section', + 'article', + 'header', + 'footer', + 'div', + 'span', + 'strong', + 'a' +]) +const TRUSTED_HTML_ALLOWED_ATTRS = new Set([ + 'aria-label', + 'class', + 'data-ai-action', + 'href' +]) + +function splitColonHeadingLine(line) { + const rawLine = String(line || '') + const trimmed = rawLine.trim() + if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) { + return [rawLine] + } + + const chineseColonIndex = trimmed.indexOf(':') + const asciiColonIndex = trimmed.indexOf(':') + const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0) + if (!colonIndexes.length) { + return [rawLine] + } + + const colonIndex = Math.min(...colonIndexes) + const title = trimmed.slice(0, colonIndex) + const body = trimmed.slice(colonIndex + 1).trim() + if (!ALLOWED_COLON_HEADING_TITLES.has(title)) { + return [rawLine] + } + return body ? [`### ${title}`, '', body] : [`### ${title}`] +} + +function normalizeBusinessFieldLine(line) { + const rawLine = String(line || '') + const trimmed = rawLine.trim() + if ( + !trimmed || + trimmed.startsWith('|') || + /^[-*+]\s/.test(trimmed) || + /^#{1,6}\s/.test(trimmed) + ) { + return rawLine + } + + const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u) + if (!match) { + return rawLine + } + const label = match[1].trim() + const value = match[2].trim() + if (!BUSINESS_FIELD_LABELS.has(label) || !value) { + return rawLine + } + return `- **${label}**:${value}` +} + +function hasOnlyTrustedHtmlTags(html = '') { + const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi + let match = tagPattern.exec(html) + while (match) { + const tagName = String(match[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) { + return false + } + const attrText = String(match[2] || '') + const attrPattern = /\s([:@\w-]+)\s*=/g + let attrMatch = attrPattern.exec(attrText) + while (attrMatch) { + const attrName = String(attrMatch[1] || '').toLowerCase() + if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) { + return false + } + attrMatch = attrPattern.exec(attrText) + } + match = tagPattern.exec(html) + } + return true +} + +function sanitizeTrustedHtmlBlock(html = '') { + const value = String(html || '').trim() + if (!value || !value.includes('class="ai-document-card-list"')) { + return '' + } + if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) { + return '' + } + if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) { + return '' + } + if (!hasOnlyTrustedHtmlTags(value)) { + return '' + } + const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim()) + if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) { + return '' + } + return value +} + +export function normalizeConversationText(text = '', options = {}) { + const shouldTrim = Boolean(options.trim) + const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n') + const normalizedLines = [] + let inFence = false + + lines.forEach((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence + normalizedLines.push(line) + return + } + if (inFence) { + normalizedLines.push(line) + return + } + + const nextLines = splitColonHeadingLine(line) + if (nextLines[0]?.startsWith('### ') && normalizedLines.length) { + const previousLine = normalizedLines[normalizedLines.length - 1] + if (String(previousLine || '').trim()) { + normalizedLines.push('') + } + } + normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine))) + }) + + const normalized = normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n') + return shouldTrim ? normalized.trim() : normalized +} + +export function extractTrustedHtmlBlocks(text = '') { + const trustedHtmlBlocks = [] + const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => { + const sanitizedHtml = sanitizeTrustedHtmlBlock(html) + if (!sanitizedHtml) { + return '' + } + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}` + trustedHtmlBlocks.push(sanitizedHtml) + return `\n\n${placeholder}\n\n` + }) + return { content, trustedHtmlBlocks } +} + +export function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = [], options = {}) { + const paragraphClass = String(options.paragraphClass || '').trim() + return trustedHtmlBlocks.reduce((nextHtml, block, index) => { + const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}` + const paragraphPattern = paragraphClass + ? new RegExp(`

${placeholder}

\\n?`, 'g') + : new RegExp(`

${placeholder}

\\n?`, 'g') + return nextHtml + .replace(paragraphPattern, block) + .replaceAll(placeholder, block) + }, html) +} diff --git a/web/src/utils/documentCenterViewModel.js b/web/src/utils/documentCenterViewModel.js index 3e1e950..7aa68c4 100644 --- a/web/src/utils/documentCenterViewModel.js +++ b/web/src/utils/documentCenterViewModel.js @@ -2,6 +2,12 @@ import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilt import { isNewDocument } from './documentCenterNewState.js' import { isArchivedDocumentRow } from './documentCenterRows.js' import { sortDocumentRowsByLatestTime } from './documentCenterSort.js' +import { + DOCUMENT_TYPE_ALL, + DOCUMENT_TYPE_APPLICATION, + DOCUMENT_TYPE_REIMBURSEMENT, + resolveDocumentTypeLabel +} from '../constants/documentProtocol.js' import { extractDateText, formatDocumentListTime, @@ -10,9 +16,11 @@ import { } from './documentCenterTime.js' import { normalizeRequestForUi } from './requestViewModel.js' -export const DOCUMENT_TYPE_ALL = 'all' -export const DOCUMENT_TYPE_APPLICATION = 'application' -export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement' +export { + DOCUMENT_TYPE_ALL, + DOCUMENT_TYPE_APPLICATION, + DOCUMENT_TYPE_REIMBURSEMENT +} export const SCENE_ALL = 'all' export const DOCUMENT_SCOPE_ALL = '全部' export const DOCUMENT_SCOPE_APPLICATION = '申请单' @@ -129,7 +137,7 @@ export function buildDocumentRow(request, options = {}) { const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT const documentTypeLabel = normalized.documentTypeLabel - || (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单') + || resolveDocumentTypeLabel(documentTypeCode) const initiatorName = String( normalized.person || normalized.employeeName diff --git a/web/src/utils/markdown.js b/web/src/utils/markdown.js index 17507c0..3870a30 100644 --- a/web/src/utils/markdown.js +++ b/web/src/utils/markdown.js @@ -1,4 +1,10 @@ import MarkdownIt from 'markdown-it' +import { + DOCUMENT_DETAIL_HREF_PREFIX, + extractTrustedHtmlBlocks, + normalizeConversationText, + restoreTrustedHtmlBlocks +} from './conversationTrustedHtml.js' const markdown = new MarkdownIt({ html: false, @@ -25,25 +31,6 @@ const ACTION_LINK_CLASS_BY_HREF = { '#review-quick-edit': 'markdown-action-link-edit', '#review-risk-panel': 'markdown-action-link-risk' } -const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' -const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g -const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' -const TRUSTED_HTML_ALLOWED_TAGS = new Set([ - 'section', - 'article', - 'header', - 'footer', - 'div', - 'span', - 'strong', - 'a' -]) -const TRUSTED_HTML_ALLOWED_ATTRS = new Set([ - 'aria-label', - 'class', - 'data-ai-action', - 'href' -]) function escapeHtml(text) { return String(text || '') @@ -136,176 +123,8 @@ markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => ( `${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : ''}` ) -const ALLOWED_COLON_HEADING_TITLES = new Set([ - '基础信息识别结果', - '报销测算参考', - '补充信息' -]) - -const BUSINESS_FIELD_LABELS = new Set([ - '时间', - '地点', - '事由', - '金额', - '费用类型', - '报销类型', - '商户', - '商户/开票方', - '客户', - '客户/项目对象', - '附件', - '附件/凭证', - '出行方式' -]) - -function splitColonHeadingLine(line) { - const rawLine = String(line || '') - const trimmed = rawLine.trim() - if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) { - return [rawLine] - } - - const chineseColonIndex = trimmed.indexOf(':') - const asciiColonIndex = trimmed.indexOf(':') - const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0) - if (!colonIndexes.length) { - return [rawLine] - } - - const colonIndex = Math.min(...colonIndexes) - const title = trimmed.slice(0, colonIndex + 1) - const titleText = title.slice(0, -1) - const body = trimmed.slice(colonIndex + 1).trim() - if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) { - return [rawLine] - } - - return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`] -} - -function normalizeBusinessFieldLine(line) { - const rawLine = String(line || '') - const trimmed = rawLine.trim() - if ( - !trimmed || - trimmed.startsWith('|') || - /^[-*+]\s/.test(trimmed) || - /^#{1,6}\s/.test(trimmed) - ) { - return rawLine - } - - const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u) - if (!match) { - return rawLine - } - const label = match[1].trim() - const value = match[2].trim() - if (!BUSINESS_FIELD_LABELS.has(label) || !value) { - return rawLine - } - return `- **${label}**:${value}` -} - -function normalizeColonHeadings(text) { - const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n') - const normalizedLines = [] - let inFence = false - - lines.forEach((line) => { - if (/^\s*(```|~~~)/.test(line)) { - inFence = !inFence - normalizedLines.push(line) - return - } - if (inFence) { - normalizedLines.push(line) - return - } - - const nextLines = splitColonHeadingLine(line) - if (nextLines[0]?.startsWith('### ') && normalizedLines.length) { - const previousLine = normalizedLines[normalizedLines.length - 1] - if (String(previousLine || '').trim()) { - normalizedLines.push('') - } - } - normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine))) - }) - - return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n') -} - -function hasOnlyTrustedHtmlTags(html = '') { - const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi - let match = tagPattern.exec(html) - while (match) { - const tagName = String(match[1] || '').toLowerCase() - if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) { - return false - } - const attrText = String(match[2] || '') - const attrPattern = /\s([:@\w-]+)\s*=/g - let attrMatch = attrPattern.exec(attrText) - while (attrMatch) { - const attrName = String(attrMatch[1] || '').toLowerCase() - if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) { - return false - } - attrMatch = attrPattern.exec(attrText) - } - match = tagPattern.exec(html) - } - return true -} - -function sanitizeTrustedHtmlBlock(html = '') { - const value = String(html || '').trim() - if (!value || !value.includes('class="ai-document-card-list"')) { - return '' - } - if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) { - return '' - } - if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) { - return '' - } - if (!hasOnlyTrustedHtmlTags(value)) { - return '' - } - const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim()) - if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) { - return '' - } - return value -} - -function extractTrustedHtmlBlocks(text = '') { - const trustedHtmlBlocks = [] - const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => { - const sanitizedHtml = sanitizeTrustedHtmlBlock(html) - if (!sanitizedHtml) { - return '' - } - const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}` - trustedHtmlBlocks.push(sanitizedHtml) - return `\n\n${placeholder}\n\n` - }) - return { content, trustedHtmlBlocks } -} - -function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) { - return trustedHtmlBlocks.reduce((nextHtml, block, index) => { - const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}` - const paragraphPattern = new RegExp(`

${placeholder}

\\n?`, 'g') - return nextHtml - .replace(paragraphPattern, block) - .replaceAll(placeholder, block) - }, html) -} - export function renderMarkdown(text = '') { const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text) - const normalized = normalizeColonHeadings(content).trim() + const normalized = normalizeConversationText(content).trim() return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : '' } diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 80935a1..6076694 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -34,6 +34,7 @@ @new-chat="openAiSidebarNewChat" @open-recent="openAiSidebarRecent" @rename-conversation="handleAiConversationRename" + @prefetch-view="prefetchAppView" @logout="handleLogout" /> @@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue' import SidebarRail from '../components/layout/SidebarRail.vue' import TopBar from '../components/layout/TopBar.vue' import FilterBar from '../components/layout/FilterBar.vue' -import AuditView from './AuditView.vue' -import BudgetCenterView from './BudgetCenterView.vue' -import DigitalEmployeesView from './DigitalEmployeesView.vue' -import DocumentsCenterView from './DocumentsCenterView.vue' -import EmployeeManagementView from './EmployeeManagementView.vue' -import OverviewView from './OverviewView.vue' -import PersonalWorkbenchView from './PersonalWorkbenchView.vue' -import PoliciesView from './PoliciesView.vue' -import ReceiptFolderView from './ReceiptFolderView.vue' -import SettingsView from './SettingsView.vue' -import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue' -import TravelRequestDetailView from './TravelRequestDetailView.vue' import { useAppShell } from '../composables/useAppShell.js' import { useSystemState } from '../composables/useSystemState.js' import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js' import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js' import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js' +import { + defineAsyncModalView, + defineAsyncRouteView, + preloadAppView, + scheduleRelatedAppViewPreload +} from './scripts/appShellAsyncViews.js' const employeeSummary = ref(null) const knowledgeSummary = ref(null) @@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0) const aiSidebarCommand = ref({ seq: 0, type: '', payload: null }) const aiActiveConversationId = ref('') const aiConversationHistory = ref([]) +const AuditView = defineAsyncRouteView('audit') +const BudgetCenterView = defineAsyncRouteView('budget') +const DigitalEmployeesView = defineAsyncRouteView('digitalEmployees') +const DocumentsCenterView = defineAsyncRouteView('documents') +const EmployeeManagementView = defineAsyncRouteView('employees') +const OverviewView = defineAsyncRouteView('overview') +const PersonalWorkbenchView = defineAsyncRouteView('workbench') +const PoliciesView = defineAsyncRouteView('policies') +const ReceiptFolderView = defineAsyncRouteView('receiptFolder') +const SettingsView = defineAsyncRouteView('settings') +const TravelReimbursementCreateView = defineAsyncModalView('travelCreate') +const TravelRequestDetailView = defineAsyncRouteView('travelDetail') function toggleSidebarCollapsed() { sidebarCollapsed.value = !sidebarCollapsed.value @@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) { mobileSidebarOpen.value = false } +function prefetchAppView(viewId) { + void preloadAppView(viewId).catch(() => {}) +} + function toggleWorkbenchMode() { const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai' if (nextMode === 'ai') { @@ -580,4 +592,12 @@ watch( }, { immediate: true } ) + +watch( + () => activeView.value, + (view) => { + scheduleRelatedAppViewPreload(view) + }, + { immediate: true } +) diff --git a/web/src/views/scripts/appShellAsyncViews.js b/web/src/views/scripts/appShellAsyncViews.js new file mode 100644 index 0000000..97fdef5 --- /dev/null +++ b/web/src/views/scripts/appShellAsyncViews.js @@ -0,0 +1,86 @@ +import { defineAsyncComponent } from 'vue' + +import AppModalLoadingState from '../../components/shared/AppModalLoadingState.vue' +import AppViewLoadingState from '../../components/shared/AppViewLoadingState.vue' + +const appViewLoaders = { + audit: () => import('../AuditView.vue'), + budget: () => import('../BudgetCenterView.vue'), + digitalEmployees: () => import('../DigitalEmployeesView.vue'), + documents: () => import('../DocumentsCenterView.vue'), + employees: () => import('../EmployeeManagementView.vue'), + overview: () => import('../OverviewView.vue'), + policies: () => import('../PoliciesView.vue'), + receiptFolder: () => import('../ReceiptFolderView.vue'), + settings: () => import('../SettingsView.vue'), + travelCreate: () => import('../TravelReimbursementCreateView.vue'), + travelDetail: () => import('../TravelRequestDetailView.vue'), + workbench: () => import('../PersonalWorkbenchView.vue') +} + +const appViewPreloadCache = new Map() + +const relatedPreloadViews = { + audit: ['documents', 'policies'], + budget: ['documents', 'overview'], + digitalEmployees: ['overview', 'settings'], + documents: ['travelDetail', 'workbench'], + employees: ['settings', 'overview'], + overview: ['workbench', 'documents'], + policies: ['audit', 'documents'], + receiptFolder: ['documents', 'workbench'], + settings: ['digitalEmployees', 'employees'], + workbench: ['documents', 'receiptFolder'] +} + +export function preloadAppView(viewId) { + const normalizedViewId = String(viewId || '').trim() + const loader = appViewLoaders[normalizedViewId] + if (!loader) { + return Promise.resolve(null) + } + if (!appViewPreloadCache.has(normalizedViewId)) { + appViewPreloadCache.set( + normalizedViewId, + loader().catch((error) => { + appViewPreloadCache.delete(normalizedViewId) + throw error + }) + ) + } + return appViewPreloadCache.get(normalizedViewId) +} + +export function defineAsyncRouteView(viewId, options = {}) { + return defineAsyncComponent({ + loader: () => preloadAppView(viewId), + loadingComponent: options.loadingComponent || AppViewLoadingState, + delay: options.delay ?? 160, + timeout: options.timeout ?? 30000, + suspensible: false + }) +} + +export function defineAsyncModalView(viewId) { + return defineAsyncRouteView(viewId, { + loadingComponent: AppModalLoadingState, + delay: 80 + }) +} + +export function scheduleRelatedAppViewPreload(viewId) { + const views = relatedPreloadViews[String(viewId || '').trim()] || [] + if (!views.length || typeof window === 'undefined') { + return + } + const runPreload = () => { + for (const view of views.slice(0, 2)) { + void preloadAppView(view).catch(() => {}) + } + } + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(runPreload, { timeout: 1600 }) + return + } + window.setTimeout(runPreload, 360) +} diff --git a/web/src/views/scripts/travelReimbursementCreateReviewModel.js b/web/src/views/scripts/travelReimbursementCreateReviewModel.js index c713f2f..9d6e5a0 100644 --- a/web/src/views/scripts/travelReimbursementCreateReviewModel.js +++ b/web/src/views/scripts/travelReimbursementCreateReviewModel.js @@ -1,319 +1,14 @@ -import { - DATE_INPUT_FORMAT, - buildReviewAttachmentStatus, - cloneReviewEditFields, - createEmptyInlineReviewState, - formatAmountDisplay, - formatReviewSceneDisplayValue, - normalizeReviewRiskLevel, - shouldShowReviewFactCard, - buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel, - buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel, - isTravelReviewPayload as isTravelReviewPayloadModel, - resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel -} from './travelReimbursementReviewModel.js' - -const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview' -const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents' -const REVIEW_PANEL_SCOPE_RISK = 'risk' -const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] -const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g - -const REVIEW_RISK_LEVEL_META = { - high: { - label: '高风险', - icon: 'mdi mdi-alert-octagon-outline', - suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。' - }, - medium: { - label: '中风险', - icon: 'mdi mdi-alert-circle-outline', - suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。' - }, - info: { - label: '提示', - icon: 'mdi mdi-information-outline', - suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' - }, - low: { - label: '低风险', - icon: 'mdi mdi-information-outline', - suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' - } -} - -export function normalizeReviewPanelScope(scope) { - const normalized = String(scope || '').trim() - return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized) - ? normalized - : '' -} - -export function canExposeReviewPanelScope(scope) { - return Boolean(normalizeReviewPanelScope(scope)) -} - -export function buildBusinessTimeContextFromReviewValues(values = {}) { - return buildBusinessTimeContextFromReviewValuesModel(values) -} - -export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) { - return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState) -} - -export 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') -} - -export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { - return isTravelReviewPayloadModel(reviewPayload, inlineState) -} - -export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { - return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText) -} - -export function resolveReviewRiskBriefs(reviewPayload) { - if (!Array.isArray(reviewPayload?.risk_briefs)) return [] - return reviewPayload.risk_briefs.filter((item) => { - const title = String(item?.title || '').trim() - return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword)) - }) -} - -export 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) - if (isTravelReviewPayload(reviewPayload, inlineState)) { - return [ - { - 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: 'transport_type', - label: '交通类型', - value: String(inlineState.transport_type || '').trim() || '待确认', - icon: 'mdi mdi-train-car', - editor: 'text', - modelKey: 'transport_type', - placeholder: '例如 火车/高铁、飞机' - }, - { - key: 'hotel_name', - label: '酒店名称', - value: String(inlineState.merchant_name || '').trim() || '待补充', - icon: 'mdi mdi-bed-outline', - editor: 'text', - modelKey: 'merchant_name', - placeholder: '请输入酒店名称' - }, - { - key: 'travel_purpose', - label: '出差事宜', - value: String(inlineState.reason_value || '').trim() || '待补充', - icon: 'mdi mdi-briefcase-edit-outline', - editor: 'textarea', - modelKey: 'reason_value', - placeholder: '请填写本次出差的具体工作内容或业务意图', - wide: true - } - ] - } - 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: 'attachments', - label: '票据状态', - value: attachmentStatus, - icon: 'mdi mdi-file-document-outline', - editor: 'upload', - modelKey: 'attachment_names', - placeholder: '' - } - ] - - if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) { - cards.splice(cards.length - 1, 0, { - key: 'customer_name', - label: '关联客户', - value: String(inlineState.customer_name || '').trim() || '待补充', - icon: 'mdi mdi-domain', - editor: 'text', - modelKey: 'customer_name', - placeholder: '请输入客户名称' - }) - } - - if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { - cards.splice(cards.length - 1, 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 normalizeReviewRiskTitle(title, fallbackTitle) { - const normalized = String(title || '').trim() - const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示' - if (!normalized) return fallback - const cleaned = normalized - .replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示') - .replace(/(高风险|中风险|低风险)/g, '') - .replace(/^[::\-—\s]+|[::\-—\s]+$/g, '') - .trim() - return cleaned || fallback -} - -export function buildReviewRiskItems(reviewPayload) { - return resolveReviewRiskBriefs(reviewPayload) - .map((brief, index) => { - const title = String(brief?.title || '').trim() - const content = String(brief?.content || '').trim() - const detail = String(brief?.detail || '').trim() - const suggestion = String(brief?.suggestion || '').trim() - const level = normalizeReviewRiskLevel(brief?.level) - const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low - const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示' - const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle) - const summary = content || normalizedTitle - - if (!normalizedTitle && !summary) return null - - return { - key: `${level}-${normalizedTitle}-${index}`, - title: normalizedTitle, - summary, - detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。', - level, - levelLabel: meta.label, - icon: meta.icon, - sourceLabel: meta.label, - suggestion: suggestion || meta.suggestion - } - }) - .filter(Boolean) -} - -export function buildReviewRiskConversationText(item, detailTarget = {}) { - const title = String(item?.title || '风险提示').trim() - const summary = String(item?.summary || '').trim() - const detail = String(item?.detail || '').trim() - const suggestion = String(item?.suggestion || '').trim() - const isInfo = String(item?.level || '').trim() === 'info' - const detailHref = String(detailTarget?.href || '').trim() - const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写' - const lines = [`${title}`] - - if (summary) { - lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`) - } - if (detail && detail !== summary) { - lines.push('', `规则依据:${detail}`) - } - if (suggestion) { - lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`) - } - if (detailHref) { - lines.push('', `[${detailLabel}](${detailHref})`) - } - return lines.join('\n') -} - -export function buildReviewMainMessageText(message) { - const text = String(message?.text || '') - if (!message?.reviewPayload) { - return text - } - return text - .replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() -} +export { + buildBusinessTimeContextFromReviewValues, + buildReviewCorrectionMessage, + buildReviewFactCards, + buildReviewFormContextFromPayload, + buildReviewMainMessageText, + buildReviewRiskConversationText, + buildReviewRiskItems, + canExposeReviewPanelScope, + isTravelReviewPayload, + normalizeReviewPanelScope, + resolveReviewRiskBriefs, + resolveReviewTravelTransportType +} from './travelReimbursementReviewPanelModel.js' diff --git a/web/tests/ai-sidebar-rail-mode.test.mjs b/web/tests/ai-sidebar-rail-mode.test.mjs index 5e83c01..6b1a771 100644 --- a/web/tests/ai-sidebar-rail-mode.test.mjs +++ b/web/tests/ai-sidebar-rail-mode.test.mjs @@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history assert.match(aiSidebar, /class="ai-nav-list"/) assert.match(aiSidebar, /v-for="item in businessNavItems"/) assert.match(aiSidebar, /ai-nav-copy/) - assert.match(aiSidebar, /item\.aiIcon/) + assert.match(aiSidebar, /item\.aiIconPaths/) assert.match(aiSidebar, /aria-current/) assert.doesNotMatch(aiSidebar, /displayHint/) assert.doesNotMatch(aiSidebar, /个人工作台/) @@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history assert.match(aiSidebar, /displayUser\.subtitle/) assert.match(aiSidebar, /aria-label="用户操作"/) assert.match(aiSidebar, /emit\('logout'\)/) - assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/) + assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/) assert.doesNotMatch(aiSidebar, /search-chat/) assert.doesNotMatch(aiSidebar, /打开系统设置/) assert.doesNotMatch(aiSidebar, /mdi-chevron-up/) @@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', () assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/) assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/) assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/) - assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/) + assert.match(aiSidebar, /const tablerIconPaths = \{/) + assert.match(aiSidebar, /plus:\s*\[/) + assert.match(aiSidebar, /search:\s*\[/) + assert.match(aiSidebar, /fileText:\s*\[/) + assert.match(aiSidebar, /book2:\s*\[/) + assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/) + assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/) + assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/) + assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/) + assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/) assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/) assert.match(quickButtonBlock, /min-height:\s*48px;/) - assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/) + assert.match(quickButtonBlock, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/) + assert.match(quickButtonBlock, /gap:\s*12px;/) + assert.match(quickButtonBlock, /padding:\s*7px 10px;/) assert.match(quickButtonBlock, /background:\s*transparent;/) assert.match(quickButtonBlock, /border-color:\s*transparent;/) assert.match(quickButtonBlock, /box-shadow:\s*none;/) @@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', () assert.doesNotMatch(navListBlock, /box-shadow:/) assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/) assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/) - assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/) + assert.match(aiSidebarStyles, /\.ai-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/) + assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/) + assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/) + assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/) + assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/) + assert.doesNotMatch(aiSidebarStyles, /linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/) assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/) assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/) assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/) diff --git a/web/tests/app-shell-route-loading.test.mjs b/web/tests/app-shell-route-loading.test.mjs index 8655dfd..497f1db 100644 --- a/web/tests/app-shell-route-loading.test.mjs +++ b/web/tests/app-shell-route-loading.test.mjs @@ -8,23 +8,65 @@ const shell = readFileSync( 'utf8' ) const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8') +const sidebarRail = readFileSync( + fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)), + 'utf8' +) +const aiSidebarRail = readFileSync( + fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)), + 'utf8' +) -test('app shell main route views are eagerly imported', () => { - assert.doesNotMatch(shell, /defineAsyncRouteView/) - assert.doesNotMatch(shell, /defineAsyncComponent/) - assert.doesNotMatch(shell, /loadingComponent:/) - assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/) - assert.doesNotMatch(shell, /floating:\s*true/) - assert.doesNotMatch(shell, /blocking:\s*true/) - assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/) - assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/) - assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/) - assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/) - assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/) +test('app shell lazily loads heavy business views with an in-workarea loading state', () => { + assert.match(shell, /defineAsyncRouteView\('audit'\)/) + assert.match(shell, /defineAsyncRouteView\('documents'\)/) + assert.match(shell, /defineAsyncRouteView\('workbench'\)/) + assert.match(shell, /defineAsyncModalView\('travelCreate'\)/) + assert.match(shell, /function prefetchAppView\(viewId\)/) + assert.match(shell, /@prefetch-view="prefetchAppView"/) + assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/) + assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/) + assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/) + assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/) + assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/) }) -test('top-level app routes are eagerly imported', () => { - assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/) +test('app view preloading is triggered from both standard and AI sidebars', () => { + assert.match(sidebarRail, /'prefetch-view'/) + assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/) + assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/) + assert.match(aiSidebarRail, /'prefetch-view'/) + assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/) + assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/) +}) + +test('async app view loader keeps transitions nonblocking and visible', () => { + const asyncViews = readFileSync( + fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)), + 'utf8' + ) + const loadingState = readFileSync( + fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)), + 'utf8' + ) + const modalLoadingState = readFileSync( + fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)), + 'utf8' + ) + + assert.match(asyncViews, /defineAsyncComponent/) + assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/) + assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/) + assert.match(asyncViews, /suspensible:\s*false/) + assert.match(asyncViews, /requestIdleCallback/) + assert.match(loadingState, /正在加载页面内容/) + assert.match(loadingState, /app-view-loading-skeleton/) + assert.match(modalLoadingState, /Teleport to="body"/) + assert.match(modalLoadingState, /正在打开智能工作台/) +}) + +test('top-level shell routes stay eager so the layout does not blank during navigation', () => { + assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/) assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/) assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/) assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/) diff --git a/web/tests/conversation-trusted-html.test.mjs b/web/tests/conversation-trusted-html.test.mjs new file mode 100644 index 0000000..6bb4b87 --- /dev/null +++ b/web/tests/conversation-trusted-html.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + extractTrustedHtmlBlocks, + normalizeConversationText, + restoreTrustedHtmlBlocks +} from '../src/utils/conversationTrustedHtml.js' + +test('conversation trusted html helper preserves valid document cards', () => { + const trustedBlock = [ + '', + '
', + '
', + '差旅申请', + '查看', + '
', + '
', + '' + ].join('') + + const extracted = extractTrustedHtmlBlocks(`结果如下:\n\n${trustedBlock}`) + assert.equal(extracted.trustedHtmlBlocks.length, 1) + assert.match(extracted.content, /AI_TRUSTED_HTML_BLOCK_0/) + + const restored = restoreTrustedHtmlBlocks( + '

AI_TRUSTED_HTML_BLOCK_0

\n', + extracted.trustedHtmlBlocks + ) + assert.match(restored, /class="ai-document-card-list"/) + assert.match(restored, /href="#ai-open-document-detail:CL-1"/) + assert.doesNotMatch(restored, /AI_TRUSTED_HTML_BLOCK_0/) +}) + +test('conversation trusted html helper rejects unsafe trusted blocks', () => { + const extracted = extractTrustedHtmlBlocks([ + '', + '
', + '危险', + '
', + '' + ].join('')) + + assert.equal(extracted.trustedHtmlBlocks.length, 0) + assert.equal(extracted.content.trim(), '') +}) + +test('conversation trusted html helper normalizes business copy outside fences', () => { + const normalized = normalizeConversationText([ + '基础信息识别结果:请核对', + '时间:2026-02-20', + '', + '```', + '金额:不要改代码块', + '```' + ].join('\n'), { trim: true }) + + assert.match(normalized, /### 基础信息识别结果/) + assert.match(normalized, /- \*\*时间\*\*:2026-02-20/) + assert.match(normalized, /```\n金额:不要改代码块\n```/) +}) diff --git a/web/tests/document-protocol-constants.test.mjs b/web/tests/document-protocol-constants.test.mjs new file mode 100644 index 0000000..d1eecb2 --- /dev/null +++ b/web/tests/document-protocol-constants.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + DOCUMENT_TYPE_APPLICATION, + DOCUMENT_TYPE_LABELS, + DOCUMENT_TYPE_REIMBURSEMENT, + INLINE_APPLICATION_STATUS_LABELS, + resolveDocumentTypeLabel +} from '../src/constants/documentProtocol.js' +import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs' + +test('document protocol constants centralize document types and labels', () => { + assert.equal(DOCUMENT_TYPE_APPLICATION, 'application') + assert.equal(DOCUMENT_TYPE_REIMBURSEMENT, 'reimbursement') + assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_APPLICATION], '申请单') + assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_REIMBURSEMENT], '报销单') + assert.equal(resolveDocumentTypeLabel('application'), '申请单') + assert.equal(resolveDocumentTypeLabel('expense_application'), '申请单') + assert.equal(resolveDocumentTypeLabel('unknown', '单据'), '单据') +}) + +test('inline application status labels live in the shared document protocol', () => { + assert.equal(INLINE_APPLICATION_STATUS_LABELS.draft, '草稿') + assert.equal(INLINE_APPLICATION_STATUS_LABELS.submitted, '审批中') + assert.equal(INLINE_APPLICATION_STATUS_LABELS.pending_payment, '待付款') +}) + +test('source surface helper loads one or more source files for source assertions', () => { + const model = readSourceFile('constants/documentProtocol.js') + assert.match(model, /DOCUMENT_TYPE_APPLICATION/) + + const combined = readSourceSurface([ + 'constants/documentProtocol.js', + 'composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' + ]) + assert.match(combined, /INLINE_APPLICATION_STATUS_LABELS/) + assert.match(combined, /normalizeInlineApplicationStatusLabel/) +}) diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs index bf773f7..28c30d4 100644 --- a/web/tests/documents-center-status-filter.test.mjs +++ b/web/tests/documents-center-status-filter.test.mjs @@ -1,38 +1,19 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import test from 'node:test' -import { fileURLToPath } from 'node:url' -const documentsCenterView = readFileSync( - fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)), - 'utf8' -) -const documentsCenterViewModel = readFileSync( - fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)), - 'utf8' -) -const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}` +import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs' -const documentsCenterStyles = readFileSync( - fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)), - 'utf8' -) -const documentListSharedStyles = readFileSync( - fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)), - 'utf8' -) -const tableLoadingState = readFileSync( - fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)), - 'utf8' -) -const reimbursementService = readFileSync( - fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), - 'utf8' -) -const requestsComposable = readFileSync( - fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)), - 'utf8' -) +const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue') +const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js') +const documentsCenterLogic = readSourceSurface([ + 'views/DocumentsCenterView.vue', + 'utils/documentCenterViewModel.js' +]) +const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css') +const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css') +const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue') +const reimbursementService = readSourceFile('services/reimbursements.js') +const requestsComposable = readSourceFile('composables/useRequests.js') test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => { assert.match(documentsCenterView, /