diff --git a/.env b/.env index 77623e2..d7a68bc 100644 --- a/.env +++ b/.env @@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=true ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082 ONLYOFFICE_BACKEND_URL=http://main:8000 ONLYOFFICE_JWT_SECRET=change-me-onlyoffice +HERMES_AGENT_SHARED_TOKEN=change-me-hermes POSTGRES_HOST=10.10.10.189 POSTGRES_PORT=5432 diff --git a/.env.example b/.env.example index beb0a1c..a2bb8ac 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=false ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082 ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000 ONLYOFFICE_JWT_SECRET=change-me-onlyoffice +HERMES_AGENT_SHARED_TOKEN=change-me-hermes POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 diff --git a/docker-compose.yml b/docker-compose.yml index 5ca16d8..e89dd20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -services: +services: main: image: x-financial-dev:latest container_name: x-financial-main @@ -6,6 +6,8 @@ services: depends_on: onlyoffice: condition: service_started + qdrant: + condition: service_started environment: WEB_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0 @@ -48,6 +50,24 @@ services: networks: - financial-internal + qdrant: + image: qdrant/qdrant:latest + container_name: x-financial-qdrant + restart: unless-stopped + ports: + - "${QDRANT_HTTP_PORT:-6333}:6333" + - "${QDRANT_GRPC_PORT:-6334}:6334" + volumes: + - qdrant-storage:/qdrant/storage + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6333/healthz >/dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - financial-internal + onlyoffice: image: onlyoffice/documentserver:latest container_name: x-financial-onlyoffice @@ -69,3 +89,6 @@ services: networks: financial-internal: name: financial-internal + +volumes: + qdrant-storage: diff --git a/hermes/skills/domain/x-financial-callback/SKILL.md b/hermes/skills/domain/x-financial-callback/SKILL.md new file mode 100644 index 0000000..5b8960a --- /dev/null +++ b/hermes/skills/domain/x-financial-callback/SKILL.md @@ -0,0 +1,61 @@ +--- +name: x-financial-callback +description: Use when a Hermes task for X-Financial must report progress or completion back to the backend through the single generic callback endpoint. +--- + +# X-Financial Callback + +Use this skill for every X-Financial task that must notify the backend after Hermes finishes work. + +## Callback contract + +Send exactly one HTTP `POST` request to the callback URL provided in the task payload. + +Use: + +- Header: `Authorization: Bearer ` +- Header: `Content-Type: application/json` +- Body: + +```json +{ + "type": "task_type_from_input", + "run_id": "agent_run_id_from_input", + "status": "succeeded", + "summary": "short human-readable summary", + "payload": {} +} +``` + +## Rules + +- Always preserve the incoming `type` and `run_id`. +- Execute the callback directly for server-dispatched tasks; do not ask the user for a second confirmation. +- Use `scripts/send_callback.py` for the actual HTTP request so large JSON bodies, quotes, and multilingual text are encoded safely. +- For normal tasks, prefer letting the script build the generic envelope for you via + `--type`, `--run-id`, `--status`, and `--summary`; then the JSON file on stdin should contain only the + task-specific business payload. +- Prefer sending a validated JSON file for non-trivial payloads: + `python3 ~/.hermes/skills/domain/x-financial-callback/scripts/send_callback.py --url "$CALLBACK_URL" --token "$CALLBACK_TOKEN" < /tmp/x-financial-callback.json` +- Before sending a large payload, validate it with `python3 -m json.tool /tmp/x-financial-callback.json >/dev/null`. +- For large multilingual payloads, write `/tmp/x-financial-callback.json` with the `write_file` tool first. + Do not generate helper Python source files or shell heredocs merely to build JSON. +- Use `status: "running"` only for optional progress updates. +- Use `status: "succeeded"` once when the task is complete. +- Use `status: "failed"` once when the task cannot be completed, and include an `error` string. +- Put task-specific business data only inside `payload`. +- Do not invent extra callback endpoints. X-Financial accepts Hermes callbacks through one shared endpoint only. +- If the callback fails, retry the same request up to 3 times before returning failure. + +## Preferred command + +```bash +python3 ~/.hermes/skills/domain/x-financial-callback/scripts/send_callback.py \ + --url "$CALLBACK_URL" \ + --token "$CALLBACK_TOKEN" \ + --type "$TASK_TYPE" \ + --run-id "$RUN_ID" \ + --status succeeded \ + --summary "short human-readable summary" \ + < /tmp/x-financial-callback.json +``` diff --git a/hermes/skills/domain/x-financial-callback/scripts/send_callback.py b/hermes/skills/domain/x-financial-callback/scripts/send_callback.py new file mode 100644 index 0000000..a1ddda0 --- /dev/null +++ b/hermes/skills/domain/x-financial-callback/scripts/send_callback.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Send one generic X-Financial Hermes callback.") + parser.add_argument("--url", required=True) + parser.add_argument("--token", required=True) + parser.add_argument("--retries", type=int, default=3) + parser.add_argument("--type", dest="task_type", default="") + parser.add_argument("--run-id", default="") + parser.add_argument("--status", choices=("running", "succeeded", "failed"), default="") + parser.add_argument("--summary", default="") + parser.add_argument("--error", default="") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + payload = json.load(sys.stdin) + if args.task_type and args.run_id and args.status: + payload = { + "type": args.task_type, + "run_id": args.run_id, + "status": args.status, + "summary": args.summary, + "payload": payload, + **({"error": args.error} if args.error else {}), + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + last_error = "" + + for attempt in range(1, max(1, args.retries) + 1): + request = Request( + args.url, + data=body, + headers={ + "Authorization": f"Bearer {args.token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=30) as response: + response_body = response.read().decode("utf-8") + if 200 <= response.status < 300: + print(response_body) + return 0 + last_error = f"HTTP {response.status}: {response_body}" + except HTTPError as exc: + last_error = f"HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}" + except URLError as exc: + last_error = str(exc.reason) + + if attempt < max(1, args.retries): + time.sleep(min(attempt, 3)) + + print(last_error or "callback failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hermes/skills/domain/x-financial-llm-wiki-ingest/SKILL.md b/hermes/skills/domain/x-financial-llm-wiki-ingest/SKILL.md new file mode 100644 index 0000000..43cae17 --- /dev/null +++ b/hermes/skills/domain/x-financial-llm-wiki-ingest/SKILL.md @@ -0,0 +1,134 @@ +--- +name: x-financial-llm-wiki-ingest +description: "Use for X-Financial制度文档归纳任务。Read the full source documents provided by the service, use the llm-wiki workflow for synthesis, and actively callback the X-Financial backend with one structured batch result." +--- + +# X-Financial LLM Wiki Ingest + +Use this skill together with the built-in `llm-wiki` skill for X-Financial制度文档归纳任务。 + +## Workflow + +1. Treat each provided `absolute_path` as the authoritative whole source document. +2. Read the original files directly. Do **not** ask the caller to pre-split them into chunks. +3. If a source is too large for one `read_file` call, do **not** retry the same full read. Continue with + line-ranged reads using `offset` and `limit` until the whole source has been covered. This is still + whole-document processing; it is only an internal reading strategy for the Hermes tool safety limit. +4. Use the built-in `llm-wiki` workflow to synthesize the documents as one batch. +5. Use the `x-financial-callback` skill to POST the completed result back to the callback URL from the task payload. +6. After the callback succeeds, return only a short acknowledgement to the caller. + +## Large-file handling + +- A `read_file` response such as “exceeds the safety limit” means the document is large, not unreadable. +- Re-read it in bounded windows, for example 300-500 lines per call, until every line range has been examined. +- Keep one running synthesis for the whole document; do not emit one callback per window and do not treat each + window as an independent document. +- For monetary tables and approval matrices, make sure you read enough surrounding content to understand the decision + dimensions before summarizing them. Do not infer missing rows from one partial visible fragment. +- If a PDF table is noisy or flattened, prioritize producing a stable wiki description of: + `who/what it applies to`, `which dimensions affect the answer`, `what values exist`, and `which exceptions apply`. +- If a table contains both domestic and overseas columns, reflect those dimensions clearly in the wiki summary. + The server may reconstruct the final display table from your wiki wording, so the wording must keep the axes clear. + +## Callback Payload Contract + +Send this object inside the generic callback body's `payload` field: + +```json +{ + "ok": true, + "summary": "本次批量归纳的简要结果", + "documents": [ + { + "document_id": "原样返回输入中的 document_id", + "knowledge_summary_markdown": "# 知识总结\n\n...", + "knowledge_candidates": [ + { + "title": "知识点标题", + "content": "可直接作为 wiki 页面片段被问答系统引用的制度知识", + "scenario": "reimbursement_policy", + "tags": ["报销", "审批"], + "evidence": ["来自原文的短证据"], + "confidence": 0.0, + "source_chunk_ids": [] + } + ], + "rule_candidates": [ + { + "template_key": "general_policy_v1", + "suggested_rule_name": "规则草稿名称", + "summary": "规则草稿摘要", + "scenario": "reimbursement_policy", + "purpose": "规则目标", + "scope": "适用范围", + "inputs": ["输入字段"], + "judgement_logic": ["判断逻辑"], + "outputs": ["输出动作"], + "admin_note": "管理员审核备注", + "runtime_rule": {}, + "evidence": ["来自原文的短证据"], + "confidence": 0.0, + "source_chunk_ids": [] + } + ] + } + ] +} +``` + +## Rules + +- Preserve every input `document_id` in the callback payload. +- The downstream knowledge assistant is allowed to answer only from the compiled wiki output. Treat every + `knowledge_candidate.content` as a reusable wiki section, not as a loose abstract. +- Prefer fewer, self-contained, reviewable wiki sections over many weak summaries. +- Each wiki section must preserve enough context for a later reader to answer questions without reopening the + raw source document: who it applies to, when it applies, what the rule is, exceptions, thresholds, and required + conditions when those facts exist in the source. +- Preserve the original decision dimensions from the source document. If a policy depends on both `职级` and `地区`, + or any other multi-axis table, keep all axes in `content` instead of collapsing them into a single generic summary. +- If a document contains a travel policy, the wiki output must cover the three core dimensions separately when the + source contains them: `交通费标准`、`住宿费标准`、`出差补贴标准`. Do not return only one or two of them. +- If the source contains a table whose rows/columns affect the answer, prefer a Markdown table when you can produce one + confidently. If the PDF extraction is noisy, a structured wiki description is acceptable, but it must keep the + answer dimensions explicit enough for the server to reconstruct a table later. +- Table-backed sections do not need OCR-grade cell preservation. What matters is that the wiki wording keeps: + - the decision axes explicit; + - the row groups explicit; + - the applicable values explicit; + - the exceptions and approval rules explicit. +- For monetary standards, do **not** use slash shorthand such as `700/450/400`, `600/400/350`, or similar compressed + sequences. Write the explicit row/column table so every amount remains bound to its original dimension. +- Do **not** paraphrase a multi-axis source table into prose if that would force the downstream QA model to guess + which number belongs to which row or column. +- Do not replace precise source distinctions with a generic "highest" or "default" amount if the source provides + multiple applicable rows. +- If the source does not contain enough information to answer a likely question safely, preserve that limitation in + `content` instead of silently filling the gap. +- Only emit rules that are supported by the source files. +- `template_key` must be one of: + - `travel_standard_v1` + - `expense_amount_limit_v1` + - `attachment_requirement_v1` + - `general_policy_v1` +- If a document has no reliable rule candidate, return an empty `rule_candidates` list. +- Keep `evidence` short and directly grounded in the original source. +- Never invent missing numeric thresholds or unsupported制度要求. +- If the batch cannot be processed, callback with `status: "failed"` and an `error` string instead of partial prose. + +## Safe callback construction + +- Do **not** hand-write a Python source file that embeds long Chinese prose inside quoted string literals. +- Do **not** use `execute_code`, inline Python, or shell heredocs to assemble the callback payload. +- Use the `write_file` tool to write the finished payload directly as plain UTF-8 JSON to + `/tmp/x-financial-callback.json`. +- Build the callback as plain JSON, save it to a UTF-8 `.json` file, validate it with + `python3 -m json.tool `, then send that file through the callback skill. +- When prose contains Chinese quotation marks, tables, or long paragraphs, keep them as JSON string values in the + JSON file itself; do not interpolate them into shell commands or Python source code. +- Prefer one validated payload file for the whole batch over piecemeal shell heredocs. +- After `python3 -m json.tool` passes, do one final content review of all table-backed candidates before sending. +- If the callback endpoint returns HTTP 400, read the response body, repair **only** the rejected candidates, validate + the JSON again, and resend. Do not treat the first 400 response as terminal failure when the server has provided + actionable correction feedback. diff --git a/server/src/app/api/v1/endpoints/hermes.py b/server/src/app/api/v1/endpoints/hermes.py new file mode 100644 index 0000000..d865f2b --- /dev/null +++ b/server/src/app/api/v1/endpoints/hermes.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.api.v1.endpoints.settings import require_hermes_agent_token +from app.schemas.common import ErrorResponse +from app.schemas.hermes import HermesCallbackRead, HermesCallbackWrite +from app.services.hermes_callbacks import HermesCallbackService + +router = APIRouter(prefix="/hermes") +DbSession = Annotated[Session, Depends(get_db)] + + +@router.post( + "/callback", + response_model=HermesCallbackRead, + dependencies=[Depends(require_hermes_agent_token)], + summary="接收 Hermes 通用回调", + description="所有 Hermes 任务统一通过该入口回传进度或完成结果,服务端依据 type 分发到对应业务处理器。", + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Hermes 回调载荷不合法或任务类型暂不支持。", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "回调引用的 AgentRun 不存在。", + }, + }, +) +def handle_hermes_callback( + payload: HermesCallbackWrite, + db: DbSession, +) -> HermesCallbackRead: + try: + return HermesCallbackService(db).handle_callback(payload) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc diff --git a/server/src/app/api/v1/endpoints/knowledge.py b/server/src/app/api/v1/endpoints/knowledge.py index 902d159..73dcf4c 100644 --- a/server/src/app/api/v1/endpoints/knowledge.py +++ b/server/src/app/api/v1/endpoints/knowledge.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Annotated from fastapi import APIRouter, Body, Depends, HTTPException, Query, status @@ -26,7 +26,11 @@ from app.schemas.knowledge import ( LlmWikiSummaryUpdateWrite, ) from app.services.agent_runs import AgentRunService -from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_SYNCING, KnowledgeService +from app.services.knowledge import ( + KNOWLEDGE_INGEST_STATUS_FAILED, + KNOWLEDGE_INGEST_STATUS_SYNCING, + KnowledgeService, +) from app.services.llm_wiki import LlmWikiService from app.services.llm_wiki_tasks import llm_wiki_task_manager @@ -169,6 +173,78 @@ def sync_llm_wiki( for item in knowledge_service.list_folder_documents(folder=payload.folder) if str(item.get("id") or "").strip() and (not requested_ids or str(item.get("id") or "").strip() in requested_ids) ] + active_run = None + for item in run_service.list_runs( + agent=AgentName.HERMES.value, + status=AgentRunStatus.RUNNING.value, + limit=100, + ): + if item.route_json.get("job_type") != "llm_wiki_sync": + continue + if item.route_json.get("folder") != payload.folder: + continue + heartbeat_raw = str(item.route_json.get("heartbeat_at") or "").strip() + heartbeat_at = None + if heartbeat_raw: + try: + heartbeat_at = datetime.fromisoformat(heartbeat_raw) + except ValueError: + heartbeat_at = None + last_seen_at = heartbeat_at or item.started_at + if last_seen_at.tzinfo is None: + last_seen_at = last_seen_at.replace(tzinfo=UTC) + if datetime.now(UTC) - last_seen_at > timedelta(minutes=30): + stale_document_ids = [ + str(document_id).strip() + for document_id in list(item.route_json.get("requested_document_ids") or []) + if str(document_id).strip() + ] + if stale_document_ids: + knowledge_service.set_document_ingest_statuses( + stale_document_ids, + status_code=KNOWLEDGE_INGEST_STATUS_FAILED, + agent_run_id=item.run_id, + ) + run_service.merge_route_json( + item.run_id, + { + "phase": "stale_failed", + "heartbeat_at": datetime.now(UTC).isoformat(), + }, + status=AgentRunStatus.FAILED.value, + result_summary="Hermes 归纳任务长时间无心跳,已自动标记为失败。", + error_message="Hermes callback heartbeat timed out.", + finished_at=datetime.now(UTC), + ) + continue + if ( + not target_document_ids + or not list(item.route_json.get("requested_document_ids") or []) + or bool( + set(target_document_ids) + & { + str(document_id).strip() + for document_id in list(item.route_json.get("requested_document_ids") or []) + if str(document_id).strip() + } + ) + ): + active_run = item + break + if active_run is not None: + return LlmWikiSyncTaskRead( + ok=True, + agent_run_id=active_run.run_id, + folder=payload.folder, + document_ids=[ + str(item).strip() + for item in list(active_run.route_json.get("requested_document_ids") or target_document_ids) + if str(item).strip() + ], + queued_at=active_run.started_at, + status=active_run.status, + summary="已有 Hermes 归纳任务正在执行,已复用当前任务而不是重复创建。", + ) task_asset = db.scalar( select(AgentAsset).where(AgentAsset.code == "task.hermes.llm_wiki_rule_formation") ) @@ -186,6 +262,8 @@ def sync_llm_wiki( "folder": payload.folder, "force": payload.force, "requested_document_ids": target_document_ids, + "requested_by_username": current_user.username, + "requested_by_name": current_user.name, "progress": { "total_documents": len(target_document_ids), "completed_documents": 0, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 391d2cb..04d1023 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -7,6 +7,7 @@ from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.health import router as health_router +from app.api.v1.endpoints.hermes import router as hermes_router from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ontology import router as ontology_router @@ -17,6 +18,7 @@ from app.api.v1.endpoints.system_logs import router as system_logs_router router = APIRouter() router.include_router(health_router, tags=["health"]) +router.include_router(hermes_router, tags=["hermes"]) router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(auth_router, tags=["auth"]) router.include_router(agent_assets_router, tags=["agent-assets"]) diff --git a/server/src/app/core/openapi.py b/server/src/app/core/openapi.py index 90c9f6e..d23b35b 100644 --- a/server/src/app/core/openapi.py +++ b/server/src/app/core/openapi.py @@ -19,6 +19,8 @@ X-Financial 后端 OpenAPI 文档。 - `X-Request-Id` - Hermes 运行时模型配置接口需要: - `Authorization: Bearer ` +- Hermes 通用回调接口同样需要: + - `Authorization: Bearer ` ## 当前模块范围 @@ -76,6 +78,10 @@ OPENAPI_TAGS = [ "name": "settings", "description": "系统设置、模型配置、模型连通性探测和 Hermes 运行时模型配置。", }, + { + "name": "hermes", + "description": "Hermes 与服务端之间的通用任务回调入口。", + }, { "name": "agent-assets", "description": "Agent 资产中心,覆盖规则、技能、MCP、任务及其版本、审核和上线流程。", diff --git a/server/src/app/main.py b/server/src/app/main.py index 6ef8c6e..33d3e1e 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -16,6 +16,7 @@ from app.services.agent_foundation import prepare_agent_foundation from app.services.employee import prepare_employee_directory from app.services.knowledge import prepare_knowledge_library from app.services.llm_wiki_tasks import llm_wiki_task_manager +from app.services.hermes_sync import sync_repository_hermes_skills @asynccontextmanager @@ -26,6 +27,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: prepare_employee_directory() prepare_agent_foundation() prepare_knowledge_library() + sync_repository_hermes_skills() logger.info( "Server ready - host=%s port=%s prefix=%s", settings.app_host, diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py index aa240f4..1518a95 100644 --- a/server/src/app/schemas/auth.py +++ b/server/src/app/schemas/auth.py @@ -12,6 +12,8 @@ class AuthUserRead(BaseModel): username: str name: str role: str + position: str = "" + grade: str = "" roleCodes: list[str] = Field(default_factory=list) email: EmailStr | str avatar: str diff --git a/server/src/app/schemas/hermes.py b/server/src/app/schemas/hermes.py new file mode 100644 index 0000000..de196cd --- /dev/null +++ b/server/src/app/schemas/hermes.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +HermesCallbackStatus = Literal["running", "succeeded", "failed"] + + +class HermesCallbackWrite(BaseModel): + type: str = Field(min_length=1, max_length=80) + run_id: str = Field(min_length=1, max_length=80) + status: HermesCallbackStatus + summary: str = "" + error: str = "" + payload: dict[str, Any] = Field(default_factory=dict) + + +class HermesCallbackRead(BaseModel): + ok: bool = True + accepted: bool = True + type: str + run_id: str + status: HermesCallbackStatus diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 9f63157..f888d84 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -31,6 +31,8 @@ class AuthenticatedUser: username: str name: str role: str + position: str + grade: str role_codes: list[str] email: str avatar: str @@ -76,6 +78,8 @@ class AuthService: username=admin_username or admin_email, name=display_name, role="管理员", + position="系统管理员", + grade="", role_codes=["manager"], email=admin_email or f"{admin_username}@local", avatar=display_name[:1].upper(), @@ -116,6 +120,8 @@ class AuthService: username=employee.email, name=employee.name, role=ROLE_LABELS.get(primary_role_code, "使用者"), + position=employee.position, + grade=employee.grade, role_codes=role_codes or ["user"], email=employee.email, avatar=(employee.name or "?")[:1].upper(), @@ -128,6 +134,8 @@ class AuthService: username=user.username, name=user.name, role=user.role, + position=user.position, + grade=user.grade, roleCodes=user.role_codes, email=user.email, avatar=user.avatar, diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index e673b8b..9830068 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -3497,7 +3497,7 @@ class ExpenseClaimService: return issues - def _is_location_required_expense_type(expense_type: str | None) -> bool: + def _is_location_required_expense_type(self, expense_type: str | None) -> bool: policy = self._get_expense_scene_policy(expense_type) if policy is None: return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES diff --git a/server/src/app/services/hermes_callbacks.py b/server/src/app/services/hermes_callbacks.py new file mode 100644 index 0000000..f09bfb4 --- /dev/null +++ b/server/src/app/services/hermes_callbacks.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.core.agent_enums import AgentRunStatus +from app.schemas.hermes import HermesCallbackRead, HermesCallbackWrite +from app.services.agent_runs import AgentRunService +from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService +from app.services.llm_wiki import LlmWikiService + + +class HermesCallbackService: + def __init__(self, db: Session) -> None: + self.db = db + self.run_service = AgentRunService(db) + + def handle_callback(self, payload: HermesCallbackWrite) -> HermesCallbackRead: + run = self.run_service.get_run(payload.run_id) + if run is None: + raise LookupError("Hermes 回调引用的 AgentRun 不存在。") + + if payload.type == "llm_wiki_sync": + self._handle_llm_wiki_sync(payload) + else: + raise ValueError(f"暂不支持的 Hermes 回调类型:{payload.type}") + + return HermesCallbackRead( + type=payload.type, + run_id=payload.run_id, + status=payload.status, + ) + + def _handle_llm_wiki_sync(self, payload: HermesCallbackWrite) -> None: + run = self.run_service.get_run(payload.run_id) + if run is None: + raise LookupError("Hermes 回调引用的 AgentRun 不存在。") + + route_json = dict(run.route_json or {}) + document_ids = [ + str(item).strip() + for item in list(route_json.get("requested_document_ids") or []) + if str(item).strip() + ] + if payload.status == "running": + self.run_service.merge_route_json( + payload.run_id, + { + "phase": "running", + "heartbeat_at": datetime.now(UTC).isoformat(), + "callback_status": payload.status, + "callback_payload": payload.payload, + }, + status=AgentRunStatus.RUNNING.value, + result_summary=payload.summary or run.result_summary, + ) + return + + if payload.status == "failed": + if document_ids: + KnowledgeService().set_document_ingest_statuses( + document_ids, + status_code=KNOWLEDGE_INGEST_STATUS_FAILED, + agent_run_id=payload.run_id, + ) + self.run_service.record_tool_call( + run_id=payload.run_id, + tool_type="http", + tool_name="hermes_callback", + request_json=payload.model_dump(mode="json"), + response_json={}, + status="failed", + duration_ms=0, + error_message=payload.error or payload.summary or "Hermes callback failed", + ) + self.run_service.merge_route_json( + payload.run_id, + { + "phase": "failed", + "heartbeat_at": datetime.now(UTC).isoformat(), + "callback_status": payload.status, + "callback_payload": payload.payload, + }, + status=AgentRunStatus.FAILED.value, + result_summary=payload.summary or payload.error or "Hermes 任务失败。", + error_message=payload.error or payload.summary or "Hermes 任务失败。", + finished_at=datetime.now(UTC), + ) + return + + result = LlmWikiService(self.db).finalize_agent_batch_callback( + agent_run_id=payload.run_id, + payload=payload.payload, + ) + self.run_service.record_tool_call( + run_id=payload.run_id, + tool_type="http", + tool_name="hermes_callback", + request_json=payload.model_dump(mode="json"), + response_json=result.model_dump(mode="json"), + status="succeeded", + duration_ms=0, + ) + self.run_service.merge_route_json( + payload.run_id, + { + "phase": "succeeded", + "heartbeat_at": datetime.now(UTC).isoformat(), + "callback_status": payload.status, + "sync_run_id": result.run_id, + "sync_result": result.model_dump(mode="json"), + "progress": { + "total_documents": len(document_ids), + "completed_documents": result.document_count, + "failed_documents": 0, + "skipped_documents": max(0, len(document_ids) - result.document_count), + "percent": 100, + }, + }, + status=AgentRunStatus.SUCCEEDED.value, + result_summary=payload.summary or result.summary, + finished_at=datetime.now(UTC), + ) diff --git a/server/src/app/services/hermes_sync.py b/server/src/app/services/hermes_sync.py index c634675..5cf8809 100644 --- a/server/src/app/services/hermes_sync.py +++ b/server/src/app/services/hermes_sync.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import tempfile from dataclasses import dataclass from pathlib import Path @@ -8,6 +9,8 @@ from typing import Any import yaml +from app.core.config import ROOT_DIR + @dataclass(frozen=True, slots=True) class HermesModelRoute: @@ -35,6 +38,26 @@ def get_hermes_config_path() -> Path: return get_hermes_home() / "config.yaml" +def sync_repository_hermes_skills( + *, + source_root: Path | None = None, + target_root: Path | None = None, +) -> Path: + source = source_root or ROOT_DIR / "hermes" / "skills" + target = target_root or get_hermes_home() / "skills" + if not source.exists(): + return target + + target.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + destination = target / item.name + if item.is_dir(): + shutil.copytree(item, destination, dirs_exist_ok=True) + elif item.is_file(): + shutil.copy2(item, destination) + return target + + def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot: target_path = config_path or get_hermes_config_path() if not target_path.exists(): diff --git a/server/src/app/services/knowledge.py b/server/src/app/services/knowledge.py index cf9bfc8..be5719f 100644 --- a/server/src/app/services/knowledge.py +++ b/server/src/app/services/knowledge.py @@ -72,6 +72,23 @@ STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"} KNOWLEDGE_INGEST_SYNC_STALE_SECONDS = 90 +KNOWLEDGE_SEARCH_RESULT_LIMIT = 3 +KNOWLEDGE_SEARCH_STOP_TERMS = { + "什么", + "怎么", + "如何", + "多少", + "是否", + "可以", + "一下", + "请问", + "帮我", + "一下子", + "这个", + "那个", + "哪些", + "一下吧", +} KNOWLEDGE_INGEST_STATUS_PUBLISHED = 1 KNOWLEDGE_INGEST_STATUS_SYNCING = 2 @@ -346,6 +363,156 @@ class KnowledgeService: self.ensure_library_ready() return self.llm_wiki_root + def search_llm_wiki(self, query: str, *, limit: int = KNOWLEDGE_SEARCH_RESULT_LIMIT) -> dict[str, Any]: + self.ensure_library_ready() + normalized_query = self._normalize_search_text(query) + if not normalized_query: + return { + "result_type": "knowledge_search", + "query": "", + "record_count": 0, + "hits": [], + "references": [], + "message": "请先输入要检索的制度或规则问题。", + } + + index = self._load_index() + if self._reconcile_document_ingest_statuses(index): + self._save_index(index) + entry_by_id = { + str(item.get("id") or "").strip(): item + for item in list(index.get("documents") or []) + if str(item.get("id") or "").strip() + } + wiki_index = self._load_llm_wiki_index() + query_terms = self._extract_search_terms(query) + hits: list[dict[str, Any]] = [] + + for wiki_document in list(wiki_index.get("documents") or []): + document_id = str(wiki_document.get("document_id") or "").strip() + if not document_id: + continue + entry = entry_by_id.get(document_id) + if entry is None or not self._has_matching_llm_wiki_artifact(entry, wiki_document): + continue + + quality_status = str(wiki_document.get("quality_status") or "").strip() + if quality_status == "failed": + continue + + document_name = str(wiki_document.get("document_name") or entry.get("original_name") or "").strip() + document_dir = self.llm_wiki_documents_root / document_id + candidates = self._load_json_file(document_dir / "knowledge_candidates.json", default=[]) + matched_in_document = False + + for index, candidate in enumerate(candidates, start=1): + if not isinstance(candidate, dict): + continue + title = str(candidate.get("title") or "").strip() + content = str(candidate.get("content") or "").strip() + tags = [str(item).strip() for item in list(candidate.get("tags") or []) if str(item).strip()] + evidence = [ + str(item).strip() for item in list(candidate.get("evidence") or []) if str(item).strip() + ] + score, matched_terms = self._score_knowledge_search_match( + query_text=normalized_query, + query_terms=query_terms, + title=title, + content=content, + tags=tags, + document_name=document_name, + evidence=evidence, + ) + if score <= 0: + continue + + matched_in_document = True + candidate_id = str(candidate.get("candidate_id") or f"candidate_{index}").strip() + hits.append( + { + "code": f"knowledge.{document_id}.{candidate_id}", + "candidate_id": candidate_id, + "title": title or document_name or "制度知识条目", + "content": content, + "excerpt": self._build_search_excerpt(content or title, query_terms), + "document_id": document_id, + "document_name": document_name, + "version": str(wiki_document.get("document_version") or "").strip() or None, + "updated_at": self._format_search_timestamp(wiki_document.get("updated_at")), + "quality_status": quality_status, + "tags": tags, + "evidence": evidence, + "score": score, + "matched_terms": matched_terms, + } + ) + + self._boost_title_family_hits(hits) + ranked_hits = sorted( + hits, + key=lambda item: ( + -int(item.get("score") or 0), + str(item.get("quality_status") or "") != "formal", + str(item.get("title") or ""), + ), + )[: max(1, limit)] + + if ranked_hits: + titles = "、".join(str(item.get("title") or "") for item in ranked_hits[:2] if str(item.get("title") or "").strip()) + return { + "result_type": "knowledge_search", + "query": str(query).strip(), + "record_count": len(ranked_hits), + "hits": ranked_hits, + "references": [str(item.get("code") or "").strip() for item in ranked_hits if str(item.get("code") or "").strip()], + "message": ( + f"已从已归纳制度知识中检索到 {len(ranked_hits)} 条相关内容。" + f"{f'优先参考:{titles}。' if titles else ''}" + ), + } + + return { + "result_type": "knowledge_search", + "query": str(query).strip(), + "record_count": 0, + "hits": [], + "references": [], + "message": ( + f"当前未在已归纳制度知识中检索到与“{str(query).strip()}”直接匹配的内容。" + "知识问答仅基于 LLM Wiki 已形成的知识条目回答;当前依据不足,不能继续扩展回答。" + ), + } + + @staticmethod + def _boost_title_family_hits(hits: list[dict[str, Any]]) -> None: + if len(hits) < 2: + return + preliminary = sorted( + hits, + key=lambda item: ( + -int(item.get("score") or 0), + str(item.get("quality_status") or "") != "formal", + str(item.get("title") or ""), + ), + ) + primary = preliminary[0] + primary_title = str(primary.get("title") or "").strip() + primary_document_id = str(primary.get("document_id") or "").strip() + if len(primary_title) < 3 or not primary_document_id: + return + + family_key = primary_title[:3] + family_hits = [ + item + for item in hits + if str(item.get("document_id") or "").strip() == primary_document_id + and str(item.get("title") or "").strip().startswith(family_key) + ] + if len(family_hits) < 2: + return + for item in family_hits: + item["score"] = int(item.get("score") or 0) + 20 + def extract_document_text(self, document_id: str) -> str: self.ensure_library_ready() entry = self.get_document_entry(document_id) @@ -830,6 +997,151 @@ class KnowledgeService: if str(item.get("document_id") or "").strip() } + @staticmethod + def _load_json_file(path: Path, *, default: Any) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return default + + @staticmethod + def _load_text_file(path: Path) -> str: + try: + return path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return "" + + @staticmethod + def _normalize_search_text(value: Any) -> str: + text = str(value or "").strip().lower() + return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text) + + @staticmethod + def _extract_search_terms(query: str) -> list[str]: + normalized = KnowledgeService._normalize_search_text(query) + if not normalized: + return [] + + terms: set[str] = set() + for part in re.findall(r"[0-9a-z]+|[\u4e00-\u9fff]+", normalized): + if len(part) <= 1: + continue + if part not in KNOWLEDGE_SEARCH_STOP_TERMS: + terms.add(part) + if not re.fullmatch(r"[\u4e00-\u9fff]+", part): + continue + upper_size = min(4, len(part)) + for size in range(2, upper_size + 1): + for index in range(0, len(part) - size + 1): + gram = part[index : index + size] + if gram in KNOWLEDGE_SEARCH_STOP_TERMS: + continue + terms.add(gram) + + return sorted(terms, key=lambda item: (-len(item), item)) + + @staticmethod + def _score_knowledge_search_match( + *, + query_text: str, + query_terms: list[str], + title: str, + content: str, + tags: list[str], + document_name: str, + evidence: list[str], + ) -> tuple[int, list[str]]: + normalized_title = KnowledgeService._normalize_search_text(title) + normalized_content = KnowledgeService._normalize_search_text(content) + normalized_tags = [KnowledgeService._normalize_search_text(item) for item in tags] + normalized_document_name = KnowledgeService._normalize_search_text(document_name) + normalized_evidence = [KnowledgeService._normalize_search_text(item) for item in evidence] + + score = 0 + matched_terms: list[str] = [] + + if query_text and query_text in normalized_title: + score += 140 + elif query_text and any(query_text in item for item in normalized_tags): + score += 120 + elif query_text and query_text in normalized_content: + score += 88 + + for phrase in [normalized_title, *normalized_tags, normalized_document_name]: + if not phrase: + continue + if phrase in query_text: + score += 24 + min(18, len(phrase) * 2) + matched_terms.append(phrase) + elif query_text and query_text in phrase: + score += 16 + + for term in query_terms: + if len(term) <= 1: + continue + term_score = 0 + if term in normalized_title: + term_score = 18 if len(term) >= 4 else 14 + elif any(term in item for item in normalized_tags): + term_score = 16 if len(term) >= 4 else 12 + elif term in normalized_content: + term_score = 10 if len(term) >= 4 else 8 + elif term in normalized_document_name or any(term in item for item in normalized_evidence): + term_score = 6 + if term_score: + score += term_score + matched_terms.append(term) + + if score <= 0: + return 0, [] + + distinct_matches = [] + for item in matched_terms: + if item and item not in distinct_matches: + distinct_matches.append(item) + score += min(24, len(distinct_matches) * 4) + return score, distinct_matches[:6] + + @staticmethod + def _build_search_excerpt(text: str, query_terms: list[str], *, max_length: int = 140) -> str: + plain_text = re.sub(r"[#*_`>\-\[\]]+", " ", str(text or "")) + plain_text = re.sub(r"\s+", " ", plain_text).strip() + if not plain_text: + return "" + + normalized_text = KnowledgeService._normalize_search_text(plain_text) + for term in query_terms: + if not term or term not in normalized_text: + continue + raw_index = plain_text.find(term) + if raw_index == -1: + continue + start = max(0, raw_index - 36) + end = min(len(plain_text), raw_index + max_length - 36) + snippet = plain_text[start:end].strip(" ,。;:") + if start > 0: + snippet = f"...{snippet}" + if end < len(plain_text): + snippet = f"{snippet}..." + return snippet + + if len(plain_text) <= max_length: + return plain_text + return f"{plain_text[: max_length - 3].rstrip()}..." + + @staticmethod + def _format_search_timestamp(value: Any) -> str | None: + raw_value = str(value or "").strip() + if not raw_value: + return None + try: + parsed = datetime.fromisoformat(raw_value) + except ValueError: + return raw_value or None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC).date().isoformat() + def _has_ingested_llm_wiki_document( self, entry: dict[str, Any], diff --git a/server/src/app/services/llm_wiki.py b/server/src/app/services/llm_wiki.py index 549307e..ca7515a 100644 --- a/server/src/app/services/llm_wiki.py +++ b/server/src/app/services/llm_wiki.py @@ -23,6 +23,7 @@ from app.core.agent_enums import ( AgentAssetType, ) from app.core.logging import get_logger +from app.core.config import get_settings from app.models.agent_asset import AgentAsset from app.schemas.agent_asset import AgentAssetCreate, AgentAssetUpdate, AgentAssetVersionCreate from app.schemas.knowledge import ( @@ -43,12 +44,15 @@ from app.services.knowledge import ( ) from app.services.runtime_chat import RuntimeChatService from app.services.system_hermes import SystemHermesService +from app.services.settings import SettingsService +from app.services.hermes_sync import sync_repository_hermes_skills logger = get_logger("app.services.llm_wiki") HERMES_CANDIDATE_MODEL_TIMEOUT_SECONDS = 10 HERMES_CANDIDATE_GROUP_SIZE = 2 HERMES_CANDIDATE_CONTENT_LIMIT = 520 +HERMES_AGENT_BATCH_TIMEOUT_SECONDS = 900 LOW_SIGNAL_DOTTED_LINE_PATTERN = re.compile(r"[..。·•]{6,}\s*[0-9]{0,3}$") PAGE_FOOTER_PATTERN = re.compile(r"^第\s*\d+\s*页\s*共\s*\d+\s*页$") POLICY_SUBSTANCE_KEYWORDS = ( @@ -106,6 +110,17 @@ class CandidateExtractionStats: quality_status: str = "failed" quality_note: str = "" + +@dataclass(slots=True) +class LlmWikiAgentBatchDispatch: + prompt: str + request_payload: dict[str, Any] + changed_document_ids: list[str] + skipped_document_ids: list[str] + process_id: int = 0 + stdout_path: str = "" + stderr_path: str = "" + RULE_TEMPLATE_CATALOG: dict[str, dict[str, str]] = { "travel_standard_v1": { "label": "差旅标准模板", @@ -406,6 +421,649 @@ class LlmWikiService: (document_dir / "knowledge_summary.md").write_text(summary_text, encoding="utf-8") return self.get_document_detail(document_id) + def build_agent_batch_dispatch( + self, + *, + folder: str, + document_ids: list[str], + force: bool, + agent_run_id: str, + ) -> LlmWikiAgentBatchDispatch: + self.knowledge_service.ensure_library_ready() + sync_repository_hermes_skills() + SettingsService(self.db).sync_hermes_runtime_model_settings() + + documents = self.knowledge_service.list_folder_documents(folder=folder) + allowed_ids = {str(item).strip() for item in document_ids if str(item).strip()} + if allowed_ids: + documents = [item for item in documents if str(item.get("id") or "").strip() in allowed_ids] + + index = self._load_wiki_index() + existing_by_id = { + str(item.get("document_id") or ""): item for item in list(index.get("documents", [])) + } + changed_entries: list[dict[str, Any]] = [] + skipped_document_ids: list[str] = [] + for entry in documents: + document_id = str(entry.get("id") or "").strip() + if not document_id: + continue + sync_reason = self._resolve_sync_reason( + entry=entry, + existing=existing_by_id.get(document_id), + force=force, + ) + if sync_reason == "unchanged_skipped": + skipped_document_ids.append(document_id) + continue + file_path, _, _ = self.knowledge_service.get_document_content(document_id) + changed_entries.append( + { + "document_id": document_id, + "document_name": str(entry["original_name"]), + "folder": str(entry["folder"]), + "absolute_path": str(file_path.resolve()), + "document_version": f"v{int(entry.get('version_number', 1))}.0", + "signature": self._build_document_signature(entry), + "sync_reason": sync_reason, + } + ) + + settings = get_settings() + callback_token = str(settings.hermes_agent_shared_token or "").strip() + if not callback_token: + raise ValueError("Hermes 回调令牌未配置,无法派发 Hermes 任务。") + callback_url = ( + f"http://127.0.0.1:{settings.app_port}{settings.api_v1_prefix}/hermes/callback" + ) + request_payload = { + "type": "llm_wiki_sync", + "run_id": agent_run_id, + "callback_url": callback_url, + "callback_token": callback_token, + "folder": folder, + "documents": changed_entries, + } + prompt = ( + "请执行一次 X-Financial 制度文档知识归集任务。\n" + "要求:\n" + "1. 这是一个批量任务,但只能作为一次 Hermes 任务整体执行;\n" + "2. 直接读取每个 absolute_path 指向的完整原文件,不要要求服务端先切块;\n" + "3. 使用 llm-wiki、x-financial-llm-wiki-ingest、x-financial-callback 三个 skill;\n" + "4. 完成后必须向 callback_url 主动 POST 一个通用回调请求;\n" + "5. 回调 body 的 type 必须保持为 llm_wiki_sync,run_id 必须保持不变;\n" + "6. 任务成功时 status=succeeded,业务结果放在 payload;失败时 status=failed 并给出 error;\n" + "7. 回调成功后,只返回一句简短确认。\n\n" + f"{json.dumps(request_payload, ensure_ascii=False, indent=2)}" + ) + return LlmWikiAgentBatchDispatch( + prompt=prompt, + request_payload=request_payload, + changed_document_ids=[item["document_id"] for item in changed_entries], + skipped_document_ids=skipped_document_ids, + ) + + def dispatch_agent_batch( + self, + *, + folder: str, + document_ids: list[str], + force: bool, + agent_run_id: str, + ) -> LlmWikiAgentBatchDispatch: + dispatch = self.build_agent_batch_dispatch( + folder=folder, + document_ids=document_ids, + force=force, + agent_run_id=agent_run_id, + ) + if not dispatch.changed_document_ids: + return dispatch + + self.knowledge_service.set_document_ingest_statuses( + dispatch.changed_document_ids, + status_code=KNOWLEDGE_INGEST_STATUS_SYNCING, + agent_run_id=agent_run_id, + ) + process_handle = self.system_hermes_service.start_query_background( + dispatch.prompt, + source="tool", + max_turns=24, + skills=("llm-wiki", "x-financial-llm-wiki-ingest", "x-financial-callback"), + log_prefix=f"llm-wiki-{agent_run_id}", + yolo=True, + ) + dispatch.process_id = process_handle.pid + dispatch.stdout_path = process_handle.stdout_path + dispatch.stderr_path = process_handle.stderr_path + return dispatch + + def finalize_agent_batch_callback( + self, + *, + agent_run_id: str, + payload: dict[str, Any], + ) -> LlmWikiSyncRead: + documents_payload = list(payload.get("documents") or []) + if not bool(payload.get("ok", True)): + raise ValueError(str(payload.get("error") or "Hermes LLM Wiki 回调失败。")) + if not documents_payload: + raise ValueError("Hermes LLM Wiki 回调未返回 documents。") + + callback_route = self._resolve_callback_route(agent_run_id) + callback_folder = str(payload.get("folder") or callback_route.get("folder") or "") + documents_by_id = { + str(item.get("id") or "").strip(): item + for item in self.knowledge_service.list_folder_documents() + if str(item.get("id") or "").strip() + } + index = self._load_wiki_index() + sync_runs = self._load_sync_runs() + existing_by_id = { + str(item.get("document_id") or ""): item for item in list(index.get("documents", [])) + } + + changed_document_count = 0 + knowledge_candidate_count = 0 + rule_candidate_count = 0 + generated_rule_asset_ids: list[str] = [] + completed_document_ids: list[str] = [] + sync_summaries: list[str] = [] + + current_user = self._resolve_callback_user(agent_run_id) + for raw_document in documents_payload: + if not isinstance(raw_document, dict): + continue + document_id = str(raw_document.get("document_id") or "").strip() + entry = documents_by_id.get(document_id) + if entry is None: + continue + document_payload = self._persist_agent_document_result( + entry=entry, + current_user=current_user, + raw_document=raw_document, + ) + existing_by_id[document_id] = document_payload["document"] + changed_document_count += 1 + completed_document_ids.append(document_id) + knowledge_candidate_count += len(document_payload["knowledge_candidates"]) + rule_candidate_count += len(document_payload["rule_candidates"]) + generated_rule_asset_ids.extend( + [ + str(item.get("generated_asset_id") or "").strip() + for item in document_payload["rule_candidates"] + if str(item.get("generated_asset_id") or "").strip() + ] + ) + sync_summaries.append( + f"{entry['original_name']}:agent_batch:知识候选 {len(document_payload['knowledge_candidates'])} 条," + f"规则候选 {len(document_payload['rule_candidates'])} 条。" + ) + + if changed_document_count <= 0: + raise ValueError("Hermes LLM Wiki 回调没有匹配到可落库的文档。") + + index["documents"] = list(existing_by_id.values()) + self._write_json_file(self.knowledge_service.llm_wiki_index_path, index) + sync_run_id = f"wiki_{uuid4().hex[:12]}" + generated_rule_ids = list(dict.fromkeys(generated_rule_asset_ids)) + summary = str(payload.get("summary") or "").strip() or ";".join(sync_summaries) + sync_runs.setdefault("runs", []) + sync_runs["runs"].append( + { + "run_id": sync_run_id, + "folder": callback_folder, + "requested_document_ids": completed_document_ids, + "changed_document_count": changed_document_count, + "knowledge_candidate_count": knowledge_candidate_count, + "rule_candidate_count": rule_candidate_count, + "generated_rule_asset_ids": generated_rule_ids, + "created_by": current_user.name, + "created_at": datetime.now(UTC).isoformat(), + "summary": sync_summaries, + "source": "hermes_callback", + "agent_run_id": agent_run_id, + } + ) + self._write_json_file(self.knowledge_service.llm_wiki_sync_runs_path, sync_runs) + self.knowledge_service.refresh_document_ingest_statuses( + document_ids=completed_document_ids, + preserve_syncing=False, + ) + return LlmWikiSyncRead( + ok=True, + run_id=sync_run_id, + folder=callback_folder, + document_count=changed_document_count, + knowledge_candidate_count=knowledge_candidate_count, + rule_candidate_count=rule_candidate_count, + generated_rule_count=len(generated_rule_ids), + generated_rule_asset_ids=generated_rule_ids, + summary=summary, + ) + + def _persist_agent_document_result( + self, + *, + entry: dict[str, Any], + current_user: CurrentUserContext, + raw_document: dict[str, Any], + ) -> dict[str, Any]: + document_id = str(entry["id"]) + document_name = str(entry["original_name"]) + document_dir = self._document_dir(document_id) + document_dir.mkdir(parents=True, exist_ok=True) + extracted_text = self.knowledge_service.extract_document_text(document_id) + text_path = document_dir / "text.md" + text_path.write_text(extracted_text, encoding="utf-8") + + source_chunk = { + "chunk_id": f"{document_id}-document", + "title": document_name, + "content": extracted_text, + "source_page": None, + "word_count": len(re.sub(r"\s+", "", extracted_text)), + "tags": self._normalize_tags([], fallback_text=extracted_text), + } + seen_knowledge_keys: set[str] = set() + seen_rule_keys: set[str] = set() + knowledge_candidates = self._normalize_knowledge_candidates( + raw_items=list(raw_document.get("knowledge_candidates") or []), + entry=entry, + chunk_group=[source_chunk], + seen_keys=seen_knowledge_keys, + extraction_mode="hermes", + )[:12] + rule_candidates = self._normalize_rule_candidates( + raw_items=list(raw_document.get("rule_candidates") or []), + entry=entry, + chunk_group=[source_chunk], + current_user=current_user, + seen_keys=seen_rule_keys, + )[:12] + generated_candidates: list[dict[str, Any]] = [] + for candidate in rule_candidates: + if candidate.get("validation_status") == "valid": + generated_asset = self._create_or_update_rule_draft(candidate, current_user=current_user) + if generated_asset is not None: + candidate["generated_asset_id"] = generated_asset["asset_id"] + candidate["generated_asset_code"] = generated_asset["asset_code"] + candidate["generated_version"] = generated_asset["version"] + generated_candidates.append(candidate) + + summary_markdown = str(raw_document.get("knowledge_summary_markdown") or "").strip() + if not summary_markdown: + summary_markdown = self._build_knowledge_summary_markdown( + entry=entry, + knowledge_candidates=knowledge_candidates, + ) + knowledge_candidates = self._upgrade_table_candidate_contents_from_summary( + knowledge_candidates=knowledge_candidates, + summary_markdown=summary_markdown, + ) + knowledge_candidates = self._synthesize_candidates_from_summary( + knowledge_candidates=knowledge_candidates, + summary_markdown=summary_markdown, + entry=entry, + ) + self._validate_agent_knowledge_candidate_quality( + knowledge_candidates, + summary_markdown=summary_markdown, + ) + document_record = { + "document_id": document_id, + "document_name": document_name, + "folder": str(entry["folder"]), + "document_version": f"v{int(entry.get('version_number', 1))}.0", + "checksum": str(entry.get("sha256") or ""), + "extracted_text_path": str(text_path), + "chunk_count": 1, + "candidate_chunk_count": 1, + "filtered_chunk_count": 0, + "group_count": 1, + "successful_group_count": 1, + "failed_group_count": 0, + "knowledge_candidate_count": len(knowledge_candidates), + "formal_knowledge_candidate_count": len(knowledge_candidates), + "fallback_knowledge_candidate_count": 0, + "rule_candidate_count": len(generated_candidates), + "quality_status": "formal" if knowledge_candidates else "failed", + "quality_note": ( + "Hermes 已基于完整原文件完成正式归纳。" + if knowledge_candidates + else "Hermes 回调未返回可用知识候选。" + ), + "updated_at": datetime.now(UTC).isoformat(), + "signature": self._build_document_signature(entry), + "sync_reason": str(raw_document.get("sync_reason") or "agent_batch"), + } + self._write_json_file(document_dir / "document.json", document_record) + self._write_json_file(document_dir / "chunks.json", [source_chunk]) + self._write_json_file(document_dir / "knowledge_candidates.json", knowledge_candidates) + self._write_json_file(document_dir / "rule_candidates.json", generated_candidates) + (document_dir / "knowledge_summary.md").write_text(summary_markdown, encoding="utf-8") + return { + "document": document_record, + "knowledge_candidates": knowledge_candidates, + "rule_candidates": generated_candidates, + } + + @staticmethod + def _validate_agent_knowledge_candidate_quality( + knowledge_candidates: list[dict[str, Any]], + *, + summary_markdown: str = "", + ) -> None: + invalid_titles: list[str] = [] + for candidate in knowledge_candidates: + content = str(candidate.get("content") or "").strip() + evidence = " ".join(str(item or "") for item in list(candidate.get("evidence") or [])) + title = str(candidate.get("title") or "").strip() or "未命名条目" + table_backed = "表" in evidence + if not table_backed: + continue + has_markdown_table = "|" in content + has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content)) + summary_has_candidate_table = False + if summary_markdown and "|" in summary_markdown: + match_terms = [ + term + for term in [title, *[str(item).strip() for item in list(candidate.get("tags") or [])]] + if len(term) >= 2 + ] + summary_has_candidate_table = any( + ("|" in section) and any(term in section for term in match_terms) + for section in re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown) + if section.strip() + ) + if (not has_markdown_table or has_slash_shorthand) and not summary_has_candidate_table: + invalid_titles.append(title) + if invalid_titles: + joined = "、".join(invalid_titles[:5]) + raise ValueError( + "Hermes 回调中的表格型知识条目既没有可直接复用的 Markdown 表格," + "也没有在 wiki 总结中提供可用于还原表格的结构化表述:" + f"{joined}。请补充可还原的 wiki 表述后重新回调。" + ) + LlmWikiService._validate_travel_knowledge_completeness( + knowledge_candidates=knowledge_candidates, + summary_markdown=summary_markdown, + ) + + @staticmethod + def _validate_travel_knowledge_completeness( + *, + knowledge_candidates: list[dict[str, Any]], + summary_markdown: str, + ) -> None: + joined_text = "\n".join( + [ + summary_markdown, + *[ + "\n".join( + [ + str(candidate.get("title") or ""), + str(candidate.get("content") or ""), + " ".join(str(item or "") for item in list(candidate.get("tags") or [])), + " ".join(str(item or "") for item in list(candidate.get("evidence") or [])), + ] + ) + for candidate in knowledge_candidates + if isinstance(candidate, dict) + ], + ] + ) + normalized = re.sub(r"\s+", "", joined_text) + if "差旅费" not in normalized and "出差" not in normalized: + return + + dimensions = { + "交通费": ("交通费", "交通工具", "飞机", "火车", "轮船"), + "住宿费": ("住宿费", "住宿限额", "酒店住宿", "住 宿", "住 宿费"), + "出差补贴": ("出差补贴", "餐补", "基本补助", "补贴标准"), + } + missing = [ + label + for label, aliases in dimensions.items() + if not any(alias.replace(" ", "") in normalized for alias in aliases) + ] + if missing: + raise ValueError( + "Hermes 回调中的差旅知识不完整,缺少影响总额计算的关键维度:" + f"{'、'.join(missing)}。请补充后重新回调。" + ) + + @staticmethod + def _upgrade_table_candidate_contents_from_summary( + *, + knowledge_candidates: list[dict[str, Any]], + summary_markdown: str, + ) -> list[dict[str, Any]]: + if "|" not in summary_markdown: + return knowledge_candidates + + sections = re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown) + table_sections = [section.strip() for section in sections if "|" in section and section.strip()] + if not table_sections: + return knowledge_candidates + + upgraded: list[dict[str, Any]] = [] + for candidate in knowledge_candidates: + copied = dict(candidate) + content = str(copied.get("content") or "").strip() + title = str(copied.get("title") or "").strip() + tags = [str(item).strip() for item in list(copied.get("tags") or []) if str(item).strip()] + has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content)) + evidence = " ".join(str(item or "") for item in list(copied.get("evidence") or [])) + table_backed = "表" in evidence + if not (table_backed or has_slash_shorthand) or "|" in content: + upgraded.append(copied) + continue + + match_terms = [ + term + for term in [title, *tags, *LlmWikiService._extract_table_match_terms(title, evidence)] + if len(term) >= 2 + ] + ranked_sections = sorted( + ( + ( + LlmWikiService._score_summary_table_section( + section=section, + title=title, + tags=tags, + evidence=evidence, + match_terms=match_terms, + ), + max((len(term) for term in match_terms if term in section), default=0), + section, + ) + for section in table_sections + ), + reverse=True, + ) + replacement = ranked_sections[0][2] if ranked_sections and ranked_sections[0][0] > 0 else "" + if replacement: + intro = "" + if content and "|" not in content: + prose_lines = [ + line.strip() + for line in content.splitlines() + if line.strip() and "|" not in line + ] + intro = "\n\n".join(prose_lines[:2]).strip() + copied["content"] = f"{intro}\n\n{replacement}".strip() if intro else replacement + quality_flags = list(copied.get("quality_flags") or []) + if "table_restored_from_summary" not in quality_flags: + quality_flags.append("table_restored_from_summary") + copied["quality_flags"] = quality_flags + upgraded.append(copied) + return upgraded + + def _synthesize_candidates_from_summary( + self, + *, + knowledge_candidates: list[dict[str, Any]], + summary_markdown: str, + entry: dict[str, Any], + ) -> list[dict[str, Any]]: + sections = re.split(r"(?m)^(?=#{2,6}\s)", summary_markdown) + if not sections: + return knowledge_candidates + + existing_text = "\n".join( + [ + "\n".join( + [ + str(candidate.get("title") or ""), + str(candidate.get("content") or ""), + ] + ) + for candidate in knowledge_candidates + if isinstance(candidate, dict) + ] + ) + normalized_existing = re.sub(r"\s+", "", existing_text) + synthesized = list(knowledge_candidates) + + for section in sections: + stripped = section.strip() + if not stripped.startswith("## "): + continue + lines = [line.rstrip() for line in stripped.splitlines() if line.strip()] + if len(lines) < 2: + continue + heading = lines[0].lstrip("#").strip() + body = "\n".join(lines[1:]).strip() + if len(body) < 40: + continue + if not any(keyword in heading for keyword in ("差旅费", "住宿", "补贴", "审批权限", "归口管理")): + continue + + compact_heading = re.sub(r"\s+", "", heading) + if compact_heading and compact_heading in normalized_existing: + continue + + if "住宿" in heading and "住宿" in normalized_existing: + continue + if "补贴" in heading and "补贴" in normalized_existing: + continue + if "交通工具" in heading and "交通工具" in normalized_existing: + continue + + synthesized.append( + { + "candidate_id": f"kc_{uuid4().hex[:12]}", + "title": heading, + "content": body, + "domain": "expense", + "scenario": self._infer_summary_candidate_scenario(heading), + "tags": self._normalize_tags([heading], fallback_text=body), + "source_document_id": str(entry["id"]), + "source_document_name": str(entry["original_name"]), + "source_chunk_ids": [f"{entry['id']}-document"], + "evidence": [f"wiki summary section: {heading}"], + "confidence": 0.9, + "status": "draft", + "created_by": "hermes", + "created_at": datetime.now(UTC).isoformat(), + "extraction_mode": "hermes", + "quality_flags": ["summary_synthesized_candidate"], + "fallback_reason": "", + } + ) + normalized_existing += compact_heading + re.sub(r"\s+", "", body) + + return synthesized[:12] + + @staticmethod + def _infer_summary_candidate_scenario(heading: str) -> str: + compact_heading = re.sub(r"\s+", "", heading) + if "差旅费" in compact_heading or "住宿" in compact_heading or "补贴" in compact_heading: + return "travel_standard" + if "审批权限" in compact_heading: + return "expense_amount_limit" + return "general_policy" + + @staticmethod + def _extract_table_match_terms(title: str, evidence: str) -> list[str]: + seed_text = f"{title} {evidence}" + terms = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,}", seed_text) + stop_terms = { + "单位", + "标准", + "摘要", + "规定", + "说明", + "国内", + "国外", + "员工", + "表1", + "表2", + "表3", + } + unique_terms: list[str] = [] + for term in terms: + if term in stop_terms: + continue + if term not in unique_terms: + unique_terms.append(term) + return unique_terms[:8] + + @staticmethod + def _score_summary_table_section( + *, + section: str, + title: str, + tags: list[str], + evidence: str, + match_terms: list[str], + ) -> int: + section_heading = "" + for line in section.splitlines(): + stripped = line.strip().lstrip("#").strip() + if stripped: + section_heading = stripped + break + + score = 0 + if title and title in section: + score += 12 + if title and title in section_heading: + score += 18 + + for term in match_terms: + if term in section: + score += 3 + if section_heading and term in section_heading: + score += 6 + + for tag in tags: + if len(tag) >= 2 and tag in section_heading: + score += 2 + + core_terms = LlmWikiService._extract_table_match_terms(title, evidence) + if core_terms and not any(term in section_heading for term in core_terms): + score -= 10 + return score + + def _resolve_callback_user(self, agent_run_id: str) -> CurrentUserContext: + route_json = self._resolve_callback_route(agent_run_id) + return CurrentUserContext( + username=str(route_json.get("requested_by_username") or "hermes"), + name=str(route_json.get("requested_by_name") or "Hermes"), + role_codes=["manager"], + is_admin=True, + ) + + def _resolve_callback_route(self, agent_run_id: str) -> dict[str, Any]: + from app.services.agent_runs import AgentRunService + + run = AgentRunService(self.db).get_run(agent_run_id) + if run is None: + return {} + return dict(run.route_json or {}) + def sync_folder( self, *, @@ -923,8 +1581,15 @@ class LlmWikiService: system_prompt = ( "你是企业财务制度知识库的 Hermes 规则形成器。" "你只能基于提供的制度条款生成结构化知识候选和规则候选,不能自由发散。" + "后续知识问答系统只能基于你形成的 wiki 知识回答,不能再回原文补猜,因此 knowledge_candidates 不是摘要," + "而是可直接复用的 wiki 片段。" "封面、目录、通知、页眉页脚、密级说明、印发信息不属于知识候选,必须忽略。" "只提炼具有执行意义、审核意义、报销约束意义的条款。" + "每条 knowledge_candidate.content 都必须尽量自洽,保留适用对象、适用条件、核心要求、例外、阈值和限制;" + "如果原文没有足够信息,也要在 content 中保留该限制,不得自行补全。" + "如果原文标准同时依赖多个维度,例如“职级 × 地区”“费用类型 × 金额区间”,必须保留全部判断维度," + "不得把二维或多维标准压扁成单一通用额度;涉及金额表时,优先保留逐档结构或等价的分行表达。" + "如果原文是表格且表格结构影响答案,content 优先使用 Markdown 表格保留原始决策结构。" "规则候选必须从允许模板中选 template_key,严禁自创模板。" "runtime_rule 必须严格遵守 runtime_rule_contracts 中对应模板的字段结构和允许值。" "如果条款不适合自动规则化,可以只返回 knowledge_candidates。" @@ -937,7 +1602,8 @@ class LlmWikiService: ) user_prompt = ( "请根据以下制度分块生成候选。" - "每组最多提炼 3 条高价值 knowledge_candidates,优先保留可直接供报销审核、附件校验、审批判断使用的知识。" + "每组最多提炼 3 条高价值 knowledge_candidates,优先形成后续可被直接检索和引用的 wiki 片段," + "而不是一句话摘要。" "只返回 JSON 对象,不要输出解释,不要调用工具,不要追加任何其他文本。\n" f"{json.dumps(facts, ensure_ascii=False, indent=2)}" ) diff --git a/server/src/app/services/llm_wiki_tasks.py b/server/src/app/services/llm_wiki_tasks.py index dae5b41..463c00a 100644 --- a/server/src/app/services/llm_wiki_tasks.py +++ b/server/src/app/services/llm_wiki_tasks.py @@ -1,7 +1,11 @@ from __future__ import annotations import threading +import time +import os +import signal from datetime import UTC, datetime +from pathlib import Path from typing import Any from app.api.deps import CurrentUserContext @@ -10,7 +14,7 @@ from app.core.logging import get_logger from app.db.session import get_session_factory from app.services.agent_runs import AgentRunService from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService -from app.services.llm_wiki import LlmWikiService +from app.services.llm_wiki import HERMES_AGENT_BATCH_TIMEOUT_SECONDS, LlmWikiService logger = get_logger("app.services.llm_wiki_tasks") @@ -95,46 +99,91 @@ class LlmWikiTaskManager: result_summary="Hermes 后台归纳任务已启动。", ) - result = LlmWikiService(db).sync_folder( + dispatch = LlmWikiService(db).dispatch_agent_batch( folder=folder, - current_user=current_user, document_ids=document_ids, force=force, agent_run_id=agent_run_id, - progress_callback=lambda payload, summary: self._write_progress( - run_service=run_service, - agent_run_id=agent_run_id, - payload=payload, - summary=summary, - ), ) + if not dispatch.changed_document_ids: + knowledge_service.refresh_document_ingest_statuses( + document_ids=document_ids, + preserve_syncing=False, + ) + run_service.record_tool_call( + run_id=agent_run_id, + tool_type="llm", + tool_name="system_hermes_llm_wiki_dispatch", + request_json=request_payload, + response_json={"changed_document_ids": [], "skipped_document_ids": dispatch.skipped_document_ids}, + status="succeeded", + duration_ms=0, + ) + run_service.merge_route_json( + agent_run_id, + { + "phase": "succeeded", + "heartbeat_at": datetime.now(UTC).isoformat(), + "progress": { + "total_documents": len(document_ids), + "completed_documents": 0, + "failed_documents": 0, + "skipped_documents": len(dispatch.skipped_document_ids), + "percent": 100, + }, + }, + status=AgentRunStatus.SUCCEEDED.value, + result_summary="本次所选文档均未变化,未重复派发 Hermes 任务。", + finished_at=datetime.now(UTC), + ) + return + run_service.record_tool_call( run_id=agent_run_id, tool_type="llm", - tool_name="system_hermes_llm_wiki_sync", + tool_name="system_hermes_llm_wiki_dispatch", request_json=request_payload, - response_json=result.model_dump(mode="json"), + response_json={ + "changed_document_ids": dispatch.changed_document_ids, + "skipped_document_ids": dispatch.skipped_document_ids, + "process_id": dispatch.process_id, + }, status="succeeded", duration_ms=0, ) + current_run = run_service.get_run(agent_run_id) + if current_run is not None and current_run.status in { + AgentRunStatus.SUCCEEDED.value, + AgentRunStatus.FAILED.value, + }: + return run_service.merge_route_json( agent_run_id, { - "phase": "succeeded", + "phase": "awaiting_callback", "heartbeat_at": datetime.now(UTC).isoformat(), - "sync_run_id": result.run_id, - "sync_result": result.model_dump(mode="json"), + "requested_document_ids": dispatch.changed_document_ids, + "skipped_document_ids": dispatch.skipped_document_ids, + "hermes_process_id": dispatch.process_id, + "hermes_stdout_path": dispatch.stdout_path, + "hermes_stderr_path": dispatch.stderr_path, "progress": { - "total_documents": max(len(document_ids), result.document_count), - "completed_documents": result.document_count, + "total_documents": len(dispatch.changed_document_ids), + "completed_documents": 0, "failed_documents": 0, - "skipped_documents": max(0, len(document_ids) - result.document_count), - "percent": 100, + "skipped_documents": len(dispatch.skipped_document_ids), + "percent": 0, }, }, - status=AgentRunStatus.SUCCEEDED.value, - result_summary=result.summary, - finished_at=datetime.now(UTC), + status=AgentRunStatus.RUNNING.value, + result_summary="Hermes 任务已派发,等待 Agent 主动回调结果。", + ) + self._start_process_monitor( + agent_run_id=agent_run_id, + document_ids=dispatch.changed_document_ids, + process_id=dispatch.process_id, + stderr_path=dispatch.stderr_path, + timeout_seconds=HERMES_AGENT_BATCH_TIMEOUT_SECONDS, ) except Exception as exc: logger.exception("Background LLM Wiki sync failed run_id=%s", agent_run_id) @@ -177,6 +226,122 @@ class LlmWikiTaskManager: with self._lock: self._threads.pop(agent_run_id, None) + def _start_process_monitor( + self, + *, + agent_run_id: str, + document_ids: list[str], + process_id: int, + stderr_path: str, + timeout_seconds: int, + ) -> None: + worker = threading.Thread( + target=self._monitor_process, + kwargs={ + "agent_run_id": agent_run_id, + "document_ids": list(document_ids), + "process_id": process_id, + "stderr_path": stderr_path, + "timeout_seconds": timeout_seconds, + }, + daemon=True, + name=f"llm-wiki-monitor-{agent_run_id}", + ) + worker.start() + + @staticmethod + def _monitor_process( + *, + agent_run_id: str, + document_ids: list[str], + process_id: int, + stderr_path: str, + timeout_seconds: int, + ) -> None: + session_factory = get_session_factory() + db = session_factory() + run_service = AgentRunService(db) + knowledge_service = KnowledgeService() + started_at = time.monotonic() + try: + while True: + time.sleep(3) + run = run_service.get_run(agent_run_id) + if run is None or run.status != AgentRunStatus.RUNNING.value: + return + if time.monotonic() - started_at > timeout_seconds: + try: + os.killpg(process_id, signal.SIGTERM) + except OSError: + pass + error_message = LlmWikiTaskManager._read_process_error(stderr_path) + if document_ids: + knowledge_service.set_document_ingest_statuses( + document_ids, + status_code=KNOWLEDGE_INGEST_STATUS_FAILED, + agent_run_id=agent_run_id, + ) + run_service.merge_route_json( + agent_run_id, + { + "phase": "failed", + "heartbeat_at": datetime.now(UTC).isoformat(), + "hermes_process_id": process_id, + }, + status=AgentRunStatus.FAILED.value, + result_summary="Hermes 任务执行超时,已自动终止等待。", + error_message=error_message or "Hermes process exceeded callback timeout.", + finished_at=datetime.now(UTC), + ) + return + if LlmWikiTaskManager._is_process_alive(process_id): + continue + + error_message = LlmWikiTaskManager._read_process_error(stderr_path) + if document_ids: + knowledge_service.set_document_ingest_statuses( + document_ids, + status_code=KNOWLEDGE_INGEST_STATUS_FAILED, + agent_run_id=agent_run_id, + ) + run_service.merge_route_json( + agent_run_id, + { + "phase": "failed", + "heartbeat_at": datetime.now(UTC).isoformat(), + "hermes_process_id": process_id, + }, + status=AgentRunStatus.FAILED.value, + result_summary="Hermes 进程已退出且未回调结果。", + error_message=error_message or "Hermes process exited before callback.", + finished_at=datetime.now(UTC), + ) + return + finally: + db.close() + + @staticmethod + def _is_process_alive(process_id: int) -> bool: + stat_path = Path(f"/proc/{process_id}/stat") + if not stat_path.exists(): + return False + try: + parts = stat_path.read_text(encoding="utf-8").split() + except OSError: + return False + return len(parts) > 2 and parts[2] != "Z" + + @staticmethod + def _read_process_error(stderr_path: str) -> str: + path = Path(stderr_path) + if not stderr_path or not path.exists(): + return "" + try: + content = path.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return "" + return content[-1000:] + @staticmethod def _write_progress( *, diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index f12ae2f..6bbf52b 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -335,7 +335,6 @@ class SemanticOntologyService: context_json = payload.context_json or {} reference = self._load_reference_catalog() compact_query = self._compact(query) - entities = self._extract_entities(query, compact_query, reference) rule_scenario, scenario_score = self._detect_scenario(compact_query) time_range, _time_score = self._extract_time_range( @@ -343,7 +342,11 @@ class SemanticOntologyService: compact_query, context_json=context_json, ) + session_scenario = self._resolve_session_type_scenario(context_json) context_scenario = self._resolve_context_scenario(context_json) + if session_scenario == "knowledge": + rule_scenario = "knowledge" + scenario_score = max(scenario_score, 0.34) if rule_scenario == "unknown" and context_scenario is not None: rule_scenario = context_scenario scenario_score = max(scenario_score, 0.14) @@ -393,6 +396,8 @@ class SemanticOntologyService: constraints=constraints, ) scenario = self._resolve_scenario(rule_scenario, model_parse) + if session_scenario == "knowledge": + scenario = "knowledge" entities = self._merge_entities( entities, model_parse.entity_hints if model_parse is not None else [], @@ -419,6 +424,14 @@ class SemanticOntologyService: context_json=context_json, ) ) + relax_knowledge_follow_up = self._should_relax_knowledge_follow_up_clarification( + compact_query=compact_query, + scenario=scenario, + context_json=context_json, + missing_slots=missing_slots, + ) + if relax_knowledge_follow_up: + missing_slots = [item for item in missing_slots if item != "expense_type"] ambiguity = self._normalize_short_text_list( model_parse.ambiguity if model_parse is not None else [] ) @@ -450,12 +463,16 @@ class SemanticOntologyService: intent=intent, ), model_clarification_required=bool( - model_parse is not None and model_parse.clarification_required + model_parse is not None + and model_parse.clarification_required ), model_clarification_question=( model_parse.clarification_question if model_parse is not None else None ), ) + if relax_knowledge_follow_up: + clarification_required = False + clarification_question = None fallback_confidence = self._compute_confidence( scenario=scenario, scenario_score=scenario_score, @@ -496,6 +513,30 @@ class SemanticOntologyService: "field_errors": field_errors, } + @staticmethod + def _should_relax_knowledge_follow_up_clarification( + *, + compact_query: str, + scenario: str, + context_json: dict[str, Any], + missing_slots: list[str], + ) -> bool: + if scenario != "knowledge" or "expense_type" not in missing_slots: + return False + history = context_json.get("conversation_history") + if not isinstance(history, list): + return False + has_previous_user_turn = any( + isinstance(item, dict) + and str(item.get("role") or "").strip() == "user" + and str(item.get("content") or "").strip() + for item in history + ) + if not has_previous_user_turn: + return False + follow_up_markers = ("那", "那么", "这个", "这种", "呢", "的话", "p", "P") + return any(marker in compact_query for marker in follow_up_markers) + def _record_semantic_parse( self, *, @@ -599,6 +640,14 @@ class SemanticOntologyService: return value return None + @staticmethod + def _resolve_session_type_scenario(context_json: dict[str, Any]) -> str | None: + value = str(context_json.get("session_type") or "").strip() + if value == "knowledge": + return "knowledge" + return None + + def _detect_scenario(self, compact_query: str) -> tuple[str, float]: scores = {key: 0.0 for key in SCENARIO_KEYWORDS} for scenario, keywords in SCENARIO_KEYWORDS.items(): @@ -1593,6 +1642,8 @@ class SemanticOntologyService: ) -> tuple[bool, str | None]: if permission.level == AgentPermissionLevel.FORBIDDEN.value: return True, "当前动作超出权限范围。是否改为生成草稿或建议?" + if scenario == "knowledge" and intent in {"query", "explain"}: + return False, None if model_clarification_required: question = str(model_clarification_question or "").strip() if question: diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 54a6b94..d19c0c7 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta +import re from time import perf_counter from typing import Any @@ -37,6 +38,7 @@ from app.services.agent_conversations import AgentConversationService from app.services.expense_claims import ExpenseClaimService from app.services.agent_foundation import AgentFoundationService from app.services.agent_runs import AgentRunService +from app.services.knowledge import KnowledgeService from app.services.ontology import SemanticOntologyService from app.services.user_agent import UserAgentService @@ -62,6 +64,8 @@ class ExecutionOutcome: PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"} SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请") +KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS = ("出差", "差旅", "报销多少钱", "能报多少", "一共可以报销", "一共能报销") +KNOWLEDGE_TRAVEL_EXPANSION_TERMS = ("差旅费", "住宿费", "出差补贴", "交通费", "酒店住宿限额标准", "出差补贴标准") EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10 EXPENSE_QUERY_PREVIEW_LIMIT = 20 EXPENSE_STATUS_LABELS = { @@ -100,6 +104,7 @@ class OrchestratorService: self.asset_service = AgentAssetService(db) self.conversation_service = AgentConversationService(db) self.expense_claim_service = ExpenseClaimService(db) + self.knowledge_service = KnowledgeService(db=db) self.run_service = AgentRunService(db) self.ontology_service = SemanticOntologyService(db) self.user_agent_service = UserAgentService(db) @@ -574,7 +579,12 @@ class OrchestratorService: tool_name="knowledge.search", request_json=self._build_ontology_json(ontology), context_json=context_json, - executor=lambda: self._build_knowledge_answer(ontology, capabilities), + executor=lambda: self._build_knowledge_answer( + message=payload.message or "", + ontology=ontology, + capabilities=capabilities, + context_json=context_json, + ), fallback_factory=lambda exc: { "message": f"知识检索暂时不可用,建议稍后重试:{exc}", "degraded": True, @@ -1348,18 +1358,154 @@ class OrchestratorService: result["review_payload"] = response.review_payload.model_dump() return result - @staticmethod def _build_knowledge_answer( + self, + *, + message: str, ontology: OntologyParseResult, capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]], + context_json: dict[str, Any], ) -> dict[str, Any]: - referenced = [item.code for item in capabilities["rules"][:1]] or [ - "knowledge.policy.default" - ] - return { - "message": f"已路由到 User Agent,占位知识结果:建议先查看 {', '.join(referenced)}。", - "references": referenced, - } + payload = self.knowledge_service.search_llm_wiki(message, limit=5) + expanded_query = self._build_knowledge_expanded_query( + message=message, + context_json=context_json, + ) + if expanded_query and expanded_query != message: + expanded_payload = self.knowledge_service.search_llm_wiki(expanded_query, limit=5) + payload = self._merge_knowledge_search_payloads( + primary_payload=payload, + expanded_payload=expanded_payload, + original_query=message, + expanded_query=expanded_query, + ) + + references = [str(item).strip() for item in list(payload.get("references") or []) if str(item).strip()] + if references: + payload["references"] = references + return payload + + @staticmethod + def _merge_knowledge_search_payloads( + *, + primary_payload: dict[str, Any], + expanded_payload: dict[str, Any], + original_query: str, + expanded_query: str, + ) -> dict[str, Any]: + merged_by_code: dict[str, dict[str, Any]] = {} + for item in [ + *list(primary_payload.get("hits") or []), + *list(expanded_payload.get("hits") or []), + ]: + if not isinstance(item, dict): + continue + code = str(item.get("code") or "").strip() + if not code: + continue + existing = merged_by_code.get(code) + if existing is None or int(item.get("score") or 0) > int(existing.get("score") or 0): + merged_by_code[code] = item + + if not merged_by_code: + return primary_payload + + ranked_hits = sorted( + merged_by_code.values(), + key=lambda item: ( + -int(item.get("score") or 0), + str(item.get("quality_status") or "") != "formal", + str(item.get("title") or ""), + ), + )[:5] + merged_payload = dict(primary_payload) + merged_payload.update( + { + "query": original_query, + "expanded_query": expanded_query, + "record_count": len(ranked_hits), + "hits": ranked_hits, + "references": [ + str(item.get("code") or "").strip() + for item in ranked_hits + if str(item.get("code") or "").strip() + ], + } + ) + return merged_payload + + @staticmethod + def _build_knowledge_expanded_query( + *, + message: str, + context_json: dict[str, Any], + ) -> str: + expansions: list[str] = [] + normalized_message = "".join(str(message or "").split()) + if normalized_message and any(keyword in normalized_message for keyword in KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS): + expansions.extend(KNOWLEDGE_TRAVEL_EXPANSION_TERMS) + location = OrchestratorService._extract_knowledge_location(message) + if location: + expansions.append(location) + grade = OrchestratorService._extract_knowledge_grade(message, context_json) + if grade: + expansions.append(grade) + + history = context_json.get("conversation_history") + if not isinstance(history, list): + if not expansions: + return message + return "\n".join([message, " ".join(dict.fromkeys(expansions))]) + + previous_user_messages: list[str] = [] + for item in reversed(history): + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip() + content = str(item.get("content") or "").strip() + if role != "user" or not content or content == message: + continue + previous_user_messages.append(content) + if len(previous_user_messages) >= 2: + break + + query_parts: list[str] = [] + if previous_user_messages: + query_parts.extend(reversed(previous_user_messages)) + query_parts.append(message) + if expansions: + query_parts.append(" ".join(dict.fromkeys(expansions))) + return "\n".join(query_parts) + + @staticmethod + def _extract_knowledge_location(message: str) -> str: + patterns = ( + r"去([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", + r"到([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", + r"在([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", + ) + for pattern in patterns: + matched = re.search(pattern, str(message or "")) + if matched: + return matched.group(1) + return "" + + @staticmethod + def _extract_knowledge_grade(message: str, context_json: dict[str, Any]) -> str: + matched = re.search(r"\bP[1-9]\d?\b", str(message or ""), re.IGNORECASE) + if matched: + return matched.group(0).upper() + for key in ("grade", "employee_grade", "employeeLevel", "employee_level"): + value = str(context_json.get(key) or "").strip() + if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE): + return value.upper() + user = context_json.get("current_user") + if isinstance(user, dict): + for key in ("grade", "employee_grade", "employeeLevel", "employee_level"): + value = str(user.get(key) or "").strip() + if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE): + return value.upper() + return "" @staticmethod def _build_rule_answer(ontology: OntologyParseResult) -> dict[str, Any]: diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index 8d47ca5..dd45725 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -343,6 +343,14 @@ class SettingsService: "apiKey": self.load_saved_model_api_key(slot), "capability": model_row.capability, } + + def sync_hermes_runtime_model_settings(self) -> None: + settings_row, secrets_row = self.ensure_settings_ready() + model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) + sync_hermes_model_settings( + primary_route=self._build_hermes_model_route(model_rows["main"]), + fallback_route=self._build_hermes_model_route(model_rows["backup"]), + ) def get_admin_credentials(self) -> AdminCredentialRecord | None: settings_row, secrets_row = self.ensure_settings_ready() diff --git a/server/src/app/services/system_hermes.py b/server/src/app/services/system_hermes.py index 85663f8..bb36a82 100644 --- a/server/src/app/services/system_hermes.py +++ b/server/src/app/services/system_hermes.py @@ -5,6 +5,9 @@ import shutil import subprocess from dataclasses import dataclass from pathlib import Path +from typing import Mapping + +from app.core.config import SERVER_DIR @dataclass(frozen=True, slots=True) @@ -14,6 +17,14 @@ class HermesCliResult: command: tuple[str, ...] = () +@dataclass(frozen=True, slots=True) +class HermesProcessHandle: + pid: int + command: tuple[str, ...] = () + stdout_path: str = "" + stderr_path: str = "" + + class SystemHermesService: def __init__(self) -> None: configured_bin = str(os.getenv("HERMES_BIN", "")).strip() @@ -29,11 +40,94 @@ class SystemHermesService: source: str = "tool", max_turns: int = 1, timeout_seconds: int = 180, + skills: tuple[str, ...] = (), + env_overrides: Mapping[str, str] | None = None, + yolo: bool = False, ) -> HermesCliResult: if not self.is_available(): raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}") - command = ( + command = self._build_command( + query, + source=source, + max_turns=max_turns, + skills=skills, + yolo=yolo, + ) + env = os.environ.copy() + if env_overrides: + env.update({str(key): str(value) for key, value in env_overrides.items()}) + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + env=env, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip() + raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。") + + return self._parse_output(completed.stdout, command=command) + + def start_query_background( + self, + query: str, + *, + source: str = "tool", + max_turns: int = 1, + skills: tuple[str, ...] = (), + env_overrides: Mapping[str, str] | None = None, + log_prefix: str = "hermes", + yolo: bool = False, + ) -> HermesProcessHandle: + if not self.is_available(): + raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}") + + command = self._build_command( + query, + source=source, + max_turns=max_turns, + skills=skills, + yolo=yolo, + ) + env = os.environ.copy() + if env_overrides: + env.update({str(key): str(value) for key, value in env_overrides.items()}) + log_dir = SERVER_DIR / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + safe_prefix = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in log_prefix) + stdout_path = log_dir / f"{safe_prefix}.out.log" + stderr_path = log_dir / f"{safe_prefix}.err.log" + stdout_file = stdout_path.open("ab") + stderr_file = stderr_path.open("ab") + process = subprocess.Popen( + command, + stdout=stdout_file, + stderr=stderr_file, + env=env, + start_new_session=True, + ) + stdout_file.close() + stderr_file.close() + return HermesProcessHandle( + pid=process.pid, + command=command, + stdout_path=str(stdout_path), + stderr_path=str(stderr_path), + ) + + def _build_command( + self, + query: str, + *, + source: str, + max_turns: int, + skills: tuple[str, ...], + yolo: bool, + ) -> tuple[str, ...]: + command_parts = [ self.hermes_bin, "chat", "-Q", @@ -41,21 +135,15 @@ class SystemHermesService: source, "--max-turns", str(max_turns), - "-q", - query, - ) - completed = subprocess.run( - command, - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) - if completed.returncode != 0: - detail = (completed.stderr or completed.stdout or "").strip() - raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。") - - return self._parse_output(completed.stdout, command=command) + ] + for skill in skills: + normalized_skill = str(skill or "").strip() + if normalized_skill: + command_parts.extend(["--skills", normalized_skill]) + if yolo: + command_parts.append("--yolo") + command_parts.extend(["-q", query]) + return tuple(command_parts) @staticmethod def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult: diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 8b5ad70..1c8308f 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -1,2636 +1,2807 @@ -from __future__ import annotations - -import json -import re -from datetime import UTC, datetime, timedelta -from decimal import Decimal, InvalidOperation - -from sqlalchemy import or_, select -from sqlalchemy.orm import Session - -from app.core.agent_enums import AgentAssetStatus, AgentAssetType -from app.models.employee import Employee -from app.models.financial_record import ExpenseClaim -from app.schemas.agent_asset import AgentAssetListItem -from app.schemas.user_agent import ( - UserAgentCitation, - UserAgentDraftPayload, - UserAgentExpenseQueryRecord, - UserAgentQueryPayload, - UserAgentQueryStatusGroup, - UserAgentReviewAction, - UserAgentReviewEditField, - UserAgentReviewClaimGroup, - UserAgentReviewDocumentCard, - UserAgentReviewDocumentField, - UserAgentReviewPayload, - UserAgentReviewRiskBrief, - UserAgentReviewSlotCard, - UserAgentRequest, - UserAgentResponse, - UserAgentSuggestedAction, -) -from app.services.agent_assets import AgentAssetService -from app.services.agent_foundation import AgentFoundationService -from app.services.runtime_chat import RuntimeChatService - -SCENARIO_LABELS = { - "expense": "报销", - "accounts_receivable": "应收", - "accounts_payable": "应付", - "knowledge": "知识", - "unknown": "通用", -} - -RISK_REASON_MAP = { - "duplicate_expense": "检测到同员工、同金额或近似单据存在重复提交迹象。", - "amount_over_limit": "金额超过当前制度或预算阈值,需要补充例外说明。", - "invoice_anomaly": "票据或附件完整性不满足当前规则要求,需要补件或人工复核。", - "ar_overdue": "应收账款已出现逾期,存在回款延迟风险。", - "ap_overdue": "应付付款已出现逾期,可能影响供应商履约或合作关系。", -} - -GENERIC_EXPENSE_PROMPTS = { - "报销", - "我要报销", - "我想报销", - "帮我报销", - "我要申请报销", - "发起报销", - "提交报销", -} - -EXPLICIT_DRAFT_KEYWORDS = ("生成", "草稿", "起草", "创建", "发起", "准备") - -EXPENSE_TYPE_LABELS = { - "travel": "差旅费", - "hotel": "住宿费", - "transport": "交通费", - "meal": "餐费", - "meeting": "会务费", - "entertainment": "业务招待费", - "office": "办公费", - "training": "培训费", - "communication": "通讯费", - "welfare": "福利费", - "other": "其他费用", -} - -GROUP_SCENE_LABELS = { - "travel": "差旅费", - "entertainment": "业务招待费", - "meal": "伙食费", - "transport": "交通费", - "hotel": "住宿费", - "office": "办公费", - "training": "培训费", - "communication": "通讯费", - "welfare": "福利费", - "other": "其他费用", -} - -EXPENSE_STATUS_LABELS = { - "draft": "草稿", - "submitted": "已提交", - "review": "审核中", - "approved": "已通过", - "rejected": "已驳回", - "paid": "已付款", -} - -EXPENSE_STATUS_GROUP_LABELS = { - "draft": "草稿", - "in_progress": "审批中", - "completed": "审批完成", - "other": "其他状态", -} - -SLOT_LABELS = { - "expense_type": "报销类型", - "customer_name": "客户名称", - "time_range": "发生时间", - "location": "地点", - "merchant_name": "酒店/商户", - "amount": "金额", - "reason": "事由说明", - "participants": "参与人员", - "attachments": "票据附件", -} - -DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)") -AMOUNT_TEXT_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(?:元|万元|万)") -DOCUMENT_AMOUNT_PATTERN = re.compile( - r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" - r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" -) +from __future__ import annotations + +import json +import re +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.core.agent_enums import AgentAssetStatus, AgentAssetType +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.schemas.agent_asset import AgentAssetListItem +from app.schemas.user_agent import ( + UserAgentCitation, + UserAgentDraftPayload, + UserAgentExpenseQueryRecord, + UserAgentQueryPayload, + UserAgentQueryStatusGroup, + UserAgentReviewAction, + UserAgentReviewEditField, + UserAgentReviewClaimGroup, + UserAgentReviewDocumentCard, + UserAgentReviewDocumentField, + UserAgentReviewPayload, + UserAgentReviewRiskBrief, + UserAgentReviewSlotCard, + UserAgentRequest, + UserAgentResponse, + UserAgentSuggestedAction, +) +from app.services.agent_assets import AgentAssetService +from app.services.agent_foundation import AgentFoundationService +from app.services.runtime_chat import RuntimeChatService + +SCENARIO_LABELS = { + "expense": "报销", + "accounts_receivable": "应收", + "accounts_payable": "应付", + "knowledge": "知识", + "unknown": "通用", +} + +RISK_REASON_MAP = { + "duplicate_expense": "检测到同员工、同金额或近似单据存在重复提交迹象。", + "amount_over_limit": "金额超过当前制度或预算阈值,需要补充例外说明。", + "invoice_anomaly": "票据或附件完整性不满足当前规则要求,需要补件或人工复核。", + "ar_overdue": "应收账款已出现逾期,存在回款延迟风险。", + "ap_overdue": "应付付款已出现逾期,可能影响供应商履约或合作关系。", +} + +GENERIC_EXPENSE_PROMPTS = { + "报销", + "我要报销", + "我想报销", + "帮我报销", + "我要申请报销", + "发起报销", + "提交报销", +} + +EXPLICIT_DRAFT_KEYWORDS = ("生成", "草稿", "起草", "创建", "发起", "准备") + +EXPENSE_TYPE_LABELS = { + "travel": "差旅费", + "hotel": "住宿费", + "transport": "交通费", + "meal": "餐费", + "meeting": "会务费", + "entertainment": "业务招待费", + "office": "办公费", + "training": "培训费", + "communication": "通讯费", + "welfare": "福利费", + "other": "其他费用", +} + +GROUP_SCENE_LABELS = { + "travel": "差旅费", + "entertainment": "业务招待费", + "meal": "伙食费", + "transport": "交通费", + "hotel": "住宿费", + "office": "办公费", + "training": "培训费", + "communication": "通讯费", + "welfare": "福利费", + "other": "其他费用", +} + +EXPENSE_STATUS_LABELS = { + "draft": "草稿", + "submitted": "已提交", + "review": "审核中", + "approved": "已通过", + "rejected": "已驳回", + "paid": "已付款", +} + +EXPENSE_STATUS_GROUP_LABELS = { + "draft": "草稿", + "in_progress": "审批中", + "completed": "审批完成", + "other": "其他状态", +} + +SLOT_LABELS = { + "expense_type": "报销类型", + "customer_name": "客户名称", + "time_range": "发生时间", + "location": "地点", + "merchant_name": "酒店/商户", + "amount": "金额", + "reason": "事由说明", + "participants": "参与人员", + "attachments": "票据附件", +} + +DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)") +AMOUNT_TEXT_PATTERN = re.compile(r"(\d+(?:\.\d+)?)\s*(?:元|万元|万)") +DOCUMENT_AMOUNT_PATTERN = re.compile( + r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" + r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" +) DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") - -SOURCE_LABELS = { - "user_text": "用户描述", - "user_form": "用户修改", - "ocr": "票据识别", - "upload": "上传附件", - "detail_context": "关联单据", - "system_context": "系统上下文", - "inferred": "语义推断", - "system": "系统判断", -} - -SCENE_REQUIRED_SLOT_KEYS = { - "hotel": {"merchant_name"}, - "meeting": {"location"}, - "entertainment": {"location", "customer_name", "participants"}, -} -INFERRED_REASON_LABELS = { - "travel": "出差行程", - "hotel": "住宿报销", - "transport": "交通出行", - "meal": "餐饮用餐", - "meeting": "会务活动", - "entertainment": "客户接待", - "office": "办公采购", - "training": "培训学习", - "communication": "通讯使用", - "welfare": "员工福利", - "other": "其他费用", -} -SYSTEM_GENERATED_REASON_PREFIXES = ( - "我上传了", - "请按当前已识别信息", - "请把当前上传的票据", - "请基于当前上传的多张票据", - "我已核对右侧识别结果", - "请同步修正逐票据识别结果", - "我已修改识别信息", - "查看报销草稿", - "请解释一下当前这笔报销的合规风险和待补充项", -) - - -class UserAgentService: - def __init__(self, db: Session) -> None: - self.db = db - self.asset_service = AgentAssetService(db) - self.runtime_chat_service = RuntimeChatService(db) - - def respond(self, payload: UserAgentRequest) -> UserAgentResponse: - AgentFoundationService(self.db).ensure_foundation_ready() - citations = self._build_rule_citations(payload) - suggested_actions = self._build_suggested_actions(payload) - risk_flags = self._resolve_risk_flags(payload) - query_payload = self._build_query_payload(payload) - draft_payload = ( - self._build_draft_payload(payload) - if payload.ontology.intent == "draft" - else None - ) - review_payload = self._build_review_payload( - payload, - citations=citations, - draft_payload=draft_payload, - ) - review_answer = self._build_review_body_answer( - payload, - review_payload=review_payload, - draft_payload=draft_payload, - ) - - if payload.degraded and payload.tool_payload.get("message"): - return UserAgentResponse( - answer=review_answer or str(payload.tool_payload["message"]), - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - if review_answer: - return UserAgentResponse( - answer=review_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - guided_answer = None - if draft_payload is None or draft_payload.claim_id is None: - guided_answer = self._build_guided_answer(payload) - if guided_answer: - return UserAgentResponse( - answer=guided_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - fallback_answer = self._build_fallback_answer( - payload, - citations=citations, - draft_payload=draft_payload, - ) - answer = None - if not self._should_skip_model_answer(payload, review_payload): - answer = self._generate_answer_with_model( - payload, - citations=citations, - suggested_actions=suggested_actions, - risk_flags=risk_flags, - draft_payload=draft_payload, - fallback_answer=fallback_answer, - ) - - return UserAgentResponse( - answer=answer or fallback_answer, - citations=citations, - suggested_actions=suggested_actions, - query_payload=query_payload, - draft_payload=draft_payload, - review_payload=review_payload, - risk_flags=risk_flags, - requires_confirmation=payload.requires_confirmation, - ) - - def _build_fallback_answer( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - draft_payload: UserAgentDraftPayload | None, - ) -> str: - if payload.ontology.intent in {"query", "compare"}: - return self._build_query_answer(payload) - - if payload.ontology.intent == "risk_check": - return self._build_risk_answer(payload, citations) - - if payload.ontology.intent == "draft": - tool_message = str(payload.tool_payload.get("message") or "").strip() - if payload.tool_payload.get("draft_limit_reached"): - return tool_message or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - if tool_message and ( - str(payload.tool_payload.get("claim_id") or "").strip() - or str(payload.tool_payload.get("claim_no") or "").strip() - ): - return tool_message - if payload.ontology.intent == "draft" and draft_payload is not None: - return ( - f"已生成 {draft_payload.title},当前仅返回待人工确认的草稿内容," - "仍需人工确认后再进入正式流程。" - ) - - return self._build_explain_answer(payload, citations) - - def _build_guided_answer(self, payload: UserAgentRequest) -> str | None: - if not self._is_generic_expense_prompt(payload): - return self._build_implicit_expense_draft_guidance(payload) - - attachment_names = self._resolve_attachment_names(payload) - ocr_summary = str(payload.context_json.get("ocr_summary") or "").strip() - attachment_hint = "" - if ocr_summary: - attachment_hint = f" 我已读取附件 OCR 摘要:{ocr_summary}" - elif attachment_names: - attachment_hint = ( - f" 我已带入 {len(attachment_names)} 份附件名称,但目前还不能直接读取附件内容," - "仍需要你补充关键信息。" - ) - - return ( - "可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象," - "或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿。" - f"{attachment_hint}" - ) - - def _build_implicit_expense_draft_guidance( - self, - payload: UserAgentRequest, - ) -> str | None: - if not self._is_implicit_expense_draft_request(payload): - return None - - amount_text = next( - (item.value for item in payload.ontology.entities if item.type == "amount"), - "", - ) - expense_type = next( - ( - EXPENSE_TYPE_LABELS.get(item.normalized_value, item.value) - for item in payload.ontology.entities - if item.type == "expense_type" - ), - "报销", - ) - time_text = payload.ontology.time_range.raw or "本次" - amount_hint = f",金额 {amount_text}" if amount_text else "" - - return ( - f"已识别到一笔{time_text}的{expense_type}支出{amount_hint}。" - "如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。" - "你也可以继续上传发票或图片,我会把这些信息带入后续对话。" - ) - - def _generate_answer_with_model( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - suggested_actions: list[UserAgentSuggestedAction], - risk_flags: list[str], - draft_payload: UserAgentDraftPayload | None, - fallback_answer: str, - ) -> str | None: - messages = self._build_model_messages( - payload, - citations=citations, - suggested_actions=suggested_actions, - risk_flags=risk_flags, - draft_payload=draft_payload, - fallback_answer=fallback_answer, - ) - return self._sanitize_model_answer( + +SOURCE_LABELS = { + "user_text": "用户描述", + "user_form": "用户修改", + "ocr": "票据识别", + "upload": "上传附件", + "detail_context": "关联单据", + "system_context": "系统上下文", + "inferred": "语义推断", + "system": "系统判断", +} + +SCENE_REQUIRED_SLOT_KEYS = { + "hotel": {"merchant_name"}, + "meeting": {"location"}, + "entertainment": {"location", "customer_name", "participants"}, +} +INFERRED_REASON_LABELS = { + "travel": "出差行程", + "hotel": "住宿报销", + "transport": "交通出行", + "meal": "餐饮用餐", + "meeting": "会务活动", + "entertainment": "客户接待", + "office": "办公采购", + "training": "培训学习", + "communication": "通讯使用", + "welfare": "员工福利", + "other": "其他费用", +} +SYSTEM_GENERATED_REASON_PREFIXES = ( + "我上传了", + "请按当前已识别信息", + "请把当前上传的票据", + "请基于当前上传的多张票据", + "我已核对右侧识别结果", + "请同步修正逐票据识别结果", + "我已修改识别信息", + "查看报销草稿", + "请解释一下当前这笔报销的合规风险和待补充项", +) + + +class UserAgentService: + def __init__(self, db: Session) -> None: + self.db = db + self.asset_service = AgentAssetService(db) + self.runtime_chat_service = RuntimeChatService(db) + + def respond(self, payload: UserAgentRequest) -> UserAgentResponse: + AgentFoundationService(self.db).ensure_foundation_ready() + citations = self._build_citations(payload) + suggested_actions = self._build_suggested_actions(payload) + risk_flags = self._resolve_risk_flags(payload) + query_payload = self._build_query_payload(payload) + draft_payload = ( + self._build_draft_payload(payload) + if payload.ontology.intent == "draft" + else None + ) + review_payload = self._build_review_payload( + payload, + citations=citations, + draft_payload=draft_payload, + ) + review_answer = self._build_review_body_answer( + payload, + review_payload=review_payload, + draft_payload=draft_payload, + ) + + if payload.degraded and payload.tool_payload.get("message"): + return UserAgentResponse( + answer=review_answer or str(payload.tool_payload["message"]), + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + if review_answer: + return UserAgentResponse( + answer=review_answer, + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + guided_answer = None + if draft_payload is None or draft_payload.claim_id is None: + guided_answer = self._build_guided_answer(payload) + if guided_answer: + return UserAgentResponse( + answer=guided_answer, + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + fallback_answer = self._build_fallback_answer( + payload, + citations=citations, + draft_payload=draft_payload, + ) + answer = None + if not self._should_skip_model_answer(payload, review_payload): + answer = self._generate_answer_with_model( + payload, + citations=citations, + suggested_actions=suggested_actions, + risk_flags=risk_flags, + draft_payload=draft_payload, + fallback_answer=fallback_answer, + ) + + return UserAgentResponse( + answer=answer or fallback_answer, + citations=citations, + suggested_actions=suggested_actions, + query_payload=query_payload, + draft_payload=draft_payload, + review_payload=review_payload, + risk_flags=risk_flags, + requires_confirmation=payload.requires_confirmation, + ) + + def _build_fallback_answer( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + draft_payload: UserAgentDraftPayload | None, + ) -> str: + if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": + return self._build_explain_answer(payload, citations) + + if payload.ontology.intent in {"query", "compare"}: + return self._build_query_answer(payload) + + if payload.ontology.intent == "risk_check": + return self._build_risk_answer(payload, citations) + + if payload.ontology.intent == "draft": + tool_message = str(payload.tool_payload.get("message") or "").strip() + if payload.tool_payload.get("draft_limit_reached"): + return tool_message or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + if tool_message and ( + str(payload.tool_payload.get("claim_id") or "").strip() + or str(payload.tool_payload.get("claim_no") or "").strip() + ): + return tool_message + if payload.ontology.intent == "draft" and draft_payload is not None: + return ( + f"已生成 {draft_payload.title},当前仅返回待人工确认的草稿内容," + "仍需人工确认后再进入正式流程。" + ) + + return self._build_explain_answer(payload, citations) + + def _build_guided_answer(self, payload: UserAgentRequest) -> str | None: + if not self._is_generic_expense_prompt(payload): + return self._build_implicit_expense_draft_guidance(payload) + + attachment_names = self._resolve_attachment_names(payload) + ocr_summary = str(payload.context_json.get("ocr_summary") or "").strip() + attachment_hint = "" + if ocr_summary: + attachment_hint = f" 我已读取附件 OCR 摘要:{ocr_summary}" + elif attachment_names: + attachment_hint = ( + f" 我已带入 {len(attachment_names)} 份附件名称,但目前还不能直接读取附件内容," + "仍需要你补充关键信息。" + ) + + return ( + "可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象," + "或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿。" + f"{attachment_hint}" + ) + + def _build_implicit_expense_draft_guidance( + self, + payload: UserAgentRequest, + ) -> str | None: + if not self._is_implicit_expense_draft_request(payload): + return None + + amount_text = next( + (item.value for item in payload.ontology.entities if item.type == "amount"), + "", + ) + expense_type = next( + ( + EXPENSE_TYPE_LABELS.get(item.normalized_value, item.value) + for item in payload.ontology.entities + if item.type == "expense_type" + ), + "报销", + ) + time_text = payload.ontology.time_range.raw or "本次" + amount_hint = f",金额 {amount_text}" if amount_text else "" + + return ( + f"已识别到一笔{time_text}的{expense_type}支出{amount_hint}。" + "如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。" + "你也可以继续上传发票或图片,我会把这些信息带入后续对话。" + ) + + def _generate_answer_with_model( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + suggested_actions: list[UserAgentSuggestedAction], + risk_flags: list[str], + draft_payload: UserAgentDraftPayload | None, + fallback_answer: str, + ) -> str | None: + messages = self._build_model_messages( + payload, + citations=citations, + suggested_actions=suggested_actions, + risk_flags=risk_flags, + draft_payload=draft_payload, + fallback_answer=fallback_answer, + ) + answer = self._sanitize_model_answer( self.runtime_chat_service.complete( messages, - max_tokens=420, + max_tokens=720 if payload.ontology.scenario == "knowledge" else 420, temperature=0.2, ) ) - + return self._reject_unsupported_location_inference(payload, answer) + def _sanitize_model_answer(self, answer: str | None) -> str | None: if not answer: return None cleaned = re.sub(r".*?", "", answer, flags=re.DOTALL | re.IGNORECASE) cleaned = cleaned.strip() + leaked_reasoning_markers = ( + "用户问的是", + "让我分析一下", + "实体识别", + "从对话历史来看", + "从tool_payload来看", + "现在问题是", + "我需要:", + "关键是我", + ) + if any(marker in cleaned[:500] for marker in leaked_reasoning_markers): + return None return cleaned or None - def _build_model_messages( + @staticmethod + def _extract_query_location(message: str) -> str: + match = re.search(r"(?:去|到)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or "")) + return match.group(1) if match else "" + + def _reject_unsupported_location_inference( self, payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - suggested_actions: list[UserAgentSuggestedAction], - risk_flags: list[str], - draft_payload: UserAgentDraftPayload | None, - fallback_answer: str, - ) -> list[dict[str, str]]: - facts = { - "run_id": payload.run_id, - "user_message": payload.message, - "ontology": payload.ontology.model_dump(mode="json"), - "context": { - "entry_source": payload.context_json.get("entry_source"), - "user_name": payload.context_json.get("name"), - "user_role": payload.context_json.get("role"), - "request_context": payload.context_json.get("request_context"), - "attachment_count": payload.context_json.get("attachment_count"), - "attachment_names": self._resolve_attachment_names(payload), - "ocr_summary": payload.context_json.get("ocr_summary", ""), - "ocr_documents": payload.context_json.get("ocr_documents", []), - "conversation_id": payload.context_json.get("conversation_id"), - "conversation_scenario": payload.context_json.get("conversation_scenario"), - "conversation_intent": payload.context_json.get("conversation_intent"), - "draft_claim_id": payload.context_json.get("draft_claim_id"), - "conversation_history": self._resolve_conversation_history(payload), - }, - "tool_payload": payload.tool_payload, - "citations": [item.model_dump(mode="json") for item in citations], - "suggested_actions": [ - item.model_dump(mode="json") for item in suggested_actions - ], - "risk_flags": risk_flags, - "draft_payload": ( - draft_payload.model_dump(mode="json") - if draft_payload is not None - else None - ), - "selected_capability_codes": payload.selected_capability_codes, - "requires_confirmation": payload.requires_confirmation, - "fallback_answer": fallback_answer, - } - - system_prompt = ( - "你是企业财务共享场景中的中文智能助手,负责和最终用户直接对话。" - "你只能基于提供的事实回答,不能编造制度、流程结果或附件内容。" - "如果用户问题很笼统,例如“我要报销”,优先告诉用户你可以协助什么," - "并明确要求补充费用类型、金额、时间、事由、参与对象或上传票据。" - "如果上下文里只有附件名称,必须明确说明你只拿到了附件名称," - "不能假装已看过图片、PDF 或发票内容。" - "如果提供了 conversation_history,必须结合最近轮次理解追问、代词、省略字段和补充信息。" - "不要声称已经提交、审批、付款、入账或真正执行了任何动作;如果只是建议、草稿或待确认,要明确说清楚。" - "若给出了风险标签、制度引用或建议动作,可以简洁吸收进回答,但不要新增未提供的事实。" - "只输出最终给用户看的自然语言,不要输出 JSON、Markdown、标题、" - " 标签或任何中间推理。" - "使用简体中文,控制在 2 到 4 句。" - ) - user_prompt = ( - "请根据以下事实生成最终答复,优先保持准确、具体、可执行:\n" - f"{json.dumps(facts, ensure_ascii=False, indent=2)}" - ) - return [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - def _build_query_answer(self, payload: UserAgentRequest) -> str: - scenario = payload.ontology.scenario - data = payload.tool_payload - subject = self._resolve_subject(payload) - - if scenario == "expense": - query_payload = self._build_query_payload(payload) - scope_label = str(data.get("scope_label") or subject).strip() or subject - if query_payload is None: - return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。" - - window_prefix = ( - f"{query_payload.window_start_date} 至 {query_payload.window_end_date}" - if query_payload.recent_window_applied - and query_payload.window_start_date - and query_payload.window_end_date - else ( - f"近 {query_payload.window_days} 日内" - if query_payload.recent_window_applied and query_payload.window_days - else "当前条件下" - ) - ) - if query_payload.record_count <= 0: - if query_payload.older_record_count > 0 and query_payload.window_days: - return ( - f"{window_prefix}没有查到{query_payload.scope_label}。" - f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," - "请前往个人报销中心查看。" - ) - return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。" - - group_lines = [ - f"{item.label} {item.count} 笔" - for item in query_payload.status_groups - if item.count > 0 - ] - answer_parts = [ - f"我先为你列出{window_prefix}的{query_payload.scope_label}," - f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。" - ] - if group_lines: - answer_parts.append(f"其中包括:{'、'.join(group_lines)}。") - - hint_parts: list[str] = [] - if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count: - hint_parts.append( - f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。" - ) - elif query_payload.records: - hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。") - - if query_payload.older_record_count > 0 and query_payload.window_days: - hint_parts.append( - f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," - "请前往个人报销中心查看。" - ) - - return " ".join(answer_parts + hint_parts).strip() - - if scenario == "accounts_receivable": - record_count = int(data.get("record_count") or 0) - outstanding_amount = float(data.get("outstanding_amount") or 0) - return ( - f"{subject}共命中 {record_count} 条应收,未回款金额 {outstanding_amount:.2f} 元。" - "建议结合账龄和客户分布继续排查逾期风险。" - ) - - if scenario == "accounts_payable": - record_count = int(data.get("record_count") or 0) - outstanding_amount = float(data.get("outstanding_amount") or 0) - return ( - f"{subject}共命中 {record_count} 条应付,待付金额 {outstanding_amount:.2f} 元。" - "如需推进动作,建议先生成付款建议草稿并发起人工确认。" - ) - - return "已完成当前查询,但暂时没有更多结构化结果可展示。" - - def _build_query_payload( - self, - payload: UserAgentRequest, - ) -> UserAgentQueryPayload | None: - if payload.ontology.scenario != "expense" or payload.ontology.intent not in {"query", "compare"}: - return None - - result_type = str(payload.tool_payload.get("result_type") or "").strip() - if result_type and result_type != "expense_claim_list": - return None - - records: list[UserAgentExpenseQueryRecord] = [] - for item in payload.tool_payload.get("records") or []: - if not isinstance(item, dict): - continue - amount = float(item.get("amount") or 0) - records.append( - UserAgentExpenseQueryRecord( - claim_id=str(item.get("claim_id") or "").strip(), - claim_no=str(item.get("claim_no") or "").strip() or "未编号", - employee_name=str(item.get("employee_name") or "").strip(), - expense_type=str(item.get("expense_type") or "").strip(), - expense_type_label=str(item.get("expense_type_label") or "").strip() - or EXPENSE_TYPE_LABELS.get(str(item.get("expense_type") or "").strip(), "报销"), - amount=round(amount, 2), - status=str(item.get("status") or "").strip(), - status_label=str(item.get("status_label") or "").strip() - or EXPENSE_STATUS_LABELS.get(str(item.get("status") or "").strip(), "处理中"), - status_group=str(item.get("status_group") or "").strip() or "other", - status_group_label=str(item.get("status_group_label") or "").strip() - or EXPENSE_STATUS_GROUP_LABELS.get(str(item.get("status_group") or "").strip(), "其他状态"), - approval_stage=str(item.get("approval_stage") or "").strip() or None, - document_date=str(item.get("document_date") or "").strip(), - occurred_at=str(item.get("occurred_at") or "").strip(), - reason=str(item.get("reason") or "").strip(), - location=str(item.get("location") or "").strip(), - ) - ) - - status_groups: list[UserAgentQueryStatusGroup] = [] - for item in payload.tool_payload.get("status_groups") or []: - if not isinstance(item, dict): - continue - status_groups.append( - UserAgentQueryStatusGroup( - key=str(item.get("key") or "").strip() or "other", - label=str(item.get("label") or "").strip() or "其他状态", - count=max(0, int(item.get("count") or 0)), - ) - ) - - return UserAgentQueryPayload( - result_type="expense_claim_list", - scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单", - recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")), - window_days=( - int(payload.tool_payload["window_days"]) - if payload.tool_payload.get("window_days") not in {None, ""} - else None - ), - window_start_date=( - str(payload.tool_payload.get("window_start_date") or "").strip() or None - ), - window_end_date=( - str(payload.tool_payload.get("window_end_date") or "").strip() or None - ), - record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), - preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), - older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), - has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), - total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), - status_groups=status_groups, - records=records, - ) - - def _build_explain_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - if citations: - titles = "、".join(item.title for item in citations[:2]) - summary = citations[0].excerpt or "请结合制度全文进一步确认。" - return f"已检索到相关依据:{titles}。核心说明:{summary}" - - return ( - f"当前还没有与“{SCENARIO_LABELS.get(payload.ontology.scenario, '当前问题')}”" - "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" - ) - - def _build_risk_answer( - self, - payload: UserAgentRequest, - citations: list[UserAgentCitation], - ) -> str: - risk_flags = self._resolve_risk_flags(payload) - if not risk_flags: - return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" - - reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] - citation_text = ( - f" 参考规则:{'、'.join(item.title for item in citations[:2])}。" - if citations - else "" - ) - return ( - f"本次识别到 {len(risk_flags)} 类风险:{'、'.join(risk_flags)}。" - f"触发原因:{';'.join(reasons)}。" - "建议先复核明细、附件和审批链,再决定是否继续处理。" - f"{citation_text}" - ) - - def _build_draft_payload(self, payload: UserAgentRequest) -> UserAgentDraftPayload: - scenario_label = SCENARIO_LABELS.get(payload.ontology.scenario, "业务") - subject = self._resolve_subject(payload) - claim_no = str(payload.tool_payload.get("claim_no") or "").strip() or None - claim_status = str(payload.tool_payload.get("status") or "").strip() or None - approval_stage = str(payload.tool_payload.get("approval_stage") or "").strip() or None - is_submitted = claim_status == "submitted" - title = f"{scenario_label}处理意见草稿" - if claim_no: - title = f"{scenario_label}{'报销单' if is_submitted else '草稿'} {claim_no}" - if is_submitted: - body = ( - f"主题:{subject}\n" - f"结论:报销单已完成 AI验审,当前节点为 {approval_stage or '审批中'}。\n" - "建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n" - f"原始问题:{payload.message}" - ) - else: - body = ( - f"主题:{subject}\n" - "结论:已根据当前语义解析结果生成草稿,尚未自动执行。\n" - "建议:请先核对明细、规则命中和所需附件,再由人工确认是否提交正式流程。\n" - f"原始问题:{payload.message}" - ) - return UserAgentDraftPayload( - draft_type=payload.ontology.scenario, - title=title, - body=body, - confirmation_required=not is_submitted, - claim_id=str(payload.tool_payload.get("claim_id") or "").strip() or None, - claim_no=claim_no, - status=claim_status, - approval_stage=approval_stage, - ) - - def _build_suggested_actions( - self, - payload: UserAgentRequest, - ) -> list[UserAgentSuggestedAction]: - if self._is_generic_expense_prompt(payload): - return [ - UserAgentSuggestedAction( - label="上传票据", - action_type="ask_clarification", - description="上传发票、行程单或付款截图,继续识别报销内容。", - ), - UserAgentSuggestedAction( - label="补充报销信息", - action_type="ask_clarification", - description="补充费用类型、金额、时间和事由后继续处理。", - ), - ] - - if payload.ontology.intent in {"query", "compare"}: - return [ - UserAgentSuggestedAction( - label="查看明细", - action_type="open_detail", - description="继续查看命中记录和过滤条件。", - ), - UserAgentSuggestedAction( - label="生成处理意见", - action_type="create_draft", - description="把当前查询结果整理成可确认草稿。", - ), - ] - - if payload.ontology.intent == "risk_check": - return [ - UserAgentSuggestedAction( - label="人工复核风险", - action_type="manual_review", - description="优先检查明细、附件和规则命中原因。", - ), - UserAgentSuggestedAction( - label="生成整改建议", - action_type="create_draft", - description="把风险说明整理成处理意见草稿。", - ), - ] - - if payload.ontology.intent == "draft": - return [ - UserAgentSuggestedAction( - label="复制草稿", - action_type="copy_draft", - description="复制当前草稿后交由人工确认。", - ), - UserAgentSuggestedAction( - label="补充上下文", - action_type="ask_clarification", - description="补充单据编号、客户或供应商信息以完善草稿。", - ), - ] - - return [ - UserAgentSuggestedAction( - label="查看规则全文", - action_type="open_rule", - description="继续查看引用规则或知识内容。", - ), - UserAgentSuggestedAction( - label="补充问题上下文", - action_type="ask_clarification", - description="补充业务对象、时间或单据范围,提升回答准确度。", - ), - ] - - def _build_review_payload( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - draft_payload: UserAgentDraftPayload | None, - ) -> UserAgentReviewPayload | None: - attachment_count = self._resolve_attachment_count(payload) - ocr_documents = self._resolve_ocr_documents(payload) - if payload.ontology.scenario != "expense": - return None - if payload.ontology.intent not in {"draft", "operate"} and attachment_count <= 0 and not ocr_documents: - return None - - document_cards = self._build_review_document_cards(payload, ocr_documents=ocr_documents) - claim_groups = self._build_review_claim_groups( - payload, - document_cards=document_cards, - ) - slot_cards = self._build_review_slot_cards( - payload, - ocr_documents=ocr_documents, - claim_groups=claim_groups, - ) - missing_slot_keys = self._resolve_review_missing_slot_keys( - payload, - slot_cards=slot_cards, - ) - risk_briefs = self._build_review_risk_briefs( - payload, - citations=citations, - document_cards=document_cards, - claim_groups=claim_groups, - ) - association_choice_pending = self._is_review_association_choice_pending(payload) - can_proceed = ( - False - if association_choice_pending - else self._can_proceed_review( - payload, - missing_slot_keys=missing_slot_keys, - claim_groups=claim_groups, - ) - ) - confirmation_actions = self._build_review_confirmation_actions( - payload, - can_proceed=can_proceed, - claim_groups=claim_groups, - draft_payload=draft_payload, - ) - edit_fields = self._build_review_edit_fields( - payload, - draft_payload=draft_payload, - slot_cards=slot_cards, - ) - intent_summary = self._build_review_intent_summary( - payload, - slot_cards=slot_cards, - claim_groups=claim_groups, - ) - body_message = self._build_review_body_message( - payload, - slot_cards=slot_cards, - risk_briefs=risk_briefs, - can_proceed=can_proceed, - document_cards=document_cards, - ) - - return UserAgentReviewPayload( - intent_summary=intent_summary, - body_message=body_message, - scenario=payload.ontology.scenario, - intent=payload.ontology.intent, - can_proceed=can_proceed, - missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys], - risk_briefs=risk_briefs, - slot_cards=slot_cards, - document_cards=document_cards, - claim_groups=claim_groups, - confirmation_actions=confirmation_actions, - edit_fields=edit_fields, - ) - - def _build_review_slot_cards( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> list[UserAgentReviewSlotCard]: - entity_map = self._collect_entity_values(payload) - time_slot = self._build_time_slot(payload) - location_slot = self._build_location_slot(payload) - customer_slot = self._build_customer_slot(payload, entity_map=entity_map) - participants_slot = self._build_participants_slot(payload, entity_map=entity_map) - amount_slot = self._build_amount_slot(payload, entity_map=entity_map, ocr_documents=ocr_documents) - expense_type_slot = self._build_expense_type_slot( - payload, - entity_map=entity_map, - ocr_documents=ocr_documents, - ) - merchant_slot = self._build_merchant_slot(payload, ocr_documents=ocr_documents) - reason_slot = self._build_reason_slot( - payload, - claim_groups=claim_groups, - ) - attachment_slot = self._build_attachment_slot(payload) - required_keys = self._resolve_required_review_keys( - payload, - primary_expense_type=str(expense_type_slot["normalized_value"] or ""), - claim_groups=claim_groups, - ) - - cards = [ - self._make_slot_card( - key="expense_type", - value=expense_type_slot["value"], - raw_value=expense_type_slot["raw_value"], - normalized_value=expense_type_slot["normalized_value"], - source=expense_type_slot["source"], - confidence=expense_type_slot["confidence"], - evidence=expense_type_slot["evidence"], - required="expense_type" in required_keys, - ), - self._make_slot_card( - key="customer_name", - value=customer_slot["value"], - raw_value=customer_slot["raw_value"], - normalized_value=customer_slot["normalized_value"], - source=customer_slot["source"], - confidence=customer_slot["confidence"], - evidence=customer_slot["evidence"], - required="customer_name" in required_keys, - ), - self._make_slot_card( - key="time_range", - value=time_slot["value"], - raw_value=time_slot["raw_value"], - normalized_value=time_slot["normalized_value"], - source=time_slot["source"], - confidence=time_slot["confidence"], - evidence=time_slot["evidence"], - required="time_range" in required_keys, - ), - self._make_slot_card( - key="location", - value=location_slot["value"], - raw_value=location_slot["raw_value"], - normalized_value=location_slot["normalized_value"], - source=location_slot["source"], - confidence=location_slot["confidence"], - evidence=location_slot["evidence"], - required="location" in required_keys, - ), - self._make_slot_card( - key="merchant_name", - value=merchant_slot["value"], - raw_value=merchant_slot["raw_value"], - normalized_value=merchant_slot["normalized_value"], - source=merchant_slot["source"], - confidence=merchant_slot["confidence"], - evidence=merchant_slot["evidence"], - required="merchant_name" in required_keys, - ), - self._make_slot_card( - key="amount", - value=amount_slot["value"], - raw_value=amount_slot["raw_value"], - normalized_value=amount_slot["normalized_value"], - source=amount_slot["source"], - confidence=amount_slot["confidence"], - evidence=amount_slot["evidence"], - required="amount" in required_keys, - ), - self._make_slot_card( - key="reason", - value=reason_slot["value"], - raw_value=reason_slot["raw_value"], - normalized_value=reason_slot["normalized_value"], - source=reason_slot["source"], - confidence=reason_slot["confidence"], - evidence=reason_slot["evidence"], - required="reason" in required_keys, - ), - self._make_slot_card( - key="participants", - value=participants_slot["value"], - raw_value=participants_slot["raw_value"], - normalized_value=participants_slot["normalized_value"], - source=participants_slot["source"], - confidence=participants_slot["confidence"], - evidence=participants_slot["evidence"], - required="participants" in required_keys, - ), - self._make_slot_card( - key="attachments", - value=attachment_slot["value"], - raw_value=attachment_slot["raw_value"], - normalized_value=attachment_slot["normalized_value"], - source=attachment_slot["source"], - confidence=attachment_slot["confidence"], - evidence=attachment_slot["evidence"], - required="attachments" in required_keys, - ), - ] - return cards - - def _build_review_document_cards( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - ) -> list[UserAgentReviewDocumentCard]: - cards: list[UserAgentReviewDocumentCard] = [] - for index, item in enumerate(ocr_documents, start=1): - classified = self._classify_document(item, payload) - fields = self._extract_document_fields(item) - cards.append( - UserAgentReviewDocumentCard( - index=index, - filename=str(item.get("filename") or f"document-{index}"), - document_type=classified["document_type"], - suggested_expense_type=classified["expense_type"], - scene_label=GROUP_SCENE_LABELS.get( - classified["group_code"], - classified["scene_label"], - ), - summary=str(item.get("summary") or item.get("text") or "").strip(), - avg_score=float(item.get("avg_score") or 0.0), - preview_kind=str(item.get("preview_kind") or "").strip(), - preview_data_url=str(item.get("preview_data_url") or "").strip(), - warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], - fields=[ - UserAgentReviewDocumentField( - label=label, - value=value, - source="ocr", - ) - for label, value in fields.items() - if str(value).strip() - ], - ) - ) - return cards - - def _build_review_claim_groups( - self, - payload: UserAgentRequest, - *, - document_cards: list[UserAgentReviewDocumentCard], - ) -> list[UserAgentReviewClaimGroup]: - groups: dict[str, dict[str, object]] = {} - for card in document_cards: - group_code = self._normalize_group_code(card.suggested_expense_type) - bucket = groups.setdefault( - group_code, - { - "document_indexes": [], - "amount_total": 0.0, - "expense_type": str(card.suggested_expense_type or group_code).strip() or group_code, - "scene_label": GROUP_SCENE_LABELS.get( - str(card.suggested_expense_type or group_code).strip() or group_code, - GROUP_SCENE_LABELS.get(group_code, "其他费用"), - ), - "reasons": [], - }, - ) - bucket["document_indexes"].append(card.index) - bucket["amount_total"] = float(bucket["amount_total"]) + self._extract_amount_from_card(card) - bucket["reasons"].append(f"{card.filename} 识别为 {card.scene_label}") - current_expense_type = str(bucket["expense_type"] or "").strip() - current_card_type = str(card.suggested_expense_type or "").strip() - if current_expense_type and current_card_type and current_expense_type != current_card_type: - bucket["expense_type"] = group_code - bucket["scene_label"] = GROUP_SCENE_LABELS.get(group_code, "其他费用") - - if not groups: - expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "other") - group_code = self._normalize_group_code(expense_type_code) - groups[group_code] = { - "document_indexes": [], - "amount_total": self._resolve_amount_value(payload), - "expense_type": expense_type_code or "other", - "scene_label": GROUP_SCENE_LABELS.get(group_code, "其他费用"), - "reasons": ["当前主要依据用户文本和页面上下文进行分单建议。"], - } - - claim_groups: list[UserAgentReviewClaimGroup] = [] - for index, (group_code, bucket) in enumerate(groups.items(), start=1): - title = f"建议报销单 {index}:{bucket['scene_label']}" - rationale = ( - ";".join(dict.fromkeys(str(item) for item in bucket["reasons"])) - if bucket["reasons"] - else "当前仅有单一场景,无需拆单。" - ) - claim_groups.append( - UserAgentReviewClaimGroup( - group_code=group_code, - title=title, - expense_type=str(bucket["expense_type"]), - scene_label=str(bucket["scene_label"]), - document_indexes=list(bucket["document_indexes"]), - amount_total=round(float(bucket["amount_total"]), 2), - rationale=rationale, - ) - ) - return claim_groups - - def _build_review_risk_briefs( - self, - payload: UserAgentRequest, - *, - citations: list[UserAgentCitation], - document_cards: list[UserAgentReviewDocumentCard], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> list[UserAgentReviewRiskBrief]: - briefs: list[UserAgentReviewRiskBrief] = [] - employee_name = self._collect_entity_values(payload).get("employee_name") or str( - payload.context_json.get("name") or "" - ).strip() - if employee_name: - since = datetime.now(UTC) - timedelta(days=90) - stmt = select(ExpenseClaim).where( - ExpenseClaim.employee_name == employee_name, - ExpenseClaim.occurred_at >= since, - ) - recent_claims = list(self.db.scalars(stmt).all()) - if recent_claims: - risky_count = sum(1 for item in recent_claims if item.risk_flags_json) - draft_count = sum(1 for item in recent_claims if item.status == "draft") - briefs.append( - UserAgentReviewRiskBrief( - title="历史报销画像", - level="info", - content=( - f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销," - f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。" - ), - ) - ) - current_amount = self._resolve_amount_value(payload) - if current_amount > 0: - duplicate_count = sum( - 1 - for item in recent_claims - if abs(float(item.amount) - current_amount) < 0.01 - ) - if duplicate_count: - briefs.append( - UserAgentReviewRiskBrief( - title="金额重复预警", - level="warning", - content=( - f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," - "提交前建议核对是否为重复报销或拆分不当。" - ), - ) - ) - - if citations: - briefs.append( - UserAgentReviewRiskBrief( - title="制度注意事项", - level="info", - content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。", - ) - ) - - warning_count = sum(len(item.warnings) for item in document_cards) - if warning_count: - briefs.append( - UserAgentReviewRiskBrief( - title="票据识别提醒", - level="warning", - content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。", - ) - ) - - if len(claim_groups) > 1: - briefs.append( - UserAgentReviewRiskBrief( - title="建议拆单", - level="high", - content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", - ) - ) - - return briefs[:4] - - def _build_review_confirmation_actions( - self, - payload: UserAgentRequest, - *, - can_proceed: bool, - claim_groups: list[UserAgentReviewClaimGroup], - draft_payload: UserAgentDraftPayload | None, - ) -> list[UserAgentReviewAction]: - if self._is_review_association_choice_pending(payload): - claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() - link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿" - return [ - UserAgentReviewAction( - label="取消", - action_type="cancel_review", - description="放弃当前识别结果,并退出本次核对流程。", - emphasis="secondary", - ), - UserAgentReviewAction( - label="修改识别信息", - action_type="edit_review", - description="打开结构化模板,按已识别字段逐项修改。", - emphasis="secondary", - ), - UserAgentReviewAction( - label=link_label, - action_type="link_to_existing_draft", - description=( - f"把本次上传票据并入现有草稿 {claim_no}。" - if claim_no - else "把本次上传票据并入现有草稿。" - ), - emphasis="primary", - ), - UserAgentReviewAction( - label="单独建立报销单", - action_type="create_new_claim_from_documents", - description="基于当前上传的多张票据,新建一张独立的报销草稿。", - emphasis="secondary", - ), - ] - - primary_action = UserAgentReviewAction( - label="继续下一步" if can_proceed else "保存为草稿", - action_type="next_step" if can_proceed else "save_draft", - description=( - "当前识别信息已满足继续处理条件,确认后进入下一步。" - if can_proceed - else "暂存当前识别结果,后续可以继续补充或修改。" - ), - emphasis="primary", - ) - if len(claim_groups) > 1 and can_proceed: - primary_action.description = f"系统建议拆分为 {len(claim_groups)} 张报销单,确认后继续下一步。" - if draft_payload is not None and draft_payload.claim_no and not can_proceed: - primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" - - return [ - UserAgentReviewAction( - label="取消", - action_type="cancel_review", - description="放弃当前识别结果,并退出本次核对流程。", - emphasis="secondary", - ), - UserAgentReviewAction( - label="修改识别信息", - action_type="edit_review", - description="打开结构化模板,按已识别字段逐项修改。", - emphasis="secondary", - ), - primary_action, - ] - - def _build_review_intent_summary( - self, - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> str: - slots = {item.key: item for item in slot_cards} - expense_type = slots.get("expense_type") - amount = slots.get("amount") - time_range = slots.get("time_range") - location = slots.get("location") - customer = slots.get("customer_name") - - summary = "我先根据您当前提供的信息整理出一笔报销。" - if expense_type and expense_type.value: - summary = f"识别到您希望报销一笔“{expense_type.value}”费用。" - details: list[str] = [] - if customer and customer.value: - details.append(f"客户为 {customer.value}") - if time_range and time_range.value: - details.append(f"时间为 {time_range.value}") - if location and location.value: - details.append(f"地点为 {location.value}") - if amount and amount.value: - details.append(f"金额为 {amount.value}") - reason = slots.get("reason") - if reason and reason.value: - details.append(f"事由是 {reason.value}") - if details: - return f"{summary} {','.join(details)}。" - return summary - - def _build_review_body_answer( - self, - payload: UserAgentRequest, - *, - review_payload: UserAgentReviewPayload | None, - draft_payload: UserAgentDraftPayload | None, + answer: str | None, ) -> str | None: - if review_payload is None: + if not answer or payload.ontology.scenario != "knowledge": + return answer + location = self._extract_query_location(payload.message) + if not location: + return answer + hit_text = "\n".join( + str(item.get("content") or "") + for item in list(payload.tool_payload.get("hits") or []) + if isinstance(item, dict) + ) + if location in hit_text: + return answer + inference_markers = ( + f"{location}属于", + f"{location}市属于", + f"{location}归入", + f"{location}归属", + f"{location}是省会", + ) + if any(marker in answer for marker in inference_markers): return None - if payload.ontology.scenario != "expense": - return None - if payload.ontology.intent not in {"draft", "operate"}: - return None - if payload.tool_payload.get("draft_limit_reached"): - return ( - str(payload.tool_payload.get("message") or "").strip() - or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - ) - - review_action = str(payload.context_json.get("review_action") or "").strip() - if review_action == "save_draft": - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" - "后续您可以继续补充缺失项,或修改识别结果后再继续提交。" - ) - return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" - if review_action == "link_to_existing_draft": - document_count = self._resolve_review_document_count(payload) - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" - ) - return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。" - if review_action == "create_new_claim_from_documents": - document_count = self._resolve_review_document_count(payload) - if draft_payload is not None and draft_payload.claim_no: - return ( - f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" - ) - return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" - if review_action == "next_step": - if draft_payload is not None and draft_payload.status == "submitted": - stage_text = draft_payload.approval_stage or "审批中" - return f"报销单 {draft_payload.claim_no or ''} 已完成 AI验审,当前节点为 {stage_text}。".strip() - if payload.tool_payload.get("submission_blocked"): - return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" - return ( - f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " - "当前关键信息已基本齐全,您确认无误后可以继续下一步。" - ) - if review_action == "edit_review": - return ( - f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " - f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}" - ) - return review_payload.body_message or None - - def _build_review_body_message( - self, - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - risk_briefs: list[UserAgentReviewRiskBrief], - can_proceed: bool, - document_cards: list[UserAgentReviewDocumentCard], - ) -> str: - if self._is_review_association_choice_pending(payload): - claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() - document_count = len(document_cards) or self._resolve_review_document_count(payload) - if claim_no: - return ( - f"已识别出本次上传的 {document_count} 张票据。" - f"系统检测到你已有草稿 {claim_no},请选择关联到该草稿,或单独建立一张新的报销单。" - ) - return ( - f"已识别出本次上传的 {document_count} 张票据。" - "系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。" - ) - - review_payload = UserAgentReviewPayload( - intent_summary="", - body_message="", - scenario=payload.ontology.scenario, - intent=payload.ontology.intent, - can_proceed=can_proceed, - missing_slots=self._resolve_review_missing_slot_labels(slot_cards), - risk_briefs=risk_briefs, - slot_cards=slot_cards, - document_cards=[], - claim_groups=[], - confirmation_actions=[], - edit_fields=[], + return answer + + def _build_model_messages( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + suggested_actions: list[UserAgentSuggestedAction], + risk_flags: list[str], + draft_payload: UserAgentDraftPayload | None, + fallback_answer: str, + ) -> list[dict[str, str]]: + facts = { + "run_id": payload.run_id, + "user_message": payload.message, + "ontology": payload.ontology.model_dump(mode="json"), + "context": { + "entry_source": payload.context_json.get("entry_source"), + "user_name": payload.context_json.get("name"), + "user_role": payload.context_json.get("role"), + "user_position": payload.context_json.get("position"), + "user_grade": payload.context_json.get("grade"), + "user_role_codes": payload.context_json.get("role_codes", []), + "is_admin": bool(payload.context_json.get("is_admin")), + "request_context": payload.context_json.get("request_context"), + "attachment_count": payload.context_json.get("attachment_count"), + "attachment_names": self._resolve_attachment_names(payload), + "ocr_summary": payload.context_json.get("ocr_summary", ""), + "ocr_documents": payload.context_json.get("ocr_documents", []), + "conversation_id": payload.context_json.get("conversation_id"), + "conversation_scenario": payload.context_json.get("conversation_scenario"), + "conversation_intent": payload.context_json.get("conversation_intent"), + "draft_claim_id": payload.context_json.get("draft_claim_id"), + "conversation_history": self._resolve_conversation_history(payload), + }, + "tool_payload": payload.tool_payload, + "citations": [item.model_dump(mode="json") for item in citations], + "suggested_actions": [ + item.model_dump(mode="json") for item in suggested_actions + ], + "risk_flags": risk_flags, + "draft_payload": ( + draft_payload.model_dump(mode="json") + if draft_payload is not None + else None + ), + "selected_capability_codes": payload.selected_capability_codes, + "requires_confirmation": payload.requires_confirmation, + "fallback_answer": fallback_answer, + } + + answer_style_instruction = ( + "当前是制度知识问答场景。回答要自然、完整、略作展开:先直接回答结论,再补充适用条件、例外或注意事项;" + "若事实中包含分档、金额、时限、条件等结构化信息,优先使用一个 Markdown 表格帮助用户理解;" + "允许使用简短标题、分段和项目符号,但不要为了变长而重复。通常控制在 3 到 6 段。" + "知识问答只能依据 tool_payload.hits 中的 LLM Wiki 内容作答;如果 hits 中没有足够依据,就明确说明当前 LLM Wiki 依据不足," + "不得用常识、外部知识或未提供的制度内容补答。" + "只能陈述 hits 中明确出现的事实;如果 wiki 没有写出某个维度,就不得自行推断该维度不存在、统一适用或默认适用。" + "如果问题涉及城市、地区、职级、时间或其他适用条件,而 hits 只给出了分类标准、没有给出用户问题所需的映射关系," + "必须明确说“当前 LLM Wiki 没有提供足以确定该条件归属的依据”,不能用常识把城市自行归类。" + "如果用户消息里出现了具体城市、人员、项目、供应商等专有值,而这些值没有在 hits 的 title/content/evidence 中逐字出现," + "就只能把它们当作用户提供的条件,不能进一步推断其行政层级、地区类别、组织归属或其他外部属性。" + "如果用户的问题需要把多个相关制度条目合并后才能回答,例如同一问题同时涉及标准、补贴、审批条件、时限或附件," + "必须综合全部相关 hits 作答,不能只抓第一条命中的内容。" + "如果是追问,要先继承 conversation_history 中仍然有效的事实,再结合本轮新增条件回答;" + "如果用户本轮只改变了一个条件,应当保留前文其他条件,并明确说明本轮替换了什么。" + if payload.ontology.scenario == "knowledge" + else "使用简体中文,控制在 2 到 4 句。" ) - return ( - f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} " - f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" + + personalization_instruction = ( + "如果上下文中提供了 user_name、user_position、user_grade 等用户信息,要把这些信息视为可用事实," + "但只能在问题与用户身份、职级、权限、适用标准、审批层级或个人处理路径有关时使用。" + "当用户使用“我”“本人”“我能报多少”这类第一人称问法时,若答案依赖职级或身份,必须优先按当前登录用户信息作答;" + "只有当用户明确提出“假设 P3 员工”这类假设场景时,才使用题目中给出的假设职级,并且要明确说明本次是按假设条件计算。" + "在会话刚开始、且本次回答确实需要结合用户身份时,可以自然地用一次称呼开场,例如“张三,您好”," + "随后说明“根据您当前的 P4 职级……”之类的判断依据。" + "如果用户问的是纯通用制度问题,或身份信息与答案无关,就直接回答制度本身,不要为了显得亲切而生硬插入姓名、岗位或职级。" + "同一会话中不要每一轮都重复称呼用户,也不要在没有明确事实支撑时猜测其职级、权限或可享受标准。" ) + + system_prompt = ( + "你是企业财务共享场景中的中文智能助手,负责和最终用户直接对话。" + "你只能基于提供的事实回答,不能编造制度、流程结果或附件内容。" + "如果用户问题很笼统,例如“我要报销”,优先告诉用户你可以协助什么," + "并明确要求补充费用类型、金额、时间、事由、参与对象或上传票据。" + "如果上下文里只有附件名称,必须明确说明你只拿到了附件名称," + "不能假装已看过图片、PDF 或发票内容。" + "如果提供了 conversation_history,必须结合最近轮次理解追问、代词、省略字段和补充信息。" + f"{personalization_instruction}" + "绝不能向用户展示中间分析、实体识别过程、工具推理过程、思考草稿或类似“让我分析一下”的自述;" + "只给最终答案。如果问题可以计算,先直接给结论,再列分项、公式和必要说明。" + "不要声称已经提交、审批、付款、入账或真正执行了任何动作;如果只是建议、草稿或待确认,要明确说清楚。" + "若给出了风险标签、制度引用或建议动作,可以简洁吸收进回答,但不要新增未提供的事实。" + "只输出最终给用户看的自然语言,不要输出 JSON、" + " 标签或任何中间推理。" + f"{answer_style_instruction}" + ) + user_prompt = ( + "请根据以下事实生成最终答复,优先保持准确、具体、可执行:\n" + f"{json.dumps(facts, ensure_ascii=False, indent=2)}" + ) + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + def _build_query_answer(self, payload: UserAgentRequest) -> str: + scenario = payload.ontology.scenario + data = payload.tool_payload + subject = self._resolve_subject(payload) + + if scenario == "expense": + query_payload = self._build_query_payload(payload) + scope_label = str(data.get("scope_label") or subject).strip() or subject + if query_payload is None: + return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。" + + window_prefix = ( + f"{query_payload.window_start_date} 至 {query_payload.window_end_date}" + if query_payload.recent_window_applied + and query_payload.window_start_date + and query_payload.window_end_date + else ( + f"近 {query_payload.window_days} 日内" + if query_payload.recent_window_applied and query_payload.window_days + else "当前条件下" + ) + ) + if query_payload.record_count <= 0: + if query_payload.older_record_count > 0 and query_payload.window_days: + return ( + f"{window_prefix}没有查到{query_payload.scope_label}。" + f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," + "请前往个人报销中心查看。" + ) + return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。" + + group_lines = [ + f"{item.label} {item.count} 笔" + for item in query_payload.status_groups + if item.count > 0 + ] + answer_parts = [ + f"我先为你列出{window_prefix}的{query_payload.scope_label}," + f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。" + ] + if group_lines: + answer_parts.append(f"其中包括:{'、'.join(group_lines)}。") + + hint_parts: list[str] = [] + if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count: + hint_parts.append( + f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。" + ) + elif query_payload.records: + hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。") + + if query_payload.older_record_count > 0 and query_payload.window_days: + hint_parts.append( + f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," + "请前往个人报销中心查看。" + ) + + return " ".join(answer_parts + hint_parts).strip() + + if scenario == "accounts_receivable": + record_count = int(data.get("record_count") or 0) + outstanding_amount = float(data.get("outstanding_amount") or 0) + return ( + f"{subject}共命中 {record_count} 条应收,未回款金额 {outstanding_amount:.2f} 元。" + "建议结合账龄和客户分布继续排查逾期风险。" + ) + + if scenario == "accounts_payable": + record_count = int(data.get("record_count") or 0) + outstanding_amount = float(data.get("outstanding_amount") or 0) + return ( + f"{subject}共命中 {record_count} 条应付,待付金额 {outstanding_amount:.2f} 元。" + "如需推进动作,建议先生成付款建议草稿并发起人工确认。" + ) + + return "已完成当前查询,但暂时没有更多结构化结果可展示。" + + def _build_query_payload( + self, + payload: UserAgentRequest, + ) -> UserAgentQueryPayload | None: + if payload.ontology.scenario != "expense" or payload.ontology.intent not in {"query", "compare"}: + return None + + result_type = str(payload.tool_payload.get("result_type") or "").strip() + if result_type and result_type != "expense_claim_list": + return None + + records: list[UserAgentExpenseQueryRecord] = [] + for item in payload.tool_payload.get("records") or []: + if not isinstance(item, dict): + continue + amount = float(item.get("amount") or 0) + records.append( + UserAgentExpenseQueryRecord( + claim_id=str(item.get("claim_id") or "").strip(), + claim_no=str(item.get("claim_no") or "").strip() or "未编号", + employee_name=str(item.get("employee_name") or "").strip(), + expense_type=str(item.get("expense_type") or "").strip(), + expense_type_label=str(item.get("expense_type_label") or "").strip() + or EXPENSE_TYPE_LABELS.get(str(item.get("expense_type") or "").strip(), "报销"), + amount=round(amount, 2), + status=str(item.get("status") or "").strip(), + status_label=str(item.get("status_label") or "").strip() + or EXPENSE_STATUS_LABELS.get(str(item.get("status") or "").strip(), "处理中"), + status_group=str(item.get("status_group") or "").strip() or "other", + status_group_label=str(item.get("status_group_label") or "").strip() + or EXPENSE_STATUS_GROUP_LABELS.get(str(item.get("status_group") or "").strip(), "其他状态"), + approval_stage=str(item.get("approval_stage") or "").strip() or None, + document_date=str(item.get("document_date") or "").strip(), + occurred_at=str(item.get("occurred_at") or "").strip(), + reason=str(item.get("reason") or "").strip(), + location=str(item.get("location") or "").strip(), + ) + ) + + status_groups: list[UserAgentQueryStatusGroup] = [] + for item in payload.tool_payload.get("status_groups") or []: + if not isinstance(item, dict): + continue + status_groups.append( + UserAgentQueryStatusGroup( + key=str(item.get("key") or "").strip() or "other", + label=str(item.get("label") or "").strip() or "其他状态", + count=max(0, int(item.get("count") or 0)), + ) + ) + + return UserAgentQueryPayload( + result_type="expense_claim_list", + scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单", + recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")), + window_days=( + int(payload.tool_payload["window_days"]) + if payload.tool_payload.get("window_days") not in {None, ""} + else None + ), + window_start_date=( + str(payload.tool_payload.get("window_start_date") or "").strip() or None + ), + window_end_date=( + str(payload.tool_payload.get("window_end_date") or "").strip() or None + ), + record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), + preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), + older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), + has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), + total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), + status_groups=status_groups, + records=records, + ) + + def _build_explain_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": + if citations: + return self._build_knowledge_search_answer(payload, citations) + + tool_message = str(payload.tool_payload.get("message") or "").strip() + if tool_message: + return tool_message + + if citations: + titles = "、".join(item.title for item in citations[:2]) + summary = citations[0].excerpt or "请结合制度全文进一步确认。" + return f"已检索到相关依据:{titles}。核心说明:{summary}" + + return ( + f"当前还没有与“{SCENARIO_LABELS.get(payload.ontology.scenario, '当前问题')}”" + "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" + ) + + def _build_knowledge_search_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)] + primary_hit = hits[0] if hits else {} + title = str(primary_hit.get("title") or citations[0].title or "相关制度").strip() + content = str(primary_hit.get("content") or citations[0].excerpt or "").strip() + query = str(payload.message or "").strip() - @staticmethod - def _resolve_review_missing_slot_labels( - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[str]: - return [item.label for item in slot_cards if item.status == "missing"] - - @staticmethod - def _build_review_guidance_copy( - review_payload: UserAgentReviewPayload, - *, - mention_save_draft: bool, - ) -> str: - missing_count = len(review_payload.missing_slots) - reminder_count = len(review_payload.risk_briefs) - - if review_payload.can_proceed: - if reminder_count: - return ( - f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。" - "您可以展开下方卡片查看详情,确认无误后继续下一步。" - ) - return "当前关键信息已基本齐全,您确认无误后可以继续下一步。" - - issue_parts: list[str] = [] - if missing_count: - issue_parts.append(f"{missing_count} 项信息待补充") - if reminder_count: - issue_parts.append(f"{reminder_count} 条提醒") - issue_summary = "、".join(issue_parts) if issue_parts else "一些细节还需要进一步确认" - - suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else "。" - return ( - f"当前还有 {issue_summary}。" - f"您可以展开下方卡片查看详情,继续补充或修改{suffix}" - ) - - @staticmethod - def _can_proceed_review( - payload: UserAgentRequest, - *, - missing_slot_keys: list[str], - claim_groups: list[UserAgentReviewClaimGroup], - ) -> bool: - if payload.ontology.ambiguity: - return False - if missing_slot_keys: - return False - if not claim_groups: - return False - return True - - def _build_review_edit_fields( - self, - payload: UserAgentRequest, - *, - draft_payload: UserAgentDraftPayload | None, - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[UserAgentReviewEditField]: - slot_map = {item.key: item for item in slot_cards} - employee = self._resolve_employee_profile(payload) - reporter_name = ( - slot_map.get("reporter_name").value - if slot_map.get("reporter_name") - else str(payload.context_json.get("name") or "").strip() - ) - manager_name = self._resolve_manager_name(employee) - reason = slot_map.get("reason").value if slot_map.get("reason") else "" - attachments = "、".join(self._resolve_attachment_names(payload)) - - fields = [ - UserAgentReviewEditField( - key="claim_no", - label="报销单据编号", - value=str(draft_payload.claim_no if draft_payload is not None and draft_payload.claim_no else "待生成"), - placeholder="保存草稿后自动生成", - required=False, - group="basic", - ), - UserAgentReviewEditField( - key="expense_type", - label="报销类型", - value=slot_map.get("expense_type").value if slot_map.get("expense_type") else "", - placeholder="例如:业务招待费 / 差旅费", - group="basic", - ), - UserAgentReviewEditField( - key="occurred_date", - label="业务发生时间", - value=slot_map.get("time_range").normalized_value if slot_map.get("time_range") and slot_map.get("time_range").normalized_value else slot_map.get("time_range").value if slot_map.get("time_range") else "", - placeholder="例如:2026-05-11", - group="basic", - ), - UserAgentReviewEditField( - key="reporter_name", - label="报销人", - value=reporter_name, - placeholder="请输入报销人姓名", - group="basic", - ), - UserAgentReviewEditField( - key="manager_name", - label="直属上司姓名", - value=manager_name, - placeholder="请输入直属上司姓名", - required=False, - group="basic", - ), - UserAgentReviewEditField( - key="customer_name", - label="客户名称", - value=slot_map.get("customer_name").value if slot_map.get("customer_name") else "", - placeholder="请输入客户名称", - group="business", - ), - UserAgentReviewEditField( - key="business_location", - label="业务地点", - value=slot_map.get("location").normalized_value if slot_map.get("location") and slot_map.get("location").normalized_value else slot_map.get("location").value if slot_map.get("location") else "", - placeholder="例如:北京 / 客户现场", - required=False, - group="business", - ), - UserAgentReviewEditField( - key="merchant_name", - label="酒店/商户", - value=slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "", - placeholder="请输入酒店或商户名称", - required=False, - group="business", - ), - UserAgentReviewEditField( - key="amount", - label="金额", - value=slot_map.get("amount").normalized_value if slot_map.get("amount") and slot_map.get("amount").normalized_value else slot_map.get("amount").value if slot_map.get("amount") else "", - placeholder="例如:200.00元", - group="business", - ), - UserAgentReviewEditField( - key="participants", - label="参与人员", - value=slot_map.get("participants").value if slot_map.get("participants") else "", - placeholder="例如:客户 2 人,我方 1 人", - group="business", - ), - UserAgentReviewEditField( - key="reason", - label="事由", - value=reason, - placeholder="请输入报销事由", - field_type="textarea", - group="business", - ), - UserAgentReviewEditField( - key="attachment_names", - label="附件清单", - value=attachments, - placeholder="例如:发票.jpg、行程单.png", - required=False, - field_type="textarea", - group="attachments", - ), + paragraph_parts = [ + f"根据已归纳的制度条目《{title}》,我先把与你这次问题最相关的要求整理出来。", ] - return fields - - def _resolve_employee_profile(self, payload: UserAgentRequest) -> Employee | None: - candidates = [ - str(payload.context_json.get("name") or "").strip(), - str(payload.user_id or "").strip(), - self._collect_entity_values(payload).get("employee_name", ""), - ] - normalized = [item for item in dict.fromkeys(candidates) if item] - if not normalized: - return None - - stmt = ( - select(Employee) - .where( - or_( - Employee.name.in_(normalized), - Employee.employee_no.in_(normalized), - Employee.email.in_(normalized), - ) - ) - .limit(1) + if content: + paragraph_parts.append(f"核心规定是:{content}") + else: + paragraph_parts.append("我已检索到相关制度知识,但当前条目的摘要信息还不够完整,建议结合制度原文进一步确认。") + + if len(hits) > 1: + related_titles = "、".join( + str(item.get("title") or "").strip() + for item in hits[1:3] + if str(item.get("title") or "").strip() + ) + if related_titles: + paragraph_parts.append( + f"另外,系统还命中了与本问题相关的制度条目:{related_titles}。" + "如果你的问题涉及多个环节,通常需要把这些要求一并核对。" + ) + + paragraph_parts.append( + "如果你希望,我也可以继续按“适用对象、报销标准、审批条件、所需附件”几个维度," + "把这条制度再展开整理成一版更适合直接执行的说明。" ) - return self.db.scalar(stmt) - - @staticmethod - def _resolve_manager_name(employee: Employee | None) -> str: - if employee is None: - return "" - if employee.manager is not None and employee.manager.name: - return employee.manager.name - if employee.organization_unit is not None and employee.organization_unit.manager_name: - return employee.organization_unit.manager_name - return "" - - @staticmethod - def _extract_message_reason(message: str) -> str: - for line in str(message or "").splitlines(): - cleaned = line.strip() - if not cleaned: - continue - if cleaned.startswith(("附件名称:", "OCR摘要:", "关联单号:")): - continue - return cleaned[:300] - return "" - - @staticmethod - def _looks_like_system_generated_reason_message(message: str) -> bool: - cleaned = str(message or "").strip() - if not cleaned: - return False - compact = re.sub(r"\s+", "", cleaned) - return compact.startswith(SYSTEM_GENERATED_REASON_PREFIXES) - - def _resolve_reason_source_text(self, payload: UserAgentRequest) -> str: - explicit_text = payload.context_json.get("user_input_text") - if isinstance(explicit_text, str): - return explicit_text.strip() - if self._looks_like_system_generated_reason_message(payload.message): - return "" - return str(payload.message or "").strip() - - @classmethod - def _resolve_reason_text(cls, message: str) -> str: - reason = cls._extract_message_reason(message) - if not reason: - return "" - - compact = re.sub(r"\s+", "", reason) - if compact in GENERIC_EXPENSE_PROMPTS: - return "" - - instruction_prefixes = ( - "帮我生成", - "请帮我生成", - "生成", - "起草", - "创建", - "发起", - "准备", - "帮我报销", - "我要报销", - "我想报销", - ) - if compact.startswith(instruction_prefixes): - for separator in (",", ",", "。", ";", ";", ":", ":"): - if separator in reason: - trailing = reason.split(separator, 1)[1].strip() - if trailing: - return trailing[:300] - return "" - - return reason - - @staticmethod + return "\n\n".join(part for part in paragraph_parts if part) + + def _build_risk_answer( + self, + payload: UserAgentRequest, + citations: list[UserAgentCitation], + ) -> str: + risk_flags = self._resolve_risk_flags(payload) + if not risk_flags: + return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" + + reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] + citation_text = ( + f" 参考规则:{'、'.join(item.title for item in citations[:2])}。" + if citations + else "" + ) + return ( + f"本次识别到 {len(risk_flags)} 类风险:{'、'.join(risk_flags)}。" + f"触发原因:{';'.join(reasons)}。" + "建议先复核明细、附件和审批链,再决定是否继续处理。" + f"{citation_text}" + ) + + def _build_draft_payload(self, payload: UserAgentRequest) -> UserAgentDraftPayload: + scenario_label = SCENARIO_LABELS.get(payload.ontology.scenario, "业务") + subject = self._resolve_subject(payload) + claim_no = str(payload.tool_payload.get("claim_no") or "").strip() or None + claim_status = str(payload.tool_payload.get("status") or "").strip() or None + approval_stage = str(payload.tool_payload.get("approval_stage") or "").strip() or None + is_submitted = claim_status == "submitted" + title = f"{scenario_label}处理意见草稿" + if claim_no: + title = f"{scenario_label}{'报销单' if is_submitted else '草稿'} {claim_no}" + if is_submitted: + body = ( + f"主题:{subject}\n" + f"结论:报销单已完成 AI验审,当前节点为 {approval_stage or '审批中'}。\n" + "建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n" + f"原始问题:{payload.message}" + ) + else: + body = ( + f"主题:{subject}\n" + "结论:已根据当前语义解析结果生成草稿,尚未自动执行。\n" + "建议:请先核对明细、规则命中和所需附件,再由人工确认是否提交正式流程。\n" + f"原始问题:{payload.message}" + ) + return UserAgentDraftPayload( + draft_type=payload.ontology.scenario, + title=title, + body=body, + confirmation_required=not is_submitted, + claim_id=str(payload.tool_payload.get("claim_id") or "").strip() or None, + claim_no=claim_no, + status=claim_status, + approval_stage=approval_stage, + ) + + def _build_suggested_actions( + self, + payload: UserAgentRequest, + ) -> list[UserAgentSuggestedAction]: + if payload.ontology.scenario == "knowledge": + return [] + + if self._is_generic_expense_prompt(payload): + return [ + UserAgentSuggestedAction( + label="上传票据", + action_type="ask_clarification", + description="上传发票、行程单或付款截图,继续识别报销内容。", + ), + UserAgentSuggestedAction( + label="补充报销信息", + action_type="ask_clarification", + description="补充费用类型、金额、时间和事由后继续处理。", + ), + ] + + if payload.ontology.intent in {"query", "compare"}: + return [ + UserAgentSuggestedAction( + label="查看明细", + action_type="open_detail", + description="继续查看命中记录和过滤条件。", + ), + UserAgentSuggestedAction( + label="生成处理意见", + action_type="create_draft", + description="把当前查询结果整理成可确认草稿。", + ), + ] + + if payload.ontology.intent == "risk_check": + return [ + UserAgentSuggestedAction( + label="人工复核风险", + action_type="manual_review", + description="优先检查明细、附件和规则命中原因。", + ), + UserAgentSuggestedAction( + label="生成整改建议", + action_type="create_draft", + description="把风险说明整理成处理意见草稿。", + ), + ] + + if payload.ontology.intent == "draft": + return [ + UserAgentSuggestedAction( + label="复制草稿", + action_type="copy_draft", + description="复制当前草稿后交由人工确认。", + ), + UserAgentSuggestedAction( + label="补充上下文", + action_type="ask_clarification", + description="补充单据编号、客户或供应商信息以完善草稿。", + ), + ] + + return [ + UserAgentSuggestedAction( + label="查看规则全文", + action_type="open_rule", + description="继续查看引用规则或知识内容。", + ), + UserAgentSuggestedAction( + label="补充问题上下文", + action_type="ask_clarification", + description="补充业务对象、时间或单据范围,提升回答准确度。", + ), + ] + + def _build_review_payload( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + draft_payload: UserAgentDraftPayload | None, + ) -> UserAgentReviewPayload | None: + attachment_count = self._resolve_attachment_count(payload) + ocr_documents = self._resolve_ocr_documents(payload) + if payload.ontology.scenario != "expense": + return None + if payload.ontology.intent not in {"draft", "operate"} and attachment_count <= 0 and not ocr_documents: + return None + + document_cards = self._build_review_document_cards(payload, ocr_documents=ocr_documents) + claim_groups = self._build_review_claim_groups( + payload, + document_cards=document_cards, + ) + slot_cards = self._build_review_slot_cards( + payload, + ocr_documents=ocr_documents, + claim_groups=claim_groups, + ) + missing_slot_keys = self._resolve_review_missing_slot_keys( + payload, + slot_cards=slot_cards, + ) + risk_briefs = self._build_review_risk_briefs( + payload, + citations=citations, + document_cards=document_cards, + claim_groups=claim_groups, + ) + association_choice_pending = self._is_review_association_choice_pending(payload) + can_proceed = ( + False + if association_choice_pending + else self._can_proceed_review( + payload, + missing_slot_keys=missing_slot_keys, + claim_groups=claim_groups, + ) + ) + confirmation_actions = self._build_review_confirmation_actions( + payload, + can_proceed=can_proceed, + claim_groups=claim_groups, + draft_payload=draft_payload, + ) + edit_fields = self._build_review_edit_fields( + payload, + draft_payload=draft_payload, + slot_cards=slot_cards, + ) + intent_summary = self._build_review_intent_summary( + payload, + slot_cards=slot_cards, + claim_groups=claim_groups, + ) + body_message = self._build_review_body_message( + payload, + slot_cards=slot_cards, + risk_briefs=risk_briefs, + can_proceed=can_proceed, + document_cards=document_cards, + ) + + return UserAgentReviewPayload( + intent_summary=intent_summary, + body_message=body_message, + scenario=payload.ontology.scenario, + intent=payload.ontology.intent, + can_proceed=can_proceed, + missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys], + risk_briefs=risk_briefs, + slot_cards=slot_cards, + document_cards=document_cards, + claim_groups=claim_groups, + confirmation_actions=confirmation_actions, + edit_fields=edit_fields, + ) + + def _build_review_slot_cards( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> list[UserAgentReviewSlotCard]: + entity_map = self._collect_entity_values(payload) + time_slot = self._build_time_slot(payload) + location_slot = self._build_location_slot(payload) + customer_slot = self._build_customer_slot(payload, entity_map=entity_map) + participants_slot = self._build_participants_slot(payload, entity_map=entity_map) + amount_slot = self._build_amount_slot(payload, entity_map=entity_map, ocr_documents=ocr_documents) + expense_type_slot = self._build_expense_type_slot( + payload, + entity_map=entity_map, + ocr_documents=ocr_documents, + ) + merchant_slot = self._build_merchant_slot(payload, ocr_documents=ocr_documents) + reason_slot = self._build_reason_slot( + payload, + claim_groups=claim_groups, + ) + attachment_slot = self._build_attachment_slot(payload) + required_keys = self._resolve_required_review_keys( + payload, + primary_expense_type=str(expense_type_slot["normalized_value"] or ""), + claim_groups=claim_groups, + ) + + cards = [ + self._make_slot_card( + key="expense_type", + value=expense_type_slot["value"], + raw_value=expense_type_slot["raw_value"], + normalized_value=expense_type_slot["normalized_value"], + source=expense_type_slot["source"], + confidence=expense_type_slot["confidence"], + evidence=expense_type_slot["evidence"], + required="expense_type" in required_keys, + ), + self._make_slot_card( + key="customer_name", + value=customer_slot["value"], + raw_value=customer_slot["raw_value"], + normalized_value=customer_slot["normalized_value"], + source=customer_slot["source"], + confidence=customer_slot["confidence"], + evidence=customer_slot["evidence"], + required="customer_name" in required_keys, + ), + self._make_slot_card( + key="time_range", + value=time_slot["value"], + raw_value=time_slot["raw_value"], + normalized_value=time_slot["normalized_value"], + source=time_slot["source"], + confidence=time_slot["confidence"], + evidence=time_slot["evidence"], + required="time_range" in required_keys, + ), + self._make_slot_card( + key="location", + value=location_slot["value"], + raw_value=location_slot["raw_value"], + normalized_value=location_slot["normalized_value"], + source=location_slot["source"], + confidence=location_slot["confidence"], + evidence=location_slot["evidence"], + required="location" in required_keys, + ), + self._make_slot_card( + key="merchant_name", + value=merchant_slot["value"], + raw_value=merchant_slot["raw_value"], + normalized_value=merchant_slot["normalized_value"], + source=merchant_slot["source"], + confidence=merchant_slot["confidence"], + evidence=merchant_slot["evidence"], + required="merchant_name" in required_keys, + ), + self._make_slot_card( + key="amount", + value=amount_slot["value"], + raw_value=amount_slot["raw_value"], + normalized_value=amount_slot["normalized_value"], + source=amount_slot["source"], + confidence=amount_slot["confidence"], + evidence=amount_slot["evidence"], + required="amount" in required_keys, + ), + self._make_slot_card( + key="reason", + value=reason_slot["value"], + raw_value=reason_slot["raw_value"], + normalized_value=reason_slot["normalized_value"], + source=reason_slot["source"], + confidence=reason_slot["confidence"], + evidence=reason_slot["evidence"], + required="reason" in required_keys, + ), + self._make_slot_card( + key="participants", + value=participants_slot["value"], + raw_value=participants_slot["raw_value"], + normalized_value=participants_slot["normalized_value"], + source=participants_slot["source"], + confidence=participants_slot["confidence"], + evidence=participants_slot["evidence"], + required="participants" in required_keys, + ), + self._make_slot_card( + key="attachments", + value=attachment_slot["value"], + raw_value=attachment_slot["raw_value"], + normalized_value=attachment_slot["normalized_value"], + source=attachment_slot["source"], + confidence=attachment_slot["confidence"], + evidence=attachment_slot["evidence"], + required="attachments" in required_keys, + ), + ] + return cards + + def _build_review_document_cards( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + ) -> list[UserAgentReviewDocumentCard]: + cards: list[UserAgentReviewDocumentCard] = [] + for index, item in enumerate(ocr_documents, start=1): + classified = self._classify_document(item, payload) + fields = self._extract_document_fields(item) + cards.append( + UserAgentReviewDocumentCard( + index=index, + filename=str(item.get("filename") or f"document-{index}"), + document_type=classified["document_type"], + suggested_expense_type=classified["expense_type"], + scene_label=GROUP_SCENE_LABELS.get( + classified["group_code"], + classified["scene_label"], + ), + summary=str(item.get("summary") or item.get("text") or "").strip(), + avg_score=float(item.get("avg_score") or 0.0), + preview_kind=str(item.get("preview_kind") or "").strip(), + preview_data_url=str(item.get("preview_data_url") or "").strip(), + warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], + fields=[ + UserAgentReviewDocumentField( + label=label, + value=value, + source="ocr", + ) + for label, value in fields.items() + if str(value).strip() + ], + ) + ) + return cards + + def _build_review_claim_groups( + self, + payload: UserAgentRequest, + *, + document_cards: list[UserAgentReviewDocumentCard], + ) -> list[UserAgentReviewClaimGroup]: + groups: dict[str, dict[str, object]] = {} + for card in document_cards: + group_code = self._normalize_group_code(card.suggested_expense_type) + bucket = groups.setdefault( + group_code, + { + "document_indexes": [], + "amount_total": 0.0, + "expense_type": str(card.suggested_expense_type or group_code).strip() or group_code, + "scene_label": GROUP_SCENE_LABELS.get( + str(card.suggested_expense_type or group_code).strip() or group_code, + GROUP_SCENE_LABELS.get(group_code, "其他费用"), + ), + "reasons": [], + }, + ) + bucket["document_indexes"].append(card.index) + bucket["amount_total"] = float(bucket["amount_total"]) + self._extract_amount_from_card(card) + bucket["reasons"].append(f"{card.filename} 识别为 {card.scene_label}") + current_expense_type = str(bucket["expense_type"] or "").strip() + current_card_type = str(card.suggested_expense_type or "").strip() + if current_expense_type and current_card_type and current_expense_type != current_card_type: + bucket["expense_type"] = group_code + bucket["scene_label"] = GROUP_SCENE_LABELS.get(group_code, "其他费用") + + if not groups: + expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "other") + group_code = self._normalize_group_code(expense_type_code) + groups[group_code] = { + "document_indexes": [], + "amount_total": self._resolve_amount_value(payload), + "expense_type": expense_type_code or "other", + "scene_label": GROUP_SCENE_LABELS.get(group_code, "其他费用"), + "reasons": ["当前主要依据用户文本和页面上下文进行分单建议。"], + } + + claim_groups: list[UserAgentReviewClaimGroup] = [] + for index, (group_code, bucket) in enumerate(groups.items(), start=1): + title = f"建议报销单 {index}:{bucket['scene_label']}" + rationale = ( + ";".join(dict.fromkeys(str(item) for item in bucket["reasons"])) + if bucket["reasons"] + else "当前仅有单一场景,无需拆单。" + ) + claim_groups.append( + UserAgentReviewClaimGroup( + group_code=group_code, + title=title, + expense_type=str(bucket["expense_type"]), + scene_label=str(bucket["scene_label"]), + document_indexes=list(bucket["document_indexes"]), + amount_total=round(float(bucket["amount_total"]), 2), + rationale=rationale, + ) + ) + return claim_groups + + def _build_review_risk_briefs( + self, + payload: UserAgentRequest, + *, + citations: list[UserAgentCitation], + document_cards: list[UserAgentReviewDocumentCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> list[UserAgentReviewRiskBrief]: + briefs: list[UserAgentReviewRiskBrief] = [] + employee_name = self._collect_entity_values(payload).get("employee_name") or str( + payload.context_json.get("name") or "" + ).strip() + if employee_name: + since = datetime.now(UTC) - timedelta(days=90) + stmt = select(ExpenseClaim).where( + ExpenseClaim.employee_name == employee_name, + ExpenseClaim.occurred_at >= since, + ) + recent_claims = list(self.db.scalars(stmt).all()) + if recent_claims: + risky_count = sum(1 for item in recent_claims if item.risk_flags_json) + draft_count = sum(1 for item in recent_claims if item.status == "draft") + briefs.append( + UserAgentReviewRiskBrief( + title="历史报销画像", + level="info", + content=( + f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销," + f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。" + ), + ) + ) + current_amount = self._resolve_amount_value(payload) + if current_amount > 0: + duplicate_count = sum( + 1 + for item in recent_claims + if abs(float(item.amount) - current_amount) < 0.01 + ) + if duplicate_count: + briefs.append( + UserAgentReviewRiskBrief( + title="金额重复预警", + level="warning", + content=( + f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," + "提交前建议核对是否为重复报销或拆分不当。" + ), + ) + ) + + if citations: + briefs.append( + UserAgentReviewRiskBrief( + title="制度注意事项", + level="info", + content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。", + ) + ) + + warning_count = sum(len(item.warnings) for item in document_cards) + if warning_count: + briefs.append( + UserAgentReviewRiskBrief( + title="票据识别提醒", + level="warning", + content=f"当前共有 {warning_count} 条票据识别提示,建议逐张确认 OCR 识别字段。", + ) + ) + + if len(claim_groups) > 1: + briefs.append( + UserAgentReviewRiskBrief( + title="建议拆单", + level="high", + content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", + ) + ) + + return briefs[:4] + + def _build_review_confirmation_actions( + self, + payload: UserAgentRequest, + *, + can_proceed: bool, + claim_groups: list[UserAgentReviewClaimGroup], + draft_payload: UserAgentDraftPayload | None, + ) -> list[UserAgentReviewAction]: + if self._is_review_association_choice_pending(payload): + claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() + link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿" + return [ + UserAgentReviewAction( + label="取消", + action_type="cancel_review", + description="放弃当前识别结果,并退出本次核对流程。", + emphasis="secondary", + ), + UserAgentReviewAction( + label="修改识别信息", + action_type="edit_review", + description="打开结构化模板,按已识别字段逐项修改。", + emphasis="secondary", + ), + UserAgentReviewAction( + label=link_label, + action_type="link_to_existing_draft", + description=( + f"把本次上传票据并入现有草稿 {claim_no}。" + if claim_no + else "把本次上传票据并入现有草稿。" + ), + emphasis="primary", + ), + UserAgentReviewAction( + label="单独建立报销单", + action_type="create_new_claim_from_documents", + description="基于当前上传的多张票据,新建一张独立的报销草稿。", + emphasis="secondary", + ), + ] + + primary_action = UserAgentReviewAction( + label="继续下一步" if can_proceed else "保存为草稿", + action_type="next_step" if can_proceed else "save_draft", + description=( + "当前识别信息已满足继续处理条件,确认后进入下一步。" + if can_proceed + else "暂存当前识别结果,后续可以继续补充或修改。" + ), + emphasis="primary", + ) + if len(claim_groups) > 1 and can_proceed: + primary_action.description = f"系统建议拆分为 {len(claim_groups)} 张报销单,确认后继续下一步。" + if draft_payload is not None and draft_payload.claim_no and not can_proceed: + primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" + + return [ + UserAgentReviewAction( + label="取消", + action_type="cancel_review", + description="放弃当前识别结果,并退出本次核对流程。", + emphasis="secondary", + ), + UserAgentReviewAction( + label="修改识别信息", + action_type="edit_review", + description="打开结构化模板,按已识别字段逐项修改。", + emphasis="secondary", + ), + primary_action, + ] + + def _build_review_intent_summary( + self, + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> str: + slots = {item.key: item for item in slot_cards} + expense_type = slots.get("expense_type") + amount = slots.get("amount") + time_range = slots.get("time_range") + location = slots.get("location") + customer = slots.get("customer_name") + + summary = "我先根据您当前提供的信息整理出一笔报销。" + if expense_type and expense_type.value: + summary = f"识别到您希望报销一笔“{expense_type.value}”费用。" + details: list[str] = [] + if customer and customer.value: + details.append(f"客户为 {customer.value}") + if time_range and time_range.value: + details.append(f"时间为 {time_range.value}") + if location and location.value: + details.append(f"地点为 {location.value}") + if amount and amount.value: + details.append(f"金额为 {amount.value}") + reason = slots.get("reason") + if reason and reason.value: + details.append(f"事由是 {reason.value}") + if details: + return f"{summary} {','.join(details)}。" + return summary + + def _build_review_body_answer( + self, + payload: UserAgentRequest, + *, + review_payload: UserAgentReviewPayload | None, + draft_payload: UserAgentDraftPayload | None, + ) -> str | None: + if review_payload is None: + return None + if payload.ontology.scenario != "expense": + return None + if payload.ontology.intent not in {"draft", "operate"}: + return None + if payload.tool_payload.get("draft_limit_reached"): + return ( + str(payload.tool_payload.get("message") or "").strip() + or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + ) + + review_action = str(payload.context_json.get("review_action") or "").strip() + if review_action == "save_draft": + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" + "后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + ) + return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + if review_action == "link_to_existing_draft": + document_count = self._resolve_review_document_count(payload) + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。" + "您可以继续补充识别字段,确认无误后再提交审批。" + ) + return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。" + if review_action == "create_new_claim_from_documents": + document_count = self._resolve_review_document_count(payload) + if draft_payload is not None and draft_payload.claim_no: + return ( + f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。" + "您可以继续补充识别字段,确认无误后再提交审批。" + ) + return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" + if review_action == "next_step": + if draft_payload is not None and draft_payload.status == "submitted": + stage_text = draft_payload.approval_stage or "审批中" + return f"报销单 {draft_payload.claim_no or ''} 已完成 AI验审,当前节点为 {stage_text}。".strip() + if payload.tool_payload.get("submission_blocked"): + return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" + return ( + f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " + "当前关键信息已基本齐全,您确认无误后可以继续下一步。" + ) + if review_action == "edit_review": + return ( + f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " + f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}" + ) + return review_payload.body_message or None + + def _build_review_body_message( + self, + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + risk_briefs: list[UserAgentReviewRiskBrief], + can_proceed: bool, + document_cards: list[UserAgentReviewDocumentCard], + ) -> str: + if self._is_review_association_choice_pending(payload): + claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() + document_count = len(document_cards) or self._resolve_review_document_count(payload) + if claim_no: + return ( + f"已识别出本次上传的 {document_count} 张票据。" + f"系统检测到你已有草稿 {claim_no},请选择关联到该草稿,或单独建立一张新的报销单。" + ) + return ( + f"已识别出本次上传的 {document_count} 张票据。" + "系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。" + ) + + review_payload = UserAgentReviewPayload( + intent_summary="", + body_message="", + scenario=payload.ontology.scenario, + intent=payload.ontology.intent, + can_proceed=can_proceed, + missing_slots=self._resolve_review_missing_slot_labels(slot_cards), + risk_briefs=risk_briefs, + slot_cards=slot_cards, + document_cards=[], + claim_groups=[], + confirmation_actions=[], + edit_fields=[], + ) + return ( + f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} " + f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" + ) + + @staticmethod + def _resolve_review_missing_slot_labels( + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[str]: + return [item.label for item in slot_cards if item.status == "missing"] + + @staticmethod + def _build_review_guidance_copy( + review_payload: UserAgentReviewPayload, + *, + mention_save_draft: bool, + ) -> str: + missing_count = len(review_payload.missing_slots) + reminder_count = len(review_payload.risk_briefs) + + if review_payload.can_proceed: + if reminder_count: + return ( + f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。" + "您可以展开下方卡片查看详情,确认无误后继续下一步。" + ) + return "当前关键信息已基本齐全,您确认无误后可以继续下一步。" + + issue_parts: list[str] = [] + if missing_count: + issue_parts.append(f"{missing_count} 项信息待补充") + if reminder_count: + issue_parts.append(f"{reminder_count} 条提醒") + issue_summary = "、".join(issue_parts) if issue_parts else "一些细节还需要进一步确认" + + suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else "。" + return ( + f"当前还有 {issue_summary}。" + f"您可以展开下方卡片查看详情,继续补充或修改{suffix}" + ) + + @staticmethod + def _can_proceed_review( + payload: UserAgentRequest, + *, + missing_slot_keys: list[str], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> bool: + if payload.ontology.ambiguity: + return False + if missing_slot_keys: + return False + if not claim_groups: + return False + return True + + def _build_review_edit_fields( + self, + payload: UserAgentRequest, + *, + draft_payload: UserAgentDraftPayload | None, + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[UserAgentReviewEditField]: + slot_map = {item.key: item for item in slot_cards} + employee = self._resolve_employee_profile(payload) + reporter_name = ( + slot_map.get("reporter_name").value + if slot_map.get("reporter_name") + else str(payload.context_json.get("name") or "").strip() + ) + manager_name = self._resolve_manager_name(employee) + reason = slot_map.get("reason").value if slot_map.get("reason") else "" + attachments = "、".join(self._resolve_attachment_names(payload)) + + fields = [ + UserAgentReviewEditField( + key="claim_no", + label="报销单据编号", + value=str(draft_payload.claim_no if draft_payload is not None and draft_payload.claim_no else "待生成"), + placeholder="保存草稿后自动生成", + required=False, + group="basic", + ), + UserAgentReviewEditField( + key="expense_type", + label="报销类型", + value=slot_map.get("expense_type").value if slot_map.get("expense_type") else "", + placeholder="例如:业务招待费 / 差旅费", + group="basic", + ), + UserAgentReviewEditField( + key="occurred_date", + label="业务发生时间", + value=slot_map.get("time_range").normalized_value if slot_map.get("time_range") and slot_map.get("time_range").normalized_value else slot_map.get("time_range").value if slot_map.get("time_range") else "", + placeholder="例如:2026-05-11", + group="basic", + ), + UserAgentReviewEditField( + key="reporter_name", + label="报销人", + value=reporter_name, + placeholder="请输入报销人姓名", + group="basic", + ), + UserAgentReviewEditField( + key="manager_name", + label="直属上司姓名", + value=manager_name, + placeholder="请输入直属上司姓名", + required=False, + group="basic", + ), + UserAgentReviewEditField( + key="customer_name", + label="客户名称", + value=slot_map.get("customer_name").value if slot_map.get("customer_name") else "", + placeholder="请输入客户名称", + group="business", + ), + UserAgentReviewEditField( + key="business_location", + label="业务地点", + value=slot_map.get("location").normalized_value if slot_map.get("location") and slot_map.get("location").normalized_value else slot_map.get("location").value if slot_map.get("location") else "", + placeholder="例如:北京 / 客户现场", + required=False, + group="business", + ), + UserAgentReviewEditField( + key="merchant_name", + label="酒店/商户", + value=slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "", + placeholder="请输入酒店或商户名称", + required=False, + group="business", + ), + UserAgentReviewEditField( + key="amount", + label="金额", + value=slot_map.get("amount").normalized_value if slot_map.get("amount") and slot_map.get("amount").normalized_value else slot_map.get("amount").value if slot_map.get("amount") else "", + placeholder="例如:200.00元", + group="business", + ), + UserAgentReviewEditField( + key="participants", + label="参与人员", + value=slot_map.get("participants").value if slot_map.get("participants") else "", + placeholder="例如:客户 2 人,我方 1 人", + group="business", + ), + UserAgentReviewEditField( + key="reason", + label="事由", + value=reason, + placeholder="请输入报销事由", + field_type="textarea", + group="business", + ), + UserAgentReviewEditField( + key="attachment_names", + label="附件清单", + value=attachments, + placeholder="例如:发票.jpg、行程单.png", + required=False, + field_type="textarea", + group="attachments", + ), + ] + return fields + + def _resolve_employee_profile(self, payload: UserAgentRequest) -> Employee | None: + candidates = [ + str(payload.context_json.get("name") or "").strip(), + str(payload.user_id or "").strip(), + self._collect_entity_values(payload).get("employee_name", ""), + ] + normalized = [item for item in dict.fromkeys(candidates) if item] + if not normalized: + return None + + stmt = ( + select(Employee) + .where( + or_( + Employee.name.in_(normalized), + Employee.employee_no.in_(normalized), + Employee.email.in_(normalized), + ) + ) + .limit(1) + ) + return self.db.scalar(stmt) + + @staticmethod + def _resolve_manager_name(employee: Employee | None) -> str: + if employee is None: + return "" + if employee.manager is not None and employee.manager.name: + return employee.manager.name + if employee.organization_unit is not None and employee.organization_unit.manager_name: + return employee.organization_unit.manager_name + return "" + + @staticmethod + def _extract_message_reason(message: str) -> str: + for line in str(message or "").splitlines(): + cleaned = line.strip() + if not cleaned: + continue + if cleaned.startswith(("附件名称:", "OCR摘要:", "关联单号:")): + continue + return cleaned[:300] + return "" + + @staticmethod + def _looks_like_system_generated_reason_message(message: str) -> bool: + cleaned = str(message or "").strip() + if not cleaned: + return False + compact = re.sub(r"\s+", "", cleaned) + return compact.startswith(SYSTEM_GENERATED_REASON_PREFIXES) + + def _resolve_reason_source_text(self, payload: UserAgentRequest) -> str: + explicit_text = payload.context_json.get("user_input_text") + if isinstance(explicit_text, str): + return explicit_text.strip() + if self._looks_like_system_generated_reason_message(payload.message): + return "" + return str(payload.message or "").strip() + + @classmethod + def _resolve_reason_text(cls, message: str) -> str: + reason = cls._extract_message_reason(message) + if not reason: + return "" + + compact = re.sub(r"\s+", "", reason) + if compact in GENERIC_EXPENSE_PROMPTS: + return "" + + instruction_prefixes = ( + "帮我生成", + "请帮我生成", + "生成", + "起草", + "创建", + "发起", + "准备", + "帮我报销", + "我要报销", + "我想报销", + ) + if compact.startswith(instruction_prefixes): + for separator in (",", ",", "。", ";", ";", ":", ":"): + if separator in reason: + trailing = reason.split(separator, 1)[1].strip() + if trailing: + return trailing[:300] + return "" + + return reason + + @staticmethod def _should_skip_model_answer( payload: UserAgentRequest, review_payload: UserAgentReviewPayload | None, ) -> bool: if payload.ontology.scenario == "expense" and payload.ontology.intent in {"query", "compare"}: return True - if review_payload is None: - return False - return payload.ontology.scenario == "expense" and ( - payload.ontology.intent == "draft" - or int(payload.context_json.get("attachment_count") or 0) > 0 - ) - - def _build_rule_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: - domain = self._resolve_domain(payload.ontology.scenario) - items = self.asset_service.list_assets( - asset_type=AgentAssetType.RULE.value, - status=AgentAssetStatus.ACTIVE.value, - domain=domain, - ) - ranked = self._rank_rule_assets(items, payload) - citations: list[UserAgentCitation] = [] - for item in ranked[:2]: - detail = self.asset_service.get_asset(item.id) - if detail is None: - continue - excerpt = self._extract_excerpt(str(detail.current_version_content or "")) - citations.append( - UserAgentCitation( - source_type="rule", - code=detail.code, - title=detail.name, - version=detail.current_version, - updated_at=detail.updated_at.date().isoformat(), - excerpt=excerpt, - ) - ) - return citations - - @staticmethod - def _resolve_risk_flags(payload: UserAgentRequest) -> list[str]: - tool_flags = payload.tool_payload.get("risk_flags") - if isinstance(tool_flags, list) and tool_flags: - return [str(item) for item in tool_flags] - return [str(item) for item in payload.ontology.risk_flags] - - @staticmethod - def _resolve_subject(payload: UserAgentRequest) -> str: - named_entities = [ - item.value - for item in payload.ontology.entities - if item.type in {"employee", "customer", "vendor", "project"} - ] - if named_entities: - return f"{'、'.join(named_entities)} 相关数据" - return f"{SCENARIO_LABELS.get(payload.ontology.scenario, '当前')}场景数据" - - @staticmethod - def _is_generic_expense_prompt(payload: UserAgentRequest) -> bool: - if payload.ontology.scenario != "expense": - return False - normalized_message = re.sub(r"\s+", "", payload.message) - return normalized_message in GENERIC_EXPENSE_PROMPTS - - @staticmethod - def _is_implicit_expense_draft_request(payload: UserAgentRequest) -> bool: - if payload.ontology.scenario != "expense" or payload.ontology.intent != "draft": - return False - - compact_message = re.sub(r"\s+", "", payload.message) - if any(keyword in compact_message for keyword in EXPLICIT_DRAFT_KEYWORDS): - return False - - return True - - @staticmethod - def _resolve_attachment_names(payload: UserAgentRequest) -> list[str]: - names = payload.context_json.get("attachment_names") - if not isinstance(names, list): - return [] - return [str(name) for name in names if str(name).strip()] - - @staticmethod - def _resolve_attachment_count(payload: UserAgentRequest) -> int: - names = UserAgentService._resolve_attachment_names(payload) - if names: - return len(names) - try: - return max(0, int(payload.context_json.get("attachment_count") or 0)) - except (TypeError, ValueError): - return 0 - - @staticmethod - def _resolve_ocr_documents(payload: UserAgentRequest) -> list[dict[str, object]]: - documents = payload.context_json.get("ocr_documents") - if not isinstance(documents, list): - return [] - overrides = payload.context_json.get("review_document_form_values") - override_map: dict[tuple[int, str], dict[str, object]] = {} - if isinstance(overrides, list): - for item in overrides: - if not isinstance(item, dict): - continue - filename = str(item.get("filename") or "").strip() - index = int(item.get("index") or 0) - if not filename and index <= 0: - continue - override_map[(index, filename)] = item - normalized: list[dict[str, object]] = [] - for index, item in enumerate(documents[:8], start=1): - if not isinstance(item, dict): - continue - normalized_item = dict(item) - override = override_map.get((index, str(normalized_item.get("filename") or "").strip())) - if override is None: - override = override_map.get((index, "")) - if override is not None: - summary = str(override.get("summary") or "").strip() - scene_label = str(override.get("scene_label") or "").strip() - fields = override.get("fields") - if summary: - normalized_item["summary"] = summary - if scene_label: - normalized_item["scene_label"] = scene_label - if isinstance(fields, list): - normalized_item["document_fields"] = [ - { - "key": str(field.get("key") or field.get("label") or "").strip(), - "label": str(field.get("label") or "").strip(), - "value": str(field.get("value") or "").strip(), - } - for field in fields - if isinstance(field, dict) - and str(field.get("label") or "").strip() - and str(field.get("value") or "").strip() - ] - normalized.append(normalized_item) - return normalized - - @staticmethod - def _is_review_association_choice_pending(payload: UserAgentRequest) -> bool: - return bool(payload.tool_payload.get("pending_association_decision")) - - def _resolve_review_document_count(self, payload: UserAgentRequest) -> int: - return max( - len(self._resolve_ocr_documents(payload)), - self._resolve_attachment_count(payload), - ) - - @staticmethod - def _resolve_conversation_history(payload: UserAgentRequest) -> list[dict[str, object]]: - history = payload.context_json.get("conversation_history") - if not isinstance(history, list): - return [] - - normalized: list[dict[str, object]] = [] - for item in history[-8:]: - if not isinstance(item, dict): - continue - role = str(item.get("role") or "").strip() - content = str(item.get("content") or "").strip() - if not role or not content: - continue - normalized.append({"role": role, "content": content}) - return normalized - - @staticmethod - def _resolve_domain(scenario: str) -> str | None: - if scenario == "expense": - return "expense" - if scenario == "accounts_receivable": - return "ar" - if scenario == "accounts_payable": - return "ap" - return None - - @staticmethod - def _rank_rule_assets( - items: list[AgentAssetListItem], - payload: UserAgentRequest, - ) -> list[AgentAssetListItem]: - def score(item: AgentAssetListItem) -> tuple[int, str]: - tags = {str(value) for value in item.scenario_json or []} - weight = 0 - if payload.ontology.scenario in tags: - weight += 3 - if payload.ontology.intent in tags: - weight += 2 - for risk_flag in payload.ontology.risk_flags: - if risk_flag in tags: - weight += 4 - return weight, item.code - - ranked = sorted(items, key=score, reverse=True) - return [item for item in ranked if score(item)[0] > 0] - - @staticmethod - def _extract_excerpt(content: str) -> str: - lines = [line.strip() for line in str(content).splitlines() if line.strip()] - cleaned: list[str] = [] - for line in lines: - normalized = re.sub(r"^[#>\-\*\d\.\s`]+", "", line).strip() - if normalized: - cleaned.append(normalized) - if len(cleaned) >= 2: - break - return ";".join(cleaned[:2]) - - def _collect_entity_values(self, payload: UserAgentRequest) -> dict[str, str]: - values = { - "employee_name": "", - "customer": "", - "participants": "", - "amount": "", - "expense_type": "", - "expense_type_code": "", - } - participants: list[str] = [] - for item in payload.ontology.entities: - if item.type == "employee" and not values["employee_name"]: - values["employee_name"] = item.value - elif item.type == "customer" and not values["customer"]: - values["customer"] = item.value - elif item.type == "amount" and item.role != "threshold" and not values["amount"]: - values["amount"] = f"{item.value}元" if "元" not in item.value else item.value - elif item.type == "expense_type" and not values["expense_type_code"]: - values["expense_type_code"] = item.normalized_value - values["expense_type"] = EXPENSE_TYPE_LABELS.get( - item.normalized_value, - item.value, - ) - elif item.type in {"participant", "person"} and item.value.strip(): - participants.append(item.value.strip()) - if participants: - values["participants"] = "、".join(dict.fromkeys(participants)) - return values - - def _format_time_range(self, payload: UserAgentRequest) -> str: - time_range = payload.ontology.time_range - if time_range.start_date and time_range.end_date: - if time_range.start_date == time_range.end_date: - return time_range.start_date - normalized = f"{time_range.start_date} 至 {time_range.end_date}" - return normalized - if time_range.raw: - return time_range.raw - return "" - - def _resolve_location_value(self, payload: UserAgentRequest) -> str: - review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return value - - if str(payload.context_json.get("entry_source") or "").strip() == "detail": - request_context = payload.context_json.get("request_context") - if isinstance(request_context, dict): - for key in ("city", "location"): - value = str(request_context.get(key) or "").strip() - if value: - return value - - labeled_match = re.search(r"(?:业务地点|发生地点|地点)[::]\s*(?P[^\n,。;]+)", payload.message) - if labeled_match: - return labeled_match.group("value").strip() - - city_match = re.search(r"去(?P[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message) - if city_match: - return city_match.group("city").strip() - if "客户现场" in payload.message.replace(" ", ""): - return "客户现场" - return "" - - @staticmethod - def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: - values = payload.context_json.get("review_form_values") - if not isinstance(values, dict): - return {} - normalized: dict[str, str] = {} - for key, value in values.items(): - cleaned_key = str(key or "").strip() - if not cleaned_key: - continue - normalized[cleaned_key] = str(value or "").strip() - return normalized - - @staticmethod - def _build_slot_value( - *, - value: str = "", - raw_value: str = "", - normalized_value: str = "", - source: str = "system", - confidence: float = 0.0, - evidence: str = "", - ) -> dict[str, str | float]: - return { - "value": str(value or "").strip(), - "raw_value": str(raw_value or "").strip(), - "normalized_value": str(normalized_value or "").strip(), - "source": str(source or "system").strip() or "system", - "confidence": float(confidence), - "evidence": str(evidence or "").strip(), - } - - def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str( - review_form_values.get("occurred_date") - or review_form_values.get("time_range") - or review_form_values.get("business_time") - or "" - ).strip() - if edited_value: - raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() - return self._build_slot_value( - value=edited_value, - raw_value=raw_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - time_range = payload.ontology.time_range - if time_range.start_date and time_range.end_date: - normalized_value = ( - time_range.start_date - if time_range.start_date == time_range.end_date - else f"{time_range.start_date} 至 {time_range.end_date}" - ) - raw_value = str(time_range.raw or "").strip() - return self._build_slot_value( - value=normalized_value, - raw_value=raw_value, - normalized_value=normalized_value, - source="user_text", - confidence=0.92, - evidence="系统已根据当前日期将相对时间换算为标准日期。", - ) - - return self._build_slot_value() - - def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - for key in ("business_location", "location"): - value = str(review_form_values.get(key) or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - if str(payload.context_json.get("entry_source") or "").strip() == "detail": - request_context = payload.context_json.get("request_context") - if isinstance(request_context, dict): - for key in ("city", "location"): - value = str(request_context.get(key) or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="detail_context", - confidence=0.68, - evidence="来源于当前关联单据,仅作为辅助上下文,需要用户再次核对。", - ) - - value = self._resolve_location_value(payload) - if value: - evidence = "用户在文本中明确描述了业务地点。" - if value == "客户现场": - evidence = "用户明确提到“客户现场”,但未提供具体城市或地址。" - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.82, - evidence=evidence, - ) - return self._build_slot_value() - - def _build_customer_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - value = str(review_form_values.get("customer_name") or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - value = entity_map.get("customer", "") - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.88, - evidence="用户在原始描述中直接提到了客户对象。", - ) - return self._build_slot_value() - - def _build_participants_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - value = str(review_form_values.get("participants") or "").strip() - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - value = entity_map.get("participants", "") - if value: - return self._build_slot_value( - value=value, - normalized_value=value, - source="user_text", - confidence=0.8, - evidence="用户在当前描述中补充了参与人员。", - ) - return self._build_slot_value() - - def _build_reason_slot( - self, - payload: UserAgentRequest, - *, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("reason") or "").strip() - if edited_value: - return self._build_slot_value( - value=edited_value, - raw_value=edited_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload)) - if reason_value: - return self._build_slot_value( - value=reason_value, - raw_value=reason_value, - normalized_value=reason_value, - source="user_text", - confidence=0.76, - evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。", - ) - - inferred_reason = self._infer_reason_from_claim_groups( - claim_groups=claim_groups, - ) - if inferred_reason: - return self._build_slot_value( - value=inferred_reason, - raw_value=inferred_reason, - normalized_value=inferred_reason, - source="ocr", - confidence=0.68, - evidence="系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。", - ) - return self._build_slot_value() - - def _build_amount_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_amount = str(review_form_values.get("amount") or "").strip() - if edited_amount: - normalized = self._normalize_amount_text(edited_amount) - return self._build_slot_value( - value=normalized, - raw_value=edited_amount, - normalized_value=normalized, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - amount_value = entity_map.get("amount", "") - if amount_value: - normalized = self._normalize_amount_text(amount_value) - return self._build_slot_value( - value=normalized, - raw_value=amount_value, - normalized_value=normalized, - source="user_text", - confidence=0.92, - evidence="用户在原始描述中直接给出了金额。", - ) - - ocr_total_amount = self._sum_ocr_amounts(ocr_documents) - if ocr_total_amount > 0: - normalized = f"{ocr_total_amount:.2f}元" - return self._build_slot_value( - value=normalized, - normalized_value=normalized, - source="ocr", - confidence=0.76, - evidence="金额来自 OCR 汇总结果,仍建议用户核对票据原文。", - ) - return self._build_slot_value() - - def _build_expense_type_slot( - self, - payload: UserAgentRequest, - *, - entity_map: dict[str, str], - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip() - if edited_value: - normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) - return self._build_slot_value( - value=normalized_label, - raw_value=edited_value, - normalized_value=normalized_code, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - expense_type_code = entity_map.get("expense_type_code", "") - expense_type_value = EXPENSE_TYPE_LABELS.get(expense_type_code, entity_map.get("expense_type", "")) - if expense_type_value: - return self._build_slot_value( - value=expense_type_value, - raw_value=expense_type_value, - normalized_value=expense_type_code, - source="user_text", - confidence=0.9, - evidence="系统根据用户描述中的业务场景判断费用类型。", - ) - - inferred_label = self._infer_expense_type_from_documents(payload, ocr_documents) if ocr_documents else "" - if inferred_label: - normalized_code, normalized_label = self._normalize_expense_type_input(inferred_label) - return self._build_slot_value( - value=normalized_label, - raw_value=inferred_label, - normalized_value=normalized_code, - source="ocr", - confidence=0.74, - evidence="系统根据票据内容推断费用类型,仍建议用户确认。", - ) - return self._build_slot_value() - - def _build_merchant_slot( - self, - payload: UserAgentRequest, - *, - ocr_documents: list[dict[str, object]], - ) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - edited_value = str(review_form_values.get("merchant_name") or "").strip() - if edited_value: - return self._build_slot_value( - value=edited_value, - normalized_value=edited_value, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else "" - if merchant_value: - return self._build_slot_value( - value=merchant_value, - normalized_value=merchant_value, - source="ocr", - confidence=0.72, - evidence="商户名称来自 OCR 票据识别结果,仍建议用户核对。", - ) - return self._build_slot_value() - - def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: - review_form_values = self._resolve_review_form_values(payload) - attachment_names = str(review_form_values.get("attachment_names") or "").strip() - if attachment_names: - return self._build_slot_value( - value=attachment_names, - normalized_value=attachment_names, - source="user_form", - confidence=1.0, - evidence="来源于用户修改后的结构化表单。", - ) - - count = self._resolve_attachment_count(payload) - if count > 0: - names = self._resolve_attachment_names(payload) - value = "、".join(names) if names else f"{count} 份附件" - return self._build_slot_value( - value=value, - raw_value=value, - normalized_value=str(count), - source="upload", - confidence=1.0, - evidence="系统已接收到用户上传的附件。", - ) - return self._build_slot_value() - - @staticmethod - def _normalize_amount_text(value: str) -> str: - cleaned = str(value or "").strip() - if not cleaned: - return "" - match = AMOUNT_TEXT_PATTERN.search(cleaned) - if not match: - return cleaned - number = float(match.group(1)) - return f"{number:.2f}元" - - @staticmethod - def _normalize_expense_type_input(value: str) -> tuple[str, str]: - compact = str(value or "").replace(" ", "") - if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))): - return "entertainment", "业务招待费" - if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")): - return "travel", "差旅费" - if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")): - return "hotel", "住宿费" - if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")): - return "transport", "交通费" - if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): - return "meal", "餐费" - if "会务" in compact: - return "meeting", "会务费" - if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")): - return "office", "办公费" - if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")): - return "training", "培训费" - if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")): - return "communication", "通讯费" - if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")): - return "welfare", "福利费" - return "other", str(value or "").strip() or "其他费用" - - def _resolve_required_review_keys( - self, - payload: UserAgentRequest, - *, - primary_expense_type: str, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> set[str]: - required = {"expense_type", "time_range", "amount", "reason", "attachments"} - scene_codes = { - str(item.group_code or "").strip() - for item in claim_groups - if str(item.group_code or "").strip() - } - if primary_expense_type: - scene_codes.add(primary_expense_type) - - for scene_code in scene_codes: - required.update(SCENE_REQUIRED_SLOT_KEYS.get(scene_code, set())) - - compact_message = re.sub(r"\s+", "", self._resolve_reason_source_text(payload) or payload.message) - if "entertainment" in scene_codes or ( - "客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客")) - ): - required.update({"customer_name", "participants"}) - - return required - - @staticmethod - def _infer_reason_from_claim_groups( - *, - claim_groups: list[UserAgentReviewClaimGroup], - ) -> str: - if len(claim_groups) == 1: - document_indexes = list(claim_groups[0].document_indexes or []) - if not document_indexes: - return "" - - expense_type = str(claim_groups[0].expense_type or "").strip() - group_code = str(claim_groups[0].group_code or "").strip() - if expense_type: - return INFERRED_REASON_LABELS.get(expense_type, "") or str(claim_groups[0].scene_label or "").strip() - if group_code: - return INFERRED_REASON_LABELS.get(group_code, "") or str(claim_groups[0].scene_label or "").strip() - return "" - - @staticmethod - def _resolve_review_missing_slot_keys( - payload: UserAgentRequest, - *, - slot_cards: list[UserAgentReviewSlotCard], - ) -> list[str]: - required_keys = {item.key for item in slot_cards if item.required} - slot_map = {item.key: item for item in slot_cards} - missing_keys = { - item.key - for item in slot_cards - if item.required and (item.status == "missing" or not str(item.value).strip()) - } - for key in payload.ontology.missing_slots: - normalized_key = str(key or "").strip() - if ( - normalized_key - and normalized_key in required_keys - and ( - normalized_key not in slot_map - or slot_map[normalized_key].status == "missing" - or not str(slot_map[normalized_key].value).strip() - ) - ): - missing_keys.add(normalized_key) - - ordered_keys: list[str] = [] - for item in slot_cards: - if item.required and item.key in missing_keys and item.key not in ordered_keys: - ordered_keys.append(item.key) - return ordered_keys - - def _make_slot_card( - self, - *, - key: str, - value: str, - raw_value: str, - normalized_value: str, - source: str, - confidence: float, - evidence: str, - required: bool = True, - ) -> UserAgentReviewSlotCard: - is_missing = required and not str(value).strip() - source_key = source if source in SOURCE_LABELS else "system" - return UserAgentReviewSlotCard( - key=key, - label=SLOT_LABELS.get(key, key), - value=str(value or "").strip(), - raw_value=str(raw_value or "").strip(), - normalized_value=str(normalized_value or "").strip(), - source=source, - source_label=SOURCE_LABELS.get(source_key, "系统判断"), - confidence=confidence, - required=required, - confirmed=not is_missing and source in {"user_text", "user_form"}, - status="missing" if is_missing else "identified" if source in {"user_text", "user_form"} else "inferred", - hint=f"建议补充 {SLOT_LABELS.get(key, key)}。" - if is_missing and required - else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""), - evidence=evidence, - ) - - def _classify_document( - self, - item: dict[str, object], - payload: UserAgentRequest, - ) -> dict[str, str]: - provided_type = str(item.get("document_type") or "").strip().lower() - expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "") - has_customer = bool(self._collect_entity_values(payload).get("customer")) - if provided_type: - if provided_type in {"flight_itinerary", "train_ticket"}: - return { - "document_type": provided_type, - "expense_type": "travel", - "group_code": "travel", - "scene_label": "差旅票据", - } - if provided_type == "hotel_invoice": - return { - "document_type": provided_type, - "expense_type": "hotel", - "group_code": "travel", - "scene_label": "住宿票据", - } - if provided_type in {"taxi_receipt", "parking_toll_receipt"}: - return { - "document_type": provided_type, - "expense_type": "transport", - "group_code": "travel", - "scene_label": "交通票据", - } - if provided_type == "meal_receipt": - group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" - return { - "document_type": provided_type, - "expense_type": group_code, - "group_code": group_code, - "scene_label": "餐饮票据", - } - if provided_type == "office_invoice": - return { - "document_type": provided_type, - "expense_type": "office", - "group_code": "office", - "scene_label": "办公用品票据", - } - if provided_type == "meeting_invoice": - return { - "document_type": provided_type, - "expense_type": "meeting", - "group_code": "meeting", - "scene_label": "会务票据", - } - if provided_type == "training_invoice": - return { - "document_type": provided_type, - "expense_type": "training", - "group_code": "training", - "scene_label": "培训票据", - } - - text = " ".join( - [ - str(item.get("filename") or ""), - str(item.get("summary") or ""), - str(item.get("text") or ""), - ] - ).lower() - compact = text.replace(" ", "") - - if any(keyword in compact for keyword in ("机票", "航班", "火车", "高铁", "行程单")): - return { - "document_type": "travel_ticket", - "expense_type": "travel", - "group_code": "travel", - "scene_label": "差旅票据", - } - if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")): - return { - "document_type": "hotel_invoice", - "expense_type": "hotel", - "group_code": "travel", - "scene_label": "住宿票据", - } - if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")): - return { - "document_type": "transport_receipt", - "expense_type": "transport", - "group_code": "travel", - "scene_label": "交通票据", - } - if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")): - group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" - return { - "document_type": "meal_receipt", - "expense_type": group_code, - "group_code": group_code, - "scene_label": "餐饮票据", - } - if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")): - return { - "document_type": "other", - "expense_type": "office", - "group_code": "office", - "scene_label": "办公用品票据", - } - return { - "document_type": "other", - "expense_type": expense_type_code or "other", - "group_code": self._normalize_group_code(expense_type_code or "other"), - "scene_label": "其他票据", - } - - @staticmethod - def _normalize_group_code(expense_type_code: str) -> str: - if expense_type_code in {"travel", "hotel", "transport"}: - return "travel" - if expense_type_code in {"entertainment", "meal", "office", "training", "communication", "welfare"}: - return expense_type_code - return "other" - - def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]: - raw_fields = item.get("document_fields") - normalized_fields: dict[str, str] = {} - if isinstance(raw_fields, list): - for field in raw_fields: - if not isinstance(field, dict): - continue - key = str(field.get("key") or "").strip() - label = str(field.get("label") or "").strip() - value = str(field.get("value") or "").strip() - if not value: - continue - normalized_label = self._normalize_document_field_label(key=key, label=label) - display_label = normalized_label or label - normalized_value = self._normalize_document_field_value( - label=display_label, - value=value, - ) - if display_label and normalized_value: - normalized_fields.setdefault(display_label, normalized_value) - - text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() - amount_value = self._extract_amount_text_from_value(text) - if amount_value and "金额" not in normalized_fields: - normalized_fields["金额"] = amount_value - date_match = DATE_TEXT_PATTERN.search(text) - if date_match and "时间" not in normalized_fields: - normalized_fields["时间"] = date_match.group(1) - - merchant = self._extract_document_merchant_name_from_text(text) - if merchant and "商户/酒店" not in normalized_fields: - normalized_fields["商户/酒店"] = merchant - return normalized_fields - - @staticmethod - def _normalize_document_field_label(*, key: str, label: str) -> str: - compact_key = str(key or "").strip().lower().replace("_", "") - compact_label = str(label or "").replace(" ", "") - if compact_key in { - "amount", - "totalamount", - "paymentamount", - "paidamount", - "actualamount", - } or any( - token in compact_label - for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") - ): - return "金额" - if compact_key in {"date", "time", "issuedat", "invoicedate"} or any( - token in compact_label for token in ("日期", "时间", "开票日期", "发生时间") - ): - return "时间" - if compact_key in {"merchant", "merchantname", "sellername", "vendorname"} or any( - token in compact_label for token in ("商户", "酒店", "销售方", "开票方", "收款方") - ): - return "商户/酒店" - return label - - def _normalize_document_field_value(self, *, label: str, value: str) -> str: - normalized_label = str(label or "").strip() - raw_value = str(value or "").strip() - if not normalized_label or not raw_value: - return "" - if normalized_label == "金额": - return self._extract_amount_text_from_value(raw_value) or raw_value - if normalized_label == "时间": - match = DATE_TEXT_PATTERN.search(raw_value) - return match.group(1) if match else raw_value - return raw_value - - def _extract_amount_text_from_value(self, value: str) -> str: - raw_value = str(value or "").strip() - if not raw_value: - return "" - best_amount: Decimal | None = None - for pattern in (DOCUMENT_AMOUNT_PATTERN, DOCUMENT_CURRENCY_AMOUNT_PATTERN, AMOUNT_TEXT_PATTERN): - for match in pattern.finditer(raw_value): - try: - candidate = Decimal(str(match.group(1)).replace(",", ".")) - except (InvalidOperation, TypeError): - continue - if candidate <= Decimal("0.00"): - continue - if best_amount is None or candidate > best_amount: - best_amount = candidate - if best_amount is None: - return "" - return f"{best_amount.quantize(Decimal('0.01')):.2f}元" - - def _extract_document_merchant_name(self, item: dict[str, object]) -> str: - fields = self._extract_document_fields(item) - merchant = str(fields.get("商户/酒店") or "").strip() - if merchant: - return merchant - text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() - return self._extract_document_merchant_name_from_text(text) - - @staticmethod - def _extract_document_merchant_name_from_text(text: str) -> str: - for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): - if keyword in text: - return keyword - return "" - - @staticmethod - def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float: - for item in card.fields: - if item.label != "金额": - continue - try: - normalized_value = str(item.value).replace("元", "").replace("¥", "").replace("¥", "").strip() - return float(normalized_value) - except ValueError: - return 0.0 - return 0.0 - - def _resolve_amount_value(self, payload: UserAgentRequest) -> float: - for item in payload.ontology.entities: - if item.type == "amount" and item.role != "threshold": - try: - return float(item.normalized_value) - except ValueError: - return 0.0 - return 0.0 - - def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float: - total = 0.0 - for item in ocr_documents: - fields = self._extract_document_fields(item) - amount_text = str(fields.get("金额") or "").replace("元", "").replace("¥", "").replace("¥", "").strip() - if not amount_text: - continue - try: - total += float(amount_text) - except ValueError: - continue - return total - - def _infer_expense_type_from_documents( - self, - payload: UserAgentRequest, - ocr_documents: list[dict[str, object]], - ) -> str: - labels: list[str] = [] - for item in ocr_documents: - classified = self._classify_document(item, payload) - label = GROUP_SCENE_LABELS.get(classified["group_code"], "") - if label and label not in labels: - labels.append(label) - return " + ".join(labels[:3]) + if review_payload is None: + return False + return payload.ontology.scenario == "expense" and ( + payload.ontology.intent == "draft" + or int(payload.context_json.get("attachment_count") or 0) > 0 + ) + + def _build_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: + knowledge_citations = self._build_knowledge_citations(payload) + if payload.ontology.scenario == "knowledge": + return knowledge_citations[:3] + + rule_citations = self._build_rule_asset_citations(payload) + if knowledge_citations: + return (knowledge_citations + rule_citations)[:3] + return rule_citations + + @staticmethod + def _build_knowledge_citations(payload: UserAgentRequest) -> list[UserAgentCitation]: + citations: list[UserAgentCitation] = [] + for item in list(payload.tool_payload.get("hits") or [])[:3]: + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("document_name") or "").strip() + code = str(item.get("code") or item.get("candidate_id") or "").strip() + if not title or not code: + continue + citations.append( + UserAgentCitation( + source_type="knowledge", + code=code, + title=title, + version=str(item.get("version") or "").strip() or None, + updated_at=str(item.get("updated_at") or "").strip() or None, + excerpt=( + str(item.get("excerpt") or "").strip() + or str(item.get("content") or "").strip() + or None + ), + ) + ) + return citations + + def _build_rule_asset_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: + domain = self._resolve_domain(payload.ontology.scenario) + items = self.asset_service.list_assets( + asset_type=AgentAssetType.RULE.value, + status=AgentAssetStatus.ACTIVE.value, + domain=domain, + ) + ranked = self._rank_rule_assets(items, payload) + citations: list[UserAgentCitation] = [] + for item in ranked[:2]: + detail = self.asset_service.get_asset(item.id) + if detail is None: + continue + excerpt = self._extract_excerpt(str(detail.current_version_content or "")) + citations.append( + UserAgentCitation( + source_type="rule", + code=detail.code, + title=detail.name, + version=detail.current_version, + updated_at=detail.updated_at.date().isoformat(), + excerpt=excerpt, + ) + ) + return citations + + @staticmethod + def _resolve_risk_flags(payload: UserAgentRequest) -> list[str]: + tool_flags = payload.tool_payload.get("risk_flags") + if isinstance(tool_flags, list) and tool_flags: + return [str(item) for item in tool_flags] + return [str(item) for item in payload.ontology.risk_flags] + + @staticmethod + def _resolve_subject(payload: UserAgentRequest) -> str: + named_entities = [ + item.value + for item in payload.ontology.entities + if item.type in {"employee", "customer", "vendor", "project"} + ] + if named_entities: + return f"{'、'.join(named_entities)} 相关数据" + return f"{SCENARIO_LABELS.get(payload.ontology.scenario, '当前')}场景数据" + + @staticmethod + def _is_generic_expense_prompt(payload: UserAgentRequest) -> bool: + if payload.ontology.scenario != "expense": + return False + normalized_message = re.sub(r"\s+", "", payload.message) + return normalized_message in GENERIC_EXPENSE_PROMPTS + + @staticmethod + def _is_implicit_expense_draft_request(payload: UserAgentRequest) -> bool: + if payload.ontology.scenario != "expense" or payload.ontology.intent != "draft": + return False + + compact_message = re.sub(r"\s+", "", payload.message) + if any(keyword in compact_message for keyword in EXPLICIT_DRAFT_KEYWORDS): + return False + + return True + + @staticmethod + def _resolve_attachment_names(payload: UserAgentRequest) -> list[str]: + names = payload.context_json.get("attachment_names") + if not isinstance(names, list): + return [] + return [str(name) for name in names if str(name).strip()] + + @staticmethod + def _resolve_attachment_count(payload: UserAgentRequest) -> int: + names = UserAgentService._resolve_attachment_names(payload) + if names: + return len(names) + try: + return max(0, int(payload.context_json.get("attachment_count") or 0)) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _resolve_ocr_documents(payload: UserAgentRequest) -> list[dict[str, object]]: + documents = payload.context_json.get("ocr_documents") + if not isinstance(documents, list): + return [] + overrides = payload.context_json.get("review_document_form_values") + override_map: dict[tuple[int, str], dict[str, object]] = {} + if isinstance(overrides, list): + for item in overrides: + if not isinstance(item, dict): + continue + filename = str(item.get("filename") or "").strip() + index = int(item.get("index") or 0) + if not filename and index <= 0: + continue + override_map[(index, filename)] = item + normalized: list[dict[str, object]] = [] + for index, item in enumerate(documents[:8], start=1): + if not isinstance(item, dict): + continue + normalized_item = dict(item) + override = override_map.get((index, str(normalized_item.get("filename") or "").strip())) + if override is None: + override = override_map.get((index, "")) + if override is not None: + summary = str(override.get("summary") or "").strip() + scene_label = str(override.get("scene_label") or "").strip() + fields = override.get("fields") + if summary: + normalized_item["summary"] = summary + if scene_label: + normalized_item["scene_label"] = scene_label + if isinstance(fields, list): + normalized_item["document_fields"] = [ + { + "key": str(field.get("key") or field.get("label") or "").strip(), + "label": str(field.get("label") or "").strip(), + "value": str(field.get("value") or "").strip(), + } + for field in fields + if isinstance(field, dict) + and str(field.get("label") or "").strip() + and str(field.get("value") or "").strip() + ] + normalized.append(normalized_item) + return normalized + + @staticmethod + def _is_review_association_choice_pending(payload: UserAgentRequest) -> bool: + return bool(payload.tool_payload.get("pending_association_decision")) + + def _resolve_review_document_count(self, payload: UserAgentRequest) -> int: + return max( + len(self._resolve_ocr_documents(payload)), + self._resolve_attachment_count(payload), + ) + + @staticmethod + def _resolve_conversation_history(payload: UserAgentRequest) -> list[dict[str, object]]: + history = payload.context_json.get("conversation_history") + if not isinstance(history, list): + return [] + + normalized: list[dict[str, object]] = [] + for item in history[-8:]: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip() + content = str(item.get("content") or "").strip() + if not role or not content: + continue + normalized.append({"role": role, "content": content}) + return normalized + + @staticmethod + def _resolve_domain(scenario: str) -> str | None: + if scenario == "expense": + return "expense" + if scenario == "accounts_receivable": + return "ar" + if scenario == "accounts_payable": + return "ap" + return None + + @staticmethod + def _rank_rule_assets( + items: list[AgentAssetListItem], + payload: UserAgentRequest, + ) -> list[AgentAssetListItem]: + def score(item: AgentAssetListItem) -> tuple[int, str]: + tags = {str(value) for value in item.scenario_json or []} + weight = 0 + if payload.ontology.scenario in tags: + weight += 3 + if payload.ontology.intent in tags: + weight += 2 + for risk_flag in payload.ontology.risk_flags: + if risk_flag in tags: + weight += 4 + return weight, item.code + + ranked = sorted(items, key=score, reverse=True) + return [item for item in ranked if score(item)[0] > 0] + + @staticmethod + def _extract_excerpt(content: str) -> str: + lines = [line.strip() for line in str(content).splitlines() if line.strip()] + cleaned: list[str] = [] + for line in lines: + normalized = re.sub(r"^[#>\-\*\d\.\s`]+", "", line).strip() + if normalized: + cleaned.append(normalized) + if len(cleaned) >= 2: + break + return ";".join(cleaned[:2]) + + def _collect_entity_values(self, payload: UserAgentRequest) -> dict[str, str]: + values = { + "employee_name": "", + "customer": "", + "participants": "", + "amount": "", + "expense_type": "", + "expense_type_code": "", + } + participants: list[str] = [] + for item in payload.ontology.entities: + if item.type == "employee" and not values["employee_name"]: + values["employee_name"] = item.value + elif item.type == "customer" and not values["customer"]: + values["customer"] = item.value + elif item.type == "amount" and item.role != "threshold" and not values["amount"]: + values["amount"] = f"{item.value}元" if "元" not in item.value else item.value + elif item.type == "expense_type" and not values["expense_type_code"]: + values["expense_type_code"] = item.normalized_value + values["expense_type"] = EXPENSE_TYPE_LABELS.get( + item.normalized_value, + item.value, + ) + elif item.type in {"participant", "person"} and item.value.strip(): + participants.append(item.value.strip()) + if participants: + values["participants"] = "、".join(dict.fromkeys(participants)) + return values + + def _format_time_range(self, payload: UserAgentRequest) -> str: + time_range = payload.ontology.time_range + if time_range.start_date and time_range.end_date: + if time_range.start_date == time_range.end_date: + return time_range.start_date + normalized = f"{time_range.start_date} 至 {time_range.end_date}" + return normalized + if time_range.raw: + return time_range.raw + return "" + + def _resolve_location_value(self, payload: UserAgentRequest) -> str: + review_form_values = self._resolve_review_form_values(payload) + for key in ("business_location", "location"): + value = str(review_form_values.get(key) or "").strip() + if value: + return value + + if str(payload.context_json.get("entry_source") or "").strip() == "detail": + request_context = payload.context_json.get("request_context") + if isinstance(request_context, dict): + for key in ("city", "location"): + value = str(request_context.get(key) or "").strip() + if value: + return value + + labeled_match = re.search(r"(?:业务地点|发生地点|地点)[::]\s*(?P[^\n,。;]+)", payload.message) + if labeled_match: + return labeled_match.group("value").strip() + + city_match = re.search(r"去(?P[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message) + if city_match: + return city_match.group("city").strip() + if "客户现场" in payload.message.replace(" ", ""): + return "客户现场" + return "" + + @staticmethod + def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]: + values = payload.context_json.get("review_form_values") + if not isinstance(values, dict): + return {} + normalized: dict[str, str] = {} + for key, value in values.items(): + cleaned_key = str(key or "").strip() + if not cleaned_key: + continue + normalized[cleaned_key] = str(value or "").strip() + return normalized + + @staticmethod + def _build_slot_value( + *, + value: str = "", + raw_value: str = "", + normalized_value: str = "", + source: str = "system", + confidence: float = 0.0, + evidence: str = "", + ) -> dict[str, str | float]: + return { + "value": str(value or "").strip(), + "raw_value": str(raw_value or "").strip(), + "normalized_value": str(normalized_value or "").strip(), + "source": str(source or "system").strip() or "system", + "confidence": float(confidence), + "evidence": str(evidence or "").strip(), + } + + def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str( + review_form_values.get("occurred_date") + or review_form_values.get("time_range") + or review_form_values.get("business_time") + or "" + ).strip() + if edited_value: + raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip() + return self._build_slot_value( + value=edited_value, + raw_value=raw_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + time_range = payload.ontology.time_range + if time_range.start_date and time_range.end_date: + normalized_value = ( + time_range.start_date + if time_range.start_date == time_range.end_date + else f"{time_range.start_date} 至 {time_range.end_date}" + ) + raw_value = str(time_range.raw or "").strip() + return self._build_slot_value( + value=normalized_value, + raw_value=raw_value, + normalized_value=normalized_value, + source="user_text", + confidence=0.92, + evidence="系统已根据当前日期将相对时间换算为标准日期。", + ) + + return self._build_slot_value() + + def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + for key in ("business_location", "location"): + value = str(review_form_values.get(key) or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + if str(payload.context_json.get("entry_source") or "").strip() == "detail": + request_context = payload.context_json.get("request_context") + if isinstance(request_context, dict): + for key in ("city", "location"): + value = str(request_context.get(key) or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="detail_context", + confidence=0.68, + evidence="来源于当前关联单据,仅作为辅助上下文,需要用户再次核对。", + ) + + value = self._resolve_location_value(payload) + if value: + evidence = "用户在文本中明确描述了业务地点。" + if value == "客户现场": + evidence = "用户明确提到“客户现场”,但未提供具体城市或地址。" + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.82, + evidence=evidence, + ) + return self._build_slot_value() + + def _build_customer_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + value = str(review_form_values.get("customer_name") or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + value = entity_map.get("customer", "") + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.88, + evidence="用户在原始描述中直接提到了客户对象。", + ) + return self._build_slot_value() + + def _build_participants_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + value = str(review_form_values.get("participants") or "").strip() + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + value = entity_map.get("participants", "") + if value: + return self._build_slot_value( + value=value, + normalized_value=value, + source="user_text", + confidence=0.8, + evidence="用户在当前描述中补充了参与人员。", + ) + return self._build_slot_value() + + def _build_reason_slot( + self, + payload: UserAgentRequest, + *, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("reason") or "").strip() + if edited_value: + return self._build_slot_value( + value=edited_value, + raw_value=edited_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload)) + if reason_value: + return self._build_slot_value( + value=reason_value, + raw_value=reason_value, + normalized_value=reason_value, + source="user_text", + confidence=0.76, + evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。", + ) + + inferred_reason = self._infer_reason_from_claim_groups( + claim_groups=claim_groups, + ) + if inferred_reason: + return self._build_slot_value( + value=inferred_reason, + raw_value=inferred_reason, + normalized_value=inferred_reason, + source="ocr", + confidence=0.68, + evidence="系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。", + ) + return self._build_slot_value() + + def _build_amount_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_amount = str(review_form_values.get("amount") or "").strip() + if edited_amount: + normalized = self._normalize_amount_text(edited_amount) + return self._build_slot_value( + value=normalized, + raw_value=edited_amount, + normalized_value=normalized, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + amount_value = entity_map.get("amount", "") + if amount_value: + normalized = self._normalize_amount_text(amount_value) + return self._build_slot_value( + value=normalized, + raw_value=amount_value, + normalized_value=normalized, + source="user_text", + confidence=0.92, + evidence="用户在原始描述中直接给出了金额。", + ) + + ocr_total_amount = self._sum_ocr_amounts(ocr_documents) + if ocr_total_amount > 0: + normalized = f"{ocr_total_amount:.2f}元" + return self._build_slot_value( + value=normalized, + normalized_value=normalized, + source="ocr", + confidence=0.76, + evidence="金额来自 OCR 汇总结果,仍建议用户核对票据原文。", + ) + return self._build_slot_value() + + def _build_expense_type_slot( + self, + payload: UserAgentRequest, + *, + entity_map: dict[str, str], + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip() + if edited_value: + normalized_code, normalized_label = self._normalize_expense_type_input(edited_value) + return self._build_slot_value( + value=normalized_label, + raw_value=edited_value, + normalized_value=normalized_code, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + expense_type_code = entity_map.get("expense_type_code", "") + expense_type_value = EXPENSE_TYPE_LABELS.get(expense_type_code, entity_map.get("expense_type", "")) + if expense_type_value: + return self._build_slot_value( + value=expense_type_value, + raw_value=expense_type_value, + normalized_value=expense_type_code, + source="user_text", + confidence=0.9, + evidence="系统根据用户描述中的业务场景判断费用类型。", + ) + + inferred_label = self._infer_expense_type_from_documents(payload, ocr_documents) if ocr_documents else "" + if inferred_label: + normalized_code, normalized_label = self._normalize_expense_type_input(inferred_label) + return self._build_slot_value( + value=normalized_label, + raw_value=inferred_label, + normalized_value=normalized_code, + source="ocr", + confidence=0.74, + evidence="系统根据票据内容推断费用类型,仍建议用户确认。", + ) + return self._build_slot_value() + + def _build_merchant_slot( + self, + payload: UserAgentRequest, + *, + ocr_documents: list[dict[str, object]], + ) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + edited_value = str(review_form_values.get("merchant_name") or "").strip() + if edited_value: + return self._build_slot_value( + value=edited_value, + normalized_value=edited_value, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else "" + if merchant_value: + return self._build_slot_value( + value=merchant_value, + normalized_value=merchant_value, + source="ocr", + confidence=0.72, + evidence="商户名称来自 OCR 票据识别结果,仍建议用户核对。", + ) + return self._build_slot_value() + + def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]: + review_form_values = self._resolve_review_form_values(payload) + attachment_names = str(review_form_values.get("attachment_names") or "").strip() + if attachment_names: + return self._build_slot_value( + value=attachment_names, + normalized_value=attachment_names, + source="user_form", + confidence=1.0, + evidence="来源于用户修改后的结构化表单。", + ) + + count = self._resolve_attachment_count(payload) + if count > 0: + names = self._resolve_attachment_names(payload) + value = "、".join(names) if names else f"{count} 份附件" + return self._build_slot_value( + value=value, + raw_value=value, + normalized_value=str(count), + source="upload", + confidence=1.0, + evidence="系统已接收到用户上传的附件。", + ) + return self._build_slot_value() + + @staticmethod + def _normalize_amount_text(value: str) -> str: + cleaned = str(value or "").strip() + if not cleaned: + return "" + match = AMOUNT_TEXT_PATTERN.search(cleaned) + if not match: + return cleaned + number = float(match.group(1)) + return f"{number:.2f}元" + + @staticmethod + def _normalize_expense_type_input(value: str) -> tuple[str, str]: + compact = str(value or "").replace(" ", "") + if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))): + return "entertainment", "业务招待费" + if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")): + return "travel", "差旅费" + if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")): + return "hotel", "住宿费" + if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")): + return "transport", "交通费" + if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): + return "meal", "餐费" + if "会务" in compact: + return "meeting", "会务费" + if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")): + return "office", "办公费" + if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")): + return "training", "培训费" + if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")): + return "communication", "通讯费" + if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")): + return "welfare", "福利费" + return "other", str(value or "").strip() or "其他费用" + + def _resolve_required_review_keys( + self, + payload: UserAgentRequest, + *, + primary_expense_type: str, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> set[str]: + required = {"expense_type", "time_range", "amount", "reason", "attachments"} + scene_codes = { + str(item.group_code or "").strip() + for item in claim_groups + if str(item.group_code or "").strip() + } + if primary_expense_type: + scene_codes.add(primary_expense_type) + + for scene_code in scene_codes: + required.update(SCENE_REQUIRED_SLOT_KEYS.get(scene_code, set())) + + compact_message = re.sub(r"\s+", "", self._resolve_reason_source_text(payload) or payload.message) + if "entertainment" in scene_codes or ( + "客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客")) + ): + required.update({"customer_name", "participants"}) + + return required + + @staticmethod + def _infer_reason_from_claim_groups( + *, + claim_groups: list[UserAgentReviewClaimGroup], + ) -> str: + if len(claim_groups) == 1: + document_indexes = list(claim_groups[0].document_indexes or []) + if not document_indexes: + return "" + + expense_type = str(claim_groups[0].expense_type or "").strip() + group_code = str(claim_groups[0].group_code or "").strip() + if expense_type: + return INFERRED_REASON_LABELS.get(expense_type, "") or str(claim_groups[0].scene_label or "").strip() + if group_code: + return INFERRED_REASON_LABELS.get(group_code, "") or str(claim_groups[0].scene_label or "").strip() + return "" + + @staticmethod + def _resolve_review_missing_slot_keys( + payload: UserAgentRequest, + *, + slot_cards: list[UserAgentReviewSlotCard], + ) -> list[str]: + required_keys = {item.key for item in slot_cards if item.required} + slot_map = {item.key: item for item in slot_cards} + missing_keys = { + item.key + for item in slot_cards + if item.required and (item.status == "missing" or not str(item.value).strip()) + } + for key in payload.ontology.missing_slots: + normalized_key = str(key or "").strip() + if ( + normalized_key + and normalized_key in required_keys + and ( + normalized_key not in slot_map + or slot_map[normalized_key].status == "missing" + or not str(slot_map[normalized_key].value).strip() + ) + ): + missing_keys.add(normalized_key) + + ordered_keys: list[str] = [] + for item in slot_cards: + if item.required and item.key in missing_keys and item.key not in ordered_keys: + ordered_keys.append(item.key) + return ordered_keys + + def _make_slot_card( + self, + *, + key: str, + value: str, + raw_value: str, + normalized_value: str, + source: str, + confidence: float, + evidence: str, + required: bool = True, + ) -> UserAgentReviewSlotCard: + is_missing = required and not str(value).strip() + source_key = source if source in SOURCE_LABELS else "system" + return UserAgentReviewSlotCard( + key=key, + label=SLOT_LABELS.get(key, key), + value=str(value or "").strip(), + raw_value=str(raw_value or "").strip(), + normalized_value=str(normalized_value or "").strip(), + source=source, + source_label=SOURCE_LABELS.get(source_key, "系统判断"), + confidence=confidence, + required=required, + confirmed=not is_missing and source in {"user_text", "user_form"}, + status="missing" if is_missing else "identified" if source in {"user_text", "user_form"} else "inferred", + hint=f"建议补充 {SLOT_LABELS.get(key, key)}。" + if is_missing and required + else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""), + evidence=evidence, + ) + + def _classify_document( + self, + item: dict[str, object], + payload: UserAgentRequest, + ) -> dict[str, str]: + provided_type = str(item.get("document_type") or "").strip().lower() + expense_type_code = self._collect_entity_values(payload).get("expense_type_code", "") + has_customer = bool(self._collect_entity_values(payload).get("customer")) + if provided_type: + if provided_type in {"flight_itinerary", "train_ticket"}: + return { + "document_type": provided_type, + "expense_type": "travel", + "group_code": "travel", + "scene_label": "差旅票据", + } + if provided_type == "hotel_invoice": + return { + "document_type": provided_type, + "expense_type": "hotel", + "group_code": "travel", + "scene_label": "住宿票据", + } + if provided_type in {"taxi_receipt", "parking_toll_receipt"}: + return { + "document_type": provided_type, + "expense_type": "transport", + "group_code": "travel", + "scene_label": "交通票据", + } + if provided_type == "meal_receipt": + group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" + return { + "document_type": provided_type, + "expense_type": group_code, + "group_code": group_code, + "scene_label": "餐饮票据", + } + if provided_type == "office_invoice": + return { + "document_type": provided_type, + "expense_type": "office", + "group_code": "office", + "scene_label": "办公用品票据", + } + if provided_type == "meeting_invoice": + return { + "document_type": provided_type, + "expense_type": "meeting", + "group_code": "meeting", + "scene_label": "会务票据", + } + if provided_type == "training_invoice": + return { + "document_type": provided_type, + "expense_type": "training", + "group_code": "training", + "scene_label": "培训票据", + } + + text = " ".join( + [ + str(item.get("filename") or ""), + str(item.get("summary") or ""), + str(item.get("text") or ""), + ] + ).lower() + compact = text.replace(" ", "") + + if any(keyword in compact for keyword in ("机票", "航班", "火车", "高铁", "行程单")): + return { + "document_type": "travel_ticket", + "expense_type": "travel", + "group_code": "travel", + "scene_label": "差旅票据", + } + if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")): + return { + "document_type": "hotel_invoice", + "expense_type": "hotel", + "group_code": "travel", + "scene_label": "住宿票据", + } + if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")): + return { + "document_type": "transport_receipt", + "expense_type": "transport", + "group_code": "travel", + "scene_label": "交通票据", + } + if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")): + group_code = "entertainment" if expense_type_code == "entertainment" or has_customer else "meal" + return { + "document_type": "meal_receipt", + "expense_type": group_code, + "group_code": group_code, + "scene_label": "餐饮票据", + } + if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")): + return { + "document_type": "other", + "expense_type": "office", + "group_code": "office", + "scene_label": "办公用品票据", + } + return { + "document_type": "other", + "expense_type": expense_type_code or "other", + "group_code": self._normalize_group_code(expense_type_code or "other"), + "scene_label": "其他票据", + } + + @staticmethod + def _normalize_group_code(expense_type_code: str) -> str: + if expense_type_code in {"travel", "hotel", "transport"}: + return "travel" + if expense_type_code in {"entertainment", "meal", "office", "training", "communication", "welfare"}: + return expense_type_code + return "other" + + def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]: + raw_fields = item.get("document_fields") + normalized_fields: dict[str, str] = {} + if isinstance(raw_fields, list): + for field in raw_fields: + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + if not value: + continue + normalized_label = self._normalize_document_field_label(key=key, label=label) + display_label = normalized_label or label + normalized_value = self._normalize_document_field_value( + label=display_label, + value=value, + ) + if display_label and normalized_value: + normalized_fields.setdefault(display_label, normalized_value) + + text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() + amount_value = self._extract_amount_text_from_value(text) + if amount_value and "金额" not in normalized_fields: + normalized_fields["金额"] = amount_value + date_match = DATE_TEXT_PATTERN.search(text) + if date_match and "时间" not in normalized_fields: + normalized_fields["时间"] = date_match.group(1) + + merchant = self._extract_document_merchant_name_from_text(text) + if merchant and "商户/酒店" not in normalized_fields: + normalized_fields["商户/酒店"] = merchant + return normalized_fields + + @staticmethod + def _normalize_document_field_label(*, key: str, label: str) -> str: + compact_key = str(key or "").strip().lower().replace("_", "") + compact_label = str(label or "").replace(" ", "") + if compact_key in { + "amount", + "totalamount", + "paymentamount", + "paidamount", + "actualamount", + } or any( + token in compact_label + for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") + ): + return "金额" + if compact_key in {"date", "time", "issuedat", "invoicedate"} or any( + token in compact_label for token in ("日期", "时间", "开票日期", "发生时间") + ): + return "时间" + if compact_key in {"merchant", "merchantname", "sellername", "vendorname"} or any( + token in compact_label for token in ("商户", "酒店", "销售方", "开票方", "收款方") + ): + return "商户/酒店" + return label + + def _normalize_document_field_value(self, *, label: str, value: str) -> str: + normalized_label = str(label or "").strip() + raw_value = str(value or "").strip() + if not normalized_label or not raw_value: + return "" + if normalized_label == "金额": + return self._extract_amount_text_from_value(raw_value) or raw_value + if normalized_label == "时间": + match = DATE_TEXT_PATTERN.search(raw_value) + return match.group(1) if match else raw_value + return raw_value + + def _extract_amount_text_from_value(self, value: str) -> str: + raw_value = str(value or "").strip() + if not raw_value: + return "" + best_amount: Decimal | None = None + for pattern in (DOCUMENT_AMOUNT_PATTERN, DOCUMENT_CURRENCY_AMOUNT_PATTERN, AMOUNT_TEXT_PATTERN): + for match in pattern.finditer(raw_value): + try: + candidate = Decimal(str(match.group(1)).replace(",", ".")) + except (InvalidOperation, TypeError): + continue + if candidate <= Decimal("0.00"): + continue + if best_amount is None or candidate > best_amount: + best_amount = candidate + if best_amount is None: + return "" + return f"{best_amount.quantize(Decimal('0.01')):.2f}元" + + def _extract_document_merchant_name(self, item: dict[str, object]) -> str: + fields = self._extract_document_fields(item) + merchant = str(fields.get("商户/酒店") or "").strip() + if merchant: + return merchant + text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() + return self._extract_document_merchant_name_from_text(text) + + @staticmethod + def _extract_document_merchant_name_from_text(text: str) -> str: + for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): + if keyword in text: + return keyword + return "" + + @staticmethod + def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float: + for item in card.fields: + if item.label != "金额": + continue + try: + normalized_value = str(item.value).replace("元", "").replace("¥", "").replace("¥", "").strip() + return float(normalized_value) + except ValueError: + return 0.0 + return 0.0 + + def _resolve_amount_value(self, payload: UserAgentRequest) -> float: + for item in payload.ontology.entities: + if item.type == "amount" and item.role != "threshold": + try: + return float(item.normalized_value) + except ValueError: + return 0.0 + return 0.0 + + def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float: + total = 0.0 + for item in ocr_documents: + fields = self._extract_document_fields(item) + amount_text = str(fields.get("金额") or "").replace("元", "").replace("¥", "").replace("¥", "").strip() + if not amount_text: + continue + try: + total += float(amount_text) + except ValueError: + continue + return total + + def _infer_expense_type_from_documents( + self, + payload: UserAgentRequest, + ocr_documents: list[dict[str, object]], + ) -> str: + labels: list[str] = [] + for item in ocr_documents: + classified = self._classify_document(item, payload) + label = GROUP_SCENE_LABELS.get(classified["group_code"], "") + if label and label not in labels: + labels.append(label) + return " + ".join(labels[:3]) diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index fdf396d..d83fca7 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -14,9 +14,9 @@ "updated_at": "2026-05-09T08:39:53.788042+00:00", "uploaded_by": "admin", "version_number": 1, - "ingest_status": 4, - "ingest_status_updated_at": "2026-05-15T09:37:21.829390+00:00", - "ingest_agent_run_id": "run_ef06cc90605f4bd2" + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-16T01:48:21.849424+00:00", + "ingest_agent_run_id": "run_a7b447f69939442f" } ] } \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/chunks.json b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/chunks.json index ecc99c7..ce2ec35 100644 --- a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/chunks.json +++ b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/chunks.json @@ -1,619 +1,19 @@ [ { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-001", - "title": "远光软件股份有限公司文件", - "content": "远光软件股份有限公司文件\n远光制度〔2024〕14 号\n关于颁布《公司支出管理办法(2024)》的\n通知\n公司各部门、分支机构、子公司:\n为适应公司业务发展需要,优化、完善支出和报销标准,规\n范支出业务审批和报销过程,防范经营风险,依据国家有关法\n律法规,参照国家电网公司和国网数科公司有关管理规定,结\n合市场经营环境和公司实际情况,在广泛征求意见的基础上,\n公司对《公司支出管理办法》进行了修订,现予颁布。本办法自\n颁布之日起施行,原办法同时废止。\n特此通知。\n附件:1.公司支出管理办法(2024)\n2.修订说明\n远光软件股份有限公司\n2024 年 4 月 17 日\n远光软件股份有限公司\n2024 年 4 月 17 日印发", - "source_page": 1, - "word_count": 286, - "tags": [ - "附件", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-002", - "title": "附件 1", - "content": "公司支出管理办法(2024)", - "source_page": 2, - "word_count": 14, - "tags": [ - "附件" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-003", - "title": "第一章", - "content": "总则.............................................................. 4", - "source_page": 2, - "word_count": 65, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-004", - "title": "第一条", - "content": "目的............................................................. 4", - "source_page": 2, - "word_count": 64, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-005", - "title": "第二条", - "content": "范围............................................................. 4", - "source_page": 2, - "word_count": 64, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-006", - "title": "第三条", - "content": "管理原则......................................................... 4", - "source_page": 2, - "word_count": 62, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-007", - "title": "第二章", - "content": "职责分工 .......................................................... 4", - "source_page": 2, - "word_count": 63, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-008", - "title": "第四条", - "content": "归口管理部门主要职责 ............................................. 4", - "source_page": 2, - "word_count": 56, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-009", - "title": "第五条", - "content": "计划财务部主要职责............................................... 5", - "source_page": 2, - "word_count": 57, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-010", - "title": "第六条", - "content": "经办部门(个人)主要职责 ......................................... 5", - "source_page": 2, - "word_count": 54, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-011", - "title": "第七条", - "content": "各级管理人员主要职责 ............................................. 5", - "source_page": 2, - "word_count": 56, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-012", - "title": "第三章", - "content": "支出报销申请与审批 ................................................ 6", - "source_page": 2, - "word_count": 58, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-013", - "title": "第八条", - "content": "支出报销申请..................................................... 6", - "source_page": 2, - "word_count": 60, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-014", - "title": "第九条", - "content": "支出报销审批..................................................... 7", - "source_page": 2, - "word_count": 60, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-015", - "title": "第十条", - "content": "支出成本中心归属................................................. 7", - "source_page": 2, - "word_count": 58, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-016", - "title": "第四章", - "content": "重点支出管理规定 .................................................. 7\n第十一条 备用金借款....................................................... 7\n第十二条 市内交通费....................................................... 8\n第十三条 差旅费........................................................... 8\n第十四条 业务招待费...................................................... 11\n第十五条 会议费.......................................................... 11\n第十六条 广告宣传费...................................................... 11\n第十七条 培训费.......................................................... 12\n第十八条 通信费.......................................................... 12", - "source_page": 2, - "word_count": 587, - "tags": [ - "差旅", - "交通" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-017", - "title": "第十九条 邮递费........................................", - "content": "第十九条 邮递费.......................................................... 12\n第二十条 薪酬福利支出.................................................... 12", - "source_page": 3, - "word_count": 131, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-018", - "title": "第二十一条", - "content": "对外捐赠支出 ................................................. 13", - "source_page": 3, - "word_count": 57, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-019", - "title": "第二十二条", - "content": "涉外业务汇率标准 ............................................. 13", - "source_page": 3, - "word_count": 55, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-020", - "title": "第五章", - "content": "附则............................................................. 13", - "source_page": 3, - "word_count": 65, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-021", - "title": "第二十三条", - "content": "本办法的归口与实施 ........................................... 13", - "source_page": 3, - "word_count": 54, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-022", - "title": "第二十四条", - "content": "附件 ......................................................... 13\n附表 1:员工支出报销审批权限表 ............................................ 14\n附表 2:岗位支出报销审批权限表 ............................................ 15\n附表 3:支出归口管理部门与归口业务范围 .................................... 18", - "source_page": 3, - "word_count": 240, - "tags": [ - "附件", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-023", - "title": "第一条", - "content": "目的\n为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善\n支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律\n法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实\n际情况,在广泛征求意见的基础上,制定本办法。", - "source_page": 4, - "word_count": 133, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-024", - "title": "第二条", - "content": "范围\n本办法适用于公司各类成本费用和资本性支出。\n本办法适用于全公司范围,包括:公司各部门、分支机构(非独立法人)、全资\n子公司。控股子公司应参照本办法制订支出管理办法,按子公司相关议事规则审批、\n报公司计划财务部备案后执行。", - "source_page": 4, - "word_count": 109, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-025", - "title": "第三条", - "content": "管理原则\n公司支出管理遵循“预算先行、厉行节约、效益优先,分级授权、分类控制、批\n办分离”的基本原则。\n1\n公司支出管理遵循预算先行的控制原则,应在预算目标范围内支出,并遵循\n公司内控、预算、考核管理相关规定,预算外支出履行预算审批程序后执行。\n2\n各部门(分支机构)、各岗位应在部门(岗位)职责与授权业务范围内,秉\n持厉行节约、效益优先原则,规范开展支出业务活动。\n3\n各级管理人员应根据其管理岗位,在公司支出授权审批范围和本人职责范\n围内,履行支出审批职权,承担审批责任。岗位审批权限与职级待遇分离,\n审批权限按管理岗位执行。\n4\n公司支出管理遵循批办分离的牵制原则,支出经办人和审批人不得为同一\n人。", - "source_page": 4, - "word_count": 288, - "tags": [ - "审批", - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-026", - "title": "第四条", - "content": "职责分工\n归口管理部门主要职责\n公司实行支出归口管理,归口管理部门主要职责如下:\n1\n在遵循公司财务、采购、人资等相关制度规定的基础上,根据业务管理需要,\n确定各项支出业务的开支范围、标准、方式和管理流程。", - "source_page": 4, - "word_count": 98, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-027", - "title": "2", - "content": "2\n根据公司有关规定,对支出业务的合规性、预算(计划)执行控制情况等进\n行检查、分析与监督。\n支出归口管理部门与范围按“附表3”执行,部门职责变化时,相应调整归口分\n工。", - "source_page": 5, - "word_count": 81, - "tags": [ - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-028", - "title": "第五条", - "content": "计划财务部主要职责\n1\n明确支出报销审批流程、审核要点和报销资料规范。\n2\n协助归口管理部门确定各项支出业务的开支范围和标准。\n3\n负责报销业务财务审核,对业务原始凭据的完整性、合规性进行审查,对报\n销事项与业务原始凭据的业务关联性、内容一致性、金额准确性等进行复\n核,可要求报销人提供不限于本办法规定的佐证资料。\n4\n办理报销业务的结算支付。\n5\n组织开展支出报销业务日常财务稽核与宣贯。", - "source_page": 5, - "word_count": 183, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-029", - "title": "第六条", - "content": "1\n经办部门(个人)主要职责\n在部门(岗位)职责与授权业务范围内,秉持预算先行、厉行节约、效益优\n先原则,规范开展支出业务活动。\n2\n业务经办过程中,应严格遵循开支范围和标准,切实履行业务流程与审批程\n序,并遵循“发票、资金、物资”三流一致原则,及时取得真实、合规、关\n联、完整的业务原始凭据,验证发票真伪。\n3\n各项物资(原材料、固定资产、无形资产、低值易耗品、办公用品)、服务、\n外包分包业务的采购,应以经审核的需求计划(项目采购预算)为前提,并\n严格遵循公司招标、采购与物资管理相关规定。\n4\n及时提交报销申请,履行报销审批流程,配合提供发票,以及不限于本办法\n规定的业务佐证资料。\n5\n对支出业务的真实性、合规性、必要性、合理性以及业务原始凭据的真实性、\n合规性、关联性、完整性,承担全部责任。", - "source_page": 5, - "word_count": 334, - "tags": [ - "发票", - "审批", - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-030", - "title": "第七条", - "content": "各级管理人员主要职责\n各级管理人员应在授权审批范围和职责范围内,履行支出报销审批职权,承担审\n批责任。", - "source_page": 5, - "word_count": 49, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-031", - "title": "1", - "content": "1\n第一审批人:应对支出业务的真实性、合规性、必要性、合理性进行全面审\n核,并承担审批责任。\n2\n后续审批人:应对支出业务的必要性、合理性进行审核,并承担审批责任。", - "source_page": 6, - "word_count": 78, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-032", - "title": "第八条", - "content": "1\n支出报销申请与审批\n支出报销申请\n申请方式\n支出报销申请通过公司财务信息化系统实现(以下简称“系统单据”),财务原\n则上不接受“纸质申请单据”。\n(1)经办人应及时填写“系统单据”,并同步提交业务原始凭据。\n(2)除“员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付”等支\n出业务外,其他支出均需提交税务机关认可的票据。\n① “工会经费、员工福利、职工活动、业务招待、车票、政府规费”以外的\n支出,原则上均应取得增值税专用发票;应取得但未取得增值税专用发\n票的,经办人应在“系统单据”中说明原因。\n② 汇总开具的增值税发票,应附税控系统明细清单。\n(3)财务审核发现业务原始凭据不完整的,经办人应在三个工作日内补充并重新\n提交至财务;经办人填单错误、业务原始凭据不合规,以及不能按时补充业\n务原始凭据的,财务退单处理。\n2\n申请时限\n支出报销申请时限指“业务完成日”至“附件影像资料挂接系统单据日”的期间。\n(1)公司各类支出报销结算申请时限为三个月。\n① 逾期需说明原因,经分管领导审批后方可报销。\n② 按自然月度计算并定期发生的费用支出,需月度结束后方可报销。\n③ 差旅费原则上需在行程结束三个月内提交报销申请(连续出差超过一个\n月时,原则上应按月报销),逾期不予报销出差补贴。\n(2)预付款项,原则上应在次月底前完成结算,不得长期挂账。\n3\n结算方式\n(1)员工支出业务:原则上采用“公对私”结算方式,报销申请审批通过后,公", - "source_page": 6, - "word_count": 589, + "chunk_id": "bf761bd8eccf402bb676423d64401a56-document", + "title": "远光《公司支出管理办法(2024)》.pdf", + "content": "商密【中】\n\n远光软件股份有限公司文件\n远光制度〔2024〕14 号\n\n关于颁布《公司支出管理办法(2024)》的\n通知\n公司各部门、分支机构、子公司:\n为适应公司业务发展需要,优化、完善支出和报销标准,规\n范支出业务审批和报销过程,防范经营风险,依据国家有关法\n律法规,参照国家电网公司和国网数科公司有关管理规定,结\n合市场经营环境和公司实际情况,在广泛征求意见的基础上,\n公司对《公司支出管理办法》进行了修订,现予颁布。本办法自\n颁布之日起施行,原办法同时废止。\n特此通知。\n附件:1.公司支出管理办法(2024)\n2.修订说明\n\n远光软件股份有限公司\n2024 年 4 月 17 日\n\n远光软件股份有限公司\n\n2024 年 4 月 17 日印发\n第 1 页 共 20 页\n商密【中】\n\n\f附件 1\n\n公司支出管理办法(2024)\n目录\n第一章\n\n总则.............................................................. 4\n\n第一条\n\n目的............................................................. 4\n\n第二条\n\n范围............................................................. 4\n\n第三条\n\n管理原则......................................................... 4\n\n第二章\n\n职责分工 .......................................................... 4\n\n第四条\n\n归口管理部门主要职责 ............................................. 4\n\n第五条\n\n计划财务部主要职责............................................... 5\n\n第六条\n\n经办部门(个人)主要职责 ......................................... 5\n\n第七条\n\n各级管理人员主要职责 ............................................. 5\n\n第三章\n\n支出报销申请与审批 ................................................ 6\n\n第八条\n\n支出报销申请..................................................... 6\n\n第九条\n\n支出报销审批..................................................... 7\n\n第十条\n\n支出成本中心归属................................................. 7\n\n第四章\n\n重点支出管理规定 .................................................. 7\n\n第十一条 备用金借款....................................................... 7\n第十二条 市内交通费....................................................... 8\n第十三条 差旅费........................................................... 8\n第十四条 业务招待费...................................................... 11\n第十五条 会议费.......................................................... 11\n第十六条 广告宣传费...................................................... 11\n第十七条 培训费.......................................................... 12\n第十八条 通信费.......................................................... 12\n第 2 页 共 20 页\n商密【中】\n\n\f第十九条 邮递费.......................................................... 12\n第二十条 薪酬福利支出.................................................... 12\n第二十一条\n\n对外捐赠支出 ................................................. 13\n\n第二十二条\n\n涉外业务汇率标准 ............................................. 13\n\n第五章\n\n附则............................................................. 13\n\n第二十三条\n\n本办法的归口与实施 ........................................... 13\n\n第二十四条\n\n附件 ......................................................... 13\n\n附表 1:员工支出报销审批权限表 ............................................ 14\n附表 2:岗位支出报销审批权限表 ............................................ 15\n附表 3:支出归口管理部门与归口业务范围 .................................... 18\n\n第 3 页 共 20 页\n商密【中】\n\n\f第一章 总则\n第一条\n\n目的\n\n为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善\n支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律\n法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实\n际情况,在广泛征求意见的基础上,制定本办法。\n第二条\n\n范围\n\n本办法适用于公司各类成本费用和资本性支出。\n本办法适用于全公司范围,包括:公司各部门、分支机构(非独立法人)、全资\n子公司。控股子公司应参照本办法制订支出管理办法,按子公司相关议事规则审批、\n报公司计划财务部备案后执行。\n第三条\n\n管理原则\n\n公司支出管理遵循“预算先行、厉行节约、效益优先,分级授权、分类控制、批\n办分离”的基本原则。\n1\n\n公司支出管理遵循预算先行的控制原则,应在预算目标范围内支出,并遵循\n公司内控、预算、考核管理相关规定,预算外支出履行预算审批程序后执行。\n\n2\n\n各部门(分支机构)、各岗位应在部门(岗位)职责与授权业务范围内,秉\n持厉行节约、效益优先原则,规范开展支出业务活动。\n\n3\n\n各级管理人员应根据其管理岗位,在公司支出授权审批范围和本人职责范\n围内,履行支出审批职权,承担审批责任。岗位审批权限与职级待遇分离,\n审批权限按管理岗位执行。\n\n4\n\n公司支出管理遵循批办分离的牵制原则,支出经办人和审批人不得为同一\n人。\n第二章\n\n第四条\n\n职责分工\n\n归口管理部门主要职责\n\n公司实行支出归口管理,归口管理部门主要职责如下:\n1\n\n在遵循公司财务、采购、人资等相关制度规定的基础上,根据业务管理需要,\n确定各项支出业务的开支范围、标准、方式和管理流程。\n第 4 页 共 20 页\n商密【中】\n\n\f2\n\n根据公司有关规定,对支出业务的合规性、预算(计划)执行控制情况等进\n行检查、分析与监督。\n\n支出归口管理部门与范围按“附表3”执行,部门职责变化时,相应调整归口分\n工。\n第五条\n\n计划财务部主要职责\n\n1\n\n明确支出报销审批流程、审核要点和报销资料规范。\n\n2\n\n协助归口管理部门确定各项支出业务的开支范围和标准。\n\n3\n\n负责报销业务财务审核,对业务原始凭据的完整性、合规性进行审查,对报\n销事项与业务原始凭据的业务关联性、内容一致性、金额准确性等进行复\n核,可要求报销人提供不限于本办法规定的佐证资料。\n\n4\n\n办理报销业务的结算支付。\n\n5\n\n组织开展支出报销业务日常财务稽核与宣贯。\n\n第六条\n1\n\n经办部门(个人)主要职责\n在部门(岗位)职责与授权业务范围内,秉持预算先行、厉行节约、效益优\n先原则,规范开展支出业务活动。\n\n2\n\n业务经办过程中,应严格遵循开支范围和标准,切实履行业务流程与审批程\n序,并遵循“发票、资金、物资”三流一致原则,及时取得真实、合规、关\n联、完整的业务原始凭据,验证发票真伪。\n\n3\n\n各项物资(原材料、固定资产、无形资产、低值易耗品、办公用品)、服务、\n外包分包业务的采购,应以经审核的需求计划(项目采购预算)为前提,并\n严格遵循公司招标、采购与物资管理相关规定。\n\n4\n\n及时提交报销申请,履行报销审批流程,配合提供发票,以及不限于本办法\n规定的业务佐证资料。\n\n5\n\n对支出业务的真实性、合规性、必要性、合理性以及业务原始凭据的真实性、\n合规性、关联性、完整性,承担全部责任。\n\n第七条\n\n各级管理人员主要职责\n\n各级管理人员应在授权审批范围和职责范围内,履行支出报销审批职权,承担审\n批责任。\n第 5 页 共 20 页\n商密【中】\n\n\f1\n\n第一审批人:应对支出业务的真实性、合规性、必要性、合理性进行全面审\n核,并承担审批责任。\n\n2\n\n后续审批人:应对支出业务的必要性、合理性进行审核,并承担审批责任。\n第三章\n\n第八条\n1\n\n支出报销申请与审批\n\n支出报销申请\n申请方式\n\n支出报销申请通过公司财务信息化系统实现(以下简称“系统单据”),财务原\n则上不接受“纸质申请单据”。\n(1)经办人应及时填写“系统单据”,并同步提交业务原始凭据。\n(2)除“员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付”等支\n出业务外,其他支出均需提交税务机关认可的票据。\n① “工会经费、员工福利、职工活动、业务招待、车票、政府规费”以外的\n支出,原则上均应取得增值税专用发票;应取得但未取得增值税专用发\n票的,经办人应在“系统单据”中说明原因。\n② 汇总开具的增值税发票,应附税控系统明细清单。\n(3)财务审核发现业务原始凭据不完整的,经办人应在三个工作日内补充并重新\n提交至财务;经办人填单错误、业务原始凭据不合规,以及不能按时补充业\n务原始凭据的,财务退单处理。\n2\n\n申请时限\n\n支出报销申请时限指“业务完成日”至“附件影像资料挂接系统单据日”的期间。\n(1)公司各类支出报销结算申请时限为三个月。\n① 逾期需说明原因,经分管领导审批后方可报销。\n② 按自然月度计算并定期发生的费用支出,需月度结束后方可报销。\n③ 差旅费原则上需在行程结束三个月内提交报销申请(连续出差超过一个\n月时,原则上应按月报销),逾期不予报销出差补贴。\n(2)预付款项,原则上应在次月底前完成结算,不得长期挂账。\n3\n\n结算方式\n\n(1)员工支出业务:原则上采用“公对私”结算方式,报销申请审批通过后,公\n第 6 页 共 20 页\n商密【中】\n\n\f司支付给经办人。\n(2)岗位支出业务:原则上采用“公对公”结算方式,报销申请审批通过后,公\n司与供应商直接结算。结算起点(1000 元)以下、且确实无法与供应商直接\n结算的小额支出,报销时应附向供应商付款凭据的截图佐证。\n第九条\n1\n\n支出报销审批\n审批权限\n\n(1)预算内支出,按“附表 1、附表 2”执行。\n(2)预算外支出,经办部门提交预算调整申请,经公司总裁批准后,再按“附表\n1、附表 2”执行。\n2\n\n审批时限\n\n各级管理人员原则上应在待批单据流转至本岗位后三个工作日内完成审批。\n3\n\n财务审核时限\n\n(1)影像扫描:“系统单据和纸质原始凭据”流转至影像岗后,原则上应在一个\n工作日内处理完毕。\n(2)审核与支付:已完成审批的系统单据,原则上应在三个工作日内处理完毕。\n第十条\n\n支出成本中心归属\n\n支出成本中心归属原则上基于责任原则与受益原则确定,特殊情况由相关业务\n部门协商确定。\n第四章\n第十一条\n\n重点支出管理规定\n\n备用金借款\n\n备用金是公司借支给正式员工,用于支付与公司经济业务相关的、必须预支且尚\n不具备报销条件的费用支出款项。\n1\n\n备用金借款必须是真实合法的经济业务,应遵循“前款不清、后款不借”的\n原则,严禁以各种名义挪用公司资金。\n\n2\n\n非正式员工不得申请备用金借款。\n\n3\n\n备用金借款按季定期清理。季度不能及时报账核销的,借款人应向其分管领\n导申请延期审批,但不得跨年。\n\n4\n\n员工备用金借款额度原则上不得超过一万元。\n第 7 页 共 20 页\n商密【中】\n\n\f第十二条\n\n市内交通费\n\n市内交通费是指员工为公司生产经营活动,在工作所在地发生的市内交通费用,\n包括工作时间内外出办理公事、夜间工作或非工作日发生的市内交通费用,不包括员\n工正常上下班所发生的交通费用、从居住地公出或公出结束返回居住地发生的市内\n交通费。\n1\n\n市内交通费凭据报销,应与工作实际相符,严禁报销与工作无关的交通费,\n不接受充值预付费方式的交通费。\n\n2\n\n基于厉行节约原则,鼓励员工选择公交、地铁等公共交通出行。出租车仅限\n“紧急公务、接送客户、夜间工作至 22:00 后、以及其他确有需要的特别情\n形”等事项的市内交通使用,由部门负责人从严管理。\n\n第十三条\n\n差旅费\n\n差旅费是指员工因公出差所发生的交通费、住宿费、出差补贴等。\n各部门(单位)应严格执行出差审批程序,从严控制出差人数和出差天数,严禁\n无实质内容、无明确公务目的的差旅活动。部门副职及以下员工出差需通过公司商旅\n系统事前审批。\n1\n\n交通费\n表1\n\n员工职级\n\n交通工具等级标准\n国内(含港澳台)、国外\n\n飞机\n\n公司领导、高层经理、\n中层经理\n(P5 及以上、外聘专\n家)\n\n经济舱\n\n基层经理、其他人员\n(P4 及以下)\n\n经济舱\n(注 3)\n\n火车\n\n轮船\n\n其他交通工具\n(不含小汽\n车)\n\n火车硬席(硬\n卧、硬座)、\n高铁/动车二\n等座,全列软\n席列车二等软\n座\n\n三等舱\n\n凭据报销\n\n第 8 页 共 20 页\n商密【中】\n\n\f国内(含港澳台)、国外\n员工职级\n\n飞机\n\n火车\n\n其他交通工具\n(不含小汽\n车)\n\n轮船\n\n注 1:交通工具选乘应遵循“性价比优先”原则。\n注 2:基层经理及以下人员(P4 及以下)乘坐飞机需事前报经部门负责人审批。基层经\n理(P4)应选乘 6 折及以下经济舱、其他人员(P1-P3)应选乘 5 折及以下经济舱。\n注 3:夜间乘坐高铁/动车 6 小时以上时,可选乘卧铺。\n注 4:出差人员应当严格执行交通工具等级标准,确因紧急公务、特别情形等事项导致\n交通工具超过规定标准时,超标 20%以内时由部门负责人审批,超标 20%以上时需分管\n领导审批。\n注 5:公司已为员工购买了商业保险(含交通险),出差期间发生的保险费不再报销。\n注 6:出租车仅限“紧急公务、接送客户、夜间工作至 22:00 后、以及其他确有需要的\n特别情形”等事项的市内交通使用,由部门负责人从严管理。往返机场、车站、港口,\n原则上应选择“公交车、轨道交通”。\n注 7:自驾车出差发生的路桥费、停车费、油费、电费等据实报销,由部门负责人从严\n管理。自驾发生事故或违章罚款等造成的损失,责任自负。\n2\n\n住宿费\n\n(1)酒店住宿\n表2\n\n酒店住宿限额标准\n单位:人民币元\n国内\n\n员工职级\n公司领导\n(P8及以上)\n高层经理\n(P7)\n中层经理、基层经理\n(P4~P6、外聘专家)\n其他员工\n\n国外\n\n直辖市/特区/\n港澳台\n\n省会城市\n\n其他地区\n\n500\n\n450\n\n400\n\n800\n\n450\n\n400\n\n350\n\n700\n\n400\n\n350\n\n300\n\n600\n\n350\n\n300\n\n250\n\n500\n\n注1:出差人员应严格执行住宿限额标准,确因紧急公务、特别情形等事项导致住宿超\n过规定标准时,超标20%以内时由部门负责人审批,超标20%以上时需分管领导审批。\n注2:外出参加会议、培训,统一安排食宿的,会议期间的住宿费按外部会议组织方通\n知标准凭据报销。不统一安排食宿的,按照上表标准执行。\n(2)异地工作用房\n① 因公长期出差人员申请异地工作用房的,按照《公司物业租赁管理办法》\n第 9 页 共 20 页\n商密【中】\n\n\f执行。\n② 异地工作用房产生的租金、房屋初始配置费、物业管理费、取暖费、水电\n燃气支出(含公摊)、网络宽带费等费用,按照《公司物业租赁管理办法》确\n定标准进行报销。\n3\n\n出差补贴\n\n出差补贴按出差自然天数(日历天数)进行报销,具体标准如下:\n表3\n\n出差补贴标准\n单位:人民币元/天\n国内\n\n补助类型\n\n全额\n补助\n\n项目\n\n国外\n港澳台\n\n直辖市/特区/西藏\n\n其他地区\n\n餐补\n\n自行解决餐食\n\n75\n\n65\n\n55\n\n140\n\n基本\n补助\n\n基本出差补贴\n\n35\n\n35\n\n35\n\n35\n\n110\n\n100\n\n90\n\n175\n\n合计\n\n注:因组织安排、销售、推广、调研、培训、会议、研讨、借调等出差,主办方统一安排\n餐食的,不再报销餐补。\n4\n\n差旅费其他注意事项\n\n(1)因公发生的订票、签转、退票等费用凭据报销,由部门负责人审核确认。\n(2)出差记录链条中断时,应提供业务佐证材料:\n① 登机牌、高速道路通行记录、其他道路通行记录、租车记录等。\n② 支付记录。\n③ 出差审批邮件、短信、微信等。\n(3)出差或调动工作期间,经部门负责人批准就近回家省亲办事的,其绕道交通\n费,扣除出差直线单程交通费,多开支的部分由个人自理。绕道和在家期间\n不得报销住宿费、出差补贴。\n(4)出差交通费原始凭据丢失的,提供情况说明和订单详情、支付截图等佐证材\n料,可按票面价值的 75%报销,未提供的,不予报销。通过商旅订票未取得\n火车票的,从差旅补贴或其他差旅杂费中扣减。\n(5)探亲路费应严格遵循《公司员工探亲管理办法》相关规定,不得以因公差旅\n第 10 页 共 20 页\n商密【中】\n\n\f方式报销,不享受出差补贴。探亲路费原始凭据丢失的,不允许报销。\n(6)员工工作地调动时,所发生的行李、家具等邮寄费,在每人每公里 1 元以内\n凭据报销,超过部分自理。\n(7)经组织安排到异地挂职锻炼、培养锻炼、人才帮扶、借调支援等工作人员,\n在途期间的交通费、餐补和基本补助按出差规定执行;在异地单位工作期间,\n不适用出差补贴标准规定,按照组织人事部确定的挂职人员报销标准执行;\n所在单位不安排住宿的,住宿标准按公司酒店住宿限额标准执行。\n(8)境外出差(港澳台与国外出差)应单列预算、事前履行经费申请与审批程序。\n(9)以上涉及事前审批的事项,以公司商旅系统审批截图或审批邮件为报销必备\n附件。\n(10)因公出差原则上应使用商旅系统统一预定,特殊情况未通过商旅系统下单\n的,应邮件知会商旅客服并抄送部门负责人。\n第十四条\n\n业务招待费\n\n业务招待费是指各级单位正常生产经营管理过程中需要发生的、用于必要招待\n的各项费用,包括用于接待客户和相关单位的餐饮等合理支出。未能“公对公”结算\n的,应附“向供应商付款凭据”佐证。\n第十五条\n\n会议费\n\n会议费是指公司主办或承办会议发生的会场租金、文件资料、设备租金等支出,\n以及参加外部会议所发生的会务相关支出。\n1\n\n公司主办或承办的会议\n\n(1)会议费中不得列支与会议无关的旅游观光、宴请、礼品馈赠等支出。\n(2)经费预算 30,000 元及以上(包括不在会议费列支,但与会议直接相关的培训\n费、差旅费等全部支出)的公司内部会议、研讨与集中培训,需事前报请公\n司总裁审批。\n(3)报销时应附:经审批的会议申请与预算、会议通知、参会人员签到表、会议\n开支明细等业务佐证材料,会务费发票应附服务方费用明细清单。\n2\n\n参加外部会议的,报销时应附会议通知、参会回执等业务佐证材料。\n\n第十六条\n\n广告宣传费\n第 11 页 共 20 页\n商密【中】\n\n\f广告宣传费是指展示企业形象、宣传企业文化及营销活动中推广企业产品、业务\n所发生的支出,包括广告费和业务宣传费。\n1\n\n广告费\n\n广告费指公司通过各种公开媒体宣传所发生的费用,报销时应附广告投放等业\n务佐证材料。\n2\n\n业务宣传费\n\n业务宣传费是指公司自身开展宣传与营销活动所发生的费用,包括未通过媒体\n传播的广告性支出,以及发放的印有公司标志的礼品、纪念品等。业务宣传费报销时\n应附活动方案、费用预算等业务佐证材料,并需遵循公司采购与物资管理相关规定。\n第十七条\n\n培训费\n\n培训费是指公司安排、主办或承办培训发生的学费、书籍杂费、师资费、资料费、\n场地费、设备材料费、住宿费、交通费等,以及员工根据工作需要,经归口管理部门\n批准参加外部培训、考证、教育产生的相关费用。\n1\n\n报销资格按《公司员工教育培训管理办法》认定标准执行。\n\n2\n\n经归口管理部门认定符合办法标准的,培训期间的主要交通费(往返)、住\n宿费按出差规定标准执行,其他费用自理。\n\n第十八条\n\n通信费\n\n通信费是指为满足公司日常办公需要发生的电话、传真、集团网、网络连接等支\n出,其中员工通讯费按《公司员工因公通讯费用实施细则》执行。\n第十九条\n\n邮递费\n\n邮递费是指公司因业务需要邮递物品而支付的费用,包括邮件费、托运费、快递\n费等,员工报销的应附快递底单,单位统一结算的应附寄件明细清单与支出分摊表。\n第二十条\n1\n\n薪酬福利支出\n\n公司相关制度规定的职工薪资、奖励提成、福利费支出,按公司相关制度规\n定执行。\n\n2\n\n临时的奖励及福利支出,需事先计划并报公司总裁审批,具体支出时由分管\n领导根据已批准的计划审批。\n\n3\n\n职工福利费由组织人事部实行年度计划管理,对未纳入福利计划的福利项\n第 12 页 共 20 页\n商密【中】\n\n\f目,不得列支和报销。\n4\n\n薪酬福利支出事前审批程序以组织人事部规定标准执行。\n\n5\n\n职工活动支出,按照《公司团建管理办法》或《工会经费管理办法》执行。\n\n第二十一条 对外捐赠支出\n公司对外捐赠支出由品牌及市场运营中心归口管理,并严格预算单控,未纳入对\n外捐赠预算的,不得对外捐赠。\n1\n\n预算内对外捐赠事项发生时,实施捐赠的业务部门应提出捐赠申请,详细说\n明捐赠事由、捐赠对象、捐赠金额等内容,经业务部门负责人审批,品牌及\n市场运营中心归口审核,业务部门分管领导审批后执行。\n\n2\n\n未纳入预算的对外捐赠事项,实施捐赠的业务部门应提出捐赠申请,详细说\n明捐赠必要性,履行对外捐赠的预算调整决策程序,并纳入预算范围后方可\n实施。\n\n3\n\n捐赠事项完成后,实施捐赠的业务部门应将捐赠活动的凭据资料报品牌及\n市场运营中心备案,凭据资料包括但不限于发票、收据、捐赠协议、接收函、\n捐赠清单等。\n\n第二十二条 涉外业务汇率标准\n以人民币结算的,凭据报销;以外币结算的,按支付凭据所载汇率折算,支付凭\n据未载明汇率的按业务发生月第一个交易日“中国银行外汇折算价”执行,中国银行\n无折算价的币种按“中国外汇交易中心参考汇率”执行。\n第五章\n\n附则\n\n第二十三条 本办法的归口与实施\n1\n\n本办法自颁布之日起施行,原办法同时废止。\n\n2\n\n本办法由公司计划财务部负责制定、修订、解释及实施协调工作。\n\n第二十四条 附件\n附表1:员工支出报销审批权限表\n附表2:岗位支出报销审批权限表\n附表3:支出归口管理部门与归口业务范围\n\n第 13 页 共 20 页\n商密【中】\n\n\f附表 1:员工支出报销审批权限表\n单位:人民币万元\n审批权限\n支出项目\n\n部门经理/\n机构总经理/\n事业部总经理\n\n员工支出\n\n总监/\n\n副总裁/\n\n高级副总裁/\n\n一级部门总经理\n\n总工程师\n\n各委员会主任\n\n补充说明\n\n总裁\n\n业务招待\n\n0.5\n\n1\n\n2\n\n3\n\n15\n\n因公借款\n\n0.5\n\n1\n\n2\n\n3\n\n15\n1.差旅费、市内交通。\n\n其他支出\n\n1\n\n2\n\n3\n\n5\n\n50\n\n2.客服及商务、教育、邮递。\n3.职工活动等其他临时性支出。\n\n第 14 页 共 20 页\n商密【中】\n\n\f附表 2:岗位支出报销审批权限表\n单位:人民币万元\n审批权限\n部门经理/\n机构总经理/\n事业部总经理\n\n总监/\n一级部门\n总经理\n\n资产采购\n\n1\n\n2\n\n10\n\n15\n\n200\n\n基建工程\n\n——\n\n——\n\n5\n\n10\n\n50\n\n股权投资、\n兼并收购\n\n——\n\n——\n\n——\n\n——\n\n——\n\n由董事长审批。\n1.生产采购:产品生产用原材料、辅助\n材料、机物料等。\n2.项目采购:项目外采的设备、软件、公\n有云资源等。\n\n支出项目\n\n资本性\n支出\n\n收益性\n支出\n\n高级\n副总裁\n\n副总裁\n\n补充说明\n\n总裁\n\n材料采购\n\n——\n\n10\n\n20\n\n30\n\n500\n\n分包外包\n(内部单位)\n\n——\n\n10\n\n20\n\n30\n\n500\n\n分包外包\n(外部单位)\n\n——\n\n10\n\n20\n\n30\n\n200\n\n保证金\n\n5\n\n50\n\n100\n\n200\n\n500\n\n销售退款\n\n5\n\n10\n\n30\n\n50\n\n200\n\n房屋租金\n\n5\n\n10\n\n20\n\n30\n\n200\n\n第 15 页 共 20 页\n商密【中】\n\n包括固定资产、无形资产、低值易耗品。\n\n1.研发、实施、运维、服务等分包外包。\n2.委托加工、项目土建装修等。\n1.投标相关保证金:投标保证金、质保\n金、履约保证金。\n2.招标相关保证金。\n1.销售退款。\n2.代付款(代收后)。\n不含水电及杂费,需经业务归口部门审\n批。\n\n\f统结支出\n\n公司统一结算或批量采购的:\n1.食堂采购、通勤车。\n2.交通费、住宿费。\n3.业务招待费、通讯费等。\n\n代付子公司\n经营支出\n\n代付子公司投标支出。\n\n市场营销\n\n1.广告费。\n2.业务宣传费:业务宣传、企业文化宣\n传、市场活动、营销活动。\n\n专项服务、\n外聘劳务\n\n1\n\n3\n\n5\n\n10\n\n50\n\n国家机关、事业单位、代行政府职能的\n社会团体收费。\n\n政府规费\n\n财务专用\n\n外部单位与个人提供的咨询、培训、劳务\n等服务(非专家意见、非鉴证报告)。\n\n慰问救济、对\n外捐赠\n\n——\n\n——\n\n1\n\n2\n\n10\n\n公司制度外的慰问救济捐赠。\n\n税费支出\n\n50\n\n100\n\n200\n\n300\n\n500\n\n增值税及各类附加费、所得税等。\n\n其他支出\n\n1\n\n2\n\n3\n\n5\n\n50\n\n设备租赁、公务车支出等\n\n资金转户/调拨\n\n50\n\n——\n\n——\n\n2000\n\n3000\n\n5\n\n10\n\n30\n\n50\n\n200\n\n银行手续费\n错付退款\n其他支付\n\n不含销售退款。\n——\n\n——\n\n——\n\n第 16 页 共 20 页\n商密【中】\n\n15\n\n50\n\n出纳提现等。\n\n\f注 1:上述权限额度均含本数,各级管理人员权限指各自职责范围内审批权限。\n注 2:上述权限中业务流转执行以组织关系为基准的逐级审批规则。特殊事项经决策后,可越级至终审岗审批。\n注 3:仅结算业务,由业务部门审批确定是否达到财务入账条件,并按照上述对应支出项目权限审批。\n注 4:代付子公司经营支出原则上与子公司按季度汇总结算。\n注 5:薪酬福利支出分配计划审批按照人事归口管理部门规定执行。\n注 6:上述权限不含协管、高级顾问、顾问及主持工作人员,公司如有其相关文件通知,从其规定授权。\n注 7:全资子公司总经理由母公司未明确层级管理人员担任的,审批权限按“总监/一级部门总经理”执行。\n注 8:全资子公司员工薪资、奖金提成、福利支出、临时的奖励及福利支出审批按照人事归口管理部门规定执行。\n注 9:全资子公司其他部门经理,按照公司 1 号文对应岗位授权,由平级人员分角色担任的,按最高岗位授权,逐级审批。\n\n第 17 页 共 20 页\n商密【中】\n\n\f附表 3:支出归口管理部门与归口业务范围\n归口管理部门\n办公室(党委办公室)\n工会委员会\n\n归口业务范围\n1.党建支出。\n2.公务车支出。\n工会支出。\n1.投标业务支出:标书费、投标保证金、中标服务费(或保险)等。\n\n营销中心\n\n2.机构营销业务支出:客服及商务支出、营销活动支出。\n3.客户培训、销售退款等支出。\n1.广告费支出。\n\n品牌及市场运营中心\n\n2.业务宣传支出:业务宣传、文化宣传、市场活动。\n3.对外捐赠支出。\n1.薪酬福利(不含食堂、误餐)、外聘劳务、员工保险支出。\n\n组织人事部\n\n2.探亲差旅、条件艰苦及安全风险较高区域补助等支出。\n3.其他福利支出。\n\n人力资源服务部\n\n1.招聘业务。\n2.员工教育培训支出。\n1.员工备用金、差旅费、市内交通费、出国经费、误餐费支出。\n\n计划财务部\n\n2.税金及附加、审计评估、财务费用支出。\n3.客服及商务支出。\n\n产业投资部\n\n股权投资支出、并购业务支出。\n\n证券与法律事务部\n\n法律事务类支出、上市信披类支出、商标注册类支出。\n\n产品规划设计部\n\n知识产权类支出、信息技术咨询类支出、研发活动类支出。\n\nDAP 研发中心\n\n研究开发过程中支付给外单位的检测、评测、测试及化验等支出。\n\n信息管理部\n\n1.IT 类资产的购置(建造)、租入、运维、修理等支出。\n2.网络使用费支出。\n1.非 IT 类资产的购置、租入、运维、修理、装修等支出,财产保险支出。\n\n后勤服务部\n\n2.食堂支出。\n3.办公费用支出。\n4.商旅统付结算。\n\n第 18 页 共 20 页\n商密【中】\n\n\f附件 2\n\n修订说明\n\n《公司支出管理办法》修订涉及修改报销标准 2 项,取消报销规定 1 项,新增\n报销规定 2 项,审批权限变化 2 项,具体情况如下。\n一、报销标准变化情况\n只调整了差旅费、异地调动邮寄费两项内容。\n1.差旅费。只调整了公司领导出差交通工具标准和出差补贴。\n出差交通工具标准:公司领导(P8)乘坐火车标准(由“不限”标准调低为\n“二等座”)、轮船标准(由“二等舱”调低为“三等舱”)。\n出差补贴:原制度描述出差补助为包干制,实际执行时在两项补助之外还存在\n报销打车费情况,故取消了原制度中“包干”表述。\n2.异地调动邮寄费。将原制度“可凭据报销一次个人物品邮寄费用,金额超过\n1000 元的需事前审批”,调整为“1 元/人/公里内报销,超标自理”。\n二、取消报销规定内容\n因公用车补贴已纳入工资内发放,新制度中取消该费用相关表述。\n三、新增规定内容\n1.异地挂职锻炼补贴标准。经组织安排到异地挂职锻炼、培养锻炼、人才帮\n扶、借调支援等工作人员,在途期间的交通费、餐补和基本补助按出差规定执行;\n在异地单位工作期间,不适用出差补贴标准规定,按照组织人事部确定的挂职人员\n报销标准执行;所在单位不安排住宿的,住宿标准按公司酒店住宿限额标准执行。\n2.对使用商旅订票进行了规范。因公出差原则上应使用商旅系统统一预定。\n四、审批权限变化情况\n只调整了“投标保证金”“审批流转程序”两项内容:\n1.调整投标保证金审批权限。因对投标缴纳保证金的时效性要求较高,为提高\n审批效率,调整投标保证金审批权限,具体如下(单位:万元):\n\n第 19 页 共 20 页\n商密【中】\n\n\f审批权限\n支出项目\n\n部门经理/\n总监/\n\n副总裁/\n\n高级副总裁/\n\n一级部门总经理\n\n总工程师\n\n各委员会主任\n\n机构总经理/\n\n总裁\n\n事业部总经理\n保证金\n\n5\n\n50\n\n100\n\n200\n\n2.明确支出审批流转程序。业务流转执行以组织关系为基准的逐级审批规则。\n特殊事项经决策后,可越级至终审岗审批。\n\n第 20 页 共 20 页\n商密【中】\n\n500", + "source_page": null, + "word_count": 11643, "tags": [ "差旅", + "住宿", + "交通", + "餐饮", "附件", "发票", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-033", - "title": "司支付给经办人。", - "content": "司支付给经办人。\n(2)岗位支出业务:原则上采用“公对公”结算方式,报销申请审批通过后,公\n司与供应商直接结算。结算起点(1000 元)以下、且确实无法与供应商直接\n结算的小额支出,报销时应附向供应商付款凭据的截图佐证。", - "source_page": 7, - "word_count": 106, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-034", - "title": "第九条", - "content": "1\n支出报销审批\n审批权限\n(1)预算内支出,按“附表 1、附表 2”执行。\n(2)预算外支出,经办部门提交预算调整申请,经公司总裁批准后,再按“附表\n1、附表 2”执行。\n2\n审批时限\n各级管理人员原则上应在待批单据流转至本岗位后三个工作日内完成审批。\n3\n财务审核时限\n(1)影像扫描:“系统单据和纸质原始凭据”流转至影像岗后,原则上应在一个\n工作日内处理完毕。\n(2)审核与支付:已完成审批的系统单据,原则上应在三个工作日内处理完毕。", - "source_page": 7, - "word_count": 204, - "tags": [ "审批", "预算" ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-035", - "title": "第十条", - "content": "支出成本中心归属\n支出成本中心归属原则上基于责任原则与受益原则确定,特殊情况由相关业务\n部门协商确定。", - "source_page": 7, - "word_count": 49, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-036", - "title": "第十一条", - "content": "重点支出管理规定\n备用金借款\n备用金是公司借支给正式员工,用于支付与公司经济业务相关的、必须预支且尚\n不具备报销条件的费用支出款项。\n1\n备用金借款必须是真实合法的经济业务,应遵循“前款不清、后款不借”的\n原则,严禁以各种名义挪用公司资金。\n2\n非正式员工不得申请备用金借款。\n3\n备用金借款按季定期清理。季度不能及时报账核销的,借款人应向其分管领\n导申请延期审批,但不得跨年。\n4\n员工备用金借款额度原则上不得超过一万元。", - "source_page": 7, - "word_count": 199, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-037", - "title": "第十二条", - "content": "市内交通费\n市内交通费是指员工为公司生产经营活动,在工作所在地发生的市内交通费用,\n包括工作时间内外出办理公事、夜间工作或非工作日发生的市内交通费用,不包括员\n工正常上下班所发生的交通费用、从居住地公出或公出结束返回居住地发生的市内\n交通费。\n1\n市内交通费凭据报销,应与工作实际相符,严禁报销与工作无关的交通费,\n不接受充值预付费方式的交通费。\n2\n基于厉行节约原则,鼓励员工选择公交、地铁等公共交通出行。出租车仅限\n“紧急公务、接送客户、夜间工作至 22:00 后、以及其他确有需要的特别情\n形”等事项的市内交通使用,由部门负责人从严管理。", - "source_page": 8, - "word_count": 259, - "tags": [ - "交通" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-038", - "title": "第十三条", - "content": "差旅费\n差旅费是指员工因公出差所发生的交通费、住宿费、出差补贴等。\n各部门(单位)应严格执行出差审批程序,从严控制出差人数和出差天数,严禁\n无实质内容、无明确公务目的的差旅活动。部门副职及以下员工出差需通过公司商旅\n系统事前审批。\n1\n交通费\n表1\n员工职级\n交通工具等级标准\n国内(含港澳台)、国外\n飞机\n公司领导、高层经理、\n中层经理\n(P5 及以上、外聘专\n家)\n经济舱\n基层经理、其他人员\n(P4 及以下)\n经济舱\n(注 3)\n火车\n轮船\n其他交通工具\n(不含小汽\n车)\n火车硬席(硬\n卧、硬座)、\n高铁/动车二\n等座,全列软\n席列车二等软\n座\n三等舱\n凭据报销", - "source_page": 8, - "word_count": 249, - "tags": [ - "差旅", - "住宿", - "交通", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-039", - "title": "国内(含港澳台)、国外", - "content": "国内(含港澳台)、国外\n员工职级\n飞机\n火车\n其他交通工具\n(不含小汽\n车)\n轮船\n注 1:交通工具选乘应遵循“性价比优先”原则。\n注 2:基层经理及以下人员(P4 及以下)乘坐飞机需事前报经部门负责人审批。基层经\n理(P4)应选乘 6 折及以下经济舱、其他人员(P1-P3)应选乘 5 折及以下经济舱。\n注 3:夜间乘坐高铁/动车 6 小时以上时,可选乘卧铺。\n注 4:出差人员应当严格执行交通工具等级标准,确因紧急公务、特别情形等事项导致\n交通工具超过规定标准时,超标 20%以内时由部门负责人审批,超标 20%以上时需分管\n领导审批。\n注 5:公司已为员工购买了商业保险(含交通险),出差期间发生的保险费不再报销。\n注 6:出租车仅限“紧急公务、接送客户、夜间工作至 22:00 后、以及其他确有需要的\n特别情形”等事项的市内交通使用,由部门负责人从严管理。往返机场、车站、港口,\n原则上应选择“公交车、轨道交通”。\n注 7:自驾车出差发生的路桥费、停车费、油费、电费等据实报销,由部门负责人从严\n管理。自驾发生事故或违章罚款等造成的损失,责任自负。\n2\n住宿费\n(1)酒店住宿\n表2\n酒店住宿限额标准\n单位:人民币元\n国内\n员工职级\n公司领导\n(P8及以上)\n高层经理\n(P7)\n中层经理、基层经理\n(P4~P6、外聘专家)\n其他员工\n国外\n直辖市/特区/\n港澳台\n省会城市\n其他地区\n500\n450\n400\n800\n450\n400\n350\n700\n400\n350\n300\n600\n350\n300\n250\n500\n注1:出差人员应严格执行住宿限额标准,确因紧急公务、特别情形等事项导致住宿超\n过规定标准时,超标20%以内时由部门负责人审批,超标20%以上时需分管领导审批。\n注2:外出参加会议、培训,统一安排食宿的,会议期间的住宿费按外部会议组织方通\n知标准凭据报销。不统一安排食宿的,按照上表标准执行。\n(2)异地工作用房", - "source_page": 9, - "word_count": 737, - "tags": [ - "住宿", - "交通", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-040", - "title": "① 因公长期出差人员申请异地工作用房的,按照《公司物业租赁管理办法》", - "content": "① 因公长期出差人员申请异地工作用房的,按照《公司物业租赁管理办法》", - "source_page": 9, - "word_count": 33, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-041", - "title": "执行。", - "content": "执行。\n② 异地工作用房产生的租金、房屋初始配置费、物业管理费、取暖费、水电\n燃气支出(含公摊)、网络宽带费等费用,按照《公司物业租赁管理办法》确\n定标准进行报销。\n3\n出差补贴\n出差补贴按出差自然天数(日历天数)进行报销,具体标准如下:\n表3\n出差补贴标准\n单位:人民币元/天\n国内\n补助类型\n全额\n补助\n项目\n国外\n港澳台\n直辖市/特区/西藏\n其他地区\n餐补\n自行解决餐食\n75\n65\n55\n140\n基本\n补助\n基本出差补贴\n35\n35\n35\n35\n110\n100\n90\n175\n合计\n注:因组织安排、销售、推广、调研、培训、会议、研讨、借调等出差,主办方统一安排\n餐食的,不再报销餐补。\n4\n差旅费其他注意事项\n(1)因公发生的订票、签转、退票等费用凭据报销,由部门负责人审核确认。\n(2)出差记录链条中断时,应提供业务佐证材料:\n① 登机牌、高速道路通行记录、其他道路通行记录、租车记录等。\n② 支付记录。\n③ 出差审批邮件、短信、微信等。\n(3)出差或调动工作期间,经部门负责人批准就近回家省亲办事的,其绕道交通\n费,扣除出差直线单程交通费,多开支的部分由个人自理。绕道和在家期间\n不得报销住宿费、出差补贴。\n(4)出差交通费原始凭据丢失的,提供情况说明和订单详情、支付截图等佐证材\n料,可按票面价值的 75%报销,未提供的,不予报销。通过商旅订票未取得\n火车票的,从差旅补贴或其他差旅杂费中扣减。\n(5)探亲路费应严格遵循《公司员工探亲管理办法》相关规定,不得以因公差旅", - "source_page": 10, - "word_count": 584, - "tags": [ - "差旅", - "住宿", - "交通", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-042", - "title": "方式报销,不享受出差补贴。探亲路费原始凭据丢失的,不允许报销。", - "content": "方式报销,不享受出差补贴。探亲路费原始凭据丢失的,不允许报销。\n(6)员工工作地调动时,所发生的行李、家具等邮寄费,在每人每公里 1 元以内\n凭据报销,超过部分自理。\n(7)经组织安排到异地挂职锻炼、培养锻炼、人才帮扶、借调支援等工作人员,\n在途期间的交通费、餐补和基本补助按出差规定执行;在异地单位工作期间,\n不适用出差补贴标准规定,按照组织人事部确定的挂职人员报销标准执行;\n所在单位不安排住宿的,住宿标准按公司酒店住宿限额标准执行。\n(8)境外出差(港澳台与国外出差)应单列预算、事前履行经费申请与审批程序。\n(9)以上涉及事前审批的事项,以公司商旅系统审批截图或审批邮件为报销必备\n附件。\n(10)因公出差原则上应使用商旅系统统一预定,特殊情况未通过商旅系统下单\n的,应邮件知会商旅客服并抄送部门负责人。", - "source_page": 11, - "word_count": 343, - "tags": [ - "住宿", - "交通", - "附件", - "审批", - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-043", - "title": "第十四条", - "content": "业务招待费\n业务招待费是指各级单位正常生产经营管理过程中需要发生的、用于必要招待\n的各项费用,包括用于接待客户和相关单位的餐饮等合理支出。未能“公对公”结算\n的,应附“向供应商付款凭据”佐证。", - "source_page": 11, - "word_count": 93, - "tags": [ - "餐饮" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-044", - "title": "第十五条", - "content": "会议费\n会议费是指公司主办或承办会议发生的会场租金、文件资料、设备租金等支出,\n以及参加外部会议所发生的会务相关支出。\n1\n公司主办或承办的会议\n(1)会议费中不得列支与会议无关的旅游观光、宴请、礼品馈赠等支出。\n(2)经费预算 30,000 元及以上(包括不在会议费列支,但与会议直接相关的培训\n费、差旅费等全部支出)的公司内部会议、研讨与集中培训,需事前报请公\n司总裁审批。\n(3)报销时应附:经审批的会议申请与预算、会议通知、参会人员签到表、会议\n开支明细等业务佐证材料,会务费发票应附服务方费用明细清单。\n2\n参加外部会议的,报销时应附会议通知、参会回执等业务佐证材料。", - "source_page": 11, - "word_count": 275, - "tags": [ - "差旅", - "发票", - "审批", - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-045", - "title": "第十六条", - "content": "广告宣传费", - "source_page": 11, - "word_count": 5, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-046", - "title": "广告宣传费是指展示企业形象、宣传企业文化及营销活动中推广企业产品、业务", - "content": "广告宣传费是指展示企业形象、宣传企业文化及营销活动中推广企业产品、业务\n所发生的支出,包括广告费和业务宣传费。\n1\n广告费\n广告费指公司通过各种公开媒体宣传所发生的费用,报销时应附广告投放等业\n务佐证材料。\n2\n业务宣传费\n业务宣传费是指公司自身开展宣传与营销活动所发生的费用,包括未通过媒体\n传播的广告性支出,以及发放的印有公司标志的礼品、纪念品等。业务宣传费报销时\n应附活动方案、费用预算等业务佐证材料,并需遵循公司采购与物资管理相关规定。", - "source_page": 12, - "word_count": 212, - "tags": [ - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-047", - "title": "第十七条", - "content": "培训费\n培训费是指公司安排、主办或承办培训发生的学费、书籍杂费、师资费、资料费、\n场地费、设备材料费、住宿费、交通费等,以及员工根据工作需要,经归口管理部门\n批准参加外部培训、考证、教育产生的相关费用。\n1\n报销资格按《公司员工教育培训管理办法》认定标准执行。\n2\n经归口管理部门认定符合办法标准的,培训期间的主要交通费(往返)、住\n宿费按出差规定标准执行,其他费用自理。", - "source_page": 12, - "word_count": 178, - "tags": [ - "住宿", - "交通" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-048", - "title": "第十八条", - "content": "通信费\n通信费是指为满足公司日常办公需要发生的电话、传真、集团网、网络连接等支\n出,其中员工通讯费按《公司员工因公通讯费用实施细则》执行。", - "source_page": 12, - "word_count": 67, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-049", - "title": "第十九条", - "content": "邮递费\n邮递费是指公司因业务需要邮递物品而支付的费用,包括邮件费、托运费、快递\n费等,员工报销的应附快递底单,单位统一结算的应附寄件明细清单与支出分摊表。", - "source_page": 12, - "word_count": 75, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-050", - "title": "第二十条", - "content": "1\n薪酬福利支出\n公司相关制度规定的职工薪资、奖励提成、福利费支出,按公司相关制度规\n定执行。\n2\n临时的奖励及福利支出,需事先计划并报公司总裁审批,具体支出时由分管\n领导根据已批准的计划审批。\n3\n职工福利费由组织人事部实行年度计划管理,对未纳入福利计划的福利项", - "source_page": 12, - "word_count": 124, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-051", - "title": "目,不得列支和报销。", - "content": "目,不得列支和报销。\n4\n薪酬福利支出事前审批程序以组织人事部规定标准执行。\n5\n职工活动支出,按照《公司团建管理办法》或《工会经费管理办法》执行。", - "source_page": 13, - "word_count": 70, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-052", - "title": "第二十一条 对外捐赠支出", - "content": "公司对外捐赠支出由品牌及市场运营中心归口管理,并严格预算单控,未纳入对\n外捐赠预算的,不得对外捐赠。\n1\n预算内对外捐赠事项发生时,实施捐赠的业务部门应提出捐赠申请,详细说\n明捐赠事由、捐赠对象、捐赠金额等内容,经业务部门负责人审批,品牌及\n市场运营中心归口审核,业务部门分管领导审批后执行。\n2\n未纳入预算的对外捐赠事项,实施捐赠的业务部门应提出捐赠申请,详细说\n明捐赠必要性,履行对外捐赠的预算调整决策程序,并纳入预算范围后方可\n实施。\n3\n捐赠事项完成后,实施捐赠的业务部门应将捐赠活动的凭据资料报品牌及\n市场运营中心备案,凭据资料包括但不限于发票、收据、捐赠协议、接收函、\n捐赠清单等。", - "source_page": 13, - "word_count": 284, - "tags": [ - "发票", - "审批", - "预算" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-053", - "title": "第二十二条 涉外业务汇率标准", - "content": "以人民币结算的,凭据报销;以外币结算的,按支付凭据所载汇率折算,支付凭\n据未载明汇率的按业务发生月第一个交易日“中国银行外汇折算价”执行,中国银行\n无折算价的币种按“中国外汇交易中心参考汇率”执行。", - "source_page": 13, - "word_count": 97, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-054", - "title": "第五章", - "content": "附则", - "source_page": 13, - "word_count": 2, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-055", - "title": "第二十三条 本办法的归口与实施", - "content": "1\n本办法自颁布之日起施行,原办法同时废止。\n2\n本办法由公司计划财务部负责制定、修订、解释及实施协调工作。", - "source_page": 13, - "word_count": 51, - "tags": [] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-056", - "title": "第二十四条 附件", - "content": "附表1:员工支出报销审批权限表\n附表2:岗位支出报销审批权限表\n附表3:支出归口管理部门与归口业务范围", - "source_page": 13, - "word_count": 49, - "tags": [ - "附件", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-057", - "title": "附表 1:员工支出报销审批权限表", - "content": "附表 1:员工支出报销审批权限表\n单位:人民币万元\n审批权限\n支出项目\n部门经理/\n机构总经理/\n事业部总经理\n员工支出\n总监/\n副总裁/\n高级副总裁/\n一级部门总经理\n总工程师\n各委员会主任\n补充说明\n总裁\n业务招待\n0.5\n1\n2\n3\n15\n因公借款\n0.5\n1\n2\n3\n15\n1.差旅费、市内交通。\n其他支出\n1\n2\n3\n5\n50\n2.客服及商务、教育、邮递。\n3.职工活动等其他临时性支出。", - "source_page": 14, - "word_count": 162, - "tags": [ - "差旅", - "交通", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-058", - "title": "附表 2:岗位支出报销审批权限表", - "content": "附表 2:岗位支出报销审批权限表\n单位:人民币万元\n审批权限\n部门经理/\n机构总经理/\n事业部总经理\n总监/\n一级部门\n总经理\n资产采购\n1\n2\n10\n15\n200\n基建工程\n——\n——\n5\n10\n50\n股权投资、\n兼并收购\n——\n——\n——\n——\n——\n由董事长审批。\n1.生产采购:产品生产用原材料、辅助\n材料、机物料等。\n2.项目采购:项目外采的设备、软件、公\n有云资源等。\n支出项目\n资本性\n支出\n收益性\n支出\n高级\n副总裁\n副总裁\n补充说明\n总裁\n材料采购\n——\n10\n20\n30\n500\n分包外包\n(内部单位)\n——\n10\n20\n30\n500\n分包外包\n(外部单位)\n——\n10\n20\n30\n200\n保证金\n5\n50\n100\n200\n500\n销售退款\n5\n10\n30\n50\n200\n房屋租金\n5\n10\n20\n30\n200\n包括固定资产、无形资产、低值易耗品。\n1.研发、实施、运维、服务等分包外包。\n2.委托加工、项目土建装修等。\n1.投标相关保证金:投标保证金、质保\n金、履约保证金。\n2.招标相关保证金。\n1.销售退款。\n2.代付款(代收后)。\n不含水电及杂费,需经业务归口部门审\n批。", - "source_page": 15, - "word_count": 410, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-059", - "title": "统结支出", - "content": "统结支出\n公司统一结算或批量采购的:\n1.食堂采购、通勤车。\n2.交通费、住宿费。\n3.业务招待费、通讯费等。\n代付子公司\n经营支出\n代付子公司投标支出。\n市场营销\n1.广告费。\n2.业务宣传费:业务宣传、企业文化宣\n传、市场活动、营销活动。\n专项服务、\n外聘劳务\n1\n3\n5\n10\n50\n国家机关、事业单位、代行政府职能的\n社会团体收费。\n政府规费\n财务专用\n外部单位与个人提供的咨询、培训、劳务\n等服务(非专家意见、非鉴证报告)。\n慰问救济、对\n外捐赠\n——\n——\n1\n2\n10\n公司制度外的慰问救济捐赠。\n税费支出\n50\n100\n200\n300\n500\n增值税及各类附加费、所得税等。\n其他支出\n1\n2\n3\n5\n50\n设备租赁、公务车支出等\n资金转户/调拨\n50\n——\n——\n2000\n3000\n5\n10\n30\n50\n200\n银行手续费\n错付退款\n其他支付\n不含销售退款。\n——\n——\n——\n15\n50\n出纳提现等。", - "source_page": 16, - "word_count": 344, - "tags": [ - "住宿", - "交通" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-060", - "title": "注 1:上述权限额度均含本数,各级管理人员权限指各自职责范围内审批权限。", - "content": "注 1:上述权限额度均含本数,各级管理人员权限指各自职责范围内审批权限。\n注 2:上述权限中业务流转执行以组织关系为基准的逐级审批规则。特殊事项经决策后,可越级至终审岗审批。\n注 3:仅结算业务,由业务部门审批确定是否达到财务入账条件,并按照上述对应支出项目权限审批。\n注 4:代付子公司经营支出原则上与子公司按季度汇总结算。\n注 5:薪酬福利支出分配计划审批按照人事归口管理部门规定执行。\n注 6:上述权限不含协管、高级顾问、顾问及主持工作人员,公司如有其相关文件通知,从其规定授权。\n注 7:全资子公司总经理由母公司未明确层级管理人员担任的,审批权限按“总监/一级部门总经理”执行。\n注 8:全资子公司员工薪资、奖金提成、福利支出、临时的奖励及福利支出审批按照人事归口管理部门规定执行。\n注 9:全资子公司其他部门经理,按照公司 1 号文对应岗位授权,由平级人员分角色担任的,按最高岗位授权,逐级审批。", - "source_page": 17, - "word_count": 384, - "tags": [ - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-061", - "title": "附表 3:支出归口管理部门与归口业务范围", - "content": "附表 3:支出归口管理部门与归口业务范围\n归口管理部门\n办公室(党委办公室)\n工会委员会\n归口业务范围\n1.党建支出。\n2.公务车支出。\n工会支出。\n1.投标业务支出:标书费、投标保证金、中标服务费(或保险)等。\n营销中心\n2.机构营销业务支出:客服及商务支出、营销活动支出。\n3.客户培训、销售退款等支出。\n1.广告费支出。\n品牌及市场运营中心\n2.业务宣传支出:业务宣传、文化宣传、市场活动。\n3.对外捐赠支出。\n1.薪酬福利(不含食堂、误餐)、外聘劳务、员工保险支出。\n组织人事部\n2.探亲差旅、条件艰苦及安全风险较高区域补助等支出。\n3.其他福利支出。\n人力资源服务部\n1.招聘业务。\n2.员工教育培训支出。\n1.员工备用金、差旅费、市内交通费、出国经费、误餐费支出。\n计划财务部\n2.税金及附加、审计评估、财务费用支出。\n3.客服及商务支出。\n产业投资部\n股权投资支出、并购业务支出。\n证券与法律事务部\n法律事务类支出、上市信披类支出、商标注册类支出。\n产品规划设计部\n知识产权类支出、信息技术咨询类支出、研发活动类支出。\nDAP 研发中心\n研究开发过程中支付给外单位的检测、评测、测试及化验等支出。\n信息管理部\n1.IT 类资产的购置(建造)、租入、运维、修理等支出。\n2.网络使用费支出。\n1.非 IT 类资产的购置、租入、运维、修理、装修等支出,财产保险支出。\n后勤服务部\n2.食堂支出。\n3.办公费用支出。\n4.商旅统付结算。", - "source_page": 18, - "word_count": 575, - "tags": [ - "差旅", - "交通" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-062", - "title": "附件 2", - "content": "修订说明\n《公司支出管理办法》修订涉及修改报销标准 2 项,取消报销规定 1 项,新增\n报销规定 2 项,审批权限变化 2 项,具体情况如下。\n一、报销标准变化情况\n只调整了差旅费、异地调动邮寄费两项内容。\n1.差旅费。只调整了公司领导出差交通工具标准和出差补贴。\n出差交通工具标准:公司领导(P8)乘坐火车标准(由“不限”标准调低为\n“二等座”)、轮船标准(由“二等舱”调低为“三等舱”)。\n出差补贴:原制度描述出差补助为包干制,实际执行时在两项补助之外还存在\n报销打车费情况,故取消了原制度中“包干”表述。\n2.异地调动邮寄费。将原制度“可凭据报销一次个人物品邮寄费用,金额超过\n1000 元的需事前审批”,调整为“1 元/人/公里内报销,超标自理”。\n二、取消报销规定内容\n因公用车补贴已纳入工资内发放,新制度中取消该费用相关表述。\n三、新增规定内容\n1.异地挂职锻炼补贴标准。经组织安排到异地挂职锻炼、培养锻炼、人才帮\n扶、借调支援等工作人员,在途期间的交通费、餐补和基本补助按出差规定执行;\n在异地单位工作期间,不适用出差补贴标准规定,按照组织人事部确定的挂职人员\n报销标准执行;所在单位不安排住宿的,住宿标准按公司酒店住宿限额标准执行。\n2.对使用商旅订票进行了规范。因公出差原则上应使用商旅系统统一预定。\n四、审批权限变化情况\n只调整了“投标保证金”“审批流转程序”两项内容:\n1.调整投标保证金审批权限。因对投标缴纳保证金的时效性要求较高,为提高\n审批效率,调整投标保证金审批权限,具体如下(单位:万元):", - "source_page": 19, - "word_count": 628, - "tags": [ - "差旅", - "住宿", - "交通", - "附件", - "审批" - ] - }, - { - "chunk_id": "bf761bd8eccf402bb676423d64401a56-chunk-063", - "title": "审批权限", - "content": "审批权限\n支出项目\n部门经理/\n总监/\n副总裁/\n高级副总裁/\n一级部门总经理\n总工程师\n各委员会主任\n机构总经理/\n总裁\n事业部总经理\n保证金\n5\n50\n100\n200\n2.明确支出审批流转程序。业务流转执行以组织关系为基准的逐级审批规则。\n特殊事项经决策后,可越级至终审岗审批。\n500", - "source_page": 20, - "word_count": 126, - "tags": [ - "审批" - ] } ] \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/document.json b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/document.json index b3d5ba0..845c020 100644 --- a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/document.json +++ b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/document.json @@ -5,19 +5,19 @@ "document_version": "v1.0", "checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md", - "chunk_count": 63, - "candidate_chunk_count": 39, - "filtered_chunk_count": 24, - "group_count": 20, - "successful_group_count": 0, - "failed_group_count": 20, - "knowledge_candidate_count": 1, - "formal_knowledge_candidate_count": 0, - "fallback_knowledge_candidate_count": 1, - "rule_candidate_count": 0, - "quality_status": "fallback_only", - "quality_note": "Hermes 未形成正式知识候选,当前仅保留降级兜底预览,不能作为正式知识上线。", - "updated_at": "2026-05-15T09:37:21.556445+00:00", + "chunk_count": 1, + "candidate_chunk_count": 1, + "filtered_chunk_count": 0, + "group_count": 1, + "successful_group_count": 1, + "failed_group_count": 0, + "knowledge_candidate_count": 10, + "formal_knowledge_candidate_count": 10, + "fallback_knowledge_candidate_count": 0, + "rule_candidate_count": 4, + "quality_status": "formal", + "quality_note": "Hermes 已基于完整原文件完成正式归纳。", + "updated_at": "2026-05-16T01:52:22.750933+00:00", "signature": { "document_id": "bf761bd8eccf402bb676423d64401a56", "original_name": "远光《公司支出管理办法(2024)》.pdf", @@ -26,5 +26,5 @@ "version_number": 1, "updated_at": "2026-05-09T08:39:53.788042+00:00" }, - "sync_reason": "forced_rebuild" + "sync_reason": "agent_batch" } \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_candidates.json b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_candidates.json index 31a8b8c..95abdc6 100644 --- a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_candidates.json +++ b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_candidates.json @@ -1,30 +1,297 @@ [ { - "candidate_id": "kc_e897af4a528d", - "title": "第一条", - "content": "目的\n为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善\n支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律\n法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实\n际情况,在广泛征求意见的基础上,制定本办法。", + "candidate_id": "kc_288da32a1cfb", + "title": "差旅费交通费标准", + "content": "公司领导、高层经理、中层经理(P5及以上、外聘专家)乘坐飞机经济舱;基层经理、其他人员(P4及以下)乘坐经济舱但P4应选乘6折及以下、P1-P3应选乘5折及以下。火车硬席(硬卧、硬座)、高铁/动车二等座。超标20%以内部门负责人审批,超标20%以上分管领导审批。\n\n#### 交通费标准\n| 员工职级 | 飞机 | 火车 | 轮船 | 其他交通工具 |\n|---------|------|------|------|------------|\n| 公司领导、高层经理、中层经理(P5及以上、外聘专家) | 经济舱 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |\n| 基层经理、其他人员(P4及以下) | 经济舱(注2) | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |\n\n**注**:\n- 交通工具选乘遵循\"性价比优先\"原则\n- P4及以下人员乘坐飞机需事前报部门负责人审批\n- P4应选乘6折及以下经济舱;P1-P3应选乘5折及以下经济舱\n- 夜间乘坐高铁/动车6小时以上可选乘卧铺\n- 超标20%以内部门负责人审批;超标20%以上分管领导审批", "domain": "expense", "scenario": "reimbursement_policy", "tags": [ + "差旅费", + "交通费", + "审批权限", + "差旅", + "交通", "审批" ], "source_document_id": "bf761bd8eccf402bb676423d64401a56", "source_document_name": "远光《公司支出管理办法(2024)》.pdf", "source_chunk_ids": [ - "bf761bd8eccf402bb676423d64401a56-chunk-023" + "bf761bd8eccf402bb676423d64401a56-document" ], "evidence": [ - "目的\n为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善\n支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律\n法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实\n际情况,在广泛征求意见的基础上,制定本办法。" + "表1 交通工具等级标准", + "注2:基层经理及以下人员(P4及以下)乘坐飞机需事前报经部门负责人审批。P4应选乘6折及以下经济舱、P1-P3应选乘5折及以下经济舱。" ], - "confidence": 0.4, + "confidence": 0.95, "status": "draft", "created_by": "hermes", - "created_at": "2026-05-15T09:37:21.489335+00:00", - "extraction_mode": "fallback", + "created_at": "2026-05-16T01:52:21.714431+00:00", + "extraction_mode": "hermes", "quality_flags": [ - "fallback_only", - "not_formal_ingest" + "table_restored_from_summary" ], - "fallback_reason": "Hermes 未能从正文条款中形成正式知识候选。当前结果仅为降级兜底预览,不能视为正式归纳。" + "fallback_reason": "" + }, + { + "candidate_id": "kc_1d3d64b598cf", + "title": "差旅费住宿费标准", + "content": "国内住宿费限额:直辖市/特区/港澳台800元(公司领导)、省会城市500元(公司领导)、其他地区450元(公司领导);中层经理、基层经理(P4-P6)在直辖市600元、省会城市400元、其他地区350元;其他员工在直辖市500元、省会城市350元、其他地区300元。超标20%以内部门负责人审批,超标20%以上分管领导审批。\n\n#### 住宿费标准(国内)\n| 员工职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 |\n|---------|-----------------|---------|---------|\n| 公司领导(P8及以上) | 800 | 500 | 450 |\n| 高层经理(P7) | 700 | 450 | 400 |\n| 中层经理、基层经理(P4-P6、外聘专家) | 600 | 400 | 350 |\n| 其他员工 | 500 | 350 | 300 |\n\n- 超标20%以内部门负责人审批;超标20%以上分管领导审批", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "差旅费", + "住宿费", + "报销标准", + "差旅", + "住宿", + "审批" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "表2 酒店住宿限额标准" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714478+00:00", + "extraction_mode": "hermes", + "quality_flags": [ + "table_restored_from_summary" + ], + "fallback_reason": "" + }, + { + "candidate_id": "kc_49cc35a20597", + "title": "出差补贴标准", + "content": "国内直辖市/特区/西藏:餐补75元+基本补助35元=110元/天;国内其他地区:餐补65元+基本补助35元=100元/天;港澳台:餐补55元+基本补助35元=90元/天。因组织安排、销售、推广、调研、培训、会议、研讨、借调等出差,主办方统一安排餐食的,不再报销餐补。\n\n#### 出差补贴标准\n| 补助类型 | 国内直辖市/特区/西藏 | 国内其他地区 | 港澳台 | 国外 |\n|---------|-------------------|------------|-------|------|\n| 餐补(自行解决) | 75 | 65 | 55 | 140 |\n| 基本补助 | 35 | 35 | 35 | 110/100/90 |\n| 合计 | 110 | 100 | 90 | 175 |", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "差旅费", + "出差补贴", + "报销标准", + "差旅" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "表3 出差补贴标准" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714498+00:00", + "extraction_mode": "hermes", + "quality_flags": [ + "table_restored_from_summary" + ], + "fallback_reason": "" + }, + { + "candidate_id": "kc_63f2f841a3e3", + "title": "备用金借款规定", + "content": "备用金是公司借支给正式员工用于与公司经济业务相关的预支费用。原则:前款不清、后款不借。非正式员工不得申请。按季定期清理,季度不能及时报账核销的应向分管领导申请延期,但不得跨年。借款额度原则上不得超过一万元。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "备用金", + "借款", + "报销规定" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第十一条 备用金借款" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714514+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_bf172f57d0d1", + "title": "市内交通费报销规定", + "content": "市内交通费凭据报销,应与工作实际相符,严禁报销与工作无关的交通费,不接受充值预付费方式的交通费。鼓励选择公交、地铁等公共交通。出租车仅限紧急公务、接送客户、夜间工作至22:00后、以及其他确有需要的特别情形等事项。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "市内交通费", + "报销规定", + "交通" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第十二条 市内交通费" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714527+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_e955e0e59c0e", + "title": "报销申请时限规定", + "content": "公司各类支出报销结算申请时限为三个月(按自然月度计算)。逾期需说明原因,经分管领导审批后方可报销。预付款项原则上应在次月底前完成结算,不得长期挂账。差旅费原则上需在行程结束三个月内提交报销申请,连续出差超过一个月时原则上应按月报销,逾期不予报销出差补贴。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "报销时限", + "申请时限", + "差旅费", + "差旅", + "审批" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第八条 支出报销申请 第2项申请时限" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714541+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_292e06107e25", + "title": "票据要求规定", + "content": "除员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等支出业务外,其他支出均需提交税务机关认可的票据。原则上均应取得增值税专用发票。应取得但未取得增值税专用发票的,经办人应在系统单据中说明原因。汇总开具的增值税发票应附税控系统明细清单。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "票据", + "发票", + "报销规定" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第八条 支出报销申请 第1项申请方式(2)" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714555+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_827a71a7acf6", + "title": "审批权限基本原则", + "content": "预算内支出按附表1、附表2执行。预算外支出需先经办部门提交预算调整申请,经公司总裁批准后,再按附表1、附表2执行。各级管理人员原则上应在待批单据流转至本岗位后三个工作日内完成审批。审批权限与职级待遇分离,按管理岗位执行。批办分离,经办人和审批人不得为同一人。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "审批权限", + "管理原则", + "报销规定", + "审批", + "预算" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第三条 管理原则", + "第九条 支出报销审批" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714567+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_ddf19230b6eb", + "title": "对外捐赠支出规定", + "content": "对外捐赠支出由品牌及市场运营中心归口管理,严格预算单控,未纳入对外捐赠预算的不得对外捐赠。预算内捐赠:业务部门申请→部门负责人审批→品牌及市场运营中心审核→分管领导审批执行。未纳入预算的需履行预算调整决策程序并纳入预算范围后方可实施。完成后应将凭据资料报品牌及市场运营中心备案。", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "对外捐赠", + "审批权限", + "报销规定", + "审批", + "预算" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "第二十一条 对外捐赠支出" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714581+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "" + }, + { + "candidate_id": "kc_39b392741c79", + "title": "支出归口管理部门划分", + "content": "计划财务部:备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估;营销中心:投标业务、机构营销业务;品牌及市场运营中心:广告费、业务宣传费、对外捐赠;组织人事部:薪酬福利、外聘劳务、员工保险;信息管理部:IT类资产、网络使用费;后勤服务部:非IT类资产、食堂、办公费用。\n\n## 附表3:支出归口管理部门\n| 归口管理部门 | 归口业务范围 |\n|-------------|-------------|\n| 办公室(党委办公室) | 党建支出、公务车支出 |\n| 工会委员会 | 工会支出 |\n| 营销中心 | 投标业务、机构营销业务、客户培训、销售退款 |\n| 品牌及市场运营中心 | 广告费、业务宣传费、对外捐赠 |\n| 组织人事部 | 薪酬福利、外聘劳务、员工保险、探亲差旅、其他福利 |\n| 人力资源服务部 | 招聘业务、员工教育培训 |\n| 计划财务部 | 备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估 |\n| 产业投资部 | 股权投资、并购业务 |\n| 证券与法律事务部 | 法律事务、上市信披、商标注册 |\n| 产品规划设计部 | 知识产权、信息技术咨询、研发活动 |\n| DAP研发中心 | 研发过程中对外单位的检测、评测、测试、化验 |\n| 信息管理部 | IT类资产购置/租入/运维、网络使用费 |\n| 后勤服务部 | 非IT类资产、食堂、办公费用、商旅统付结算 |", + "domain": "expense", + "scenario": "reimbursement_policy", + "tags": [ + "归口管理", + "职责分工", + "报销规定", + "差旅", + "交通" + ], + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "evidence": [ + "附表3:支出归口管理部门与归口业务范围" + ], + "confidence": 0.95, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714595+00:00", + "extraction_mode": "hermes", + "quality_flags": [ + "table_restored_from_summary" + ], + "fallback_reason": "" } ] \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_summary.md b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_summary.md index 2f642be..00ff270 100644 --- a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_summary.md +++ b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/knowledge_summary.md @@ -1,19 +1,147 @@ -# 远光《公司支出管理办法(2024)》.pdf 知识总结 +# 知识总结 -## 概览 +## 文档概述 +- **文件名称**:远光软件股份有限公司《公司支出管理办法(2024)》(远光制度〔2024〕14号) +- **发文日期**:2024年4月17日 +- **密级**:商密【中】 +- **适用范围**:公司各部门、分支机构(非独立法人)、全资子公司;控股子公司参照执行 -- 来源文档:远光《公司支出管理办法(2024)》.pdf -- 知识条目数:1 +## 第一章 总则 +- **目的**:适应业务发展需要,优化支出和报销标准,规范审批和报销过程,防范经营风险 +- **管理原则**:预算先行、厉行节约、效益优先、分级授权、分类控制、批办分离 +- **核心原则**: + 1. 预算先行:应在预算目标范围内支出,预算外支出需履行预算审批程序 + 2. 厉行节约、效益优先 + 3. 审批权限按管理岗位执行,与职级待遇分离 + 4. 经办人和审批人不得为同一人(批办分离) -## 核心知识 +## 第二章 职责分工 +- **归口管理部门**:确定支出业务的开支范围、标准、方式和管理流程 +- **计划财务部**:明确审批流程、审核要点、报销资料规范;负责财务审核和结算支付 +- **经办部门/个人**:遵循开支范围和标准,遵循"发票、资金、物资"三流一致原则 +- **各级管理人员**:第一审批人全面审核;后续审批人审核必要性和合理性 -### 1. 第一条 +## 第三章 支出报销申请与审批 +- **申请方式**:通过公司财务信息化系统(系统单据),不接受纸质申请 +- **票据要求**:原则上需取得增值税专用发票(除工会经费、员工福利、职工活动、业务招待、车票、政府规费外) +- **申请时限**:业务完成日至附件影像资料挂接系统单据日,三个月内;逾期需分管领导审批 +- **结算方式**:员工支出"公对私";岗位支出"公对公"(结算起点1000元) +- **审批时限**:各级管理人员三个工作日内完成审批 +- **财务审核时限**:影像扫描一个工作日;审核与支付三个工作日 -目的 -为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善 -支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律 -法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实 -际情况,在广泛征求意见的基础上,制定本办法。 +## 第四章 重点支出管理规定 -- 适用场景:reimbursement_policy -- 标签:审批 \ No newline at end of file +### 第十一条 备用金借款 +- 正式员工可申请,用于与公司经济业务相关的预支费用 +- 遵循"前款不清、后款不借"原则 +- 非正式员工不得申请 +- 按季定期清理,不可跨年 +- 借款额度原则上不得超过一万元 + +### 第十二条 市内交通费 +- 凭据报销,应与工作实际相符 +- 鼓励选择公交、地铁等公共交通 +- 出租车仅限紧急公务、接送客户、夜间工作至22:00后等情形 + +### 第十三条 差旅费 + +#### 交通费标准 +| 员工职级 | 飞机 | 火车 | 轮船 | 其他交通工具 | +|---------|------|------|------|------------| +| 公司领导、高层经理、中层经理(P5及以上、外聘专家) | 经济舱 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 | +| 基层经理、其他人员(P4及以下) | 经济舱(注2) | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 | + +**注**: +- 交通工具选乘遵循"性价比优先"原则 +- P4及以下人员乘坐飞机需事前报部门负责人审批 +- P4应选乘6折及以下经济舱;P1-P3应选乘5折及以下经济舱 +- 夜间乘坐高铁/动车6小时以上可选乘卧铺 +- 超标20%以内部门负责人审批;超标20%以上分管领导审批 + +#### 住宿费标准(国内) +| 员工职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 | +|---------|-----------------|---------|---------| +| 公司领导(P8及以上) | 800 | 500 | 450 | +| 高层经理(P7) | 700 | 450 | 400 | +| 中层经理、基层经理(P4-P6、外聘专家) | 600 | 400 | 350 | +| 其他员工 | 500 | 350 | 300 | + +- 超标20%以内部门负责人审批;超标20%以上分管领导审批 + +#### 出差补贴标准 +| 补助类型 | 国内直辖市/特区/西藏 | 国内其他地区 | 港澳台 | 国外 | +|---------|-------------------|------------|-------|------| +| 餐补(自行解决) | 75 | 65 | 55 | 140 | +| 基本补助 | 35 | 35 | 35 | 110/100/90 | +| 合计 | 110 | 100 | 90 | 175 | + +### 第十四条 业务招待费 +- 用于接待客户和相关单位的餐饮等合理支出 +- 未能"公对公"结算的,应附向供应商付款凭据佐证 + +### 第十五条 会议费 +- 不得列支旅游观光、宴请、礼品馈赠等无关支出 +- 经费预算30000元及以上的公司内部会议,需事前报请公司总裁审批 +- 报销应附会议申请与预算、会议通知、参会人员签到表、会议开支明细等 + +### 第十六条 广告宣传费 +- 广告费:各种公开媒体宣传费用 +- 业务宣传费:公司自身宣传与营销活动、印有公司标志的礼品、纪念品等 + +### 第十七条 培训费 +- 报销资格按《公司员工教育培训管理办法》认定 +- 培训期间交通费、住宿费按出差规定标准执行 + +### 第十八条 通信费 +- 员工通讯费按《公司员工因公通讯费用实施细则》执行 + +### 第十九条 邮递费 +- 邮件费、托运费、快递费等 +- 员工报销应附快递底单;单位统一结算应附寄件明细清单与支出分摊表 + +### 第二十条 薪酬福利支出 +- 按公司相关制度规定执行 +- 临时奖励及福利支出需事先计划并报公司总裁审批 +- 职工福利费由组织人事部年度计划管理 + +### 第二十一条 对外捐赠支出 +- 由品牌及市场运营中心归口管理,严格预算单控 +- 预算内捐赠:业务部门申请→部门负责人审批→品牌及市场运营中心审核→分管领导审批 +- 未纳入预算:需履行预算调整决策程序后方可实施 + +### 第二十二条 涉外业务汇率标准 +- 人民币结算:凭据报销 +- 外币结算:按支付凭据所载汇率折算;未载明汇率的按业务发生月第一个交易日"中国银行外汇折算价"执行 + +## 审批权限(摘要) + +### 员工支出审批权限(单位:万元) +| 支出项目 | 部门经理/机构总经理/事业部总经理 | 总监/副总裁/高级副总裁/一级部门总经理 | 总裁 | +|---------|------------------------------|------------------------------------|------| +| 业务招待 | 0.5 | 1-3 | 15 | +| 因公借款 | 0.5 | 1-3 | 15 | +| 其他支出(差旅费、市内交通等) | 1 | 2-5 | 50 | + +### 岗位支出审批权限(单位:万元) +| 支出项目 | 部门经理/机构总经理/事业部总经理 | 总监/一级部门总经理 | 高级副总裁/副总裁 | 总裁 | +|---------|------------------------------|-------------------|-----------------|------| +| 资产采购 | 1-2 | 10 | 15 | 200 | +| 基建工程 | —— | 5 | 10 | 50 | +| 市场营销(广告费、业务宣传费) | 1 | 3-5 | 10 | 50 | + +## 附表3:支出归口管理部门 +| 归口管理部门 | 归口业务范围 | +|-------------|-------------| +| 办公室(党委办公室) | 党建支出、公务车支出 | +| 工会委员会 | 工会支出 | +| 营销中心 | 投标业务、机构营销业务、客户培训、销售退款 | +| 品牌及市场运营中心 | 广告费、业务宣传费、对外捐赠 | +| 组织人事部 | 薪酬福利、外聘劳务、员工保险、探亲差旅、其他福利 | +| 人力资源服务部 | 招聘业务、员工教育培训 | +| 计划财务部 | 备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估 | +| 产业投资部 | 股权投资、并购业务 | +| 证券与法律事务部 | 法律事务、上市信披、商标注册 | +| 产品规划设计部 | 知识产权、信息技术咨询、研发活动 | +| DAP研发中心 | 研发过程中对外单位的检测、评测、测试、化验 | +| 信息管理部 | IT类资产购置/租入/运维、网络使用费 | +| 后勤服务部 | 非IT类资产、食堂、办公费用、商旅统付结算 | \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/rule_candidates.json b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/rule_candidates.json index 0637a08..7507eb6 100644 --- a/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/rule_candidates.json +++ b/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/rule_candidates.json @@ -1 +1,339 @@ -[] \ No newline at end of file +[ + { + "candidate_id": "rc_1eeb0b1b066e", + "source_type": "policy_document", + "template_key": "travel_standard_v1", + "template_label": "差旅标准模板", + "domain": "expense", + "scenario": "reimbursement_policy", + "suggested_rule_name": "差旅费报销标准规则", + "summary": "员工因公出差发生的交通费、住宿费、出差补贴按职级和地区分类计算标准。", + "template_sections": { + "purpose": "规范差旅费报销标准,控制差旅成本", + "scope": "公司全体员工因公出差报销", + "inputs": [ + "员工职级", + "出差目的地(直辖市/省会/其他地区/港澳台/国外)", + "交通工具类型", + "住宿天数" + ], + "judgement_logic": [ + "根据职级确定交通费标准(飞机舱位要求)", + "根据职级和地区确定住宿费上限", + "根据地区确定出差补贴(餐补+基本补助)", + "超标部分需额外审批" + ], + "outputs": [ + "交通费报销金额", + "住宿费报销上限", + "出差补贴金额" + ], + "admin_note": "P4及以下人员乘坐飞机需事前审批,P4应选乘6折及以下,P1-P3应选乘5折及以下" + }, + "rule_markdown_draft": "# 差旅费报销标准规则\n\n## 模板信息\n\n- 模板键:`travel_standard_v1`\n- 来源文档:远光《公司支出管理办法(2024)》.pdf\n- Hermes 置信度:0.95\n- 审核人:admin\n\n## 目标\n\n规范差旅费报销标准,控制差旅成本\n\n## 适用范围\n\n公司全体员工因公出差报销\n\n## 输入字段\n\n- 员工职级\n- 出差目的地(直辖市/省会/其他地区/港澳台/国外)\n- 交通工具类型\n- 住宿天数\n\n## 判断规则\n\n- 根据职级确定交通费标准(飞机舱位要求)\n- 根据职级和地区确定住宿费上限\n- 根据地区确定出差补贴(餐补+基本补助)\n- 超标部分需额外审批\n\n## 输出\n\n- 交通费报销金额\n- 住宿费报销上限\n- 出差补贴金额\n\n## 来源依据\n\n- 第十三条 差旅费\n- 表1-表3\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\nP4及以下人员乘坐飞机需事前审批,P4应选乘6折及以下,P1-P3应选乘5折及以下\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"travel_standard_v1\",\n \"rule_name\": \"差旅费报销标准规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法(2024)》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [\n \"travel\",\n \"hotel\",\n \"transport\"\n ],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"control_points\": [\n {\n \"control_code\": \"route_closed_loop\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"destination_match\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"hotel_city_match\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"hotel_limit\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"transport_class_limit\",\n \"severity\": \"high\",\n \"enabled\": true\n }\n ],\n \"exception_policy\": {\n \"allow_with_explanation\": true,\n \"keywords\": []\n },\n \"output\": {\n \"risk_code\": \"travel_policy_review\",\n \"action\": \"review\",\n \"message\": \"差旅制度命中后需要人工复核。\"\n }\n}\n```", + "runtime_rule": { + "kind": "policy_rule_draft", + "version": 1, + "template_key": "travel_standard_v1", + "rule_name": "差旅费报销标准规则", + "scenario": "reimbursement_policy", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "review_required": true, + "target": { + "expense_types": [ + "travel", + "hotel", + "transport" + ], + "scene_codes": [ + "reimbursement_policy" + ] + }, + "control_points": [ + { + "control_code": "route_closed_loop", + "severity": "high", + "enabled": true + }, + { + "control_code": "destination_match", + "severity": "high", + "enabled": true + }, + { + "control_code": "hotel_city_match", + "severity": "high", + "enabled": true + }, + { + "control_code": "hotel_limit", + "severity": "high", + "enabled": true + }, + { + "control_code": "transport_class_limit", + "severity": "high", + "enabled": true + } + ], + "exception_policy": { + "allow_with_explanation": true, + "keywords": [] + }, + "output": { + "risk_code": "travel_policy_review", + "action": "review", + "message": "差旅制度命中后需要人工复核。" + } + }, + "evidence": [ + "第十三条 差旅费", + "表1-表3" + ], + "confidence": 0.95, + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "validation_status": "valid", + "validation_errors": [], + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714835+00:00", + "generated_asset_id": "143247d8-83f8-4257-b05f-161e0b264056", + "generated_asset_code": "rule.expense.hermes.travel_standard_v1.34eb3bb565", + "generated_version": "v1.0.1" + }, + { + "candidate_id": "rc_c41885da6b01", + "source_type": "policy_document", + "template_key": "expense_amount_limit_v1", + "template_label": "金额上限模板", + "domain": "expense", + "scenario": "reimbursement_policy", + "suggested_rule_name": "备用金借款限额规则", + "summary": "正式员工备用金借款额度上限一万元,按季清理,不得跨年。", + "template_sections": { + "purpose": "控制备用金风险,规范借款管理", + "scope": "公司正式员工备用金借款", + "inputs": [ + "借款人身份(正式/非正式)", + "借款金额", + "借款时间" + ], + "judgement_logic": [ + "非正式员工不得申请备用金借款", + "借款金额不得超过一万元", + "按季定期清理,跨年需延期审批" + ], + "outputs": [ + "是否可借款", + "借款额度上限", + "清理要求" + ], + "admin_note": "遵循\"前款不清、后款不借\"原则" + }, + "rule_markdown_draft": "# 备用金借款限额规则\n\n## 模板信息\n\n- 模板键:`expense_amount_limit_v1`\n- 来源文档:远光《公司支出管理办法(2024)》.pdf\n- Hermes 置信度:0.95\n- 审核人:admin\n\n## 目标\n\n控制备用金风险,规范借款管理\n\n## 适用范围\n\n公司正式员工备用金借款\n\n## 输入字段\n\n- 借款人身份(正式/非正式)\n- 借款金额\n- 借款时间\n\n## 判断规则\n\n- 非正式员工不得申请备用金借款\n- 借款金额不得超过一万元\n- 按季定期清理,跨年需延期审批\n\n## 输出\n\n- 是否可借款\n- 借款额度上限\n- 清理要求\n\n## 来源依据\n\n- 第十一条 备用金借款\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n遵循\"前款不清、后款不借\"原则\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"expense_amount_limit_v1\",\n \"rule_name\": \"备用金借款限额规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法(2024)》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ],\n \"metric\": \"claim_total\"\n },\n \"threshold\": {\n \"currency\": \"CNY\",\n \"comparator\": \"gt\",\n \"warn_amount\": null,\n \"block_amount\": null,\n \"source\": \"manual_fill_required\"\n },\n \"exception_policy\": {\n \"allow_with_explanation\": true,\n \"keywords\": []\n },\n \"output\": {\n \"risk_code\": \"amount_limit_review\",\n \"action\": \"review\",\n \"message\": \"金额超标后需要人工复核。\"\n }\n}\n```", + "runtime_rule": { + "kind": "policy_rule_draft", + "version": 1, + "template_key": "expense_amount_limit_v1", + "rule_name": "备用金借款限额规则", + "scenario": "reimbursement_policy", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "review_required": true, + "target": { + "expense_types": [], + "scene_codes": [ + "reimbursement_policy" + ], + "metric": "claim_total" + }, + "threshold": { + "currency": "CNY", + "comparator": "gt", + "warn_amount": null, + "block_amount": null, + "source": "manual_fill_required" + }, + "exception_policy": { + "allow_with_explanation": true, + "keywords": [] + }, + "output": { + "risk_code": "amount_limit_review", + "action": "review", + "message": "金额超标后需要人工复核。" + } + }, + "evidence": [ + "第十一条 备用金借款" + ], + "confidence": 0.95, + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "validation_status": "valid", + "validation_errors": [], + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714926+00:00", + "generated_asset_id": "71088dd7-a2c1-471a-8b96-d722d7c47f6a", + "generated_asset_code": "rule.expense.hermes.expense_amount_limit_v1.8562a39b81", + "generated_version": "v1.0.0" + }, + { + "candidate_id": "rc_231e079a910a", + "source_type": "policy_document", + "template_key": "attachment_requirement_v1", + "template_label": "附件要求模板", + "domain": "expense", + "scenario": "reimbursement_policy", + "suggested_rule_name": "票据附件要求规则", + "summary": "除特定支出类型外均需取得增值税专用发票,汇总开具的发票需附税控系统明细清单。", + "template_sections": { + "purpose": "确保票据合规完整", + "scope": "公司各类支出报销", + "inputs": [ + "支出业务类型" + ], + "judgement_logic": [ + "员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等不需发票", + "其他支出原则上需取得增值税专用发票", + "汇总开具的增值税发票需附税控系统明细清单", + "未取得专票需在系统单据中说明原因" + ], + "outputs": [ + "所需票据类型", + "附件要求" + ], + "admin_note": "" + }, + "rule_markdown_draft": "# 票据附件要求规则\n\n## 模板信息\n\n- 模板键:`attachment_requirement_v1`\n- 来源文档:远光《公司支出管理办法(2024)》.pdf\n- Hermes 置信度:0.95\n- 审核人:admin\n\n## 目标\n\n确保票据合规完整\n\n## 适用范围\n\n公司各类支出报销\n\n## 输入字段\n\n- 支出业务类型\n\n## 判断规则\n\n- 员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等不需发票\n- 其他支出原则上需取得增值税专用发票\n- 汇总开具的增值税发票需附税控系统明细清单\n- 未取得专票需在系统单据中说明原因\n\n## 输出\n\n- 所需票据类型\n- 附件要求\n\n## 来源依据\n\n- 第八条 支出报销申请 第1项申请方式\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n待审核人补充备注。\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"attachment_requirement_v1\",\n \"rule_name\": \"票据附件要求规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法(2024)》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"attachment_requirements\": {\n \"min_attachment_count\": 1,\n \"items\": [],\n \"manual_fill_required\": true\n },\n \"missing_attachment_action\": \"block\",\n \"output\": {\n \"risk_code\": \"invoice_anomaly\",\n \"action\": \"block\",\n \"message\": \"附件或单据不完整,需补件后再提交。\"\n }\n}\n```", + "runtime_rule": { + "kind": "policy_rule_draft", + "version": 1, + "template_key": "attachment_requirement_v1", + "rule_name": "票据附件要求规则", + "scenario": "reimbursement_policy", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "review_required": true, + "target": { + "expense_types": [], + "scene_codes": [ + "reimbursement_policy" + ] + }, + "attachment_requirements": { + "min_attachment_count": 1, + "items": [], + "manual_fill_required": true + }, + "missing_attachment_action": "block", + "output": { + "risk_code": "invoice_anomaly", + "action": "block", + "message": "附件或单据不完整,需补件后再提交。" + } + }, + "evidence": [ + "第八条 支出报销申请 第1项申请方式" + ], + "confidence": 0.95, + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "validation_status": "valid", + "validation_errors": [], + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.714991+00:00", + "generated_asset_id": "ab6e6849-3c84-4b58-bf5a-e320a5640c68", + "generated_asset_code": "rule.expense.hermes.attachment_requirement_v1.945712a66b", + "generated_version": "v1.0.0" + }, + { + "candidate_id": "rc_b5173ec16d21", + "source_type": "policy_document", + "template_key": "general_policy_v1", + "template_label": "通用制度模板", + "domain": "expense", + "scenario": "reimbursement_policy", + "suggested_rule_name": "报销申请时限规则", + "summary": "各类支出报销申请时限为三个月,逾期需分管领导审批。差旅费逾期不予报销出差补贴。", + "template_sections": { + "purpose": "规范报销时效性管理", + "scope": "公司各类支出报销申请", + "inputs": [ + "业务完成日期", + "报销提交日期" + ], + "judgement_logic": [ + "计算业务完成日至报销提交日期间", + "超过三个月需说明原因并经分管领导审批", + "差旅费行程结束超过三个月不报销出差补贴", + "连续出差超一个月应按月报销" + ], + "outputs": [ + "是否逾期", + "是否可报销", + "所需额外审批" + ], + "admin_note": "预付款项应在次月底前完成结算" + }, + "rule_markdown_draft": "# 报销申请时限规则\n\n## 模板信息\n\n- 模板键:`general_policy_v1`\n- 来源文档:远光《公司支出管理办法(2024)》.pdf\n- Hermes 置信度:0.95\n- 审核人:admin\n\n## 目标\n\n规范报销时效性管理\n\n## 适用范围\n\n公司各类支出报销申请\n\n## 输入字段\n\n- 业务完成日期\n- 报销提交日期\n\n## 判断规则\n\n- 计算业务完成日至报销提交日期间\n- 超过三个月需说明原因并经分管领导审批\n- 差旅费行程结束超过三个月不报销出差补贴\n- 连续出差超一个月应按月报销\n\n## 输出\n\n- 是否逾期\n- 是否可报销\n- 所需额外审批\n\n## 来源依据\n\n- 第八条 支出报销申请 第2项申请时限\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n预付款项应在次月底前完成结算\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"general_policy_v1\",\n \"rule_name\": \"报销申请时限规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法(2024)》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"control_points\": [\n \"计算业务完成日至报销提交日期间\",\n \"超过三个月需说明原因并经分管领导审批\",\n \"差旅费行程结束超过三个月不报销出差补贴\",\n \"连续出差超一个月应按月报销\"\n ],\n \"review_checklist\": [\n \"是否逾期\",\n \"是否可报销\",\n \"所需额外审批\"\n ],\n \"output\": {\n \"risk_code\": \"general_policy_review\",\n \"action\": \"review\",\n \"message\": \"通用制度草稿需由人工补充执行细节。\"\n }\n}\n```", + "runtime_rule": { + "kind": "policy_rule_draft", + "version": 1, + "template_key": "general_policy_v1", + "rule_name": "报销申请时限规则", + "scenario": "reimbursement_policy", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "review_required": true, + "target": { + "expense_types": [], + "scene_codes": [ + "reimbursement_policy" + ] + }, + "control_points": [ + "计算业务完成日至报销提交日期间", + "超过三个月需说明原因并经分管领导审批", + "差旅费行程结束超过三个月不报销出差补贴", + "连续出差超一个月应按月报销" + ], + "review_checklist": [ + "是否逾期", + "是否可报销", + "所需额外审批" + ], + "output": { + "risk_code": "general_policy_review", + "action": "review", + "message": "通用制度草稿需由人工补充执行细节。" + } + }, + "evidence": [ + "第八条 支出报销申请 第2项申请时限" + ], + "confidence": 0.95, + "source_document_id": "bf761bd8eccf402bb676423d64401a56", + "source_document_name": "远光《公司支出管理办法(2024)》.pdf", + "source_chunk_ids": [ + "bf761bd8eccf402bb676423d64401a56-document" + ], + "validation_status": "valid", + "validation_errors": [], + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-16T01:52:21.715034+00:00", + "generated_asset_id": "86e683fc-27d2-43ec-8e3c-6f1397965266", + "generated_asset_code": "rule.expense.hermes.general_policy_v1.88ae87de96", + "generated_version": "v1.0.0" + } +] \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/index.json b/server/storage/knowledge/.llm_wiki/index.json index dcf8ae0..2a565ef 100644 --- a/server/storage/knowledge/.llm_wiki/index.json +++ b/server/storage/knowledge/.llm_wiki/index.json @@ -7,19 +7,19 @@ "document_version": "v1.0", "checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md", - "chunk_count": 63, - "candidate_chunk_count": 39, - "filtered_chunk_count": 24, - "group_count": 20, - "successful_group_count": 0, - "failed_group_count": 20, - "knowledge_candidate_count": 1, - "formal_knowledge_candidate_count": 0, - "fallback_knowledge_candidate_count": 1, - "rule_candidate_count": 0, - "quality_status": "fallback_only", - "quality_note": "Hermes 未形成正式知识候选,当前仅保留降级兜底预览,不能作为正式知识上线。", - "updated_at": "2026-05-15T09:37:21.556445+00:00", + "chunk_count": 1, + "candidate_chunk_count": 1, + "filtered_chunk_count": 0, + "group_count": 1, + "successful_group_count": 1, + "failed_group_count": 0, + "knowledge_candidate_count": 10, + "formal_knowledge_candidate_count": 10, + "fallback_knowledge_candidate_count": 0, + "rule_candidate_count": 4, + "quality_status": "formal", + "quality_note": "Hermes 已基于完整原文件完成正式归纳。", + "updated_at": "2026-05-16T01:52:22.750933+00:00", "signature": { "document_id": "bf761bd8eccf402bb676423d64401a56", "original_name": "远光《公司支出管理办法(2024)》.pdf", @@ -28,7 +28,7 @@ "version_number": 1, "updated_at": "2026-05-09T08:39:53.788042+00:00" }, - "sync_reason": "forced_rebuild" + "sync_reason": "agent_batch" } ] } \ No newline at end of file diff --git a/server/storage/knowledge/.llm_wiki/sync_runs.json b/server/storage/knowledge/.llm_wiki/sync_runs.json index 0eb611c..0d9fd1a 100644 --- a/server/storage/knowledge/.llm_wiki/sync_runs.json +++ b/server/storage/knowledge/.llm_wiki/sync_runs.json @@ -111,6 +111,243 @@ "summary": [ "远光《公司支出管理办法(2024)》.pdf:forced_rebuild,知识候选 1 条,规则候选 0 条,归纳质量 fallback_only。" ] + }, + { + "run_id": "wiki_d714c8ce5b6b", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 10, + "rule_candidate_count": 5, + "generated_rule_asset_ids": [ + "cfec89e4-335c-45d9-a4e5-b799ba408645", + "51338fe7-ec4b-4f4c-91d2-fb9401ee153b", + "bcf3cdc6-b6b2-4ada-b9d9-42d6687903cd", + "e244c8a4-10cb-47fd-ae51-977ae645c45b", + "304c1570-9fe0-4c4b-8950-d55e9aab2565" + ], + "created_by": "admin", + "created_at": "2026-05-15T10:19:34.663079+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 10 条,规则候选 5 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_4de66ca333574b7e" + }, + { + "run_id": "wiki_e6f8e8b3c137", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 9, + "rule_candidate_count": 4, + "generated_rule_asset_ids": [ + "c7ea86ba-8b94-4c12-a9fc-d4dd4bff145e", + "522c6b6c-bdfd-47b0-9a25-2a6d2da5b5e5", + "c7a1a2a4-2b1a-4339-af10-b25e97b0c309", + "be9adadd-daa5-4625-8789-0a666090ad57" + ], + "created_by": "admin", + "created_at": "2026-05-15T10:20:56.773603+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 9 条,规则候选 4 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_48a4c41cca8342b9" + }, + { + "run_id": "wiki_10f632d2c6bd", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 6, + "generated_rule_asset_ids": [ + "bd6b2ef9-29b6-44ff-9b97-25ba15be4538", + "21a180e1-d760-4ad9-a4f0-ed29463075cd", + "019c68be-0c2c-4a84-bca8-0ba8502bcec7", + "de95fea3-5b9d-4f61-abb7-ce139a82e844", + "95028ad9-5e3d-4281-b062-c3bc05060cb4", + "f7ab1491-e121-477a-9023-6d74580ec5ea" + ], + "created_by": "admin", + "created_at": "2026-05-16T00:34:48.749438+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 6 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_022ae24990ec4355" + }, + { + "run_id": "wiki_a9e871817dc5", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 5, + "generated_rule_asset_ids": [ + "31b8d05a-c878-42d2-bacc-a6ad09bc6a94", + "6cc1b7e0-be52-4bf7-8d9b-06b9f8c5b961", + "48e412f3-55d9-480f-b2ad-5240e7a6de0b", + "0d6dcdd2-bafd-4e81-9d2c-48f3b84febbe", + "29624254-e83a-46a3-b323-117c097a8976" + ], + "created_by": "admin", + "created_at": "2026-05-16T00:54:27.313665+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 5 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_d72cf90db5df427f" + }, + { + "run_id": "wiki_5d984aa5564f", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 4, + "generated_rule_asset_ids": [ + "02c92cfb-ce24-499d-a051-bf64e481eea1", + "fce1bfd6-9e47-48fb-8132-ebc0d3c57c5e", + "72f3a5c0-5d46-43dc-8e5c-8f647e0e878f", + "1eafd703-a834-4f50-9db6-fe89b7dbd514" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:09:42.078805+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 4 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_4f322b84564b4d25" + }, + { + "run_id": "wiki_f65445f69883", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 4, + "generated_rule_asset_ids": [ + "143247d8-83f8-4257-b05f-161e0b264056", + "21a180e1-d760-4ad9-a4f0-ed29463075cd", + "b12e2d99-2fd9-453a-84a8-7ad52bfab4a7", + "af0b4d7c-6b00-4a64-8168-07cae57f80bd" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:17:06.436319+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 4 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_17ff3f4584cb4cfa" + }, + { + "run_id": "wiki_96d5bf187cdc", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 11, + "rule_candidate_count": 4, + "generated_rule_asset_ids": [ + "e7330580-138b-4e82-b611-2a2b968f1bea", + "84c685fc-6b8a-4c44-981b-d8d89c02cadb", + "1c93075e-24aa-46ca-a702-f8b640b244cf", + "0eef894b-b6c7-4ef4-b133-daee3a5e245e" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:26:09.822587+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 11 条,规则候选 4 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_88d5b60192c9450d" + }, + { + "run_id": "wiki_12d3e3d3a8d8", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 5, + "generated_rule_asset_ids": [ + "bd6b2ef9-29b6-44ff-9b97-25ba15be4538", + "7c59c499-b416-4c58-8abb-515c76f4dc8a", + "51baae43-2cf5-464e-86e3-4eb23b305b3c", + "db28bdd5-3d78-4c69-b503-862db57c9cf2", + "2afad60c-dce7-4802-bb09-b5d760abc1b4" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:30:07.306867+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 5 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_982a86680f5a44a9" + }, + { + "run_id": "wiki_7f12b25fc9db", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 12, + "rule_candidate_count": 6, + "generated_rule_asset_ids": [ + "bd6b2ef9-29b6-44ff-9b97-25ba15be4538", + "d14051ad-c370-4162-8649-d565d13ef59f", + "f8e48e1d-c444-4358-be5d-bc29fb293933", + "30aee1d5-e930-4d81-88b6-ccbbac898d6e", + "671f8908-c4ed-421e-9b8b-5ce4ca461330", + "9beb22d9-8582-4a31-b84a-353467e33485" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:45:59.739933+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 6 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_56b615c81e4c42f3" + }, + { + "run_id": "wiki_3c538493f66e", + "folder": "报销制度", + "requested_document_ids": [ + "bf761bd8eccf402bb676423d64401a56" + ], + "changed_document_count": 1, + "knowledge_candidate_count": 10, + "rule_candidate_count": 4, + "generated_rule_asset_ids": [ + "143247d8-83f8-4257-b05f-161e0b264056", + "71088dd7-a2c1-471a-8b96-d722d7c47f6a", + "ab6e6849-3c84-4b58-bf5a-e320a5640c68", + "86e683fc-27d2-43ec-8e3c-6f1397965266" + ], + "created_by": "admin", + "created_at": "2026-05-16T01:52:22.811984+00:00", + "summary": [ + "远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 10 条,规则候选 4 条。" + ], + "source": "hermes_callback", + "agent_run_id": "run_a7b447f69939442f" } ] } \ No newline at end of file diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index fdbe57d..95a8c6e 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -30,10 +30,12 @@ def test_employee_can_login_with_seed_default_password() -> None: LoginRequest(username=employee.email, password="123456") ) - assert result.ok is True - assert result.user.username == employee.email - assert result.user.name == employee.name - assert result.user.roleCodes + assert result.ok is True + assert result.user.username == employee.email + assert result.user.name == employee.name + assert result.user.position == employee.position + assert result.user.grade == employee.grade + assert result.user.roleCodes assert result.user.isAdmin is False @@ -50,10 +52,11 @@ def test_admin_can_login_with_database_password() -> None: LoginRequest(username="superadmin", password="admin123") ) - assert result.ok is True - assert result.user.username == "superadmin" - assert result.user.isAdmin is True - assert result.user.roleCodes == ["manager"] + assert result.ok is True + assert result.user.username == "superadmin" + assert result.user.isAdmin is True + assert result.user.position == "系统管理员" + assert result.user.roleCodes == ["manager"] def test_disabled_employee_cannot_login() -> None: diff --git a/server/tests/test_llm_wiki_service.py b/server/tests/test_llm_wiki_service.py index 9c64b1f..1baf254 100644 --- a/server/tests/test_llm_wiki_service.py +++ b/server/tests/test_llm_wiki_service.py @@ -662,3 +662,52 @@ def test_llm_wiki_sync_endpoint_records_agent_run(monkeypatch) -> None: assert latest_run.tool_calls[0].tool_name == "system_hermes_llm_wiki_sync" assert latest_run.tool_calls[0].status == "succeeded" assert latest_run.route_json["sync_run_id"] == "wiki_test_sync" + + +def test_llm_wiki_callback_finalizes_one_whole_document_result(tmp_path) -> None: + document_id = upload_policy_document(tmp_path) + + with build_session() as db: + run = AgentRunService(db).create_run( + agent="hermes", + source=AgentRunSource.SCHEDULE.value, + user_id="admin", + route_json={ + "job_type": "llm_wiki_sync", + "folder": "报销制度", + "requested_document_ids": [document_id], + "requested_by_username": "admin", + "requested_by_name": "管理员", + }, + ) + service = LlmWikiService(db, storage_root=tmp_path) + candidate_payload = build_candidate_payload(f"{document_id}-document") + result = service.finalize_agent_batch_callback( + agent_run_id=run.run_id, + payload={ + "ok": True, + "summary": "Hermes 已完成整文档归纳。", + "folder": "报销制度", + "documents": [ + { + "document_id": document_id, + "knowledge_summary_markdown": "# Hermes 整文档归纳结果", + **candidate_payload, + } + ], + }, + ) + + detail = service.get_document_detail(document_id) + + assert result.document_count == 1 + assert result.knowledge_candidate_count == 1 + assert result.rule_candidate_count == 1 + assert detail.chunk_count == 1 + assert len(detail.chunks) == 1 + assert detail.chunks[0].chunk_id == f"{document_id}-document" + assert detail.knowledge_summary_markdown == "# Hermes 整文档归纳结果" + assert detail.quality_status == "formal" + + knowledge_detail = KnowledgeService(storage_root=tmp_path).get_document_detail(document_id) + assert knowledge_detail.stateCode == KNOWLEDGE_INGEST_STATUS_INGESTED diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 93eb52c..80f80c4 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -258,19 +258,57 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N assert result.intent == "query" assert result.time_range.start_date == "2026-04-01" assert result.time_range.end_date == "2026-04-30" - assert any( - item.type == "employee" and item.normalized_value == "张三" - for item in result.entities + + +def test_semantic_ontology_service_treats_travel_amount_question_as_knowledge_query() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我要去武汉出差3天,请问我一共可以报销多少费用?", + user_id="pytest", + context_json={ + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + }, + ) ) - assert any( - item.type == "expense_type" and item.normalized_value == "travel" - for item in result.entities - ) - assert any( - item.field == "amount" and item.operator == ">" and item.value == 5000 - for item in result.constraints + + assert result.scenario == "knowledge" + assert result.intent == "query" + assert result.clarification_required is False + assert result.clarification_question is None + assert result.missing_slots == [] + + +def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_query() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="那P4员工可以报销多少钱?", + user_id="pytest", + context_json={ + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + "conversation_history": [ + { + "role": "user", + "content": "我要去武汉出差3天,请问我一共可以报销多少费用?", + } + ], + }, + ) ) + assert result.scenario == "knowledge" + assert result.intent == "query" + assert result.clarification_required is False + def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None: session_factory = build_session_factory() diff --git a/server/tests/test_orchestrator_service.py b/server/tests/test_orchestrator_service.py index 4a660af..41fb5ae 100644 --- a/server/tests/test_orchestrator_service.py +++ b/server/tests/test_orchestrator_service.py @@ -1,15 +1,19 @@ from __future__ import annotations +import json from collections.abc import Generator from datetime import UTC, datetime, timedelta from decimal import Decimal +from pathlib import Path from fastapi.testclient import TestClient from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.api.deps import CurrentUserContext from app.api.deps import get_db +from app.core.config import get_settings from app.db.base import Base from app.main import create_app from app.models.agent_conversation import AgentConversation, AgentConversationMessage @@ -21,6 +25,7 @@ from app.models.financial_record import ( ) from app.schemas.settings import SettingsWrite from app.services.agent_assets import AgentAssetService +from app.services.knowledge import KnowledgeService from app.services.settings import SettingsService @@ -45,6 +50,108 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: return TestClient(app), session_factory +def seed_llm_wiki_knowledge(storage_root: Path) -> str: + service = KnowledgeService(storage_root=storage_root) + detail = service.upload_document( + folder="报销制度", + filename="公司差旅制度.txt", + content=( + "差旅住宿标准:直辖市和特区住宿费最高 500 元," + "省会城市 450 元,其他地区 400 元。" + ).encode("utf-8"), + current_user=CurrentUserContext( + username="admin", + name="系统管理员", + role_codes=["manager"], + is_admin=True, + ), + ) + entry = service.get_document_entry(detail.id) + document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / detail.id + document_dir.mkdir(parents=True, exist_ok=True) + + knowledge_candidates = [ + { + "candidate_id": "kc_travel_standard", + "title": "差旅费报销标准", + "content": ( + "住宿费限额:国内直辖市或特区最高 500 元," + "省会城市 450 元,其他地区 400 元。" + "出差补贴:国内餐补 75/65/55 元/天(按地区)," + "基本补助 35 元/天,合计 110/100/90 元/天。" + ), + "domain": "expense", + "scenario": "expense_reimbursement", + "tags": ["差旅", "住宿费", "标准"], + "source_document_id": detail.id, + "source_document_name": entry["original_name"], + "source_chunk_ids": [f"{detail.id}-document"], + "evidence": ["第八条 差旅住宿标准"], + "confidence": 0.96, + "status": "draft", + "created_by": "hermes", + "created_at": "2026-05-15T10:20:55+00:00", + "extraction_mode": "hermes", + "quality_flags": [], + "fallback_reason": "", + } + ] + (document_dir / "knowledge_candidates.json").write_text( + json.dumps(knowledge_candidates, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (document_dir / "knowledge_summary.md").write_text( + ( + "# 公司差旅制度知识总结\n\n" + "## 差旅住宿标准\n" + "- 国内直辖市和特区住宿费最高 500 元。\n" + "- 省会城市 450 元,其他地区 400 元。\n" + ), + encoding="utf-8", + ) + (storage_root / "knowledge" / ".llm_wiki" / "index.json").write_text( + json.dumps( + { + "documents": [ + { + "document_id": detail.id, + "document_name": entry["original_name"], + "folder": entry["folder"], + "document_version": "v1.0", + "checksum": entry["sha256"], + "extracted_text_path": str(document_dir / "text.md"), + "chunk_count": 1, + "candidate_chunk_count": 1, + "filtered_chunk_count": 0, + "group_count": 1, + "successful_group_count": 1, + "failed_group_count": 0, + "knowledge_candidate_count": 1, + "formal_knowledge_candidate_count": 1, + "fallback_knowledge_candidate_count": 0, + "rule_candidate_count": 0, + "quality_status": "formal", + "quality_note": "Hermes 已基于完整原文件完成正式归纳。", + "updated_at": "2026-05-15T10:20:56+00:00", + "signature": { + "document_id": entry["id"], + "original_name": entry["original_name"], + "stored_name": entry["stored_name"], + "sha256": entry["sha256"], + "version_number": int(entry["version_number"]), + "updated_at": entry["updated_at"], + }, + } + ] + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + return detail.id + + def test_orchestrator_routes_user_query_to_user_agent() -> None: client, _ = build_client() @@ -75,6 +182,288 @@ def test_orchestrator_routes_user_query_to_user_agent() -> None: assert run_detail["tool_calls"][0]["tool_type"] == "database" +def test_orchestrator_answers_knowledge_question_from_llm_wiki(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + document_id = seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "差旅住宿标准按什么规则执行?", + "context_json": {"role_codes": ["employee"], "name": "测试用户"}, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["selected_agent"] == "user_agent" + assert payload["status"] == "succeeded" + assert "差旅费报销标准" in payload["result"]["answer"] + assert payload["result"]["citations"][0]["source_type"] == "knowledge" + assert payload["result"]["citations"][0]["title"] == "差旅费报销标准" + assert "500 元" in payload["result"]["citations"][0]["excerpt"] + + run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json() + tool_response = run_detail["tool_calls"][0]["response_json"] + assert tool_response["result_type"] == "knowledge_search" + assert tool_response["record_count"] == 1 + assert tool_response["hits"][0]["title"] == "差旅费报销标准" + assert tool_response["hits"][0]["document_id"] == document_id + finally: + get_settings.cache_clear() + + +def test_orchestrator_knowledge_session_forces_llm_wiki_search(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "住宿费报销标准是多少?", + "context_json": { + "role_codes": ["employee"], + "name": "测试用户", + "grade": "P3", + "session_type": "knowledge", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["trace_summary"]["scenario"] == "knowledge" + assert payload["result"]["citations"][0]["source_type"] == "knowledge" + assert "差旅费报销标准" in payload["result"]["answer"] + assert "核心规定是:" in payload["result"]["answer"] + assert "住宿费限额" in payload["result"]["answer"] + assert payload["result"]["suggested_actions"] == [] + + run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json() + tool_response = run_detail["tool_calls"][0]["response_json"] + assert tool_response["result_type"] == "knowledge_search" + assert tool_response["record_count"] == 1 + finally: + get_settings.cache_clear() + + +def test_orchestrator_knowledge_session_does_not_answer_from_summary_fallback(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + document_id = seed_llm_wiki_knowledge(storage_root) + document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / document_id + (document_dir / "knowledge_candidates.json").write_text("[]", encoding="utf-8") + client, _ = build_client() + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "住宿费报销标准是多少?", + "context_json": { + "role_codes": ["employee"], + "name": "测试用户", + "session_type": "knowledge", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["result"]["citations"] == [] + assert "知识问答仅基于 LLM Wiki 已形成的知识条目回答" in payload["result"]["answer"] + finally: + get_settings.cache_clear() + + +def test_orchestrator_knowledge_follow_up_reuses_recent_context(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + first_response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "住宿费报销标准是多少?", + "context_json": { + "role_codes": ["employee"], + "name": "测试用户", + "session_type": "knowledge", + }, + }, + ) + conversation_id = first_response.json()["conversation_id"] + + follow_up_response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "conversation_id": conversation_id, + "message": "假设p3员工去武汉出差3天,一共可以报销多少钱?", + "context_json": { + "role_codes": ["employee"], + "name": "测试用户", + "session_type": "knowledge", + }, + }, + ) + + assert follow_up_response.status_code == 200 + payload = follow_up_response.json() + assert "差旅费报销标准" in payload["result"]["answer"] + assert "住宿费限额" in payload["result"]["answer"] + assert payload["result"]["citations"][0]["source_type"] == "knowledge" + + run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json() + tool_response = run_detail["tool_calls"][0]["response_json"] + assert tool_response["result_type"] == "knowledge_search" + assert tool_response["record_count"] == 1 + finally: + get_settings.cache_clear() + + +def test_orchestrator_knowledge_answer_does_not_invent_missing_grade_detail(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "我去武汉出差3天,一共可以报销多少钱?", + "context_json": { + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + }, + }, + ) + + assert response.status_code == 200 + answer = response.json()["result"]["answer"] + assert "住宿费限额" in answer + assert "350 × 3" not in answer + assert "1320 元" not in answer + finally: + get_settings.cache_clear() + + +def test_orchestrator_answers_direct_travel_amount_question_without_clarification(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "我要去武汉出差3天,请问我一共可以报销多少费用?", + "context_json": { + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["trace_summary"]["scenario"] == "knowledge" + assert payload["status"] == "succeeded" + assert payload["result"].get("clarification_required") is not True + assert "差旅费报销标准" in payload["result"]["answer"] + assert "1320 元" not in payload["result"]["answer"] + finally: + get_settings.cache_clear() + + +def test_orchestrator_knowledge_follow_up_inherits_trip_conditions(tmp_path, monkeypatch) -> None: + storage_root = tmp_path / "storage" + monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root)) + get_settings.cache_clear() + + try: + seed_llm_wiki_knowledge(storage_root) + client, _ = build_client() + + first_response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "message": "我要去武汉出差3天,请问我一共可以报销多少费用?", + "context_json": { + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + }, + }, + ) + conversation_id = first_response.json()["conversation_id"] + + follow_up_response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "pytest", + "conversation_id": conversation_id, + "message": "那P4员工可以报销多少钱?", + "context_json": { + "role_codes": ["employee"], + "name": "曹笑竹", + "grade": "P3", + "session_type": "knowledge", + }, + }, + ) + + assert follow_up_response.status_code == 200 + answer = follow_up_response.json()["result"]["answer"] + assert "差旅费报销标准" in answer + assert "1470 元" not in answer + finally: + get_settings.cache_clear() + + def test_orchestrator_does_not_auto_seed_demo_financial_records() -> None: client, session_factory = build_client() diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 473fd31..7a92523 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -1,676 +1,759 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker -from sqlalchemy.pool import StaticPool - -from app.db.base import Base -from app.schemas.ontology import OntologyParseRequest -from app.schemas.user_agent import UserAgentRequest -from app.services.ontology import SemanticOntologyService -from app.services.user_agent import UserAgentService +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.schemas.ontology import OntologyParseRequest +from app.schemas.user_agent import UserAgentRequest +from app.services.ontology import SemanticOntologyService +from app.services.user_agent import UserAgentService + + +def build_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 test_user_agent_query_returns_readable_answer_and_actions() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="张三 4 月差旅报销金额是多少", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="张三 4 月差旅报销金额是多少", + ontology=ontology, + tool_payload={"record_count": 2, "total_amount": 8800.0}, + ) + ) + + assert "8800.00" in response.answer + assert len(response.suggested_actions) >= 1 + + +def test_user_agent_returns_readable_query_answer_when_runtime_model_is_skipped(monkeypatch) -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="张三 4 月差旅报销金额是多少", + user_id="pytest", + ) + ) + service = UserAgentService(db) + monkeypatch.setattr(service, "_generate_answer_with_model", lambda *args, **kwargs: "这是模型回答") + + response = service.respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="张三 4 月差旅报销金额是多少", + ontology=ontology, + tool_payload={"record_count": 2, "total_amount": 8800.0}, + ) + ) + + assert "共 2 笔" in response.answer + assert "8800.00" in response.answer + + +def test_user_agent_sanitizes_model_thinking_blocks() -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = UserAgentService(db) + + assert ( + service._sanitize_model_answer("内部推理\n最终答复") + == "最终答复" + ) + + +def test_user_agent_rejects_visible_reasoning_drafts() -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = UserAgentService(db) + + assert ( + service._sanitize_model_answer( + "用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..." + ) + is None + ) -def build_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 test_user_agent_query_returns_readable_answer_and_actions() -> None: +def test_user_agent_knowledge_prompt_enforces_llm_wiki_boundary() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( - query="张三 4 月差旅报销金额是多少", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="张三 4 月差旅报销金额是多少", - ontology=ontology, - tool_payload={"record_count": 2, "total_amount": 8800.0}, - ) - ) - - assert "8800.00" in response.answer - assert len(response.suggested_actions) >= 1 - - -def test_user_agent_returns_readable_query_answer_when_runtime_model_is_skipped(monkeypatch) -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="张三 4 月差旅报销金额是多少", + query="住宿费标准是多少?", user_id="pytest", + context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) - monkeypatch.setattr(service, "_generate_answer_with_model", lambda *args, **kwargs: "这是模型回答") - - response = service.respond( + messages = service._build_model_messages( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", - message="张三 4 月差旅报销金额是多少", + message="住宿费标准是多少?", ontology=ontology, - tool_payload={"record_count": 2, "total_amount": 8800.0}, - ) + tool_payload={"result_type": "knowledge_search", "hits": []}, + ), + citations=[], + suggested_actions=[], + risk_flags=[], + draft_payload=None, + fallback_answer="", ) - assert "共 2 笔" in response.answer - assert "8800.00" in response.answer + assert "只能依据 tool_payload.hits 中的 LLM Wiki 内容作答" in messages[0]["content"] -def test_user_agent_sanitizes_model_thinking_blocks() -> None: - session_factory = build_session_factory() - with session_factory() as db: - service = UserAgentService(db) - - assert ( - service._sanitize_model_answer("内部推理\n最终答复") - == "最终答复" - ) - - -def test_user_agent_guides_generic_expense_request() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我要报销", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我要报销", - ontology=ontology, - tool_payload={"record_count": 9, "total_amount": 12345.0}, - ) - ) - - assert response.review_payload is not None - assert response.answer == response.review_payload.body_message - assert response.review_payload.can_proceed is False - assert response.review_payload.missing_slots == [ - "报销类型", - "发生时间", - "金额", - "事由说明", - "票据附件", - ] - assert [item.action_type for item in response.review_payload.confirmation_actions] == [ - "cancel_review", - "edit_review", - "save_draft", - ] - - -def test_user_agent_guides_implicit_expense_draft_request() -> None: - session_factory = build_session_factory() - with session_factory() as db: - today = datetime.now(UTC).date().isoformat() - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我今天去客户现场,招待了客户,花销了1000元", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我今天去客户现场,招待了客户,花销了1000元", - ontology=ontology, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - assert response.answer == response.review_payload.body_message - assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用。") - assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"] - assert [item.action_type for item in response.review_payload.confirmation_actions] == [ - "cancel_review", - "edit_review", - "save_draft", - ] - - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["expense_type"].value == "业务招待费" - assert slot_map["time_range"].value == today - assert slot_map["time_range"].raw_value == "今天" - assert slot_map["location"].value == "客户现场" - assert slot_map["amount"].value == "1000.00元" - - -def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我前天请客户吃饭花了200元", - user_id="pytest", - context_json={ - "client_now_iso": "2026-05-12T16:30:00.000Z", - "client_timezone_offset_minutes": -480, - }, - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我前天请客户吃饭花了200元", - ontology=ontology, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["time_range"].raw_value == "前天" - assert slot_map["time_range"].value == "2026-05-11" - assert "时间为 2026-05-11" in response.review_payload.intent_summary - - -def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。", - user_id="pytest", - context_json={ - "attachment_names": ["didi-trip.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "didi-trip.png", - "summary": "滴滴出行 订单金额 32 元", - "text": "滴滴出行 订单金额 32 元", - "document_type": "taxi_receipt", - "scene_code": "transport", - } - ], - "user_input_text": "", - }, - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称:didi-trip.png", - ontology=ontology, - context_json={ - "attachment_names": ["didi-trip.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "didi-trip.png", - "summary": "滴滴出行 订单金额 32 元", - "text": "滴滴出行 订单金额 32 元", - "document_type": "taxi_receipt", - "scene_code": "transport", - } - ], - "user_input_text": "", - }, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["reason"].value == "交通出行" - assert slot_map["reason"].status == "inferred" - - -def test_user_agent_transport_flow_infers_reason_and_does_not_require_location_or_merchant() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了交通票据,帮我生成报销草稿", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了交通票据,帮我生成报销草稿", - ontology=ontology, - context_json={ - "attachment_names": ["didi-trip.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "didi-trip.png", - "summary": "滴滴出行 支付金额 32 元", - "text": "滴滴出行 支付金额 32 元", - "document_type": "taxi_receipt", - "scene_code": "transport", - "scene_label": "交通票据", - } - ], - }, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["reason"].value == "交通出行" - assert slot_map["reason"].status == "inferred" - assert "酒店/商户" not in response.review_payload.missing_slots - assert "地点" not in response.review_payload.missing_slots - assert "事由说明" not in response.review_payload.missing_slots - - -def test_user_agent_risk_response_includes_rule_citations() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="检查重复报销风险", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="检查重复报销风险", - ontology=ontology, - tool_payload={"risk_flags": ["duplicate_expense"]}, - ) - ) - - assert response.risk_flags == ["duplicate_expense"] - assert any(item.source_type == "rule" for item in response.citations) - assert "duplicate_expense" in response.answer - - -def test_user_agent_draft_returns_structured_payload() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="帮我生成张三4月差旅报销草稿", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="帮我生成张三4月差旅报销草稿", - ontology=ontology, - tool_payload={"draft_only": True}, - ) - ) - - assert response.draft_payload is not None - assert response.draft_payload.confirmation_required is True - assert response.review_payload is not None - assert response.review_payload.can_proceed is False - assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"] - assert [item.action_type for item in response.review_payload.confirmation_actions] == [ - "cancel_review", - "edit_review", - "save_draft", - ] - assert response.answer == response.review_payload.body_message - - -def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="请按当前识别信息保存报销草稿", - user_id="pytest", - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="请按当前识别信息保存报销草稿", - ontology=ontology, - context_json={"review_action": "save_draft"}, - tool_payload={ - "draft_limit_reached": True, - "message": "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。", - "status": "blocked", - }, - ) - ) - - assert ( - response.answer - == "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - ) - - -def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None: - session_factory = build_session_factory() - with session_factory() as db: - yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", - user_id="pytest", - context_json={ - "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], - "attachment_count": 2, - "ocr_documents": [ - { - "filename": "机票行程单.png", - "summary": "机票行程单 上海-北京 金额 680 元", - "text": "机票行程单 上海-北京 金额 680 元", - "avg_score": 0.93, - "warnings": [], - }, - { - "filename": "餐饮发票.jpg", - "summary": "餐饮发票 客户招待 金额 320 元", - "text": "餐饮发票 客户招待 金额 320 元", - "avg_score": 0.91, - "warnings": [], - }, - ], - }, - ) - ) - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", - ontology=ontology, - context_json={ - "name": "张三", - "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], - "attachment_count": 2, - "ocr_documents": [ - { - "filename": "机票行程单.png", - "summary": "机票行程单 上海-北京 金额 680 元", - "text": "机票行程单 上海-北京 金额 680 元", - "avg_score": 0.93, - "warnings": [], - }, - { - "filename": "餐饮发票.jpg", - "summary": "餐饮发票 客户招待 金额 320 元", - "text": "餐饮发票 客户招待 金额 320 元", - "avg_score": 0.91, - "warnings": [], - }, - ], - }, - tool_payload={"draft_only": True, "claim_no": "EXP-202605-009", "status": "draft"}, - ) - ) - - assert response.review_payload is not None - assert len(response.review_payload.document_cards) == 2 - assert len(response.review_payload.claim_groups) == 2 - assert response.review_payload.missing_slots == ["参与人员"] - assert [item.action_type for item in response.review_payload.confirmation_actions] == [ - "cancel_review", - "edit_review", - "save_draft", - ] - assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards) - assert f"时间为 {yesterday}" in response.review_payload.intent_summary - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["time_range"].value == yesterday - assert slot_map["time_range"].raw_value == "昨天" - - -def test_user_agent_sums_multi_document_amounts_from_synonym_fields() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了两张交通票据,帮我生成报销草稿", - user_id="pytest", - context_json={ - "attachment_names": ["滴滴行程单.png", "停车票.jpg"], - "attachment_count": 2, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 订单金额 ¥32.50", - "avg_score": 0.94, - "document_fields": [ - {"key": "amount", "label": "支付金额", "value": "32.50"}, - ], - "warnings": [], - }, - { - "filename": "停车票.jpg", - "summary": "停车票", - "text": "停车费 合计 18 元", - "avg_score": 0.92, - "document_fields": [ - {"key": "total_amount", "label": "合计金额", "value": "18"}, - ], - "warnings": [], - }, - ], - }, - ) - ) - - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了两张交通票据,帮我生成报销草稿", - ontology=ontology, - context_json={ - "attachment_names": ["滴滴行程单.png", "停车票.jpg"], - "attachment_count": 2, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 订单金额 ¥32.50", - "avg_score": 0.94, - "document_fields": [ - {"key": "amount", "label": "支付金额", "value": "32.50"}, - ], - "warnings": [], - }, - { - "filename": "停车票.jpg", - "summary": "停车票", - "text": "停车费 合计 18 元", - "avg_score": 0.92, - "document_fields": [ - {"key": "total_amount", "label": "合计金额", "value": "18"}, - ], - "warnings": [], - }, - ], - }, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["amount"].value == "50.50元" - document_field_labels = [ - field.label - for card in response.review_payload.document_cards - for field in card.fields - ] - assert "金额" in document_field_labels - - -def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了打车票据,帮我生成报销草稿", - user_id="pytest", - context_json={ - "attachment_names": ["滴滴行程单.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", - "avg_score": 0.94, - "warnings": [], - }, - ], - }, - ) - ) - - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了打车票据,帮我生成报销草稿", - ontology=ontology, - context_json={ - "attachment_names": ["滴滴行程单.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", - "avg_score": 0.94, - "warnings": [], - }, - ], - }, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["amount"].value == "13.40元" - - -def test_user_agent_review_payload_keeps_document_preview_data() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了打车票据,帮我生成报销草稿", - user_id="pytest", - context_json={ - "attachment_names": ["滴滴行程单.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 实付 13.4 元", - "avg_score": 0.94, - "preview_kind": "image", - "preview_data_url": "data:image/png;base64,ZmFrZQ==", - "warnings": [], - }, - ], - }, - ) - ) - - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了打车票据,帮我生成报销草稿", - ontology=ontology, - context_json={ - "attachment_names": ["滴滴行程单.png"], - "attachment_count": 1, - "ocr_documents": [ - { - "filename": "滴滴行程单.png", - "summary": "滴滴出行电子行程单", - "text": "滴滴出行 实付 13.4 元", - "avg_score": 0.94, - "preview_kind": "image", - "preview_data_url": "data:image/png;base64,ZmFrZQ==", - "warnings": [], - }, - ], - }, - tool_payload={"draft_only": True}, - ) - ) - - assert response.review_payload is not None - assert response.review_payload.document_cards[0].preview_kind == "image" - assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,") - - -def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( - query="我上传了两张票据,帮我生成报销草稿", - user_id="pytest", - ) - ) - - response = UserAgentService(db).respond( - UserAgentRequest( - run_id=ontology.run_id, - user_id="pytest", - message="我上传了两张票据,帮我生成报销草稿", - ontology=ontology, - context_json={ - "attachment_names": ["滴滴行程单.png", "餐饮发票.jpg"], - "attachment_count": 2, - "ocr_documents": [ - {"filename": "滴滴行程单.png", "summary": "滴滴出行 金额 32 元", "text": "滴滴出行 金额 32 元"}, - {"filename": "餐饮发票.jpg", "summary": "餐饮发票 金额 68 元", "text": "餐饮发票 金额 68 元"}, - ], - }, - tool_payload={ - "pending_association_decision": True, - "association_candidate_claim_no": "EXP-202605-008", - }, - ) - ) - - assert response.review_payload is not None - assert response.review_payload.can_proceed is False - assert [item.action_type for item in response.review_payload.confirmation_actions] == [ - "cancel_review", - "edit_review", - "link_to_existing_draft", - "create_new_claim_from_documents", - ] - assert "EXP-202605-008" in response.answer +def test_user_agent_model_prompt_supports_contextual_personalization() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我能坐什么舱位?", + user_id="pytest", + ) + ) + service = UserAgentService(db) + messages = service._build_model_messages( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我能坐什么舱位?", + ontology=ontology, + context_json={ + "name": "张三", + "position": "财务分析师", + "grade": "P5", + "role": "财务人员", + "role_codes": ["finance"], + }, + tool_payload={}, + ), + citations=[], + suggested_actions=[], + risk_flags=[], + draft_payload=None, + fallback_answer="", + ) + + system_prompt = messages[0]["content"] + user_prompt = messages[1]["content"] + assert "user_grade" in system_prompt + assert "conversation_history" in system_prompt + assert '"user_name": "张三"' in user_prompt + assert '"user_position": "财务分析师"' in user_prompt + assert '"user_grade": "P5"' in user_prompt + + +def test_user_agent_guides_generic_expense_request() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我要报销", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我要报销", + ontology=ontology, + tool_payload={"record_count": 9, "total_amount": 12345.0}, + ) + ) + + assert response.review_payload is not None + assert response.answer == response.review_payload.body_message + assert response.review_payload.can_proceed is False + assert response.review_payload.missing_slots == [ + "报销类型", + "发生时间", + "金额", + "事由说明", + "票据附件", + ] + assert [item.action_type for item in response.review_payload.confirmation_actions] == [ + "cancel_review", + "edit_review", + "save_draft", + ] + + +def test_user_agent_guides_implicit_expense_draft_request() -> None: + session_factory = build_session_factory() + with session_factory() as db: + today = datetime.now(UTC).date().isoformat() + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我今天去客户现场,招待了客户,花销了1000元", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我今天去客户现场,招待了客户,花销了1000元", + ontology=ontology, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + assert response.answer == response.review_payload.body_message + assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用。") + assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"] + assert [item.action_type for item in response.review_payload.confirmation_actions] == [ + "cancel_review", + "edit_review", + "save_draft", + ] + + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["expense_type"].value == "业务招待费" + assert slot_map["time_range"].value == today + assert slot_map["time_range"].raw_value == "今天" + assert slot_map["location"].value == "客户现场" + assert slot_map["amount"].value == "1000.00元" + + +def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我前天请客户吃饭花了200元", + user_id="pytest", + context_json={ + "client_now_iso": "2026-05-12T16:30:00.000Z", + "client_timezone_offset_minutes": -480, + }, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我前天请客户吃饭花了200元", + ontology=ontology, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["time_range"].raw_value == "前天" + assert slot_map["time_range"].value == "2026-05-11" + assert "时间为 2026-05-11" in response.review_payload.intent_summary + + +def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。", + user_id="pytest", + context_json={ + "attachment_names": ["didi-trip.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "didi-trip.png", + "summary": "滴滴出行 订单金额 32 元", + "text": "滴滴出行 订单金额 32 元", + "document_type": "taxi_receipt", + "scene_code": "transport", + } + ], + "user_input_text": "", + }, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称:didi-trip.png", + ontology=ontology, + context_json={ + "attachment_names": ["didi-trip.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "didi-trip.png", + "summary": "滴滴出行 订单金额 32 元", + "text": "滴滴出行 订单金额 32 元", + "document_type": "taxi_receipt", + "scene_code": "transport", + } + ], + "user_input_text": "", + }, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["reason"].value == "交通出行" + assert slot_map["reason"].status == "inferred" + + +def test_user_agent_transport_flow_infers_reason_and_does_not_require_location_or_merchant() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了交通票据,帮我生成报销草稿", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了交通票据,帮我生成报销草稿", + ontology=ontology, + context_json={ + "attachment_names": ["didi-trip.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "didi-trip.png", + "summary": "滴滴出行 支付金额 32 元", + "text": "滴滴出行 支付金额 32 元", + "document_type": "taxi_receipt", + "scene_code": "transport", + "scene_label": "交通票据", + } + ], + }, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["reason"].value == "交通出行" + assert slot_map["reason"].status == "inferred" + assert "酒店/商户" not in response.review_payload.missing_slots + assert "地点" not in response.review_payload.missing_slots + assert "事由说明" not in response.review_payload.missing_slots + + +def test_user_agent_risk_response_includes_rule_citations() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="检查重复报销风险", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="检查重复报销风险", + ontology=ontology, + tool_payload={"risk_flags": ["duplicate_expense"]}, + ) + ) + + assert response.risk_flags == ["duplicate_expense"] + assert any(item.source_type == "rule" for item in response.citations) + assert "duplicate_expense" in response.answer + + +def test_user_agent_draft_returns_structured_payload() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="帮我生成张三4月差旅报销草稿", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="帮我生成张三4月差旅报销草稿", + ontology=ontology, + tool_payload={"draft_only": True}, + ) + ) + + assert response.draft_payload is not None + assert response.draft_payload.confirmation_required is True + assert response.review_payload is not None + assert response.review_payload.can_proceed is False + assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"] + assert [item.action_type for item in response.review_payload.confirmation_actions] == [ + "cancel_review", + "edit_review", + "save_draft", + ] + assert response.answer == response.review_payload.body_message + + +def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="请按当前识别信息保存报销草稿", + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="请按当前识别信息保存报销草稿", + ontology=ontology, + context_json={"review_action": "save_draft"}, + tool_payload={ + "draft_limit_reached": True, + "message": "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。", + "status": "blocked", + }, + ) + ) + + assert ( + response.answer + == "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + ) + + +def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None: + session_factory = build_session_factory() + with session_factory() as db: + yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", + user_id="pytest", + context_json={ + "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "机票行程单.png", + "summary": "机票行程单 上海-北京 金额 680 元", + "text": "机票行程单 上海-北京 金额 680 元", + "avg_score": 0.93, + "warnings": [], + }, + { + "filename": "餐饮发票.jpg", + "summary": "餐饮发票 客户招待 金额 320 元", + "text": "餐饮发票 客户招待 金额 320 元", + "avg_score": 0.91, + "warnings": [], + }, + ], + }, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", + ontology=ontology, + context_json={ + "name": "张三", + "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "机票行程单.png", + "summary": "机票行程单 上海-北京 金额 680 元", + "text": "机票行程单 上海-北京 金额 680 元", + "avg_score": 0.93, + "warnings": [], + }, + { + "filename": "餐饮发票.jpg", + "summary": "餐饮发票 客户招待 金额 320 元", + "text": "餐饮发票 客户招待 金额 320 元", + "avg_score": 0.91, + "warnings": [], + }, + ], + }, + tool_payload={"draft_only": True, "claim_no": "EXP-202605-009", "status": "draft"}, + ) + ) + + assert response.review_payload is not None + assert len(response.review_payload.document_cards) == 2 + assert len(response.review_payload.claim_groups) == 2 + assert response.review_payload.missing_slots == ["参与人员"] + assert [item.action_type for item in response.review_payload.confirmation_actions] == [ + "cancel_review", + "edit_review", + "save_draft", + ] + assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards) + assert f"时间为 {yesterday}" in response.review_payload.intent_summary + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["time_range"].value == yesterday + assert slot_map["time_range"].raw_value == "昨天" + + +def test_user_agent_sums_multi_document_amounts_from_synonym_fields() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了两张交通票据,帮我生成报销草稿", + user_id="pytest", + context_json={ + "attachment_names": ["滴滴行程单.png", "停车票.jpg"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 订单金额 ¥32.50", + "avg_score": 0.94, + "document_fields": [ + {"key": "amount", "label": "支付金额", "value": "32.50"}, + ], + "warnings": [], + }, + { + "filename": "停车票.jpg", + "summary": "停车票", + "text": "停车费 合计 18 元", + "avg_score": 0.92, + "document_fields": [ + {"key": "total_amount", "label": "合计金额", "value": "18"}, + ], + "warnings": [], + }, + ], + }, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了两张交通票据,帮我生成报销草稿", + ontology=ontology, + context_json={ + "attachment_names": ["滴滴行程单.png", "停车票.jpg"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 订单金额 ¥32.50", + "avg_score": 0.94, + "document_fields": [ + {"key": "amount", "label": "支付金额", "value": "32.50"}, + ], + "warnings": [], + }, + { + "filename": "停车票.jpg", + "summary": "停车票", + "text": "停车费 合计 18 元", + "avg_score": 0.92, + "document_fields": [ + {"key": "total_amount", "label": "合计金额", "value": "18"}, + ], + "warnings": [], + }, + ], + }, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["amount"].value == "50.50元" + document_field_labels = [ + field.label + for card in response.review_payload.document_cards + for field in card.fields + ] + assert "金额" in document_field_labels + + +def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了打车票据,帮我生成报销草稿", + user_id="pytest", + context_json={ + "attachment_names": ["滴滴行程单.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", + "avg_score": 0.94, + "warnings": [], + }, + ], + }, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了打车票据,帮我生成报销草稿", + ontology=ontology, + context_json={ + "attachment_names": ["滴滴行程单.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", + "avg_score": 0.94, + "warnings": [], + }, + ], + }, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["amount"].value == "13.40元" + + +def test_user_agent_review_payload_keeps_document_preview_data() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了打车票据,帮我生成报销草稿", + user_id="pytest", + context_json={ + "attachment_names": ["滴滴行程单.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 实付 13.4 元", + "avg_score": 0.94, + "preview_kind": "image", + "preview_data_url": "data:image/png;base64,ZmFrZQ==", + "warnings": [], + }, + ], + }, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了打车票据,帮我生成报销草稿", + ontology=ontology, + context_json={ + "attachment_names": ["滴滴行程单.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "滴滴行程单.png", + "summary": "滴滴出行电子行程单", + "text": "滴滴出行 实付 13.4 元", + "avg_score": 0.94, + "preview_kind": "image", + "preview_data_url": "data:image/png;base64,ZmFrZQ==", + "warnings": [], + }, + ], + }, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + assert response.review_payload.document_cards[0].preview_kind == "image" + assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,") + + +def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我上传了两张票据,帮我生成报销草稿", + user_id="pytest", + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我上传了两张票据,帮我生成报销草稿", + ontology=ontology, + context_json={ + "attachment_names": ["滴滴行程单.png", "餐饮发票.jpg"], + "attachment_count": 2, + "ocr_documents": [ + {"filename": "滴滴行程单.png", "summary": "滴滴出行 金额 32 元", "text": "滴滴出行 金额 32 元"}, + {"filename": "餐饮发票.jpg", "summary": "餐饮发票 金额 68 元", "text": "餐饮发票 金额 68 元"}, + ], + }, + tool_payload={ + "pending_association_decision": True, + "association_candidate_claim_no": "EXP-202605-008", + }, + ) + ) + + assert response.review_payload is not None + assert response.review_payload.can_proceed is False + assert [item.action_type for item in response.review_payload.confirmation_actions] == [ + "cancel_review", + "edit_review", + "link_to_existing_draft", + "create_new_claim_from_documents", + ] + assert "EXP-202605-008" in response.answer diff --git a/web/src/assets/styles/views/chat-view.css b/web/src/assets/styles/views/chat-view.css index 29ca106..2e26672 100644 --- a/web/src/assets/styles/views/chat-view.css +++ b/web/src/assets/styles/views/chat-view.css @@ -37,10 +37,26 @@ .answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; } .answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; } .agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; } +.agent-answer-content { max-width: 760px; display: grid; gap: 10px; } +.agent-answer-table-wrap { overflow-x: auto; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); } +.agent-answer-table { width: 100%; min-width: 360px; border-collapse: collapse; font-size: 13px; } +.agent-answer-table th, .agent-answer-table td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; } +.agent-answer-table th { background: #eef7f2; color: #0f172a; font-weight: 800; } +.agent-answer-table td { color: #334155; font-weight: 650; } +.agent-answer-table tbody tr:last-child td { border-bottom: 0; } .agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; } .agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: #eef7f2; color: #0f766e; font-size: 12px; font-weight: 760; } .agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; } .agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; } +.agent-citation-disclosure { overflow: hidden; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; } +.agent-citation-disclosure summary { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: #0f172a; cursor: pointer; list-style: none; } +.agent-citation-disclosure summary::-webkit-details-marker { display: none; } +.agent-citation-disclosure summary strong { font-size: 12px; font-weight: 820; } +.agent-citation-disclosure summary span { color: #64748b; font-size: 12px; font-weight: 720; } +.agent-citation-disclosure summary i { margin-left: auto; color: #64748b; transition: transform .18s ease; } +.agent-citation-disclosure[open] summary { border-bottom: 1px solid #eef2f7; } +.agent-citation-disclosure[open] summary i { transform: rotate(180deg); } +.agent-citation-disclosure .agent-citation-list { padding: 10px; } .agent-detail-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } .agent-risk-chip, .agent-action-chip { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 760; } .agent-risk-chip { background: #fff1f2; color: #be123c; } diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 2b79ceb..f31ec57 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -379,6 +379,52 @@ font-size: 14px; } +.message-answer-content { + display: grid; + gap: 12px; +} + +.message-answer-content p { + margin: 0; +} + +.message-answer-table-wrap { + overflow-x: auto; + border: 1px solid #dbe4ee; + border-radius: 16px; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.message-answer-table { + width: 100%; + min-width: 360px; + border-collapse: collapse; + overflow: hidden; + font-size: 13px; +} + +.message-answer-table th, +.message-answer-table td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + text-align: left; +} + +.message-answer-table th { + background: #eff6ff; + color: #0f172a; + font-weight: 850; +} + +.message-answer-table td { + color: #334155; + font-weight: 650; +} + +.message-answer-table tbody tr:last-child td { + border-bottom: 0; +} + .message-meta-row { display: flex; flex-wrap: wrap; @@ -429,6 +475,58 @@ font-weight: 850; } +.message-citation-disclosure { + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 16px; + background: #fbfdff; +} + +.message-citation-disclosure summary { + min-height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + color: #0f172a; + cursor: pointer; + list-style: none; +} + +.message-citation-disclosure summary::-webkit-details-marker { + display: none; +} + +.message-citation-disclosure summary strong { + font-size: 12px; + font-weight: 850; +} + +.message-citation-disclosure summary span { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.message-citation-disclosure summary i { + margin-left: auto; + color: #64748b; + font-size: 16px; + transition: transform 0.18s ease; +} + +.message-citation-disclosure[open] summary { + border-bottom: 1px solid #e2e8f0; +} + +.message-citation-disclosure[open] summary i { + transform: rotate(180deg); +} + +.message-citation-disclosure .message-citation-list { + padding: 12px; +} + .expense-query-block { gap: 10px; } diff --git a/web/src/composables/useChat.js b/web/src/composables/useChat.js index cb8d9f6..d90c07e 100644 --- a/web/src/composables/useChat.js +++ b/web/src/composables/useChat.js @@ -142,6 +142,8 @@ export function useChat(activeView) { is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', + position: user.position || '', + grade: user.grade || '', active_case_id: activeCase.value?.id || '' } }) diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 3f650db..0a40435 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -86,11 +86,13 @@ function readStoredUsername() { } function buildAnonymousUser() { - return { - username: '', - name: '', - role: '', - roleCodes: [], + return { + username: '', + name: '', + role: '', + position: '', + grade: '', + roleCodes: [], email: '', avatar: '', isAdmin: false @@ -101,11 +103,13 @@ function buildLegacyAdminUser(username = '') { const normalized = String(username || '').trim() const name = normalized || DEFAULT_USER_NAME - return { - username: normalized, - name, - role: DEFAULT_USER_ROLE, - roleCodes: ['manager'], + return { + username: normalized, + name, + role: DEFAULT_USER_ROLE, + position: DEFAULT_USER_ROLE, + grade: '', + roleCodes: ['manager'], email: '', avatar: name.slice(0, 1).toUpperCase(), isAdmin: true @@ -127,11 +131,13 @@ function readStoredUser() { const name = String(payload.name || username || DEFAULT_USER_NAME).trim() const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] - return { - username, - name, - role: String(payload.role || DEFAULT_USER_ROLE), - roleCodes, + return { + username, + name, + role: String(payload.role || DEFAULT_USER_ROLE), + position: String(payload.position || ''), + grade: String(payload.grade || ''), + roleCodes, email: String(payload.email || ''), avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), isAdmin: Boolean(payload.isAdmin) diff --git a/web/src/views/ChatView.vue b/web/src/views/ChatView.vue index 9941960..c9d1e5b 100644 --- a/web/src/views/ChatView.vue +++ b/web/src/views/ChatView.vue @@ -1,322 +1,333 @@ - - - - - + + + + + diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 9283d6f..32200fc 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -1,1034 +1,1056 @@ - - - - - - - -
-
-

制度依据

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

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

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

检测到你已有单据事件

-

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

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

{{ documentPreviewDialog.filename }}

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

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

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

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

-

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

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

制度依据

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

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

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

检测到你已有单据事件

+

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

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

{{ documentPreviewDialog.filename }}

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

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

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

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

+

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

+
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ + + + + + diff --git a/web/src/views/scripts/ChatView.js b/web/src/views/scripts/ChatView.js index ce16fa4..48e21c3 100644 --- a/web/src/views/scripts/ChatView.js +++ b/web/src/views/scripts/ChatView.js @@ -3,6 +3,69 @@ import { useSystemState } from '../../composables/useSystemState.js' import { fetchOntologyParse } from '../../services/ontology.js' +function isMarkdownTableDivider(line = '') { + const value = String(line || '').trim() + if (!value.includes('|')) return false + return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value) +} + +function splitMarkdownTableRow(line = '') { + return String(line || '') + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()) +} + +function buildAnswerBlocks(text = '') { + const lines = String(text || '').replace(/\r\n/g, '\n').split('\n') + const blocks = [] + let index = 0 + + while (index < lines.length) { + const line = lines[index].trim() + if (!line) { + index += 1 + continue + } + + if ( + line.includes('|') && + index + 1 < lines.length && + isMarkdownTableDivider(lines[index + 1]) + ) { + const headers = splitMarkdownTableRow(line) + const rows = [] + index += 2 + while (index < lines.length && lines[index].includes('|') && lines[index].trim()) { + rows.push(splitMarkdownTableRow(lines[index])) + index += 1 + } + blocks.push({ type: 'table', headers, rows }) + continue + } + + const paragraphLines = [line] + index += 1 + while ( + index < lines.length && + lines[index].trim() && + !( + lines[index].includes('|') && + index + 1 < lines.length && + isMarkdownTableDivider(lines[index + 1]) + ) + ) { + paragraphLines.push(lines[index].trim()) + index += 1 + } + blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') }) + } + + return blocks +} + export default { name: 'ChatView', props: { @@ -170,7 +233,9 @@ export default { role_codes: currentUser.value?.roleCodes || [], is_admin: Boolean(currentUser.value?.isAdmin), name: currentUser.value?.name || '', - role: currentUser.value?.role || '' + role: currentUser.value?.role || '', + position: currentUser.value?.position || '', + grade: currentUser.value?.grade || '' } }) } catch (error) { @@ -212,6 +277,7 @@ export default { semanticRiskFlagsText, semanticClarificationText, semanticResultJson, + buildAnswerBlocks, applySemanticExample, useDraftAsSemanticInput, parseSemanticQuery diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 53bb66d..4f33e09 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -228,6 +228,69 @@ function createMessage(role, text, attachments = [], extras = {}) { } } +function isMarkdownTableDivider(line = '') { + const value = String(line || '').trim() + if (!value.includes('|')) return false + return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value) +} + +function splitMarkdownTableRow(line = '') { + return String(line || '') + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()) +} + +function buildAnswerBlocks(text = '') { + const lines = String(text || '').replace(/\r\n/g, '\n').split('\n') + const blocks = [] + let index = 0 + + while (index < lines.length) { + const line = lines[index].trim() + if (!line) { + index += 1 + continue + } + + if ( + line.includes('|') && + index + 1 < lines.length && + isMarkdownTableDivider(lines[index + 1]) + ) { + const headers = splitMarkdownTableRow(line) + const rows = [] + index += 2 + while (index < lines.length && lines[index].includes('|') && lines[index].trim()) { + rows.push(splitMarkdownTableRow(lines[index])) + index += 1 + } + blocks.push({ type: 'table', headers, rows }) + continue + } + + const paragraphLines = [line] + index += 1 + while ( + index < lines.length && + lines[index].trim() && + !( + lines[index].includes('|') && + index + 1 < lines.length && + isMarkdownTableDivider(lines[index + 1]) + ) + ) { + paragraphLines.push(lines[index].trim()) + index += 1 + } + blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') }) + } + + return blocks +} + function formatMessageTime(value) { if (!value) { return nowTime() @@ -3371,6 +3434,8 @@ export default { is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', + position: user.position || '', + grade: user.grade || '', ...buildClientTimeContext(), session_type: activeSessionType.value, entry_source: props.entrySource, @@ -3787,6 +3852,7 @@ export default { buildReviewRiskHint, buildReviewActionHint, buildReviewStatusTag, + buildAnswerBlocks, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage,