feat: 新增风险规则生成引擎与知识图谱可视化

后端新增风险规则自动生成和模板执行服务,支持从规则资产
批量生成并持久化风险规则文件;知识库入库日志增强图谱
查询和本地 RAG 回退,前端审计页面增加风险规则模型和流
程图组件,知识入库面板拆分为图谱可视化子组件,报销创
建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
caoxiaozhu
2026-05-23 19:54:42 +08:00
parent 5b388d08c0
commit 575f093c74
63 changed files with 35497 additions and 1517 deletions

View File

@@ -0,0 +1,90 @@
{
"schema_version": "2.0",
"rule_code": "risk.expense.generated_20260523010818",
"name": "住宿城市必须出现在本次差旅行程城市中风险规则",
"description": "当报销业务满足“住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
"enabled": true,
"risk_dimension": "natural_language_rule",
"risk_category": "报销",
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": "field_compare_v1",
"applies_to": {
"domains": [
"expense"
]
},
"inputs": {
"fields": [
{
"key": "claim.reason",
"label": "报销事由",
"type": "text",
"source": "claim"
},
{
"key": "claim.location",
"label": "申报地点",
"type": "text",
"source": "claim"
},
{
"key": "attachment.hotel_city",
"label": "住宿城市",
"type": "text",
"source": "attachment"
},
{
"key": "attachment.route_cities",
"label": "行程城市",
"type": "list",
"source": "attachment"
}
]
},
"params": {
"template_key": "field_compare_v1",
"field_keys": [
"claim.reason",
"claim.location",
"attachment.hotel_city",
"attachment.route_cities"
],
"condition_summary": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
"natural_language": "住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。",
"conditions": [
{
"left": "claim.reason",
"operator": "overlap",
"right": "claim.location"
}
]
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "WangMin",
"stability": "generated_draft",
"source_ref": "自然语言风险规则",
"created_at": "2026-05-23T01:08:18.310751+00:00",
"created_by": "WangMin",
"natural_language": "住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。",
"business_explanation": "当报销业务满足“住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
"condition_summary": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
"flow": {
"start": "报销单据提交",
"evidence": "读取报销事由、申报地点、住宿城市",
"decision": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
"pass": "未命中风险,继续业务流转",
"fail": "命中高风险,提示复核"
}
}
}

View File

@@ -0,0 +1,90 @@
{
"schema_version": "2.0",
"rule_code": "risk.expense.generated_20260523010846",
"name": "酒店发票城市必须与申报目的地或交通票风险规则",
"description": "当报销业务满足“酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
"enabled": true,
"risk_dimension": "natural_language_rule",
"risk_category": "报销",
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": "field_compare_v1",
"applies_to": {
"domains": [
"expense"
]
},
"inputs": {
"fields": [
{
"key": "claim.reason",
"label": "报销事由",
"type": "text",
"source": "claim"
},
{
"key": "claim.location",
"label": "申报地点",
"type": "text",
"source": "claim"
},
{
"key": "claim.employee_name",
"label": "报销人",
"type": "text",
"source": "claim"
},
{
"key": "attachment.route_cities",
"label": "行程城市",
"type": "list",
"source": "attachment"
}
]
},
"params": {
"template_key": "field_compare_v1",
"field_keys": [
"claim.reason",
"claim.location",
"claim.employee_name",
"attachment.route_cities"
],
"condition_summary": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。",
"conditions": [
{
"left": "claim.reason",
"operator": "overlap",
"right": "claim.location"
}
]
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "min.wang@xfinance.com",
"stability": "generated_draft",
"source_ref": "自然语言风险规则",
"created_at": "2026-05-23T01:08:46.286513+00:00",
"created_by": "min.wang@xfinance.com",
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。",
"business_explanation": "当报销业务满足“酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
"condition_summary": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
"flow": {
"start": "报销单据提交",
"evidence": "读取报销事由、申报地点、报销人",
"decision": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
"pass": "未命中风险,继续业务流转",
"fail": "命中高风险,提示复核"
}
}
}

View File

@@ -0,0 +1,90 @@
{
"schema_version": "2.0",
"rule_code": "risk.expense.generated_20260523011139",
"name": "酒店发票城市一致性校验",
"description": "校验酒店发票城市是否与申报目的地或行程城市一致,不一致时标记为高风险并要求补充说明",
"enabled": true,
"risk_dimension": "natural_language_rule",
"risk_category": "报销",
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": "field_compare_v1",
"applies_to": {
"domains": [
"expense"
]
},
"inputs": {
"fields": [
{
"key": "attachment.route_cities",
"label": "行程城市",
"type": "list",
"source": "attachment"
},
{
"key": "claim.location",
"label": "申报地点",
"type": "text",
"source": "claim"
},
{
"key": "attachment.hotel_city",
"label": "住宿城市",
"type": "text",
"source": "attachment"
},
{
"key": "claim.reason",
"label": "报销事由",
"type": "text",
"source": "claim"
}
]
},
"params": {
"template_key": "field_compare_v1",
"field_keys": [
"attachment.hotel_city",
"claim.location",
"attachment.route_cities",
"claim.reason"
],
"condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求补充异常行程说明。",
"conditions": [
{
"left": "attachment.hotel_city",
"operator": "overlap",
"right": "claim.location"
}
]
},
"outcomes": {
"pass": {
"severity": "none",
"action": "continue"
},
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "WangMin",
"stability": "generated_draft",
"source_ref": "自然语言风险规则",
"created_at": "2026-05-23T01:11:39.165281+00:00",
"created_by": "WangMin",
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求补充异常行程说明。",
"business_explanation": "校验酒店发票城市是否与申报目的地或行程城市一致,不一致时标记为高风险并要求补充说明",
"condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
"flow": {
"start": "提交酒店发票",
"evidence": "读取住宿城市、申报地点、行程城市",
"decision": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
"pass": "继续流转",
"fail": "提示高风险:酒店发票城市与申报目的地及行程城市均不一致,需补充异常行程说明"
}
}
}

View File

@@ -23,6 +23,7 @@ from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
@@ -33,6 +34,7 @@ from app.schemas.agent_asset import (
)
from app.schemas.common import ErrorResponse
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation import RiskRuleGenerationService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
@@ -154,6 +156,35 @@ def save_agent_asset_rule_json(
_handle_asset_error(exc)
@router.post(
"/risk-rules/generate",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description="根据业务域、风险等级和自然语言描述生成 JSON 风险规则,并保存为待审核草稿资产。",
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
actor = (x_actor or current_user.name or "system").strip() or "system"
asset_id = RiskRuleGenerationService(db).generate_rule_asset(
payload,
actor=actor,
request_id=x_request_id,
)
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
return asset
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,
@@ -508,11 +539,7 @@ def create_agent_asset_review(
try:
role_codes = {item.strip() for item in current_user.role_codes}
if payload.review_status.value == "pending":
if not (
current_user.is_admin
or "manager" in role_codes
or "finance" in role_codes
):
if not (current_user.is_admin or "manager" in role_codes or "finance" in role_codes):
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级管理人员可以审核规则。")
@@ -599,4 +626,3 @@ def get_agent_asset_version_timeline(
return AgentAssetService(db).list_version_timeline(asset_id)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -104,11 +104,18 @@ class AgentAssetRuleJsonRead(BaseModel):
description: str = ""
evaluator: str = ""
ontology_signal: str | None = None
flow_diagram_svg: str | None = None
inputs: dict[str, Any] = Field(default_factory=dict)
outcomes: dict[str, Any] = Field(default_factory=dict)
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetRiskRuleGenerateRequest(BaseModel):
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
risk_level: str = Field(default="medium", pattern="^(low|medium|high)$")
natural_language: str = Field(min_length=8, max_length=2000)
class AgentAssetVersionTimelineItemRead(BaseModel):
event_type: str
version: str

View File

@@ -42,6 +42,7 @@ class AgentAssetJsonRuleMixin:
description=str(payload.get("description") or asset.description or "").strip(),
evaluator=str(payload.get("evaluator") or ""),
ontology_signal=str(payload.get("ontology_signal") or "") or None,
flow_diagram_svg=str(payload.get("flow_diagram_svg") or "") or None,
inputs=payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {},
outcomes=payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {},
payload=payload,
@@ -95,4 +96,3 @@ class AgentAssetJsonRuleMixin:
)
self.db.commit()
return self.read_rule_json(asset_id)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import threading
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -31,6 +33,8 @@ from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixi
from app.services.agent_foundation_spreadsheets import AgentFoundationSpreadsheetMixin
logger = get_logger("app.services.agent_foundation")
_foundation_ready_lock = threading.RLock()
_foundation_ready_keys: set[str] = set()
def prepare_agent_foundation() -> None:
@@ -57,6 +61,17 @@ class AgentFoundationService(
self.db = db
def ensure_foundation_ready(self) -> None:
cache_key = self._foundation_cache_key()
if cache_key in _foundation_ready_keys:
return
with _foundation_ready_lock:
if cache_key in _foundation_ready_keys:
return
self._prepare_foundation()
_foundation_ready_keys.add(cache_key)
def _prepare_foundation(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_agent_asset_schema()
@@ -69,6 +84,10 @@ class AgentFoundationService(
logger.exception("Failed to prepare agent foundation")
raise
def _foundation_cache_key(self) -> str:
bind = self.db.get_bind()
return str(getattr(bind, "url", "") or id(bind))
def _sync_demo_financial_records(self) -> None:
if get_settings().seed_demo_financial_records:
self._seed_financial_records()

View File

@@ -6,12 +6,14 @@ from typing import Any
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
from app.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
from app.services.agent_foundation import AgentFoundationService
from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json
logger = get_logger("app.services.agent_runs")
@@ -42,7 +44,7 @@ class AgentRunService:
run = self.repository.get_by_run_id(run_id)
if run is None:
return None
return self._serialize_run(run)
return self._serialize_run(run, enrich_knowledge_ingest=True)
def create_run(
self,
@@ -314,9 +316,19 @@ class AgentRunService:
except ValueError:
return None
@staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead:
def _serialize_run(
self,
run: AgentRun,
*,
enrich_knowledge_ingest: bool = False,
) -> AgentRunRead:
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
route_json = run.route_json
if enrich_knowledge_ingest:
route_json = enrich_knowledge_ingest_route_json(
dict(run.route_json or {}),
storage_root=get_settings().resolved_storage_root_dir,
)
return AgentRunRead(
id=run.id,
run_id=run.run_id,
@@ -325,7 +337,7 @@ class AgentRunService:
user_id=run.user_id,
task_id=run.task_id,
ontology_json=run.ontology_json,
route_json=run.route_json,
route_json=route_json,
permission_level=run.permission_level,
status=run.status,
result_summary=run.result_summary,

View File

@@ -1,36 +1,19 @@
from __future__ import annotations
import re
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_claim_constants import (
AI_REVIEW_LOOKBACK_DAYS,
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
)
from app.services.expense_rule_runtime import (
ExpenseRuleRuntimeService,
RuntimeTravelPolicy,
build_default_expense_rule_catalog,
)
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin:
@@ -66,9 +49,7 @@ class ExpenseClaimPlatformRiskMixin:
if severity == "high" or action == "block":
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
deduplicated_reasons = list(
dict.fromkeys(reason for reason in blocking_reasons if reason)
)
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
return {"flags": flags, "blocking_reasons": deduplicated_reasons}
def _load_platform_risk_rule_manifests(
@@ -77,9 +58,7 @@ class ExpenseClaimPlatformRiskMixin:
rule_codes: list[str] | None,
) -> list[dict[str, Any]]:
code_filter = {
str(code or "").strip()
for code in list(rule_codes or [])
if str(code or "").strip()
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
}
manifests_by_code: dict[str, dict[str, Any]] = {}
@@ -224,12 +203,10 @@ class ExpenseClaimPlatformRiskMixin:
normalized_contexts.append(
{
"scene_code": str(document_info.get("scene_code") or "").strip().lower(),
"document_type": str(
document_info.get("document_type") or ""
).strip().lower(),
"item_type": str(
getattr(context.get("item"), "item_type", "") or ""
).strip().lower(),
"document_type": str(document_info.get("document_type") or "").strip().lower(),
"item_type": str(getattr(context.get("item"), "item_type", "") or "")
.strip()
.lower(),
}
)
@@ -312,6 +289,19 @@ class ExpenseClaimPlatformRiskMixin:
claim=claim,
contexts=contexts,
)
if evaluator == "template_rule":
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=contexts,
)
if result is None:
return None
return self._build_platform_risk_flag(
manifest,
message=str(result.get("message") or "自然语言风险规则命中。"),
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
)
return None
def _evaluate_reason_too_brief_risk(
@@ -347,9 +337,7 @@ class ExpenseClaimPlatformRiskMixin:
reason_corpus = self._build_scene_reason_corpus(claim)
compact_reason = re.sub(r"\s+", "", reason_corpus)
looks_like_entertainment = (
"entertainment" in expense_types
or "招待" in compact_reason
or "客户" in compact_reason
"entertainment" in expense_types or "招待" in compact_reason or "客户" in compact_reason
)
if not looks_like_entertainment:
return None
@@ -374,32 +362,28 @@ class ExpenseClaimPlatformRiskMixin:
for context in contexts:
item = context["item"]
item_type = (
str(item.item_type or claim.expense_type or "other").strip().lower()
or "other"
str(item.item_type or claim.expense_type or "other").strip().lower() or "other"
)
policy = self._get_expense_scene_policy(item_type)
if policy is None:
continue
document_info = context.get("document_info") or {}
recognized_scene_code = (
str(document_info.get("scene_code") or "other").strip().lower()
or "other"
str(document_info.get("scene_code") or "other").strip().lower() or "other"
)
recognized_document_type = (
str(document_info.get("document_type") or "other").strip().lower()
or "other"
str(document_info.get("document_type") or "other").strip().lower() or "other"
)
if (
recognized_scene_code in set(policy.allowed_scene_codes)
or recognized_document_type in set(policy.allowed_document_types)
):
if recognized_scene_code in set(
policy.allowed_scene_codes
) or recognized_document_type in set(policy.allowed_document_types):
continue
recognized_label = str(
document_info.get("document_type_label")
or recognized_document_type
or "未知票据"
document_info.get("document_type_label") or recognized_document_type or "未知票据"
)
mismatches.append(
f"{context['index']} 条明细为{policy.label},附件识别为{recognized_label}"
)
mismatches.append(f"{context['index']} 条明细为{policy.label},附件识别为{recognized_label}")
if not mismatches:
return None
@@ -437,7 +421,10 @@ class ExpenseClaimPlatformRiskMixin:
evidence_text = "".join(evidence_cities[:5])
return self._build_platform_risk_flag(
manifest,
message=f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,建议补充异地说明或更换附件。",
message=(
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
"建议补充异地说明或更换附件。"
),
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
)
@@ -450,9 +437,7 @@ class ExpenseClaimPlatformRiskMixin:
) -> dict[str, Any] | None:
invoice_keys = self._collect_invoice_keys_from_contexts(contexts)
duplicate_keys = [
key
for key, count in self._count_values(invoice_keys).items()
if count > 1
key for key, count in self._count_values(invoice_keys).items() if count > 1
]
if duplicate_keys:
return self._build_platform_risk_flag(
@@ -504,9 +489,7 @@ class ExpenseClaimPlatformRiskMixin:
) -> dict[str, Any] | None:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
allow_keywords = [
str(value)
for value in list(params.get("allow_keywords") or [])
if str(value).strip()
str(value) for value in list(params.get("allow_keywords") or []) if str(value).strip()
]
claimant = str(claim.employee_name or "").strip()
if not claimant:
@@ -564,7 +547,10 @@ class ExpenseClaimPlatformRiskMixin:
return None
return self._build_platform_risk_flag(
manifest,
message=f"票据年份 {mismatch_years[0]} 与费用发生年份 {claim_year} 不一致,建议确认是否跨年报销。",
message=(
f"票据年份 {mismatch_years[0]} 与费用发生年份 {claim_year} 不一致,"
"建议确认是否跨年报销。"
),
evidence={"claim_year": claim_year, "invoice_years": mismatch_years},
)

View File

@@ -480,6 +480,7 @@ def _build_initial_knowledge_ingest_document(
"entity_count": 0,
"relation_count": 0,
"entities": [],
"entity_chunks": [],
"relations": [],
"events": [
{
@@ -677,7 +678,7 @@ def _build_ingest_graph(knowledge_ingest: dict[str, Any]) -> dict[str, Any]:
documents = [
item for item in list(knowledge_ingest.get("documents") or []) if isinstance(item, dict)
]
entities = _dedupe_text_items(
entities = _dedupe_entities(
entity for document in documents for entity in list(document.get("entities") or [])
)
relations = _dedupe_relations(
@@ -692,20 +693,52 @@ def _build_ingest_graph(knowledge_ingest: dict[str, Any]) -> dict[str, Any]:
}
def _dedupe_text_items(items: Any) -> list[str]:
deduped: list[str] = []
def _dedupe_entities(items: Any) -> list[dict[str, Any]]:
deduped: list[dict[str, Any]] = []
seen: set[str] = set()
for item in items:
text = str(item or "").strip()
if not text or text in seen:
if isinstance(item, dict):
name = str(
item.get("name")
or item.get("entity")
or item.get("entity_id")
or item.get("title")
or item.get("id")
or ""
).strip()
entity = dict(item)
else:
name = str(item or "").strip()
entity = {}
if not name or name in seen:
continue
seen.add(text)
deduped.append(text)
seen.add(name)
entity["name"] = name
entity["type"] = str(
entity.get("type")
or entity.get("entity_type")
or entity.get("category")
or entity.get("kind")
or "实体"
).strip()
description = str(entity.get("description") or "").strip()
descriptions = entity.get("descriptions")
if not isinstance(descriptions, list):
descriptions = [description] if description else []
entity["description"] = description
entity["descriptions"] = [
str(description_item or "").strip()
for description_item in descriptions
if str(description_item or "").strip()
][:5]
if not isinstance(entity.get("properties"), dict):
entity["properties"] = {}
deduped.append(entity)
return deduped
def _dedupe_relations(items: Any) -> list[dict[str, str]]:
deduped: list[dict[str, str]] = []
def _dedupe_relations(items: Any) -> list[dict[str, Any]]:
deduped: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for item in items:
if not isinstance(item, dict):
@@ -717,7 +750,7 @@ def _dedupe_relations(items: Any) -> list[dict[str, str]]:
if not source or not target or key in seen:
continue
seen.add(key)
deduped.append({"source": source, "target": target, "type": relation_type})
deduped.append({**item, "source": source, "target": target, "type": relation_type})
return deduped

View File

@@ -1,15 +1,21 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
MAX_INGEST_LOG_CHUNKS = 24
MAX_INGEST_LOG_ENTITIES = 24
MAX_INGEST_LOG_ENTITY_CHUNKS = 48
MAX_INGEST_LOG_RELATIONS = 24
MAX_INGEST_LOG_SECTIONS = 12
MAX_INGEST_LOG_TEXT_PREVIEW = 180
MAX_INGEST_LOG_ENTITY_DESCRIPTIONS = 5
GRAPHML_NAMESPACE = {"graphml": "http://graphml.graphdrawing.org/xmlns"}
GRAPH_PROPERTY_SEPARATOR = "<SEP>"
INGEST_SECTION_HEADING_PATTERN = re.compile(
r"^(?:#{1,4}\s+.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*)$"
@@ -42,6 +48,7 @@ def build_ingest_document_summary(
"entity_count": 0,
"relation_count": 0,
"entities": [],
"entity_chunks": [],
"relations": [],
}
@@ -62,6 +69,33 @@ def build_ingest_status_summary(
}
def enrich_knowledge_ingest_route_json(
route_json: dict[str, Any],
*,
storage_root: Path,
) -> dict[str, Any]:
if not isinstance(route_json, dict):
return route_json
ingest = route_json.get("knowledge_ingest")
if not isinstance(ingest, dict):
return route_json
graph = ingest.get("graph")
if not isinstance(graph, dict):
return route_json
workspace = _resolve_lightrag_workspace(route_json)
graph_snapshot = _load_lightrag_graph_snapshot(storage_root, workspace=workspace)
if not graph_snapshot["entities"] and not graph_snapshot["relations"]:
return route_json
next_route = dict(route_json)
next_ingest = dict(ingest)
next_graph = _enrich_graph_payload(graph, graph_snapshot)
next_ingest["graph"] = next_graph
next_route["knowledge_ingest"] = next_ingest
return next_route
def build_document_graph_summary(
storage_root: Path,
*,
@@ -74,19 +108,264 @@ def build_document_graph_summary(
entities_payload = _load_json_file(workspace_dir / "kv_store_full_entities.json")
relations_payload = _load_json_file(workspace_dir / "kv_store_full_relations.json")
chunks_payload = _load_json_file(workspace_dir / "kv_store_text_chunks.json")
entity_chunks_payload = _load_json_file(workspace_dir / "kv_store_entity_chunks.json")
graph_snapshot = _load_lightrag_graph_snapshot(storage_root, workspace=workspace)
entities = _normalize_document_entities(entities_payload, document_id)
relations = _normalize_document_relations(relations_payload, document_id)
chunks = _normalize_document_chunks(chunks_payload, document_id)
entity_chunks = _normalize_document_entity_chunks(
entity_chunks_payload,
entities,
chunk_ids={str(item.get("id") or "").strip() for item in chunks},
)
return {
"entity_count": len(entities),
"relation_count": len(relations),
"entities": entities[:MAX_INGEST_LOG_ENTITIES],
"relations": relations[:MAX_INGEST_LOG_RELATIONS],
"entities": _enrich_entity_list(entities, graph_snapshot)[:MAX_INGEST_LOG_ENTITIES],
"relations": _enrich_relation_list(relations, graph_snapshot)[:MAX_INGEST_LOG_RELATIONS],
"chunks": chunks[:MAX_INGEST_LOG_CHUNKS],
"entity_chunks": entity_chunks[:MAX_INGEST_LOG_ENTITY_CHUNKS],
}
def _resolve_lightrag_workspace(route_json: dict[str, Any]) -> str:
explicit_workspace = str(
route_json.get("lightrag_workspace") or route_json.get("workspace") or ""
).strip()
if explicit_workspace:
return explicit_workspace
return os.environ.get("LIGHTRAG_WORKSPACE", "x_financial_knowledge").strip() or "x_financial_knowledge"
def _enrich_graph_payload(
graph: dict[str, Any],
graph_snapshot: dict[str, Any],
) -> dict[str, Any]:
next_graph = dict(graph)
relation_items = _extract_relation_items(graph.get("relations"))
relation_entity_names = [
name
for relation in relation_items
for name in (relation.get("source"), relation.get("target"))
]
next_graph["entities"] = _enrich_entity_list(
_dedupe_text_items(
_extract_entity_names(graph.get("entities")) + relation_entity_names
),
graph_snapshot,
)
next_graph["relations"] = _enrich_relation_list(relation_items, graph_snapshot)
return next_graph
def _enrich_entity_list(
entity_names: list[str],
graph_snapshot: dict[str, Any],
) -> list[dict[str, Any]]:
graph_entities = graph_snapshot.get("entities") or {}
return [
graph_entities.get(entity_name)
or {
"name": entity_name,
"type": "实体",
"description": "",
"descriptions": [],
"properties": {},
}
for entity_name in entity_names
]
def _enrich_relation_list(
relations: list[dict[str, Any]],
graph_snapshot: dict[str, Any],
) -> list[dict[str, Any]]:
graph_relations = graph_snapshot.get("relations") or {}
enriched_relations: list[dict[str, Any]] = []
for relation in relations:
source = str(relation.get("source") or "").strip()
target = str(relation.get("target") or "").strip()
relation_type = str(relation.get("type") or "关联").strip()
graph_relation = (
graph_relations.get((source, target))
or graph_relations.get((target, source))
or {}
)
enriched_relations.append(
{
**relation,
"source": source,
"target": target,
"type": relation_type,
"description": graph_relation.get("description", ""),
"keywords": graph_relation.get("keywords", []),
"weight": graph_relation.get("weight", relation.get("weight", 1)),
"properties": graph_relation.get("properties", {}),
}
)
return enriched_relations
def _load_lightrag_graph_snapshot(storage_root: Path, *, workspace: str) -> dict[str, Any]:
graphml_path = (
Path(storage_root)
/ "knowledge"
/ ".lightrag"
/ str(workspace).strip()
/ "graph_chunk_entity_relation.graphml"
)
if not graphml_path.exists():
return {"entities": {}, "relations": {}}
try:
root = ElementTree.parse(graphml_path).getroot()
except (ElementTree.ParseError, OSError):
return {"entities": {}, "relations": {}}
key_names = {
str(key.attrib.get("id") or ""): str(key.attrib.get("attr.name") or "")
for key in root.findall("graphml:key", GRAPHML_NAMESPACE)
}
return {
"entities": _load_graphml_entities(root, key_names),
"relations": _load_graphml_relations(root, key_names),
}
def _load_graphml_entities(
root: ElementTree.Element,
key_names: dict[str, str],
) -> dict[str, dict[str, Any]]:
entities: dict[str, dict[str, Any]] = {}
for node in root.findall(".//graphml:node", GRAPHML_NAMESPACE):
properties = _read_graphml_data(node, key_names)
name = str(properties.get("entity_id") or node.attrib.get("id") or "").strip()
if not name:
continue
descriptions = _split_graph_property(properties.get("description"))
visible_properties = _filter_graph_properties(properties)
entities[name] = {
"name": name,
"type": str(properties.get("entity_type") or "实体").strip(),
"description": descriptions[0] if descriptions else "",
"descriptions": descriptions[:MAX_INGEST_LOG_ENTITY_DESCRIPTIONS],
"properties": visible_properties,
}
return entities
def _load_graphml_relations(
root: ElementTree.Element,
key_names: dict[str, str],
) -> dict[tuple[str, str], dict[str, Any]]:
relations: dict[tuple[str, str], dict[str, Any]] = {}
for edge in root.findall(".//graphml:edge", GRAPHML_NAMESPACE):
source = str(edge.attrib.get("source") or "").strip()
target = str(edge.attrib.get("target") or "").strip()
if not source or not target:
continue
properties = _read_graphml_data(edge, key_names)
description_parts = _split_graph_property(properties.get("description"))
relations[(source, target)] = {
"description": "; ".join(description_parts[:2]),
"keywords": _split_graph_keywords(properties.get("keywords"))[:6],
"weight": _to_float(properties.get("weight"), default=1.0),
"properties": _filter_graph_properties(properties),
}
return relations
def _read_graphml_data(
element: ElementTree.Element,
key_names: dict[str, str],
) -> dict[str, str]:
data: dict[str, str] = {}
for item in element.findall("graphml:data", GRAPHML_NAMESPACE):
key = str(item.attrib.get("key") or "")
name = key_names.get(key) or key
if not name:
continue
data[name] = str(item.text or "").strip()
return data
def _split_graph_property(value: Any) -> list[str]:
return [
_truncate_text(part, max_length=MAX_INGEST_LOG_TEXT_PREVIEW)
for part in str(value or "").split(GRAPH_PROPERTY_SEPARATOR)
if str(part or "").strip()
]
def _split_graph_keywords(value: Any) -> list[str]:
keywords: list[str] = []
for part in str(value or "").split(GRAPH_PROPERTY_SEPARATOR):
keywords.extend(part.split(","))
return [
_truncate_text(keyword, max_length=60)
for keyword in keywords
if str(keyword or "").strip()
]
def _filter_graph_properties(properties: dict[str, Any]) -> dict[str, Any]:
hidden_fields = {
"source_id",
"file_path",
"truncate",
"description",
"keywords",
}
return {
key: value
for key, value in properties.items()
if key not in hidden_fields and str(value or "").strip()
}
def _extract_entity_names(raw_entities: Any) -> list[str]:
if not isinstance(raw_entities, list):
return []
names: list[str] = []
for entity in raw_entities:
if isinstance(entity, dict):
name = str(
entity.get("name")
or entity.get("entity")
or entity.get("entity_id")
or entity.get("id")
or ""
).strip()
else:
name = str(entity or "").strip()
if name:
names.append(name)
return _dedupe_text_items(names)
def _extract_relation_items(raw_relations: Any) -> list[dict[str, Any]]:
if not isinstance(raw_relations, list):
return []
relations: list[dict[str, Any]] = []
for relation in raw_relations:
if not isinstance(relation, dict):
continue
source = str(relation.get("source") or relation.get("from") or "").strip()
target = str(relation.get("target") or relation.get("to") or "").strip()
if not source or not target:
continue
relations.append(
{
**relation,
"source": source,
"target": target,
"type": str(relation.get("type") or "关联").strip(),
}
)
return relations
def _extract_ingest_sections(text: str) -> list[dict[str, str]]:
sections: list[dict[str, str]] = []
lines = [line.strip() for line in str(text or "").splitlines()]
@@ -187,11 +466,46 @@ def _normalize_document_chunks(payload: dict[str, Any], document_id: str) -> lis
"order": _to_int(raw_chunk.get("chunk_order_index")),
"tokens": _to_int(raw_chunk.get("tokens")),
"summary": _build_chunk_summary(content),
"excerpt": _truncate_text(
content,
max_length=MAX_INGEST_LOG_TEXT_PREVIEW,
),
}
)
return sorted(chunks, key=lambda item: (item["order"], item["id"]))
def _normalize_document_entity_chunks(
payload: dict[str, Any],
entities: list[str],
*,
chunk_ids: set[str],
) -> list[dict[str, Any]]:
if not entities or not chunk_ids:
return []
entity_chunks: list[dict[str, Any]] = []
for entity in entities:
raw_entry = payload.get(entity) if isinstance(payload, dict) else {}
raw_chunk_ids = raw_entry.get("chunk_ids") if isinstance(raw_entry, dict) else []
if not isinstance(raw_chunk_ids, list):
continue
matched_chunk_ids = [
str(item or "").strip()
for item in raw_chunk_ids
if str(item or "").strip() in chunk_ids
]
if not matched_chunk_ids:
continue
entity_chunks.append(
{
"entity": entity,
"chunk_ids": _dedupe_text_items(matched_chunk_ids),
}
)
return entity_chunks
def _build_chunk_summary(content: str) -> str:
lines = [line.strip() for line in str(content or "").splitlines() if line.strip()]
text = next((line for line in lines if len(line) >= 12), lines[0] if lines else "")
@@ -217,6 +531,13 @@ def _to_int(value: Any) -> int:
return 0
def _to_float(value: Any, *, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _truncate_text(text: str, *, max_length: int) -> str:
normalized = " ".join(str(text or "").split()).strip()
if len(normalized) <= max_length:

View File

@@ -17,6 +17,7 @@ from app.services.knowledge_ingest_log import (
build_ingest_document_summary,
build_ingest_status_summary,
)
from app.services.knowledge_rag_local import query_local_text_chunks
from app.services.knowledge_rag_runtime import (
KnowledgeRagError,
RuntimeModelConfig,
@@ -95,6 +96,37 @@ class KnowledgeRagService:
"message": "请先输入要检索的知识库问题。",
}
workspace = (
os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip()
or DEFAULT_LIGHTRAG_WORKSPACE
)
local_result = query_local_text_chunks(
lightrag_root=(self.storage_root / "knowledge" / ".lightrag").resolve(),
workspace=workspace,
query=normalized_query,
limit=limit,
)
if local_result.confident:
return {
"result_type": "knowledge_search",
"query": normalized_query,
"record_count": len(local_result.hits),
"hits": local_result.hits,
"references": [
str(item.get("code") or "").strip()
for item in local_result.hits
if str(item.get("code") or "").strip()
],
"raw_references": [],
"metadata": {
"retrieval_strategy": "local_text_chunks",
"elapsed_seconds": round(local_result.elapsed_seconds, 4),
"total_chunks": local_result.total_chunks,
"best_score": local_result.best_score,
},
"message": f"已从本地知识块中检索到 {len(local_result.hits)} 条相关内容。",
}
try:
runtime = self._get_runtime()
raw = runtime.query_data(normalized_query, conversation_history=conversation_history)

View File

@@ -0,0 +1,353 @@
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass
from pathlib import Path
from time import perf_counter
from typing import Any
MAX_LOCAL_QUERY_TERMS = 14
MAX_LOCAL_HIT_CONTENT_LENGTH = 2200
MAX_LOCAL_HIT_EXCERPT_LENGTH = 240
LOCAL_CONFIDENCE_SCORE = 24
LOCAL_CONFIDENCE_MATCHES = 2
LOCAL_QUERY_STOPWORDS = {
"什么",
"多少",
"哪些",
"怎么",
"如何",
"请问",
"一下",
"关于",
"规定",
"标准",
"可以",
"是否",
"一个",
"根据",
"依据",
"给出",
"说明",
"公司",
"远光",
"软件",
"股份",
"有限",
"员工",
"当前",
"详细",
"问题",
}
LOCAL_TABLE_QUERY_HINTS = (
"标准",
"金额",
"限额",
"补贴",
"住宿",
"餐费",
"交通",
"报销",
"档位",
"额度",
)
LOCAL_DOMAIN_TERMS = (
"报销",
"费用",
"报销时限",
"申请时限",
"三个月",
"逾期",
"住宿费",
"住宿",
"差旅费",
"差旅",
"出差",
"超标",
"超过",
"审批",
"分管领导",
"部门负责人",
"业务招待",
"招待费",
"发票",
"票据",
"预算外",
"预算",
"补贴",
"餐补",
"交通费",
"会议费",
"培训费",
"通信费",
)
_index_lock = threading.RLock()
_index_cache: dict[Path, tuple[tuple[int, int], list[dict[str, Any]]]] = {}
@dataclass(frozen=True, slots=True)
class LocalKnowledgeSearchResult:
hits: list[dict[str, Any]]
confident: bool
elapsed_seconds: float
total_chunks: int
best_score: int
def query_local_text_chunks(
*,
lightrag_root: Path,
workspace: str,
query: str,
limit: int,
) -> LocalKnowledgeSearchResult:
started_at = perf_counter()
chunks = _load_text_chunks(lightrag_root / workspace / "kv_store_text_chunks.json")
query_terms = _extract_local_query_terms(query)
if not chunks or not query_terms:
return LocalKnowledgeSearchResult(
hits=[],
confident=False,
elapsed_seconds=perf_counter() - started_at,
total_chunks=len(chunks),
best_score=0,
)
prefers_tabular_evidence = any(hint in query for hint in LOCAL_TABLE_QUERY_HINTS)
candidates: list[dict[str, Any]] = []
for rank, chunk in enumerate(chunks, start=1):
content = str(chunk.get("content") or "").strip()
if not content:
continue
file_path = str(chunk.get("file_path") or "").strip()
document_id, document_name = _parse_document_identity(
file_path,
fallback_doc_id=str(chunk.get("full_doc_id") or "").strip(),
)
score, matched_terms = _score_local_chunk(
content=content,
title=document_name,
query_terms=query_terms,
rank=rank,
prefers_tabular_evidence=prefers_tabular_evidence,
)
if score <= 0:
continue
chunk_id = str(chunk.get("_id") or chunk.get("chunk_id") or "").strip()
normalized_content = _truncate_text(
content,
max_length=MAX_LOCAL_HIT_CONTENT_LENGTH,
)
candidates.append(
{
"code": f"knowledge.{document_id or 'unknown'}.{chunk_id or rank}",
"candidate_id": chunk_id or f"local-{rank}",
"title": document_name or "知识库文档",
"content": normalized_content,
"excerpt": _build_query_focused_excerpt(
normalized_content,
query_terms=query_terms,
max_length=MAX_LOCAL_HIT_EXCERPT_LENGTH,
),
"document_id": document_id,
"document_name": document_name or Path(file_path).name,
"version": None,
"updated_at": None,
"score": score,
"tags": [],
"evidence": [chunk_id] if chunk_id else [],
"file_path": file_path,
"_matched_terms": matched_terms,
}
)
ranked = sorted(
candidates,
key=lambda item: (
int(item.get("score") or 0),
len(list(item.get("_matched_terms") or [])),
-len(str(item.get("content") or "")),
),
reverse=True,
)
hits: list[dict[str, Any]] = []
for item in ranked[: max(1, limit)]:
normalized = dict(item)
normalized.pop("_matched_terms", None)
hits.append(normalized)
best_score = int(ranked[0].get("score") or 0) if ranked else 0
best_match_count = len(list(ranked[0].get("_matched_terms") or [])) if ranked else 0
confident = bool(
hits
and best_score >= LOCAL_CONFIDENCE_SCORE
and best_match_count >= LOCAL_CONFIDENCE_MATCHES
)
return LocalKnowledgeSearchResult(
hits=hits,
confident=confident,
elapsed_seconds=perf_counter() - started_at,
total_chunks=len(chunks),
best_score=best_score,
)
def _load_text_chunks(path: Path) -> list[dict[str, Any]]:
try:
stat = path.stat()
except OSError:
return []
signature = (int(stat.st_mtime_ns), int(stat.st_size))
resolved_path = path.resolve()
with _index_lock:
cached = _index_cache.get(resolved_path)
if cached is not None and cached[0] == signature:
return cached[1]
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
chunks: list[dict[str, Any]] = []
else:
chunks = [
dict(value)
for value in payload.values()
if isinstance(value, dict) and str(value.get("content") or "").strip()
]
chunks.sort(
key=lambda item: (
str(item.get("full_doc_id") or ""),
int(item.get("chunk_order_index") or 0),
str(item.get("_id") or ""),
)
)
_index_cache[resolved_path] = (signature, chunks)
return chunks
def _score_local_chunk(
*,
content: str,
title: str,
query_terms: list[str],
rank: int,
prefers_tabular_evidence: bool,
) -> tuple[int, list[str]]:
lowered_content = content.lower()
lowered_title = title.lower()
matched_terms = [
term for term in query_terms if term in lowered_content or term in lowered_title
]
if not matched_terms:
return 0, []
score = max(1, 32 - min(rank, 20))
for term in matched_terms:
weight = 8 if len(term) >= 4 else 5 if len(term) == 3 else 2
score += weight
if term in lowered_title:
score += max(4, weight)
occurrences = lowered_content.count(term)
if occurrences > 1:
score += min(8, occurrences * 2)
if prefers_tabular_evidence and ("|" in content or "" in content):
score += 12
if "# 结构化表格补充" in content:
score += 10 if prefers_tabular_evidence else 4
if "# 问答线索补充" in content:
score += 8
if "# 章节导航" in content[:260]:
score -= 20
if any(marker in content for marker in ("", "", "", "", "-", "")):
score += 4
return score, matched_terms
def _extract_local_query_terms(query: str) -> list[str]:
normalized_query = str(query or "").strip().lower()
if not normalized_query:
return []
terms: list[str] = []
seen: set[str] = set()
def remember(term: str) -> None:
normalized_term = str(term or "").strip().lower()
if (
not normalized_term
or normalized_term in seen
or normalized_term in LOCAL_QUERY_STOPWORDS
or len(normalized_term) < 2
):
return
seen.add(normalized_term)
terms.append(normalized_term)
for item in re.findall(r"[a-z0-9][a-z0-9_\-]{1,}", normalized_query):
remember(item)
for item in LOCAL_DOMAIN_TERMS:
if item in normalized_query:
remember(item)
for block in re.findall(r"[\u4e00-\u9fff]{2,24}", normalized_query):
if len(block) <= 4:
remember(block)
continue
for size in (4, 3, 2, 5):
for start in range(0, len(block) - size + 1):
remember(block[start : start + size])
if len(terms) >= MAX_LOCAL_QUERY_TERMS:
return terms
return terms[:MAX_LOCAL_QUERY_TERMS]
def _parse_document_identity(file_path: str, *, fallback_doc_id: str = "") -> tuple[str, str]:
path = Path(str(file_path or "").strip())
name = path.name
if "__" not in name:
return fallback_doc_id, name
document_id, document_name = name.split("__", maxsplit=1)
return document_id.strip() or fallback_doc_id, document_name.strip()
def _build_query_focused_excerpt(
text: str,
*,
query_terms: list[str],
max_length: int,
) -> str:
normalized = " ".join(str(text or "").split()).strip()
if not normalized:
return ""
lowered = normalized.lower()
match_positions = [
lowered.find(term) for term in query_terms if term and lowered.find(term) >= 0
]
if not match_positions:
return _truncate_text(normalized, max_length=max_length)
start = max(0, min(match_positions) - max_length // 3)
end = min(len(normalized), start + max_length)
snippet = normalized[start:end].strip()
if start > 0:
snippet = f"...{snippet.lstrip()}"
if end < len(normalized):
snippet = f"{snippet.rstrip()}..."
return snippet
def _truncate_text(text: str, *, max_length: int) -> str:
normalized = str(text or "").strip()
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3].rstrip()}..."

View File

@@ -404,6 +404,14 @@ class OrchestratorService:
ontology: OntologyParseResult,
task_asset: AgentAssetRead | None,
) -> dict[str, list[AgentAssetListItem | AgentAssetRead]]:
if ontology.scenario == "knowledge" and payload.source == AgentRunSource.USER_MESSAGE.value:
return {
"rules": [],
"skills": [],
"mcps": [],
"tasks": [],
}
domain_value = SCENARIO_TO_DOMAIN.get(ontology.scenario)
rules = self.execution_engine._rank_assets(
self.asset_service.list_assets(

View File

@@ -0,0 +1,191 @@
# ruff: noqa: E501
from __future__ import annotations
import html
from dataclasses import dataclass
@dataclass(frozen=True)
class RiskRuleFlowDiagramField:
key: str
label: str
@dataclass(frozen=True)
class RiskRuleFlowDiagramSpec:
title: str
domain_label: str
severity: str
severity_label: str
fields: tuple[RiskRuleFlowDiagramField, ...]
start: str
evidence: str
decision: str
basis: str
pass_text: str
fail_text: str
@dataclass(frozen=True)
class RiskRuleFlowDiagramPalette:
accent: str
accent_dark: str
border: str
surface: str
class RiskRuleFlowDiagramRenderer:
"""按 fireworks-tech-graph Style 7 OpenAI Official 生成只读流程 SVG。"""
_FONT = (
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, "
"'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
)
_TEXT = "#0d0d0d"
_MUTED = "#6e6e80"
_NEUTRAL_LINE = "#cbd5e1"
_NEUTRAL_BORDER = "#e2e8f0"
_NEUTRAL_SURFACE = "#ffffff"
_PALETTES = {
"low": RiskRuleFlowDiagramPalette(
accent="#2563eb",
accent_dark="#1d4ed8",
border="#bfdbfe",
surface="#eff6ff",
),
"medium": RiskRuleFlowDiagramPalette(
accent="#f97316",
accent_dark="#c2410c",
border="#fed7aa",
surface="#fff7ed",
),
"high": RiskRuleFlowDiagramPalette(
accent="#dc2626",
accent_dark="#b91c1c",
border="#fecaca",
surface="#fef2f2",
),
}
def render(self, spec: RiskRuleFlowDiagramSpec) -> str:
title = self._truncate(spec.title, 26)
palette = self._palette(spec.severity)
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
<title id="risk-flow-title">{self._escape(title)}流程说明</title>
<desc id="risk-flow-desc">风险规则只读流程图,展示从业务单据提交到风险复核的判断路径。</desc>
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="{self._NEUTRAL_LINE}"/>
</marker>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="{self._MUTED}" font-family="{self._FONT}" font-size="11" font-weight="500">RULE FLOW</text>
{self._node("业务输入", spec.start, 48, 118, 124, 60)}
{self._node("字段取数", "读取字段证据", 214, 118, 132, 60)}
{self._diamond("判断依据", spec.decision, 392, 92, 112, 112)}
{self._node("继续流转", spec.pass_text, 562, 74, 126, 60)}
{self._node("进入复核", spec.fail_text, 562, 190, 126, 62, palette=palette)}
{self._note(spec.basis, 214, 218, 290, 36)}
<line x1="172" y1="148" x2="214" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="600">是</text>
</svg>"""
def _node(
self,
title: str,
body: str,
x: int,
y: int,
width: int,
height: int,
palette: RiskRuleFlowDiagramPalette | None = None,
) -> str:
body_lines = self._wrap(body, 10 if width <= 126 else 11, 1)
border = palette.border if palette else self._NEUTRAL_BORDER
stripe = palette.accent if palette else self._NEUTRAL_LINE
surface = palette.surface if palette else self._NEUTRAL_SURFACE
return f"""<g>
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="{surface}" stroke="{border}" stroke-width="1.2"/>
<rect x="{x}" y="{y}" width="3.5" height="{height}" rx="1.75" ry="1.75" fill="{stripe}"/>
<text x="{x + width / 2:.0f}" y="{y + 24}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="600">{self._escape(title)}</text>
{self._text_lines(body_lines, x + width / 2, y + 43, "middle", self._MUTED, 11)}
</g>"""
def _diamond(
self,
title: str,
body: str,
x: int,
y: int,
width: int,
height: int,
) -> str:
cx = x + width / 2
cy = y + height / 2
points = f"{cx},{y} {x + width},{cy} {cx},{y + height} {x},{cy}"
body_lines = self._wrap(body, 8, 2)
return f"""<g>
<polygon points="{points}" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="{cx:.0f}" y="{cy - 10:.0f}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="12.5" font-weight="600">{self._escape(title)}</text>
{self._text_lines(body_lines, cx, cy + 11, "middle", self._MUTED, 10.2)}
</g>"""
def _note(
self,
body: str,
x: int,
y: int,
width: int,
height: int,
) -> str:
lines = self._wrap(body, 22, 1)
return f"""<g>
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="{x + 12}" y="{y + 22}" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10" font-weight="500">BASIS</text>
{self._text_lines(lines, x + 54, y + 22, "start", self._TEXT, 10.2)}
</g>"""
def _text_lines(
self,
lines: list[str],
x: float,
y: float,
anchor: str,
color: str,
font_size: float,
) -> str:
return "\n ".join(
f'<text x="{x:.0f}" y="{y + index * (font_size + 5):.0f}" text-anchor="{anchor}" fill="{color}" font-family="{self._FONT}" font-size="{font_size}" font-weight="400">{self._escape(line)}</text>'
for index, line in enumerate(lines)
)
@staticmethod
def _wrap(value: str, width: int, max_lines: int) -> list[str]:
text = str(value or "").strip()
if not text:
return [""]
lines = [text[index : index + width] for index in range(0, len(text), width)]
if len(lines) > max_lines:
lines = lines[:max_lines]
lines[-1] = f"{lines[-1][: max(0, width - 1)]}"
return lines
@staticmethod
def _truncate(value: str, length: int) -> str:
text = str(value or "").strip()
return text if len(text) <= length else f"{text[: length - 1]}"
@staticmethod
def _escape(value: str) -> str:
return html.escape(str(value or ""), quote=True)
@classmethod
def _palette(cls, severity: str) -> RiskRuleFlowDiagramPalette:
return cls._PALETTES.get(str(severity or "").strip().lower(), cls._PALETTES["medium"])

View File

@@ -0,0 +1,751 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
)
from app.services.runtime_chat import RuntimeChatService
@dataclass(frozen=True)
class RiskRuleField:
key: str
label: str
field_type: str
source: str
aliases: tuple[str, ...]
BUSINESS_DOMAIN_LABELS: dict[str, str] = {
AgentAssetDomain.EXPENSE.value: "报销",
AgentAssetDomain.AR.value: "应收",
AgentAssetDomain.AP.value: "应付",
}
RISK_LEVEL_LABELS: dict[str, str] = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
}
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
RiskRuleField(
"claim.location",
"申报地点",
"text",
"claim",
("地点", "城市", "出差地", "申报地点", "申报目的地", "目的地"),
),
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")),
RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")),
RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")),
RiskRuleField(
"attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号")
),
RiskRuleField(
"attachment.buyer_name", "购买方名称", "text", "attachment", ("抬头", "购买方", "开票单位")
),
RiskRuleField(
"attachment.goods_name",
"商品服务名称",
"text",
"attachment",
("品名", "商品", "服务名称", "摘要"),
),
RiskRuleField(
"attachment.issue_date",
"开票日期",
"date",
"attachment",
("开票日期", "发票日期", "票据日期"),
),
RiskRuleField(
"attachment.hotel_city",
"住宿城市",
"text",
"attachment",
("住宿城市", "酒店城市", "酒店地点", "酒店发票城市", "酒店票城市", "住宿发票城市"),
),
RiskRuleField(
"attachment.route_cities",
"行程城市",
"list",
"attachment",
("行程", "路线", "途经城市", "出差城市", "交通票行程", "交通票城市"),
),
RiskRuleField(
"attachment.ocr_text",
"票据全文",
"text",
"attachment",
("票据内容", "OCR", "全文", "关键字", "关键词"),
),
RiskRuleField(
"receivable.aging_days", "应收账龄", "number", "receivable", ("账龄", "逾期", "应收逾期")
),
RiskRuleField(
"receivable.amount_outstanding",
"应收未收金额",
"number",
"receivable",
("未收金额", "欠款", "应收余额"),
),
RiskRuleField(
"payable.vendor_name", "供应商名称", "text", "payable", ("供应商", "付款方", "往来单位")
),
RiskRuleField(
"payable.amount_outstanding", "应付未付金额", "number", "payable", ("未付金额", "应付余额")
),
)
DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = {
AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment."),
AgentAssetDomain.AR.value: ("receivable.",),
AgentAssetDomain.AP.value: ("payable.",),
}
class RiskRuleGenerationService:
def __init__(
self,
db: Session,
*,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
runtime_chat_service: RuntimeChatService | None = None,
) -> None:
self.db = db
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
self.runtime_chat_service = runtime_chat_service or RuntimeChatService(db)
self.audit_service = AuditLogService(db)
self.flow_diagram_renderer = RiskRuleFlowDiagramRenderer()
def generate_rule_asset(
self,
body: AgentAssetRiskRuleGenerateRequest,
*,
actor: str,
request_id: str | None = None,
) -> str:
domain = body.business_domain.value
if domain not in BUSINESS_DOMAIN_LABELS:
raise ValueError("当前仅支持报销、应收、应付业务域的新建风险规则。")
natural_language = self._clean_text(body.natural_language)
if len(natural_language) < 8:
raise ValueError("请至少输入 8 个字的风险规则描述。")
risk_level = str(body.risk_level or "medium").strip().lower()
if risk_level not in RISK_LEVEL_LABELS:
raise ValueError("风险等级仅支持 low、medium、high。")
created_at = datetime.now(UTC)
fields = self._resolve_fields(natural_language, domain=domain)
draft = self._compile_with_model(
natural_language=natural_language,
domain=domain,
risk_level=risk_level,
fields=fields,
) or self._build_fallback_draft(
natural_language=natural_language,
domain=domain,
risk_level=risk_level,
fields=fields,
)
draft = self._align_draft_fields(
draft,
natural_language=natural_language,
fields=fields,
)
payload = self._build_rule_payload(
draft,
natural_language=natural_language,
domain=domain,
risk_level=risk_level,
fields=fields,
created_at=created_at,
actor=actor,
)
rule_code = str(payload["rule_code"])
file_name = f"{rule_code}.json"
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
asset = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=str(payload["name"]),
description=str(payload["description"]),
domain=domain,
scenario_json=[str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[domain])],
owner=actor,
reviewer=None,
status=AgentAssetStatus.DRAFT.value,
current_version="v0.1.0",
published_version=None,
working_version="v0.1.0",
config_json={
"severity": risk_level,
"enabled": True,
"tag": "风险规则",
"detail_mode": "json_risk",
"risk_category": payload.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"ontology_signal": payload.get("ontology_signal"),
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
},
)
self.db.add(asset)
self.db.flush()
self.db.add(
AgentAssetVersion(
asset_id=asset.id,
version="v0.1.0",
content=self._build_version_markdown(payload),
content_type="markdown",
change_note="通过自然语言新建风险规则草稿。",
created_by=actor,
)
)
self.audit_service.log_action(
actor=actor,
action="generate_agent_asset_risk_rule",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=None,
after_json={"rule_code": rule_code, "risk_level": risk_level, "domain": domain},
request_id=request_id,
)
self.db.refresh(asset)
return asset.id
def _compile_with_model(
self,
*,
natural_language: str,
domain: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any] | None:
field_payload = [
{
"key": item.key,
"label": item.label,
"type": item.field_type,
"source": item.source,
}
for item in fields
]
messages = [
{
"role": "system",
"content": (
"你是 X-Financial 风险规则编译器。只能输出 JSON 对象,不要解释。"
"必须从给定字段本体中选择字段,不允许编造字段。"
"template_key 只能是 field_required_v1、field_compare_v1、keyword_match_v1。"
),
},
{
"role": "user",
"content": json.dumps(
{
"business_domain": domain,
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
"risk_level": risk_level,
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
"natural_language": natural_language,
"available_fields": field_payload,
"required_json_shape": {
"name": "规则名称",
"description": "面向业务用户的说明",
"template_key": "field_required_v1",
"field_keys": ["claim.reason"],
"condition_summary": "判断依据",
"keywords": [],
"flow": {
"start": "提交业务单据",
"evidence": "读取字段",
"decision": "判断依据",
"pass": "继续流转",
"fail": "提示风险",
},
},
},
ensure_ascii=False,
),
},
]
answer = self.runtime_chat_service.complete(
messages,
max_tokens=700,
temperature=0.1,
timeout_seconds=12,
max_attempts=1,
)
if not answer:
return None
try:
payload = json.loads(self._extract_json_object(answer))
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return self._sanitize_model_draft(payload, fields=fields)
def _sanitize_model_draft(
self,
payload: dict[str, Any],
*,
fields: list[RiskRuleField],
) -> dict[str, Any]:
allowed_fields = {item.key for item in fields}
template_key = str(payload.get("template_key") or "").strip()
if template_key not in {"field_required_v1", "field_compare_v1", "keyword_match_v1"}:
template_key = "field_required_v1"
raw_field_keys = payload.get("field_keys")
field_keys = [
str(item or "").strip()
for item in (raw_field_keys if isinstance(raw_field_keys, list) else [])
if str(item or "").strip() in allowed_fields
]
if not field_keys and fields:
field_keys = [fields[0].key]
keywords = [
str(item or "").strip()
for item in (
payload.get("keywords") if isinstance(payload.get("keywords"), list) else []
)
if str(item or "").strip()
]
flow = payload.get("flow") if isinstance(payload.get("flow"), dict) else {}
return {
"name": self._clean_text(payload.get("name"))[:80],
"description": self._clean_text(payload.get("description")),
"template_key": template_key,
"field_keys": field_keys,
"condition_summary": self._clean_text(payload.get("condition_summary")),
"keywords": keywords[:12],
"flow": {
"start": self._clean_text(flow.get("start")) or "提交业务单据",
"evidence": self._clean_text(flow.get("evidence")) or "读取规则字段",
"decision": self._clean_text(flow.get("decision")) or "判断是否命中风险",
"pass": self._clean_text(flow.get("pass")) or "继续流转",
"fail": self._clean_text(flow.get("fail")) or "提示风险并进入复核",
},
}
def _build_fallback_draft(
self,
*,
natural_language: str,
domain: str,
risk_level: str,
fields: list[RiskRuleField],
) -> dict[str, Any]:
field_keys = [item.key for item in fields[:4]]
template_key = self._infer_template_key(natural_language)
condition_summary = self._build_condition_summary(
natural_language,
template_key=template_key,
fields=fields,
)
name = self._infer_rule_name(natural_language)
description = (
f"{BUSINESS_DOMAIN_LABELS[domain]}业务满足“{natural_language}”时,系统会按"
f"{RISK_LEVEL_LABELS[risk_level]}进行提示,并要求经办人或审核人补充核对依据。"
)
return {
"name": name,
"description": description,
"template_key": template_key,
"field_keys": field_keys,
"condition_summary": condition_summary,
"keywords": self._infer_keywords(natural_language),
"flow": {
"start": f"{BUSINESS_DOMAIN_LABELS[domain]}单据提交",
"evidence": "读取" + "".join(item.label for item in fields[:3]),
"decision": condition_summary,
"pass": "未命中风险,继续业务流转",
"fail": f"命中{RISK_LEVEL_LABELS[risk_level]},提示复核",
},
}
def _build_rule_payload(
self,
draft: dict[str, Any],
*,
natural_language: str,
domain: str,
risk_level: str,
fields: list[RiskRuleField],
created_at: datetime,
actor: str,
) -> dict[str, Any]:
created_stamp = created_at.strftime("%Y%m%d%H%M%S")
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
rule_code = f"risk.{domain_slug}.generated_{created_stamp}"
template_key = str(draft.get("template_key") or "field_required_v1").strip()
field_keys = [
str(item or "").strip()
for item in list(draft.get("field_keys") or [])
if str(item or "").strip()
]
condition_summary = (
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
)
risk_category = BUSINESS_DOMAIN_LABELS[domain]
keywords = list(draft.get("keywords") or [])
field_by_key = {item.key: item for item in fields}
params: dict[str, Any] = {
"template_key": template_key,
"field_keys": field_keys,
"condition_summary": condition_summary,
"natural_language": natural_language,
}
if template_key == "field_required_v1":
params["required_fields"] = field_keys
if template_key == "field_compare_v1":
params["conditions"] = self._build_compare_conditions(field_keys)
if template_key == "keyword_match_v1":
params["keywords"] = keywords
params["search_fields"] = field_keys
payload = {
"schema_version": "2.0",
"rule_code": rule_code,
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
"description": self._clean_text(draft.get("description")) or natural_language,
"enabled": True,
"risk_dimension": "natural_language_rule",
"risk_category": risk_category,
"ontology_signal": "natural_language_risk",
"evaluator": "template_rule",
"template_key": template_key,
"applies_to": {"domains": [domain]},
"inputs": {
"fields": [
{
"key": item.key,
"label": item.label,
"type": item.field_type,
"source": item.source,
}
for item in [field_by_key[key] for key in field_keys if key in field_by_key]
],
},
"params": params,
"outcomes": {
"pass": {"severity": "none", "action": "continue"},
"fail": {
"severity": risk_level,
"action": "manual_review",
},
},
"metadata": {
"owner": actor,
"stability": "generated_draft",
"source_ref": "自然语言风险规则",
"created_at": created_at.isoformat(),
"created_by": actor,
"natural_language": natural_language,
"business_explanation": self._clean_text(draft.get("description")),
"condition_summary": condition_summary,
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
},
}
payload["flow_diagram_svg"] = self._build_flow_diagram_svg(
payload,
fields=[field_by_key[key] for key in field_keys if key in field_by_key],
domain=domain,
risk_level=risk_level,
)
return payload
def _build_flow_diagram_svg(
self,
payload: dict[str, Any],
*,
fields: list[RiskRuleField],
domain: str,
risk_level: str,
) -> str:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
condition_summary = self._clean_text(metadata.get("condition_summary"))
return self.flow_diagram_renderer.render(
RiskRuleFlowDiagramSpec(
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
domain_label=BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
severity=risk_level,
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
fields=tuple(
RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields
),
start=self._clean_text(flow.get("start")) or "业务单据提交",
evidence=self._clean_text(flow.get("evidence")) or "读取规则字段",
decision=self._clean_text(flow.get("decision"))
or condition_summary
or "判断是否命中风险",
basis=(
condition_summary
or self._clean_text(flow.get("decision"))
or "根据规则字段判断"
),
pass_text=self._clean_text(flow.get("pass")) or "未命中风险,继续流转",
fail_text=self._clean_text(flow.get("fail"))
or f"命中{RISK_LEVEL_LABELS.get(risk_level, '风险')},进入人工复核",
)
)
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
normalized = text.lower()
matched: list[tuple[int, RiskRuleField]] = []
for field in candidates:
score = self._score_field_match(field, text, normalized)
if score > 0:
matched.append((score, field))
if domain == AgentAssetDomain.EXPENSE.value:
if any(keyword in text for keyword in ("住宿", "酒店", "行程", "城市", "出差")):
matched.extend(
(10, field)
for field in candidates
if field.key
in {"claim.location", "attachment.hotel_city", "attachment.route_cities"}
)
if any(keyword in text for keyword in ("发票", "票据", "品名", "抬头", "开票")):
matched.extend(
(6, field)
for field in candidates
if field.key
in {
"attachment.invoice_no",
"attachment.buyer_name",
"attachment.goods_name",
"attachment.ocr_text",
}
)
matched.sort(key=lambda item: item[0], reverse=True)
deduped: list[RiskRuleField] = []
seen: set[str] = set()
for _, field in matched:
if field.key in seen:
continue
seen.add(field.key)
deduped.append(field)
if deduped:
return deduped[:8]
return candidates[:4]
@staticmethod
def _score_field_match(field: RiskRuleField, text: str, normalized: str) -> int:
score = 0
if field.label in text:
score += 8
for alias in field.aliases:
if alias.lower() in normalized:
score += 4 + min(len(alias), 6)
if field.key == "attachment.hotel_city" and any(term in text for term in ("酒店", "住宿")):
score += 12
if field.key == "attachment.route_cities" and any(
term in text for term in ("行程", "交通票", "路线", "途经")
):
score += 10
if field.key == "claim.location" and any(
term in text for term in ("申报目的地", "申报地点", "目的地", "出差地")
):
score += 10
if field.key.startswith("attachment.") and any(term in text for term in ("发票", "票据")):
score += 2
return score
def _align_draft_fields(
self,
draft: dict[str, Any],
*,
natural_language: str,
fields: list[RiskRuleField],
) -> dict[str, Any]:
field_by_key = {field.key: field for field in fields}
original_keys = [
str(item or "").strip()
for item in list(draft.get("field_keys") or [])
if str(item or "").strip() in field_by_key
]
preferred_keys: list[str] = []
def add_preferred(key: str, *terms: str) -> None:
if key in field_by_key and any(term in natural_language for term in terms):
preferred_keys.append(key)
add_preferred("attachment.hotel_city", "酒店", "住宿")
add_preferred("claim.location", "申报目的地", "申报地点", "目的地", "出差地")
add_preferred("attachment.route_cities", "行程", "交通票", "路线", "途经")
merged_keys: list[str] = []
for key in [*preferred_keys, *original_keys, *[field.key for field in fields]]:
if key in field_by_key and key not in merged_keys:
merged_keys.append(key)
if len(merged_keys) >= 4:
break
if draft.get("template_key") == "field_compare_v1" and len(merged_keys) < 2:
for field in fields:
if field.key not in merged_keys:
merged_keys.append(field.key)
if len(merged_keys) >= 2:
break
aligned = {**draft, "field_keys": merged_keys}
selected_fields = [field_by_key[key] for key in merged_keys if key in field_by_key]
if selected_fields:
aligned["condition_summary"] = self._build_condition_summary(
natural_language,
template_key=str(aligned.get("template_key") or "field_required_v1"),
fields=selected_fields,
)
flow = aligned.get("flow") if isinstance(aligned.get("flow"), dict) else {}
aligned["flow"] = {
**flow,
"evidence": "读取" + "".join(field.label for field in selected_fields[:3]),
"decision": aligned["condition_summary"],
}
return aligned
@staticmethod
def _build_compare_conditions(field_keys: list[str]) -> list[dict[str, str]]:
if len(field_keys) >= 2:
return [{"left": field_keys[0], "operator": "overlap", "right": field_keys[1]}]
if field_keys:
return [{"left": field_keys[0], "operator": "is_empty", "right": ""}]
return []
@staticmethod
def _infer_template_key(text: str) -> str:
if any(
keyword in text
for keyword in ("一致", "匹配", "相同", "不一致", "不符", "对应", "出现在")
):
return "field_compare_v1"
if any(
keyword in text
for keyword in ("关键词", "包含", "出现", "品名", "摘要", "服务费", "咨询费")
):
return "keyword_match_v1"
return "field_required_v1"
@staticmethod
def _infer_keywords(text: str) -> list[str]:
quoted = re.findall(r"[“\"']([^“”\"']{2,20})[”\"']", text)
keywords = [item.strip() for item in quoted if item.strip()]
for candidate in ("咨询费", "服务费", "其他", "办公用品", "招待", "红冲", "作废"):
if candidate in text and candidate not in keywords:
keywords.append(candidate)
return keywords[:8]
@staticmethod
def _infer_rule_name(text: str) -> str:
normalized = re.sub(r"\s+", "", str(text or ""))
normalized = re.sub(r"[,。;;:、,.!?]", "", normalized)
if not normalized:
return "自然语言风险规则"
return f"{normalized[:18]}风险规则"
@staticmethod
def _build_condition_summary(
natural_language: str,
*,
template_key: str,
fields: list[RiskRuleField],
) -> str:
field_text = "".join(item.label for item in fields[:3]) or "业务字段"
if template_key == "field_compare_v1":
return f"对比{field_text}之间是否一致或存在交集"
if template_key == "keyword_match_v1":
return f"检查{field_text}是否出现规则描述中的风险关键词"
return f"检查{field_text}是否满足必填和完整性要求"
@staticmethod
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
@staticmethod
def _extract_json_object(text: str) -> str:
normalized = re.sub(r"^```(?:json)?|```$", "", str(text or "").strip(), flags=re.IGNORECASE)
start = normalized.find("{")
end = normalized.rfind("}")
if start < 0 or end <= start:
raise ValueError("JSON object not found.")
return normalized[start : end + 1]
@staticmethod
def _build_version_markdown(payload: dict[str, Any]) -> str:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
fields = (
payload.get("inputs", {}).get("fields")
if isinstance(payload.get("inputs"), dict)
else []
)
field_labels = [
str(item.get("label") or item.get("key") or "").strip()
for item in fields
if isinstance(item, dict) and str(item.get("label") or item.get("key") or "").strip()
]
return "\n".join(
[
f"# {payload.get('name')}",
"",
"## 业务说明",
"",
str(payload.get("description") or ""),
"",
"## 自然语言原文",
"",
str(metadata.get("natural_language") or ""),
"",
"## 使用字段",
"",
"".join(field_labels) or "未识别字段",
"",
"## 运行时 JSON",
"",
"```json",
json.dumps(payload, ensure_ascii=False, indent=2),
"```",
]
)

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
import re
from typing import Any
from app.models.financial_record import ExpenseClaim
class RiskRuleTemplateExecutor:
def evaluate(
self,
manifest: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
if template_key == "field_required_v1":
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
if template_key == "field_compare_v1":
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
if template_key == "keyword_match_v1":
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
return None
def _evaluate_required_fields(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
required_fields = self._read_string_list(
params.get("required_fields") or params.get("field_keys")
)
missing = [
field_key
for field_key in required_fields
if not self._has_resolved_value(field_key, claim=claim, contexts=contexts)
]
if not missing:
return None
return {
"message": self._resolve_message(
params,
fallback=f"规则要求的字段未完整提供:{''.join(missing[:4])}",
),
"evidence": {
"missing_fields": missing,
"condition_summary": params.get("condition_summary"),
},
}
def _evaluate_compare_conditions(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
failures: list[dict[str, Any]] = []
for condition in conditions:
if not isinstance(condition, dict):
continue
left_key = str(condition.get("left") or "").strip()
right_key = str(condition.get("right") or "").strip()
operator = str(condition.get("operator") or "not_overlap").strip()
left_values = self._resolve_values(left_key, claim=claim, contexts=contexts)
right_values = self._resolve_values(right_key, claim=claim, contexts=contexts)
if self._condition_passes(operator, left_values, right_values):
continue
failures.append(
{
"left": left_key,
"operator": operator,
"right": right_key,
"left_values": left_values[:5],
"right_values": right_values[:5],
}
)
if not failures:
return None
return {
"message": self._resolve_message(
params,
fallback=(
"规则字段对比未通过:"
f"{params.get('condition_summary') or '字段关系不符合要求'}"
),
),
"evidence": {
"failed_conditions": failures[:5],
"condition_summary": params.get("condition_summary"),
},
}
def _evaluate_keyword_match(
self,
params: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any] | None:
keywords = self._read_string_list(params.get("keywords"))
search_fields = self._read_string_list(
params.get("search_fields") or params.get("field_keys")
)
if not keywords:
return None
corpus_parts: list[str] = []
for field_key in search_fields:
corpus_parts.extend(self._resolve_values(field_key, claim=claim, contexts=contexts))
if not corpus_parts:
corpus_parts.extend(
[
str(claim.reason or ""),
str(claim.location or ""),
*[str(item.item_reason or "") for item in list(claim.items or [])],
*[str(context.get("ocr_text") or "") for context in contexts],
]
)
corpus = "\n".join(corpus_parts)
hits = [keyword for keyword in keywords if keyword and keyword in corpus]
if not hits:
return None
return {
"message": self._resolve_message(
params,
fallback=f"识别到风险关键词:{''.join(hits[:5])}",
),
"evidence": {
"keyword_hits": hits[:8],
"search_fields": search_fields,
"condition_summary": params.get("condition_summary"),
},
}
def _resolve_values(
self,
field_key: str,
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[str]:
normalized = str(field_key or "").strip()
if not normalized:
return []
if normalized.startswith("claim."):
return self._normalize_values([getattr(claim, normalized.removeprefix("claim."), "")])
if normalized.startswith("item."):
attr = normalized.removeprefix("item.")
return self._normalize_values(
[getattr(item, attr, "") for item in list(claim.items or [])]
)
if normalized.startswith("attachment."):
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
return []
def _resolve_attachment_values(
self, field_key: str, contexts: list[dict[str, Any]]
) -> list[str]:
values: list[Any] = []
for context in contexts:
document_info = context.get("document_info") if isinstance(context, dict) else {}
if not isinstance(document_info, dict):
document_info = {}
if field_key == "ocr_text":
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
if field_key in {"hotel_city", "route_cities"}:
values.extend(self._scan_document_values(document_info, field_key))
values.extend(self._scan_document_values(document_info, "city"))
else:
values.extend(self._scan_document_values(document_info, field_key))
return self._normalize_values(values)
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
values: list[Any] = []
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:
if key in document_info:
values.append(document_info.get(key))
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
if self._field_matches(key, label, field_key):
values.append(field.get("value"))
return values
@staticmethod
def _field_matches(key: str, label: str, field_key: str) -> bool:
compact_key = key.replace("_", "")
compact_target = field_key.replace("_", "")
if compact_target in compact_key:
return True
label_map = {
"invoice_no": ("发票号", "发票号码", "票号"),
"buyer_name": ("购买方", "抬头", "买方"),
"goods_name": ("品名", "商品", "服务名称"),
"issue_date": ("日期", "开票日期", "发票日期"),
"hotel_city": ("住宿城市", "酒店城市", "酒店地点"),
"route_cities": ("行程", "路线", "城市"),
"city": ("城市", "地点"),
}
return any(item in label for item in label_map.get(field_key, ()))
def _has_resolved_value(
self,
field_key: str,
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> bool:
return bool(self._resolve_values(field_key, claim=claim, contexts=contexts))
@staticmethod
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
if operator == "is_empty":
return not left_values
if not left_values or not right_values:
return False
left_set = {value.lower() for value in left_values}
right_set = {value.lower() for value in right_values}
if operator in {"equals", "in", "overlap"}:
return bool(left_set & right_set)
if operator in {"not_equals", "not_in", "not_overlap"}:
return not bool(left_set & right_set)
if operator == "contains_any":
return any(any(right in left for right in right_set) for left in left_set)
return bool(left_set & right_set)
@staticmethod
def _normalize_values(values: list[Any]) -> list[str]:
normalized: list[str] = []
for value in values:
if isinstance(value, (list, tuple, set)):
normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value)))
continue
text = re.sub(r"\s+", " ", str(value or "")).strip()
if text and text not in normalized:
normalized.append(text)
return normalized
@staticmethod
def _read_string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item or "").strip() for item in value if str(item or "").strip()]
@staticmethod
def _resolve_message(params: dict[str, Any], *, fallback: str) -> str:
template = str(params.get("message_template") or "").strip()
return template or fallback

View File

@@ -14,8 +14,8 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T15:12:34.420412+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
@@ -35,9 +35,9 @@
"updated_at": "2026-05-22T07:00:22.328877+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T15:12:34.423374+00:00",
"ingest_completed_at": "2026-05-22T09:22:26.072669+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_completed_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
"ingest_document_sha256": "",
@@ -56,9 +56,9 @@
"updated_at": "2026-05-22T07:00:22.011016+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T15:12:34.426517+00:00",
"ingest_completed_at": "2026-05-22T09:22:52.729264+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:22:52.110824+00:00",
"ingest_completed_at": "2026-05-22T09:22:52.110824+00:00",
"ingest_document_name": "远光软件财务基础知识手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
"ingest_document_sha256": "",
@@ -77,9 +77,9 @@
"updated_at": "2026-05-22T07:00:22.352133+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T15:12:34.429968+00:00",
"ingest_completed_at": "2026-05-22T09:22:58.498888+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_completed_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_document_name": "远光软件财务术语解释手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
"ingest_document_sha256": "",
@@ -98,9 +98,9 @@
"updated_at": "2026-05-22T07:00:22.304623+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T15:12:34.433141+00:00",
"ingest_completed_at": "2026-05-22T09:24:19.530985+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_completed_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
"ingest_document_sha256": "",
@@ -119,13 +119,13 @@
"updated_at": "2026-05-22T07:00:18.153373+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.893729+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_completed_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_document_name": "远光软件公司内部控制基本规范.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "f4ae48231a974240bbaf6c9f3bfd4160",
@@ -140,13 +140,13 @@
"updated_at": "2026-05-22T07:00:18.190399+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.902022+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_completed_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_document_name": "远光软件公司合同管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "b1d08d6a9dc6404aba9098f3b7287353",
@@ -161,13 +161,13 @@
"updated_at": "2026-05-22T07:00:17.798679+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.907591+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_completed_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_document_name": "远光软件公司财务管理制度总则.docx",
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "c87fc4aabe524c6c81862c20aabe434c",
@@ -182,13 +182,13 @@
"updated_at": "2026-05-22T07:00:18.531598+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.913293+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_completed_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_document_name": "远光软件公司资产管理制度.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "13181df0179a4bacb12a2f65e3772d9b",
@@ -203,13 +203,13 @@
"updated_at": "2026-05-22T07:00:18.221073+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.918790+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_completed_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_document_name": "远光软件公司采购管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "396588b0cdd04c86a61ae0b9bd04e06c",
@@ -224,13 +224,13 @@
"updated_at": "2026-05-22T07:00:19.734422+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.933936+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_completed_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_document_name": "远光软件公司差旅费管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "fe5f834f94244b77bb62171d580ecee3",
@@ -245,13 +245,13 @@
"updated_at": "2026-05-22T07:00:20.095824+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.939406+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_completed_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_document_name": "远光软件出差审批流程说明.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "be3fca61e2be421896405082c93cf86c",
@@ -266,13 +266,13 @@
"updated_at": "2026-05-22T07:00:20.128471+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.945004+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_completed_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_document_name": "远光软件国际出差管理规定.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "c4421b3049b244a8a92cc53d502e530f",
@@ -287,13 +287,13 @@
"updated_at": "2026-05-22T07:00:19.759954+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.950298+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_completed_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_document_name": "远光软件差旅费标准速查表.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "e13cc0a8d6474b6caeeedc49c4304558",
@@ -308,13 +308,13 @@
"updated_at": "2026-05-22T07:00:18.922298+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.958758+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_completed_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_document_name": "远光软件公司发票审核标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "7170abfdde6f4e6abad2fc987564c2cf",
@@ -329,13 +329,13 @@
"updated_at": "2026-05-22T07:00:18.560177+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.963796+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_completed_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_document_name": "远光软件公司发票管理规范.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "dd0d7b32e832446e8ce9caa06c442685",
@@ -350,13 +350,13 @@
"updated_at": "2026-05-22T07:00:18.888128+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.968988+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_completed_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_document_name": "远光软件公司增值税发票操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "f268a54ee05e4dfca33fd86bcc077216",
@@ -371,13 +371,13 @@
"updated_at": "2026-05-22T07:00:18.953110+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.974057+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_completed_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_document_name": "远光软件公司电子发票管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "f3f74cb65a9a4a16933368218c5e25de",
@@ -392,13 +392,13 @@
"updated_at": "2026-05-22T07:00:21.585718+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.983136+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_completed_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "56721ca1904b437486a609b85e3d9362",
@@ -413,13 +413,13 @@
"updated_at": "2026-05-22T07:00:20.881351+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.988449+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_completed_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_document_name": "远光软件公司税务管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "2460661167ef456699ab259321db4156",
@@ -434,13 +434,13 @@
"updated_at": "2026-05-22T07:00:21.606227+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.993925+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_completed_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "e30f54ea32704fbd9701cc931b447a06",
@@ -455,13 +455,13 @@
"updated_at": "2026-05-22T07:00:21.202633+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:57.999215+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_completed_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "2d1cd10154e84cb38640dce31f33b529",
@@ -476,13 +476,13 @@
"updated_at": "2026-05-22T07:00:22.379307+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.007947+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_completed_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_document_name": "远光软件公司预算管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "229b3a79fef14360ba3cbd0a55e5e20c",
@@ -497,13 +497,13 @@
"updated_at": "2026-05-22T07:00:22.760169+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.013550+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_completed_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_document_name": "远光软件年度预算编制指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "a40da5544dea4efcade070274b84a54e",
@@ -518,13 +518,13 @@
"updated_at": "2026-05-22T07:00:22.848272+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.019078+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_completed_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_document_name": "远光软件预算执行分析报告模板.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "dcd982e40ce94105824e59ecbbae75cb",
@@ -539,13 +539,13 @@
"updated_at": "2026-05-22T07:00:22.803708+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.024507+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_completed_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_document_name": "远光软件预算编制模板.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "79cb9276398b4216ba17d5623aadf75f",
@@ -560,13 +560,13 @@
"updated_at": "2026-05-22T07:00:21.971983+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.037116+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_completed_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_document_name": "远光软件财务共享服务SLA标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "f841ca416b5d404994a7c4a310e35569",
@@ -581,13 +581,13 @@
"updated_at": "2026-05-22T07:00:21.634300+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.045292+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_completed_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "d1ad784de58a4c4a802a0b9fbce29f62",
@@ -602,13 +602,13 @@
"updated_at": "2026-05-22T07:00:21.945868+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.053890+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_completed_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_document_name": "远光软件财务共享服务操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "ce50d015861f4633a634a2eae416fa2e",
@@ -623,13 +623,13 @@
"updated_at": "2026-05-22T07:00:19.662743+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.066031+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_completed_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_document_name": "远光软件报销流程培训手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "56a0e13b705e49468d46629f3b5f691a",
@@ -644,13 +644,13 @@
"updated_at": "2026-05-22T07:00:19.323921+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.073977+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_completed_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_document_name": "远光软件新员工财务培训课件.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "22ef5d13bb5e4307a8097628eaa3d398",
@@ -665,13 +665,13 @@
"updated_at": "2026-05-22T07:00:18.988700+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.082287+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_completed_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_document_name": "远光软件财务制度培训手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "78d1a28f1c934f46b762fb1466d4be32",
@@ -686,13 +686,13 @@
"updated_at": "2026-05-22T07:00:19.686485+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.089670+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_completed_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_document_name": "远光软件财务培训课程安排.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "91fbf156593a4dcc956780962195ffd7",
@@ -707,13 +707,13 @@
"updated_at": "2026-05-22T07:00:20.476077+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.101732+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_completed_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_document_name": "远光软件报销问题处理指引.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "a24793b7f7de4749a7c531d1713a4a2b",
@@ -728,13 +728,13 @@
"updated_at": "2026-05-22T07:00:20.453567+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.109771+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_completed_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_document_name": "远光软件财务制度问答汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
},
{
"id": "3acd9c2df63b4a438c7eab876269b25d",
@@ -749,13 +749,13 @@
"updated_at": "2026-05-22T07:00:20.158497+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-22T07:03:58.117797+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_completed_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_document_name": "远光软件财务报销常见问题解答.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_655cdac08d3d4a7f"
}
]
}

View File

@@ -133,5 +133,560 @@
"processing_start_time": 1779441797,
"processing_end_time": 1779441858
}
},
"5fb3c63fbfe244a280cf3316a20150cd": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-d1529b915580d4271d51c1cc6781f3b4",
"chunk-6c549250b13b7728acb37eb6082bc178"
],
"content_summary": "远光软件股份有限公司内部控制基本规范\n\n 远光软件股份有限公司\n\n 2024年度\n\n第一章 总则\n\n第一条\n为加强和规范远光软件股份有限公司以下简称\"公司\")内部控制,提高公司经营管理水平和风险防范能力,促进公司可\n持续发展根据《中华人民共和国公司法》《企业内部控制基本规范》等法律法规制定...",
"content_length": 2631,
"created_at": "2026-05-22T16:00:26.307868+00:00",
"updated_at": "2026-05-22T16:01:43.168774+00:00",
"file_path": "/app/server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf",
"track_id": "insert_20260522_160026_d9fb8e19",
"metadata": {
"processing_start_time": 1779465626,
"processing_end_time": 1779465703
}
},
"f4ae48231a974240bbaf6c9f3bfd4160": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-1f813062c109d781d9d684a4d23bfe1e",
"chunk-f7fd90e245185f1d10c4d35de739bb44"
],
"content_summary": "远光软件股份有限公司合同管理制度\n第一章 总则\n第一条 为规范远光软件股份有限公司(以下简称\"公司\")合同管理工作,防范合同法律风险,维护公司合法权益,根据《中华人民共和国民法典》及相关法律法规,结合公司实际情况,制定本制度。\n第二条 本制度适用于公司及各分、子公司与外部主体签订的各类合同、协议等法律文件。\n第三条 合同管理遵循以下原则:\n合法合规原则\n防范风险原则\n分级管理原则\n全程管控原则。\n第二章 合同审批权限\n第四条 合同实行分级审批管理。\n合同...",
"content_length": 2026,
"created_at": "2026-05-22T16:01:48.414946+00:00",
"updated_at": "2026-05-22T16:03:00.735908+00:00",
"file_path": "/app/server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx",
"track_id": "insert_20260522_160148_3261daaa",
"metadata": {
"processing_start_time": 1779465708,
"processing_end_time": 1779465780
}
},
"b1d08d6a9dc6404aba9098f3b7287353": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-bb483fb199b8a72b369d87b3fa319ac3",
"chunk-9734bc0dd5225675ba6907f874a807ce"
],
"content_summary": "远光软件股份有限公司\n财务管理制度总则\n第一章 总则\n第一条 为规范远光软件股份有限公司(以下简称\"公司\")的财务管理工作,建立健全财务内部控制体系,防范财务风险,根据《中华人民共和国会计法》《企业会计准则》《企业内部控制基本规范》等法律法规,结合公司实际情况,制定本制度。\n第二条 本制度适用于公司总部、各分公司及全资子公司、控股子公司(以下统称\"各单位\")的财务管理工作。\n第三条 财务管理基本原则:\n合法性原则严格遵守国家法律法规和相关监管要求\n统一性原则实行统一的财...",
"content_length": 2364,
"created_at": "2026-05-22T16:02:50.958176+00:00",
"updated_at": "2026-05-22T16:03:46.921675+00:00",
"file_path": "/app/server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx",
"track_id": "insert_20260522_160250_a494f9a8",
"metadata": {
"processing_start_time": 1779465770,
"processing_end_time": 1779465826
}
},
"c87fc4aabe524c6c81862c20aabe434c": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-4e95fc3e38b2bf65fcb3f6f0664fd9df",
"chunk-4287121b009a169fe4155526bfe413ea"
],
"content_summary": "远光软件股份有限公司资产管理制度\n\n 远光软件股份有限公司\n\n 2024年度\n\n第一章 总则\n\n第一条\n为加强远光软件股份有限公司以下简称\"公司\")资产管理,保障资产安全完整,提高资产使用效益,根据国家相关法律\n法规结合公司实际情况制定本制度。\n\n第二条 本制度所称资产包括固定资产、无...",
"content_length": 2472,
"created_at": "2026-05-22T16:03:52.664534+00:00",
"updated_at": "2026-05-22T16:04:58.719410+00:00",
"file_path": "/app/server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf",
"track_id": "insert_20260522_160352_14871f9d",
"metadata": {
"processing_start_time": 1779465832,
"processing_end_time": 1779465898
}
},
"13181df0179a4bacb12a2f65e3772d9b": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-96ab661ad24e0cb4c468128a58a76b6d",
"chunk-0f617968c59d049fd95d0d6325bc5c6c"
],
"content_summary": "# Excel 工作簿:远光软件公司采购管理办法.xlsx\n\n## 工作表 1采购管理办法\n\n| 远光软件股份有限公司采购管理办法 | 列2 | 列3 | 列4 | 列5 | 列6 |\n| --- | --- | --- | --- | --- | --- |\n| 采购类别 | 采购方式 | 审批权限 | 招标要求 | 验收标准 | 备注 |\n| 办公用品 | 集中采购 | 部门负责人审批 | 年度框架协议招标 | 到货验收+质检报告 | 低值易耗品 |\n| IT设备及软件 | 公开招标 | ...",
"content_length": 2551,
"created_at": "2026-05-22T16:05:21.495224+00:00",
"updated_at": "2026-05-22T16:06:08.172318+00:00",
"file_path": "/app/server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx",
"track_id": "insert_20260522_160521_b9ae8caf",
"metadata": {
"processing_start_time": 1779465921,
"processing_end_time": 1779465968
}
},
"396588b0cdd04c86a61ae0b9bd04e06c": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-0d44293dcac8fbbb02c4da0c75078aa4",
"chunk-589aae03864f03017b4a61f95c13b5e1"
],
"content_summary": "远光软件股份有限公司差旅费管理办法\n第一章 总则\n第一条 为规范公司员工因公出差行为,合理控制差旅费用,根据国家相关规定和公司财务管理制度,制定本办法。\n第二条 本办法适用于公司全体员工因公出差的交通、住宿、伙食补贴等费用管理。\n第二章 出差审批\n第三条 员工因公出差须提前通过OA系统提交出差申请经批准后方可出差。\n第四条 出差审批权限:\n出差人员\n国内出差\n出国出差\n一般员工\n部门负责人审批\n分管副总审批\n中层管理人员\n分管副总审批\n总经理审批\n高层管理人员\n总经理审批\n董...",
"content_length": 2169,
"created_at": "2026-05-22T16:06:13.756535+00:00",
"updated_at": "2026-05-22T16:06:48.466110+00:00",
"file_path": "/app/server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx",
"track_id": "insert_20260522_160613_2749443b",
"metadata": {
"processing_start_time": 1779465973,
"processing_end_time": 1779466008
}
},
"fe5f834f94244b77bb62171d580ecee3": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-85ebe3a7090c0f5408b34e92c2384b1a",
"chunk-a645037600147280d64fd17543098f51"
],
"content_summary": "远光软件股份有限公司出差审批流程说明\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、出差申请\n\n1. 员工因公出差前须提前通过公司OA系统提交出差申请。\n\n2. 出差申请内容包括:\n\n- 出差事由及目的\n\n- 出差地点及行程安排\n\n- 出差起止日期\n\n- 预计费用(交通、住宿等)\n\n- 同行人员信息\n\n3. ...",
"content_length": 1775,
"created_at": "2026-05-22T16:06:54.012369+00:00",
"updated_at": "2026-05-22T16:07:23.262328+00:00",
"file_path": "/app/server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf",
"track_id": "insert_20260522_160654_68c70b19",
"metadata": {
"processing_start_time": 1779466014,
"processing_end_time": 1779466043
}
},
"be3fca61e2be421896405082c93cf86c": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-70109ec1883be48ac9fecfe9cb6c7a3e",
"chunk-9914d9020029547d965dbc105f824b74"
],
"content_summary": "远光软件股份有限公司国际出差管理规定\n第一章 总则\n第一条 为规范公司员工因公出国(境)出差行为,根据国家相关规定和公司差旅费管理办法,制定本规定。\n第二章 出国(境)审批\n第二条 出国出差须提前15个工作日提交申请经分管副总裁和总经理审批后方可执行。\n第三条 因私护照和港澳通行证由人力资源部统一保管出差前领取返回后3日内归还。\n第三章 国际差旅标准\n第四条 国际航班标准:\n公司领导商务舱\n其他人员经济舱飞行时间超过6小时且经批准可乘坐商务舱。\n第五...",
"content_length": 1653,
"created_at": "2026-05-22T16:07:29.049885+00:00",
"updated_at": "2026-05-22T16:08:02.190081+00:00",
"file_path": "/app/server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx",
"track_id": "insert_20260522_160729_92cc4254",
"metadata": {
"processing_start_time": 1779466049,
"processing_end_time": 1779466082
}
},
"c4421b3049b244a8a92cc53d502e530f": {
"status": "processed",
"chunks_count": 3,
"chunks_list": [
"chunk-f9e0518ef691fb9defdc3af6e1585a0e",
"chunk-8647773383d4defe28ad23523b3a612b",
"chunk-2eea91fe956cae84935e609164d08d55"
],
"content_summary": "# Excel 工作簿:远光软件差旅费标准速查表.xlsx\n\n## 工作表 1差旅费标准速查表\n\n| 远光软件股份有限公司差旅费标准速查表2024版 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 一、交通工具标准 | | | | |\n| 人员类别 | 飞机 | 火车/高铁 | 轮船 | 长途汽车 |\n| 公司领导 | 头等舱 | 商务座 | 一等舱 | 实报实销 |\n| 高层管理人员P8及以上 | 商务舱 | 一...",
"content_length": 3085,
"created_at": "2026-05-22T16:08:46.582115+00:00",
"updated_at": "2026-05-22T16:09:23.091744+00:00",
"file_path": "/app/server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx",
"track_id": "insert_20260522_160846_24457426",
"metadata": {
"processing_start_time": 1779466126,
"processing_end_time": 1779466163
}
},
"e13cc0a8d6474b6caeeedc49c4304558": {
"status": "processed",
"chunks_count": 3,
"chunks_list": [
"chunk-a3d1dc9708ed46c3ab2fdb3399ac8343",
"chunk-f7ca8767b8a4ddde879a599a598c044f",
"chunk-d3a2a23083356bd293563d06dbaa6a0c"
],
"content_summary": "# Excel 工作簿:远光软件公司发票审核标准.xlsx\n\n## 工作表 1发票审核标准\n\n| 远光软件股份有限公司发票审核标准 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 审核项目 | 审核要点 | 合格标准 | 不合格处理 | 备注 |\n| 发票真伪 | 查验发票代码、号码 | 税务局网站验证通过 | 退回不予报销 | 可登录税务局网站查验 |\n| 抬头信息 | 购买方名称全称 | 远光软件股份有限公司 | 退回重开 |...",
"content_length": 3271,
"created_at": "2026-05-22T16:09:48.187160+00:00",
"updated_at": "2026-05-22T16:11:04.764727+00:00",
"file_path": "/app/server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx",
"track_id": "insert_20260522_160948_3ceb5953",
"metadata": {
"processing_start_time": 1779466188,
"processing_end_time": 1779466264
}
},
"7170abfdde6f4e6abad2fc987564c2cf": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-65235351abce326b8aca831b128a96c6",
"chunk-8c48a48e8ce0bfd52d732de60fec82ce"
],
"content_summary": "远光软件股份有限公司发票管理规范\n第一章 总则\n第一条 为规范远光软件股份有限公司(以下简称\"公司\")发票的领购、开具、保管、取得和缴销等管理工作,根据《中华人民共和国发票管理办法》及其实施细则,结合公司实际情况,制定本规范。\n第二条 本规范适用于公司及各分、子公司的发票管理工作。\n第三条 公司财务部统一负责发票的领购、保管和开具工作,指定专人管理发票。\n第二章 发票领购与保管\n第四条 发票领购由公司财务部指定的税务专员向主管税务机关办理,领购后及时登记入账。\n第五条 发票存放应使...",
"content_length": 2581,
"created_at": "2026-05-22T16:10:55.272363+00:00",
"updated_at": "2026-05-22T16:11:54.017817+00:00",
"file_path": "/app/server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx",
"track_id": "insert_20260522_161055_6fdb6cb8",
"metadata": {
"processing_start_time": 1779466255,
"processing_end_time": 1779466314
}
},
"dd0d7b32e832446e8ce9caa06c442685": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-af51160a1e2350e227249d2eaff8df40",
"chunk-12b08b984a30d7e156cf75ac4c79ed0a"
],
"content_summary": "远光软件股份有限公司增值税发票操作指南\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、增值税发票类型\n\n1. 增值税专用发票:可用于进项税额抵扣,适用于一般纳税人之间的交易。\n\n2. 增值税普通发票:不可用于进项税额抵扣,适用于向消费者个人或小规模纳税人的销售。\n\n3. 增值税电子发票:与纸质发票具有同等法...",
"content_length": 2488,
"created_at": "2026-05-22T16:11:43.728438+00:00",
"updated_at": "2026-05-22T16:12:23.821434+00:00",
"file_path": "/app/server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf",
"track_id": "insert_20260522_161143_ca7636e0",
"metadata": {
"processing_start_time": 1779466303,
"processing_end_time": 1779466343
}
},
"f268a54ee05e4dfca33fd86bcc077216": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-4f26391db1f914c1dbf43241f51b8d4e",
"chunk-34116b8b9f61b21eaacb3b4c95f20122"
],
"content_summary": "远光软件股份有限公司电子发票管理办法\n第一章 总则\n第一条 为规范公司电子发票的管理和使用,根据国家税务总局关于电子发票的相关规定,结合公司实际情况,制定本办法。\n第二条 电子发票是指在购销商品、提供或者接受服务以及从事其他经营活动中,开具、取得的以电子方式存储的收付款凭证。\n第二章 电子发票的接收\n第三条 公司全面接受增值税电子普通发票和增值税电子专用发票,与纸质发票具有同等法律效力。\n第四条 接收电子发票时应注意:\n核实电子发票的基本信息是否完整、正确\n确认电子发票...",
"content_length": 1646,
"created_at": "2026-05-22T16:12:29.085251+00:00",
"updated_at": "2026-05-22T16:13:15.450300+00:00",
"file_path": "/app/server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx",
"track_id": "insert_20260522_161229_d25ccfca",
"metadata": {
"processing_start_time": 1779466349,
"processing_end_time": 1779466395
}
},
"f3f74cb65a9a4a16933368218c5e25de": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-c511cdd6a5cc2eea82dfb43e094b6010",
"chunk-bdfd18ae478b23604f1318623e8e9508"
],
"content_summary": "远光软件股份有限公司企业所得税汇算清缴操作手册\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、汇算清缴概述\n\n企业所得税汇算清缴是指纳税人自纳税年度终了之日起5个月内或实际经营终止之日起60日内依照税收法律、法规的规\n定计算全年应纳税所得额和应纳所得税额结清应补或应退税款。\n\n公司汇算清缴期限每年1月1...",
"content_length": 1841,
"created_at": "2026-05-22T16:13:20.470046+00:00",
"updated_at": "2026-05-22T16:13:44.636629+00:00",
"file_path": "/app/server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf",
"track_id": "insert_20260522_161320_b7bdc1ff",
"metadata": {
"processing_start_time": 1779466400,
"processing_end_time": 1779466424
}
},
"56721ca1904b437486a609b85e3d9362": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-f61c91e28e8d0f773f83e3daf161ab1c",
"chunk-7dbbc500ab3d85025ed0a40708c3585f"
],
"content_summary": "远光软件股份有限公司税务管理制度\n第一章 总则\n第一条 为规范远光软件股份有限公司(以下简称\"公司\")的税务管理工作,防范税务风险,依法合规纳税,根据国家税收法律法规,结合公司实际情况,制定本制度。\n第二条 税务管理基本原则:\n合法合规原则严格遵守国家税收法律法规\n事前筹划原则在业务决策阶段即进行税务考量\n风险防范原则建立健全税务风险内控机制\n效益优化原则在合法合规前提下合理降低税负。\n第二章 税务管理职责\n第三条 财务部税务管理职责:\n负责...",
"content_length": 1631,
"created_at": "2026-05-22T16:14:05.912808+00:00",
"updated_at": "2026-05-22T16:14:50.092490+00:00",
"file_path": "/app/server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx",
"track_id": "insert_20260522_161405_727caf72",
"metadata": {
"processing_start_time": 1779466445,
"processing_end_time": 1779466490
}
},
"2460661167ef456699ab259321db4156": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-24de3b6d9d2fc0445c2c31da82a60be1",
"chunk-e650a577ed84565fd87ee6422b4b82a2"
],
"content_summary": "# Excel 工作簿:远光软件研发费用加计扣除管理办法.xlsx\n\n## 工作表 1研发费用管理\n\n| 远光软件股份有限公司研发费用加计扣除管理办法 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 费用类别 | 加计扣除比例 | 归集要求 | 备查资料 | 备注 |\n| 人员人工费用 | 100% | 研发人员工资薪金、社保等 | 工时记录、社保缴纳记录 | 需单独核算 |\n| 直接投入费用 | 100% | 研发活动直接消耗的...",
"content_length": 1981,
"created_at": "2026-05-22T16:15:11.647928+00:00",
"updated_at": "2026-05-22T16:15:56.676286+00:00",
"file_path": "/app/server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx",
"track_id": "insert_20260522_161511_50cd19b3",
"metadata": {
"processing_start_time": 1779466511,
"processing_end_time": 1779466556
}
},
"e30f54ea32704fbd9701cc931b447a06": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-395d208f00c37309fbb2116875681c98",
"chunk-2bcb956c7e2d35e45e9aba4ddacab125"
],
"content_summary": "远光软件股份有限公司软件产品增值税即征即退操作指南\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、政策依据\n\n根据《财政部\n国家税务总局关于软件产品增值税政策的通知》财税2011100号增值税一般纳税人销售其自行开发生产的软件\n产品按13%税率征收增值税后对其增值税实际税负超过3%的部分实...",
"content_length": 2243,
"created_at": "2026-05-22T16:16:02.199606+00:00",
"updated_at": "2026-05-22T16:16:06.540773+00:00",
"file_path": "/app/server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf",
"track_id": "insert_20260522_161602_6d704aa5",
"metadata": {
"processing_start_time": 1779466562,
"processing_end_time": 1779466566
}
},
"2d1cd10154e84cb38640dce31f33b529": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-40e2a0940ea80756297bcc029686ad6e",
"chunk-5c22f4a1b19ac48deafaadf184e09cd1"
],
"content_summary": "远光软件股份有限公司预算管理制度\n第一章 总则\n第一条 为加强远光软件股份有限公司(以下简称\"公司\")全面预算管理,合理配置公司资源,提高经营管理水平,根据公司财务管理制度,制定本制度。\n第二条 预算管理基本原则:\n战略导向原则预算编制应与公司发展战略和年度经营目标相一致\n全面性原则预算应覆盖所有经济活动包括经营预算、资本预算和财务预算\n合理性原则预算编制应科学合理兼顾需要与可能\n刚性约束原则经批准的预算应严格执行不得随意调整。\n第二章 预算编...",
"content_length": 1956,
"created_at": "2026-05-22T16:16:26.359206+00:00",
"updated_at": "2026-05-22T16:23:24.252614+00:00",
"file_path": "/app/server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx",
"track_id": "insert_20260522_161626_c7563621",
"metadata": {
"processing_start_time": 1779466586,
"processing_end_time": 1779467004
}
},
"229b3a79fef14360ba3cbd0a55e5e20c": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-6493ba2ae9f87d70ec970b53687e2557",
"chunk-06f2e5b7b8d7365196980080ed26b760"
],
"content_summary": "远光软件股份有限公司年度预算编制指南\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、预算编制时间安排\n\n10月1日-15日公司管理层确定下一年度经营目标和预算编制指导意见\n\n10月16日-31日财务部组织预算编制培训下发预算编制模板\n\n11月1日-20日各部门编制并提交部门预算草案\n\n11月21日-30...",
"content_length": 1966,
"created_at": "2026-05-22T16:23:14.235349+00:00",
"updated_at": "2026-05-22T16:23:29.997956+00:00",
"file_path": "/app/server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf",
"track_id": "insert_20260522_162314_9a118e51",
"metadata": {
"processing_start_time": 1779466994,
"processing_end_time": 1779467009
}
},
"a40da5544dea4efcade070274b84a54e": {
"status": "processed",
"chunks_count": 1,
"chunks_list": [
"chunk-b844601174a058e8fbad0ea235898bd4"
],
"content_summary": "远光软件股份有限公司\n预算执行分析报告模板\n报告期间2024年 第 季度/月份\n编制部门财务部\n编制日期 年 月 日\n一、总体执行情况\n本报告期公司实现营业收入 万元,完成预算的 %,同比增长 %。\n本报告期公司费用支出 万元,占预算的 %,同比增长 %。\n二、收入预算执行分析\n各产品/服务线收入完成情况:\n产品/服务线\n预算金额万元\n实际完成万元\n完成率\n差异说明\n软件...",
"content_length": 1491,
"created_at": "2026-05-22T16:23:35.459460+00:00",
"updated_at": "2026-05-22T16:24:37.382612+00:00",
"file_path": "/app/server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx",
"track_id": "insert_20260522_162335_8ac84378",
"metadata": {
"processing_start_time": 1779467015,
"processing_end_time": 1779467077
}
},
"dcd982e40ce94105824e59ecbbae75cb": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-570f3c034d29848862e2dbe4574e0fc9",
"chunk-a8b029ac9fe90e1a6bbba83306f0027d"
],
"content_summary": "# Excel 工作簿:远光软件预算编制模板.xlsx\n\n## 工作表 1收入预算表\n\n| 2024年度收入预算表 | 列2 | 列3 | 列4 | 列5 | 列6 | 列7 | 列8 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| 编制部门: | 编制日期: | | | | | | |\n| 产品/服务线 | Q1目标 | Q2目标 | Q3目标 | Q4目标 | 全年合计 | 上年实际 | 增长率 |\n| 软件产品销售 ...",
"content_length": 2970,
"created_at": "2026-05-22T16:24:43.599321+00:00",
"updated_at": "2026-05-22T16:24:45.161319+00:00",
"file_path": "/app/server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx",
"track_id": "insert_20260522_162443_f5f8590b",
"metadata": {
"processing_start_time": 1779467083,
"processing_end_time": 1779467085
}
},
"79cb9276398b4216ba17d5623aadf75f": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-6f1d6991d45799bc8ff24afaed39244d",
"chunk-af56151a803634f02e294f2d692fc1f0"
],
"content_summary": "# Excel 工作簿远光软件财务共享服务SLA标准.xlsx\n\n## 工作表 1SLA服务标准\n\n| 远光软件财务共享服务中心SLA服务标准 | 列2 | 列3 | 列4 | 列5 | 列6 |\n| --- | --- | --- | --- | --- | --- |\n| 服务项目 | 服务内容 | 标准时效 | 紧急时效 | 可用性要求 | 备注 |\n| 费用报销 | 报销单审核处理 | 2个工作日 | 4小时 | 99% | 工作日处理 |\n| 应付账款 | 供应商付款处理 | 1个...",
"content_length": 2605,
"created_at": "2026-05-22T16:25:13.573105+00:00",
"updated_at": "2026-05-22T16:25:33.968414+00:00",
"file_path": "/app/server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx",
"track_id": "insert_20260522_162513_1c0072eb",
"metadata": {
"processing_start_time": 1779467113,
"processing_end_time": 1779467133
}
},
"f841ca416b5d404994a7c4a310e35569": {
"status": "processed",
"chunks_count": 1,
"chunks_list": [
"chunk-3b77f7e8beca3e7d537d0623f97473ee"
],
"content_summary": "远光软件股份有限公司\n财务共享服务中心运营管理办法\n第一章 总则\n第一条 为推动公司财务管理转型,提高财务服务效率和质量,建立财务共享服务中心(以下简称\"共享中心\"),制定本办法。\n第二条 财务共享服务中心负责为公司总部及各分、子公司提供标准化、流程化的财务服务。\n第二章 共享中心服务范围\n第三条 共享中心提供以下服务:\n费用报销处理接收、审核和处理各单位的费用报销业务\n应收应付管理处理应收账款和应付账款的核算和管理\n总账核算负责各单位日常会计核算和凭证处理...",
"content_length": 1402,
"created_at": "2026-05-22T16:25:23.651067+00:00",
"updated_at": "2026-05-22T16:26:05.301987+00:00",
"file_path": "/app/server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx",
"track_id": "insert_20260522_162523_86da8c93",
"metadata": {
"processing_start_time": 1779467123,
"processing_end_time": 1779467165
}
},
"d1ad784de58a4c4a802a0b9fbce29f62": {
"status": "processed",
"chunks_count": 1,
"chunks_list": [
"chunk-e9620692094a0b2c6a4059a9d54b156a"
],
"content_summary": "远光软件股份有限公司财务共享服务操作手册\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、系统登录\n\n1. 访问财务共享服务平台https://fssc.ygsoft.com\n\n2. 使用公司统一账号登录(域账号+密码)\n\n3. 首次登录需完善个人信息并设置安全问答\n\n4. 建议使用Chrome或Edge浏...",
"content_length": 1709,
"created_at": "2026-05-22T16:26:10.787100+00:00",
"updated_at": "2026-05-22T16:26:54.048075+00:00",
"file_path": "/app/server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf",
"track_id": "insert_20260522_162610_c54adde3",
"metadata": {
"processing_start_time": 1779467170,
"processing_end_time": 1779467214
}
},
"ce50d015861f4633a634a2eae416fa2e": {
"status": "processed",
"chunks_count": 1,
"chunks_list": [
"chunk-962e1165f2750bf5d5d1e2cfd62dc1d8"
],
"content_summary": "远光软件股份有限公司报销流程培训手册\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、报销流程总览\n\n步骤一准备阶段\n\n- 确认费用已实际发生\n\n- 收集原始发票及相关证明材料\n\n- 确认发票信息准确无误\n\n步骤二填报阶段\n\n- 登录公司OA系统\n\n- 选择对应报销单模板\n\n- 如实填写报销信息\n\n- 上传发...",
"content_length": 1584,
"created_at": "2026-05-22T16:26:43.730895+00:00",
"updated_at": "2026-05-22T16:27:31.775974+00:00",
"file_path": "/app/server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf",
"track_id": "insert_20260522_162643_0ae1b9f3",
"metadata": {
"processing_start_time": 1779467203,
"processing_end_time": 1779467251
}
},
"56a0e13b705e49468d46629f3b5f691a": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-f0242dd79cc2c64d1e111d0380acfa6c",
"chunk-5109df8302a1a8dc1d2254955e7e440f"
],
"content_summary": "远光软件股份有限公司新员工财务培训课件\n\n 远光软件股份有限公司\n\n 2024年度\n\n培训大纲\n\n一、公司财务管理概况\n\n二、费用报销制度详解\n\n三、发票管理要求\n\n四、差旅费标准说明\n\n五、预算管理基本知识\n\n六、常见问题解答\n\n一、公司财务管理概况\n\n远光软件股份有限公司财务部是公司财务管理的职能部门主要职责包括...",
"content_length": 2026,
"created_at": "2026-05-22T16:27:37.516228+00:00",
"updated_at": "2026-05-22T16:27:44.244066+00:00",
"file_path": "/app/server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf",
"track_id": "insert_20260522_162737_09f7230a",
"metadata": {
"processing_start_time": 1779467257,
"processing_end_time": 1779467264
}
},
"22ef5d13bb5e4307a8097628eaa3d398": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-570642e8a00db7819c2b4048ebf1b279",
"chunk-89afdbbf904b60cf6494cba2638e08a8"
],
"content_summary": "远光软件股份有限公司\n财务制度培训手册\n培训目标\n本培训手册旨在帮助公司全体员工了解和掌握公司财务管理制度的要点规范日常工作中的财务行为提高财务管理意识和操作能力。\n第一部分 公司财务制度概述\n一、财务管理制度体系\n公司财务管理制度体系由以下层次构成\n财务管理制度总则公司财务管理的基本制度和原则\n专项管理制度包括预算管理、资金管理、费用报销、资产管理等\n操作规程各项财务工作的具体操作流程和标准。\n二、财务部门组织架构\n公司财务部下设资金管理组、会计核算组、税务...",
"content_length": 1870,
"created_at": "2026-05-22T16:27:49.277442+00:00",
"updated_at": "2026-05-22T16:28:24.573683+00:00",
"file_path": "/app/server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx",
"track_id": "insert_20260522_162749_be47d137",
"metadata": {
"processing_start_time": 1779467269,
"processing_end_time": 1779467304
}
},
"78d1a28f1c934f46b762fb1466d4be32": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-6df7635e39ab9c079752505fca7213be",
"chunk-b9723c3c3219580be2ddbd88932fb2f1"
],
"content_summary": "# Excel 工作簿:远光软件财务培训课程安排.xlsx\n\n## 工作表 1培训课程安排\n\n| 远光软件股份有限公司2024年度财务培训课程安排 | 列2 | 列3 | 列4 | 列5 | 列6 | 列7 |\n| --- | --- | --- | --- | --- | --- | --- |\n| 序号 | 课程名称 | 培训对象 | 培训时间 | 课时 | 讲师 | 备注 |\n| 1 | 财务制度总则培训 | 全体新员工 | 每月第一个周五 | 2小时 | 财务部经理 | 入职必修 |\n...",
"content_length": 2368,
"created_at": "2026-05-22T16:28:30.379160+00:00",
"updated_at": "2026-05-22T16:29:03.349502+00:00",
"file_path": "/app/server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx",
"track_id": "insert_20260522_162830_89c825be",
"metadata": {
"processing_start_time": 1779467310,
"processing_end_time": 1779467343
}
},
"91fbf156593a4dcc956780962195ffd7": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-09e033e41906cfb8b08a07c907004a4c",
"chunk-74de48577772db0161356fc844026b4e"
],
"content_summary": "# Excel 工作簿:远光软件报销问题处理指引.xlsx\n\n## 工作表 1问题处理指引\n\n| 远光软件报销问题处理指引 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 问题类型 | 问题描述 | 处理方式 | 责任人 | 备注 |\n| 发票问题 | 发票抬头错误 | 退回开票方重新开具 | 经办人 | 不得自行修改 |\n| 发票问题 | 发票金额与实际不符 | 退回开票方重新开具 | 经办人 | 需重新取得 |\n| 发票问题 ...",
"content_length": 2789,
"created_at": "2026-05-22T16:28:59.059382+00:00",
"updated_at": "2026-05-22T16:29:29.050791+00:00",
"file_path": "/app/server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx",
"track_id": "insert_20260522_162859_b3e25e44",
"metadata": {
"processing_start_time": 1779467339,
"processing_end_time": 1779467369
}
},
"a24793b7f7de4749a7c531d1713a4a2b": {
"status": "processed",
"chunks_count": 2,
"chunks_list": [
"chunk-6175768b05adf2e7229c16f13ee7cffd",
"chunk-3c2a3406703711442c39071720a279c6"
],
"content_summary": "远光软件股份有限公司财务制度问答汇总\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、预算管理\n\nQ1年度预算什么时候编制\n\nA每年11月启动下一年度预算编制工作12月底前完成审批。\n\nQ2预算调整怎么申请\n\nA预算执行过程中如需调整应填写预算调整申请表说明调整原因和金额按原审批权限逐级审批。\n\n...",
"content_length": 2042,
"created_at": "2026-05-22T16:29:35.343814+00:00",
"updated_at": "2026-05-22T16:35:03.548506+00:00",
"file_path": "/app/server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf",
"track_id": "insert_20260522_162935_46588cc7",
"metadata": {
"processing_start_time": 1779467375,
"processing_end_time": 1779467703
}
},
"3acd9c2df63b4a438c7eab876269b25d": {
"status": "processed",
"chunks_count": 1,
"chunks_list": [
"chunk-cfac1ddf5942f8fe2d5a296380818faf"
],
"content_summary": "远光软件股份有限公司\n财务报销常见问题解答FAQ\n第一部分 报销基本问题\nQ1报销有时限要求吗\nA费用发生后原则上应在一个月内完成报销。超过一个月的需在报销单中说明原因超过三个月的原则上不再报销。\nQ2发票抬头写错了怎么办\nA发票抬头必须为\"远光软件股份有限公司\"全称。如抬头错误,应退回开票方重新开具。\nQ3可以用别人的发票报销吗\nA不可以。报销发票的抬头必须是本公司且发票内容应与报销人的实际业务相符。\nQ4电子发票怎么报销\nA电子发票通过公司OA系统上传PDF...",
"content_length": 1428,
"created_at": "2026-05-22T16:35:25.305595+00:00",
"updated_at": "2026-05-22T16:35:27.056080+00:00",
"file_path": "/app/server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx",
"track_id": "insert_20260522_163525_c8c85299",
"metadata": {
"processing_start_time": 1779467725,
"processing_end_time": 1779467727
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,10 @@ from app.services.knowledge_ingest_log import (
build_document_graph_summary,
build_ingest_document_summary,
build_ingest_status_summary,
enrich_knowledge_ingest_route_json,
)
from app.services.knowledge_rag import KnowledgeRagService
from app.services.knowledge_rag_local import query_local_text_chunks
def test_build_hits_prioritizes_structured_table_evidence_for_standard_queries() -> None:
@@ -82,6 +84,84 @@ def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
assert [item["candidate_id"] for item in hits] == ["clue-1", "plain-1"]
def test_query_local_text_chunks_prioritizes_relevant_policy_chunk(tmp_path) -> None:
workspace = tmp_path / "knowledge" / ".lightrag" / "x_financial_knowledge"
workspace.mkdir(parents=True)
(workspace / "kv_store_text_chunks.json").write_text(
json.dumps(
{
"chunk-travel": {
"_id": "chunk-travel",
"full_doc_id": "doc-1",
"chunk_order_index": 1,
"file_path": "/tmp/doc-1__差旅费管理办法.pdf",
"content": (
"第十三条 差旅费。酒店住宿限额标准如下其他员工直辖市350元、"
"省会城市300元、其他地区250元。确因紧急公务、特别情形等事项"
"导致住宿超过规定标准时超标20%以内由部门负责人审批,"
"超标20%以上需分管领导审批。"
),
},
"chunk-office": {
"_id": "chunk-office",
"full_doc_id": "doc-2",
"chunk_order_index": 1,
"file_path": "/tmp/doc-2__办公用品管理办法.pdf",
"content": "办公用品采购应遵循预算和验收流程。",
},
}
),
encoding="utf-8",
)
result = query_local_text_chunks(
lightrag_root=tmp_path / "knowledge" / ".lightrag",
workspace="x_financial_knowledge",
query="住宿费超过标准审批依据是什么?",
limit=2,
)
assert result.confident is True
assert result.hits[0]["candidate_id"] == "chunk-travel"
assert "住宿超过规定标准" in result.hits[0]["content"]
def test_query_knowledge_uses_local_chunks_before_lightrag_runtime(tmp_path, monkeypatch) -> None:
workspace = tmp_path / "knowledge" / ".lightrag" / "x_financial_knowledge"
workspace.mkdir(parents=True)
(workspace / "kv_store_text_chunks.json").write_text(
json.dumps(
{
"chunk-1": {
"_id": "chunk-1",
"full_doc_id": "doc-1",
"chunk_order_index": 1,
"file_path": "/tmp/doc-1__公司支出管理办法.pdf",
"content": (
"第八条 支出报销申请时限。公司各类支出报销结算申请时限为三个月。"
"逾期需说明原因,经分管领导审批后方可报销。"
),
}
}
),
encoding="utf-8",
)
def fail_if_runtime_is_used(_self):
raise AssertionError("local high-confidence queries should not initialize LightRAG")
monkeypatch.setattr(KnowledgeRagService, "_get_runtime", fail_if_runtime_is_used)
payload = KnowledgeRagService(storage_root=tmp_path).query_knowledge(
"费用发生后多久内必须报销?超过三个月还能不能报?",
limit=3,
)
assert payload["record_count"] == 1
assert payload["metadata"]["retrieval_strategy"] == "local_text_chunks"
assert "三个月" in payload["hits"][0]["content"]
def test_build_hits_demotes_chapter_navigation_for_specific_rule_queries() -> None:
hits = KnowledgeRagService._build_hits_from_query_data(
query="探亲差旅归哪个部门管理?",
@@ -227,6 +307,46 @@ def test_build_document_graph_summary_reads_lightrag_storage(tmp_path) -> None:
),
encoding="utf-8",
)
(workspace / "kv_store_entity_chunks.json").write_text(
json.dumps(
{
"远光软件": {"chunk_ids": ["chunk-1", "chunk-missing"]},
"支出管理": {"chunk_ids": ["chunk-2"]},
}
),
encoding="utf-8",
)
(workspace / "graph_chunk_entity_relation.graphml").write_text(
"""<?xml version="1.0" encoding="UTF-8"?>
<graphml xmlns="http://graphml.graphdrawing.org/xmlns">
<key id="n0" for="node" attr.name="entity_id" attr.type="string" />
<key id="n1" for="node" attr.name="entity_type" attr.type="string" />
<key id="n2" for="node" attr.name="description" attr.type="string" />
<key id="n3" for="node" attr.name="created_at" attr.type="string" />
<key id="e0" for="edge" attr.name="weight" attr.type="double" />
<key id="e1" for="edge" attr.name="description" attr.type="string" />
<key id="e2" for="edge" attr.name="keywords" attr.type="string" />
<graph edgedefault="undirected">
<node id="远光软件">
<data key="n0">远光软件</data>
<data key="n1">ORGANIZATION</data>
<data key="n2">公司主体&lt;SEP&gt;费用制度适用公司</data>
<data key="n3">2026-05-23</data>
</node>
<node id="支出管理">
<data key="n0">支出管理</data>
<data key="n1">TOPIC</data>
<data key="n2">规范费用支出、预算和审批。</data>
</node>
<edge source="远光软件" target="支出管理">
<data key="e0">2.5</data>
<data key="e1">远光软件通过支出管理制度约束费用审批。</data>
<data key="e2">制度&lt;SEP&gt;审批</data>
</edge>
</graph>
</graphml>""",
encoding="utf-8",
)
summary = build_document_graph_summary(
tmp_path,
@@ -235,10 +355,39 @@ def test_build_document_graph_summary_reads_lightrag_storage(tmp_path) -> None:
)
assert summary["entity_count"] == 2
assert summary["entities"] == ["远光软件", "支出管理"]
assert [item["name"] for item in summary["entities"]] == ["远光软件", "支出管理"]
assert summary["entities"][0]["type"] == "ORGANIZATION"
assert summary["entities"][0]["descriptions"][0] == "公司主体"
assert summary["relation_count"] == 1
assert summary["relations"] == [{"source": "远光软件", "target": "支出管理", "type": "关联"}]
assert summary["relations"][0]["source"] == "远光软件"
assert summary["relations"][0]["target"] == "支出管理"
assert summary["relations"][0]["description"] == "远光软件通过支出管理制度约束费用审批。"
assert summary["relations"][0]["keywords"] == ["制度", "审批"]
assert summary["relations"][0]["weight"] == 2.5
assert [item["id"] for item in summary["chunks"]] == ["chunk-1", "chunk-2"]
assert summary["chunks"][0]["excerpt"].startswith("第一条")
assert summary["entity_chunks"] == [
{"entity": "远光软件", "chunk_ids": ["chunk-1"]},
{"entity": "支出管理", "chunk_ids": ["chunk-2"]},
]
enriched_route = enrich_knowledge_ingest_route_json(
{
"lightrag_workspace": "test_workspace",
"knowledge_ingest": {
"graph": {
"entities": ["远光软件"],
"relations": [
{"source": "远光软件", "target": "支出管理", "type": "关联"}
],
}
},
},
storage_root=tmp_path,
)
enriched_entities = enriched_route["knowledge_ingest"]["graph"]["entities"]
assert [item["name"] for item in enriched_entities] == ["远光软件", "支出管理"]
assert enriched_entities[1]["type"] == "TOPIC"
def test_build_ingest_document_summary_extracts_sections() -> None:

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
import json
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_flow_diagram import RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec
from app.services.risk_rule_generation import RiskRuleGenerationService
class NullRuntimeChatService:
def complete(self, *args, **kwargs) -> None:
return None
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
with build_session() as db:
service = RiskRuleGenerationService(
db,
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.status == AgentAssetStatus.DRAFT.value
assert asset.config_json["detail_mode"] == "json_risk"
assert asset.config_json["evaluator"] == "template_rule"
assert asset.current_version == "v0.1.0"
file_name = asset.config_json["rule_document"]["file_name"]
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
payload = json.loads(rule_path.read_text(encoding="utf-8"))
assert payload["rule_code"] == asset.code
assert payload["outcomes"]["fail"]["severity"] == "high"
assert payload["template_key"] == "field_compare_v1"
assert payload["metadata"]["natural_language"].startswith("住宿城市")
assert payload["inputs"]["fields"]
assert payload["flow_diagram_svg"].startswith("<svg")
assert 'width="760" height="280"' in payload["flow_diagram_svg"]
assert 'data-risk-flow-style="review-node-only"' in payload["flow_diagram_svg"]
assert "RULE FLOW" in payload["flow_diagram_svg"]
assert "进入复核" in payload["flow_diagram_svg"]
assert "" in payload["flow_diagram_svg"]
assert "" in payload["flow_diagram_svg"]
assert "#dc2626" in payload["flow_diagram_svg"]
assert "#fecaca" in payload["flow_diagram_svg"]
assert "#10a37f" not in payload["flow_diagram_svg"]
assert "#f97316" not in payload["flow_diagram_svg"]
assert "feDropShadow" not in payload["flow_diagram_svg"]
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
renderer = RiskRuleFlowDiagramRenderer()
def render(severity: str, label: str) -> str:
return renderer.render(
RiskRuleFlowDiagramSpec(
title="测试规则",
domain_label="报销",
severity=severity,
severity_label=label,
fields=(),
start="业务单据提交",
evidence="读取规则字段",
decision="判断是否命中风险",
basis="根据规则字段判断",
pass_text="未命中风险,继续流转",
fail_text=f"命中{label},进入复核",
)
)
assert "#2563eb" in render("low", "低风险")
assert "#f97316" in render("medium", "中风险")
high_svg = render("high", "高风险")
assert "#dc2626" in high_svg
assert high_svg.count("#dc2626") == 1
assert "#10a37f" not in high_svg

932
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@antv/g6": "^5.1.1",
"@primevue/themes": "^4.5.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",

View File

@@ -828,6 +828,64 @@
line-height: 1.55;
}
.shared-confirm-card:has(.risk-rule-create-form) {
width: min(680px, 100%);
}
.risk-rule-create-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.risk-rule-create-form label {
display: grid;
gap: 7px;
}
.risk-rule-create-form label.span-2 {
grid-column: 1 / -1;
}
.risk-rule-create-form label span {
color: #475569;
font-size: 12px;
font-weight: 850;
}
.risk-rule-create-form select,
.risk-rule-create-form textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
}
.risk-rule-create-form select {
min-height: 42px;
padding: 0 12px;
}
.risk-rule-create-form textarea {
min-height: 140px;
resize: vertical;
padding: 12px;
line-height: 1.6;
}
.risk-rule-create-form select:focus,
.risk-rule-create-form textarea:focus {
outline: 0;
border-color: rgba(16, 185, 129, 0.5);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.risk-rule-create-form textarea::placeholder {
color: #94a3b8;
}
.publish-summary {
display: flex;
align-items: center;
@@ -1036,16 +1094,15 @@
transform: rotate(90deg);
}
.json-risk-summary-grid {
grid-template-columns: 1fr;
}
.field.span-2 {
grid-column: span 1;
}
}
.json-risk-skill-detail .detail-scroll {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.json-risk-editor-shell {
min-height: 0;
height: 100%;
@@ -1053,26 +1110,118 @@
flex-direction: column;
gap: 10px;
padding: 10px;
overflow: hidden;
}
.asset-detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar.panel {
padding: 14px 0 10px;
border: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar-main {
flex: 1 1 auto;
min-width: 0;
}
.asset-detail-topbar-main h2 {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.asset-detail-topbar-main p {
flex-basis: 100%;
margin: 0;
max-width: 860px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.asset-detail-topbar-meta {
flex: 0 0 auto;
justify-content: flex-end;
}
.asset-detail-topbar .hero-review-meta {
flex-basis: 100%;
margin-top: 2px;
}
.asset-detail-topbar .review-note-block {
flex-basis: 100%;
margin-top: 4px;
}
.asset-detail-topbar .hero-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.asset-detail-topbar .hero-stat {
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
}
.asset-detail-topbar .hero-stat span {
display: none;
}
.asset-detail-topbar .hero-stat strong {
font-size: 12px;
font-weight: 800;
}
.json-risk-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
align-items: center;
padding-top: 4px;
padding-bottom: 8px;
}
.json-risk-editor-title {
min-width: 0;
display: flex;
align-items: flex-start;
align-items: center;
gap: 12px;
}
.json-risk-head-copy {
min-width: 0;
display: grid;
gap: 6px;
}
.json-risk-head-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.json-risk-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.json-risk-editor-title p {
@@ -1085,7 +1234,7 @@
.json-risk-head-subtitle {
display: -webkit-box;
margin: 6px 0 0;
margin: 0;
max-width: 760px;
overflow: hidden;
color: #64748b;
@@ -1095,11 +1244,24 @@
-webkit-line-clamp: 2;
}
.json-risk-head-category {
margin: 6px 0 0;
color: #be123c;
.json-risk-head-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.json-risk-head-meta span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 600;
font-weight: 750;
border: 1px solid #e2e8f0;
}
.skill-name-cell .skill-list-subtitle {
@@ -1131,16 +1293,41 @@
font-weight: 800;
}
.json-risk-mode-pill.high {
background: #fef2f2;
color: #dc2626;
}
.json-risk-mode-pill.medium {
background: #fff7ed;
color: #ea580c;
}
.json-risk-mode-pill.low {
background: #ecfdf5;
color: #059669;
}
.json-risk-editor-body {
flex: 1 1 auto;
min-height: 0;
display: block;
overflow-y: auto;
overflow-x: hidden;
padding: 0 2px 2px 0;
}
.json-risk-main-stage {
min-width: 0;
min-height: 0;
display: grid;
gap: 12px;
padding-bottom: 2px;
}
.json-risk-flow-card {
min-width: 0;
overflow: hidden;
}
.json-risk-description-card {
@@ -1193,46 +1380,6 @@
letter-spacing: 0.04em;
}
.json-risk-flow-diagram {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.json-risk-flow-column {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.json-risk-flow-column.center {
text-align: center;
background: #fff1f2;
border-color: #fecdd3;
}
.json-risk-flow-column code {
font-size: 11px;
color: #334155;
}
.json-risk-flow-label {
font-size: 11px;
font-weight: 800;
color: #64748b;
text-transform: uppercase;
}
.json-risk-flow-arrow {
color: #94a3b8;
font-size: 18px;
font-weight: 800;
}
.json-risk-editor-toolbar {
display: flex;
align-items: center;
@@ -1253,3 +1400,21 @@
border: 1px solid #e2e8f0;
background: #ffffff;
}
@media (max-width: 760px) {
.risk-rule-create-form,
.json-risk-summary-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.json-risk-editor-head {
flex-direction: column;
align-items: stretch;
}
.json-risk-editor-actions {
justify-content: flex-start;
}
}

View File

@@ -570,6 +570,19 @@ tbody tr:hover {
grid-template-rows: minmax(0, 1fr);
}
.json-risk-skill-detail .detail-scroll {
min-height: 0;
overflow: hidden;
align-content: stretch;
grid-template-rows: minmax(0, 1fr);
}
.detail-loading-state {
width: 100%;
min-height: 260px;
align-self: center;
}
.detail-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;

View File

@@ -687,6 +687,13 @@
z-index: 1;
}
.insight-body.document-review-body {
display: flex;
flex-direction: column;
align-content: stretch;
overflow: hidden;
}
.review-side-card {
display: grid;
gap: 10px;
@@ -1186,6 +1193,10 @@
.review-ticket-drawer {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
flex: 1 1 auto;
overflow: hidden;
}
.review-document-switch-head {
@@ -1226,6 +1237,7 @@
.review-document-stage {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
min-height: 0;
}
@@ -1292,7 +1304,8 @@
.review-document-scroll {
display: grid;
gap: 12px;
max-height: 430px;
max-height: none;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
@@ -1320,8 +1333,15 @@
.review-document-preview-card.image img {
display: block;
width: 100%;
height: 188px;
object-fit: cover;
height: auto;
max-height: 260px;
object-fit: contain;
}
.review-document-preview-card.image {
display: grid;
place-items: center;
min-height: 220px;
}
.review-document-preview-placeholder {

View File

@@ -0,0 +1,643 @@
<template>
<section class="knowledge-graph-space">
<header class="graph-head">
<div>
<span class="graph-eyebrow">LightRAG 知识图谱</span>
<h4>实体关系空间</h4>
</div>
<div class="graph-toolbar">
<label class="graph-search">
<i class="mdi mdi-magnify"></i>
<input
v-model.trim="graphQuery"
type="search"
placeholder="搜索实体"
@keydown.enter.prevent="focusMatchedNode"
/>
</label>
<button type="button" title="定位匹配实体" @click="focusMatchedNode">
<i class="mdi mdi-crosshairs-gps"></i>
</button>
<button type="button" title="缩小" @click="zoomGraph(0.86)">
<i class="mdi mdi-magnify-minus-outline"></i>
</button>
<button type="button" title="放大" @click="zoomGraph(1.16)">
<i class="mdi mdi-magnify-plus-outline"></i>
</button>
<button type="button" title="适配画布" @click="fitGraph">
<i class="mdi mdi-fit-to-page-outline"></i>
</button>
</div>
</header>
<div class="graph-stats">
<span>{{ graphSummary.entityCount }} 实体</span>
<span>{{ graphSummary.relationCount }} 关系</span>
<span>{{ graphSummary.visibleNodeCount }} 节点可见</span>
<span>{{ graphSummary.visibleEdgeCount }} 连线可见</span>
</div>
<div class="graph-body" :class="{ empty: !graphData.nodes.length }">
<div class="graph-theater">
<div v-if="graphData.nodes.length" ref="graphContainer" class="g6-canvas"></div>
<div v-else class="graph-empty">
<strong>暂无图谱数据</strong>
<span>本次归集还没有返回可展示的实体关系</span>
</div>
<div v-if="graphData.nodes.length" class="graph-hud">
<span>G6 Force</span>
<span>拖拽节点</span>
<span>滚轮缩放</span>
<span>点击查看详情</span>
</div>
</div>
<aside class="graph-inspector">
<template v-if="selectedNode">
<div class="inspector-title">
<span>节点详情</span>
<h5>{{ selectedNode.name }}</h5>
</div>
<div class="node-facts">
<div>
<span>关系数</span>
<strong>{{ selectedNode.degree }}</strong>
</div>
<div>
<span>入边</span>
<strong>{{ selectedNodeIncoming.length }}</strong>
</div>
<div>
<span>出边</span>
<strong>{{ selectedNodeOutgoing.length }}</strong>
</div>
</div>
<div class="node-meta">
<span>类型</span>
<strong>{{ selectedNode.type || '实体' }}</strong>
</div>
<KnowledgeIngestNodeDetails
:node="selectedNode"
:incoming="selectedNodeIncoming"
:outgoing="selectedNodeOutgoing"
:relations="selectedNodeRelations"
@focus-peer="focusRelationPeer"
/>
</template>
<div v-else class="inspector-empty">
<strong>选择一个实体</strong>
<span>点击图谱中的节点查看它的入边出边和关系明细</span>
</div>
</aside>
</div>
</section>
</template>
<script setup>
import { Graph, NodeEvent } from '@antv/g6'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import KnowledgeIngestNodeDetails from './KnowledgeIngestNodeDetails.vue'
import { useKnowledgeIngestGraph } from './useKnowledgeIngestGraph.js'
const props = defineProps({
graph: {
type: Object,
default: () => ({})
},
documents: {
type: Array,
default: () => []
}
})
const graphContainer = ref(null)
let graphInstance = null
let resizeObserver = null
const {
activeNodeId,
graphData,
graphQuery,
graphSummary,
selectNodeById,
focusFirstMatch,
selectRelationPeer,
selectedNode,
selectedNodeIncoming,
selectedNodeOutgoing,
selectedNodeRelations
} = useKnowledgeIngestGraph(props)
onMounted(() => {
initGraph()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
graphInstance?.destroy()
resizeObserver = null
graphInstance = null
})
watch(
graphData,
() => {
renderGraph()
},
{ deep: true }
)
watch(activeNodeId, (nodeId) => {
syncGraphSelection(nodeId)
})
async function initGraph() {
if (!graphData.value.nodes.length) return
await nextTick()
if (!graphContainer.value || graphInstance) return
graphInstance = new Graph(buildGraphOptions())
graphInstance.on(NodeEvent.CLICK, async (event) => {
const nodeId = String(event?.target?.id || '').trim()
if (!nodeId) return
selectNodeById(nodeId)
await focusGraphNode(nodeId, { focus: false })
})
await graphInstance.render()
await syncGraphSelection(activeNodeId.value)
observeResize()
await fitGraph()
}
function buildGraphOptions() {
return {
container: graphContainer.value,
autoResize: true,
background: 'transparent',
zoomRange: [0.22, 2.8],
autoFit: {
type: 'view',
options: { padding: [86, 122, 86, 122] },
animation: { duration: 520, easing: 'ease-in-out' }
},
data: graphData.value,
layout: {
type: 'd3-force',
preventOverlap: true,
centerStrength: 0.34,
linkDistance: (edge) => (edge?.data?.weight > 2 ? 155 : 210),
edgeStrength: 0.22,
nodeStrength: (node) => (node?.data?.degree >= 5 ? -520 : -360),
collide: {
radius: (node) => 40 + Math.sqrt(Math.max(node?.data?.degree || 1, 1)) * 12,
strength: 0.88,
iterations: 3
},
manyBody: {
strength: (node) => (node?.data?.degree >= 5 ? -620 : -420),
distanceMax: 620
},
center: {
strength: 0.24
},
alpha: 0.88,
alphaDecay: 0.035,
velocityDecay: 0.32,
iterations: 360
},
node: {
type: 'circle',
style: (datum) => datum.style,
state: {
selected: {
lineWidth: 4,
stroke: '#1d4ed8',
halo: true,
haloStroke: '#60a5fa',
haloLineWidth: 24,
haloStrokeOpacity: 0.24,
shadowBlur: 22
},
active: {
lineWidth: 3,
halo: true,
haloLineWidth: 16,
haloStrokeOpacity: 0.28
},
inactive: {
opacity: 0.2
},
dimmed: {
opacity: 0.18
}
}
},
edge: {
type: 'line',
style: (datum) => datum.style,
state: {
selected: {
stroke: '#2563eb',
lineWidth: 2.8,
opacity: 0.96,
halo: true,
haloStroke: '#93c5fd',
haloLineWidth: 8,
haloStrokeOpacity: 0.2
},
active: {
stroke: '#60a5fa',
lineWidth: 2.4,
opacity: 0.9
},
inactive: {
opacity: 0.14
}
}
},
behaviors: [
{ type: 'drag-canvas', key: 'drag-canvas' },
{ type: 'zoom-canvas', key: 'zoom-canvas', sensitivity: 1.15 },
{ type: 'drag-element-force', key: 'drag-element-force', fixed: true }
],
animation: { duration: 420, easing: 'ease-out' }
}
}
async function renderGraph() {
if (!graphData.value.nodes.length) return
if (!graphInstance) {
await initGraph()
return
}
graphInstance.setData(graphData.value)
await graphInstance.render()
await syncGraphSelection(activeNodeId.value)
await fitGraph()
}
async function syncGraphSelection(nodeId) {
if (!graphInstance || graphInstance.destroyed || !nodeId) return
const stateMap = {}
for (const node of graphData.value.nodes) {
if (node.id === nodeId) {
stateMap[node.id] = ['selected']
} else if (node.states?.includes('dimmed')) {
stateMap[node.id] = ['dimmed']
} else {
stateMap[node.id] = []
}
}
for (const edge of graphData.value.edges) {
stateMap[edge.id] = edge.source === nodeId || edge.target === nodeId ? ['selected'] : ['inactive']
}
await graphInstance.setElementState(stateMap, false)
}
async function fitGraph() {
if (!graphInstance || graphInstance.destroyed) return
await graphInstance.fitView(
{ padding: [84, 118, 84, 118] },
{ duration: 420, easing: 'ease-in-out' }
)
}
async function zoomGraph(ratio) {
if (!graphInstance || graphInstance.destroyed) return
await graphInstance.zoomBy(ratio, { duration: 220, easing: 'ease-out' })
}
async function focusMatchedNode() {
const nodeId = focusFirstMatch()
if (nodeId) await focusGraphNode(nodeId)
}
async function focusRelationPeer(relation) {
const nodeId = selectRelationPeer(relation)
if (nodeId) await focusGraphNode(nodeId)
}
async function focusGraphNode(nodeId, options = {}) {
if (!graphInstance || graphInstance.destroyed) return
await syncGraphSelection(nodeId)
if (options.focus === false) return
await graphInstance.focusElement(nodeId, { duration: 360, easing: 'ease-in-out' })
}
function observeResize() {
if (!graphContainer.value || typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(() => {
if (!graphInstance || graphInstance.destroyed) return
graphInstance.setSize(graphContainer.value.clientWidth, graphContainer.value.clientHeight)
})
resizeObserver.observe(graphContainer.value)
}
</script>
<style scoped>
.knowledge-graph-space {
min-height: 1080px;
display: grid;
grid-template-rows: auto auto minmax(1020px, 1fr);
gap: 12px;
overflow: hidden;
}
.graph-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.graph-eyebrow {
color: #0f766e;
font-size: 12px;
font-weight: 850;
}
.graph-head h4 {
margin: 4px 0 0;
color: #0f172a;
font-size: 15px;
}
.graph-toolbar,
.graph-stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.graph-search {
min-width: 230px;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 9px;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
color: #64748b;
}
.graph-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #0f172a;
font-size: 12px;
}
.graph-toolbar button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
color: #475569;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.graph-toolbar button:hover {
border-color: #2563eb;
color: #2563eb;
transform: translateY(-1px);
}
.graph-stats {
justify-content: flex-start;
}
.graph-stats span {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid #dbe6ef;
border-radius: 999px;
background: #fff;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.graph-body {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
overflow: hidden;
}
.graph-theater {
position: relative;
min-height: 1020px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 54%, #eef4fb 100%);
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.82);
}
.graph-theater::before {
position: absolute;
inset: 0;
content: '';
pointer-events: none;
background:
linear-gradient(rgba(148, 163, 184, 0.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.18) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(circle at center, black 0%, black 58%, transparent 100%);
}
.g6-canvas {
position: absolute;
inset: 0;
z-index: 1;
cursor: grab;
}
.g6-canvas:active {
cursor: grabbing;
}
.graph-hud {
position: absolute;
left: 14px;
right: 14px;
bottom: 12px;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
pointer-events: none;
}
.graph-hud span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid rgba(203, 213, 225, 0.86);
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
color: #334155;
font-size: 11px;
font-weight: 750;
backdrop-filter: blur(10px);
}
.graph-inspector {
min-height: 0;
display: grid;
align-content: start;
gap: 12px;
overflow: auto;
padding: 12px;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
}
.inspector-title span {
color: #0f766e;
font-size: 12px;
font-weight: 850;
}
.inspector-title h5 {
margin: 4px 0 0;
color: #0f172a;
font-size: 15px;
line-height: 1.35;
word-break: break-word;
}
.node-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.node-facts div,
.node-meta {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px;
border-radius: 8px;
background: #f8fafc;
}
.node-meta {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.node-facts span,
.node-meta span {
color: #64748b;
font-size: 12px;
}
.node-facts strong,
.node-meta strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-empty,
.inspector-empty {
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 6px;
color: #64748b;
text-align: center;
}
.graph-empty {
position: absolute;
inset: 0;
z-index: 1;
}
.graph-empty strong,
.inspector-empty strong {
color: #0f172a;
font-size: 14px;
}
.inspector-empty strong {
color: #0f172a;
}
.graph-empty span,
.inspector-empty span {
font-size: 12px;
}
@media (prefers-reduced-motion: reduce) {
.graph-toolbar button {
transition: none;
}
}
@media (max-width: 980px) {
.knowledge-graph-space {
min-height: 980px;
grid-template-rows: auto auto minmax(900px, 1fr);
}
.graph-head {
flex-direction: column;
}
.graph-toolbar {
justify-content: flex-start;
}
.graph-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(780px, 1fr) minmax(220px, auto);
}
.graph-theater {
min-height: 780px;
}
.graph-inspector {
max-height: 240px;
}
}
@media (max-width: 620px) {
.graph-search {
min-width: 100%;
}
.graph-toolbar {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="node-detail-panel">
<section class="detail-section">
<div class="detail-section-head">
<strong>节点说明</strong>
<span>{{ safeNode.type || '实体' }}</span>
</div>
<div v-if="descriptionItems.length" class="description-list">
<p v-for="(description, index) in descriptionItems" :key="index">
{{ description }}
</p>
</div>
<div v-else class="detail-empty compact">
当前节点暂无 LightRAG 描述完成新的归集后会从图谱属性中补充
</div>
</section>
<section class="detail-section">
<div class="detail-section-head">
<strong>节点属性</strong>
<span>{{ propertyItems.length }} </span>
</div>
<dl class="property-grid">
<div>
<dt>类型</dt>
<dd>{{ safeNode.type || '实体' }}</dd>
</div>
<div>
<dt>关系数</dt>
<dd>{{ safeNode.degree || 0 }}</dd>
</div>
<div>
<dt>入边</dt>
<dd>{{ incoming.length }}</dd>
</div>
<div>
<dt>出边</dt>
<dd>{{ outgoing.length }}</dd>
</div>
</dl>
<div v-if="labelItems.length" class="label-row">
<span v-for="label in labelItems" :key="label">{{ label }}</span>
</div>
<dl v-if="propertyItems.length" class="raw-property-list">
<div v-for="item in propertyItems" :key="item.key">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</section>
<section class="detail-section relation-section">
<div class="detail-section-head">
<strong>关系语境</strong>
<span>{{ relationRows.length }} </span>
</div>
<div v-if="relationRows.length" class="relation-detail-list">
<button
v-for="relation in relationRows"
:key="relation.key"
type="button"
@click="$emit('focus-peer', relation.raw)"
>
<span class="relation-direction">{{ relation.directionLabel }}</span>
<span class="relation-peer">{{ relation.peerName }}</span>
<strong>{{ relation.type }}</strong>
<p v-if="relation.description">{{ relation.description }}</p>
<div v-if="relation.keywords.length" class="keyword-row">
<span v-for="keyword in relation.keywords" :key="keyword">{{ keyword }}</span>
</div>
</button>
</div>
<div v-else class="detail-empty compact">暂无关联关系</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: null
},
incoming: {
type: Array,
default: () => []
},
outgoing: {
type: Array,
default: () => []
},
relations: {
type: Array,
default: () => []
}
})
defineEmits(['focus-peer'])
const safeNode = computed(() => (props.node && typeof props.node === 'object' ? props.node : {}))
const descriptionItems = computed(() => {
const descriptions = Array.isArray(safeNode.value.descriptions)
? safeNode.value.descriptions
: []
const fallback = String(safeNode.value.description || '').trim()
return dedupeTextItems(descriptions.length ? descriptions : [fallback]).slice(0, 6)
})
const labelItems = computed(() => dedupeTextItems(safeNode.value.labels).slice(0, 8))
const propertyItems = computed(() => {
const properties =
safeNode.value.properties && typeof safeNode.value.properties === 'object'
? safeNode.value.properties
: {}
return Object.entries(properties)
.filter(([key, value]) => !hiddenPropertyKeys.has(key) && String(value || '').trim())
.map(([key, value]) => ({
key,
label: formatPropertyKey(key),
value: String(value).trim()
}))
.slice(0, 10)
})
const relationRows = computed(() => {
const nodeName = String(safeNode.value.name || '').trim()
if (!nodeName || !Array.isArray(props.relations)) return []
return props.relations.map((relation, index) => {
const isOutgoing = relation.source === nodeName
const peerName = isOutgoing ? relation.target : relation.source
return {
key: `${relation.source}-${relation.target}-${relation.type}-${index}`,
raw: relation,
peerName,
type: String(relation.type || '关联').trim(),
directionLabel: isOutgoing ? '指向' : '来自',
description: String(relation.description || '').trim(),
keywords: dedupeTextItems(relation.keywords).slice(0, 6)
}
})
})
const hiddenPropertyKeys = new Set(['source_id', 'file_path', 'truncate'])
function dedupeTextItems(items) {
const sourceItems = Array.isArray(items)
? items
: String(items || '')
.split('<SEP>')
.filter(Boolean)
const result = []
const seen = new Set()
for (const item of sourceItems) {
const text = String(item || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
result.push(text)
}
return result
}
function formatPropertyKey(key) {
const labels = {
entity_id: '实体ID',
entity_type: '实体类型',
created_at: '创建时间',
weight: '权重'
}
return labels[key] || key
}
</script>
<style scoped>
.node-detail-panel,
.detail-section,
.description-list,
.relation-detail-list {
display: grid;
gap: 10px;
}
.detail-section {
min-width: 0;
padding: 11px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #f8fafc;
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.detail-section-head strong {
color: #0f172a;
font-size: 13px;
}
.detail-section-head span {
max-width: 56%;
overflow: hidden;
color: #475569;
font-size: 11px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.description-list {
max-height: 190px;
overflow: auto;
padding-right: 3px;
}
.description-list p {
margin: 0;
padding: 9px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #fff;
color: #1e293b;
font-size: 12px;
line-height: 1.62;
}
.property-grid,
.raw-property-list {
display: grid;
gap: 8px;
}
.property-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.property-grid div,
.raw-property-list div {
min-width: 0;
display: grid;
gap: 4px;
}
.property-grid div {
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.property-grid dt,
.raw-property-list dt {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.property-grid dd,
.raw-property-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #0f172a;
font-size: 12px;
font-weight: 800;
}
.label-row,
.keyword-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.label-row span,
.keyword-row span,
.relation-direction {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 800;
}
.raw-property-list {
max-height: 160px;
overflow: auto;
padding-top: 2px;
}
.relation-section {
max-height: 360px;
overflow: hidden;
}
.relation-detail-list {
min-height: 0;
overflow: auto;
padding-right: 3px;
}
.relation-detail-list button {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 7px;
padding: 9px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
}
.relation-detail-list button:hover {
border-color: #60a5fa;
background: #eff6ff;
transform: translateY(-1px);
}
.relation-peer,
.relation-detail-list strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.relation-detail-list strong {
color: #1d4ed8;
}
.relation-detail-list p,
.detail-empty {
grid-column: 1 / -1;
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.keyword-row {
grid-column: 1 / -1;
}
.keyword-row span {
background: #ecfdf5;
color: #047857;
}
.detail-empty {
min-height: 82px;
display: grid;
place-items: center;
text-align: center;
}
.detail-empty.compact {
min-height: 52px;
padding: 8px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #fff;
}
@media (prefers-reduced-motion: reduce) {
.relation-detail-list button {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="evidence-panel">
<div class="evidence-head">
<strong>关联切片正文</strong>
<span>{{ safeEvidence.chunkCount }} </span>
</div>
<div v-if="safeEvidence.items.length" class="evidence-list">
<article
v-for="item in safeEvidence.items"
:key="item.documentId"
class="evidence-document"
>
<div class="evidence-document-head">
<div>
<strong>{{ item.name }}</strong>
<small>{{ item.matchType }}</small>
</div>
<span>{{ item.chunks.length }} </span>
</div>
<div v-if="item.chunks.length" class="evidence-chunks">
<div v-for="chunk in item.chunks" :key="chunk.id" class="evidence-chunk">
<span>#{{ chunk.order + 1 }}</span>
<p>{{ chunk.excerpt || chunk.summary || '暂无正文片段' }}</p>
<small>{{ chunk.tokens }} tokens</small>
</div>
</div>
<div v-else class="evidence-empty compact">
命中了该实体但当前日志只返回了 chunk id未返回对应正文片段
</div>
</article>
</div>
<div v-else class="evidence-empty">
当前日志没有返回该实体和切片的映射新归纳日志会优先显示精确切片旧日志按文档实体降级匹配
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
evidence: {
type: Object,
default: () => ({ items: [], chunkCount: 0 })
}
})
const safeEvidence = computed(() => ({
items: Array.isArray(props.evidence?.items) ? props.evidence.items : [],
chunkCount: Number(props.evidence?.chunkCount || 0)
}))
</script>
<style scoped>
.evidence-panel,
.evidence-list,
.evidence-chunks {
display: grid;
gap: 8px;
}
.evidence-head,
.evidence-document-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.evidence-head strong {
color: #0f172a;
font-size: 13px;
}
.evidence-head span,
.evidence-document-head small,
.evidence-document-head span {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.evidence-document {
min-width: 0;
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #f8fafc;
}
.evidence-document-head {
align-items: flex-start;
}
.evidence-document-head div {
min-width: 0;
display: grid;
gap: 3px;
}
.evidence-document-head strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.evidence-chunk {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
padding: 8px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #fff;
}
.evidence-chunk span {
color: #2563eb;
font-size: 11px;
font-weight: 850;
}
.evidence-chunk p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.evidence-chunk small {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}
.evidence-empty {
min-height: 96px;
display: grid;
place-items: center;
align-content: center;
gap: 6px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.evidence-empty.compact {
min-height: 56px;
padding: 8px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #fff;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<section class="ingest-run-info">
<div class="info-title">
<div>
<span>基本信息</span>
<h4>{{ model.folder || '未指定知识目录' }}</h4>
</div>
<strong :class="model.statusTone">{{ model.statusLabel }}</strong>
</div>
<div class="info-grid">
<div v-for="item in infoItems" :key="item.label" class="info-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
run: {
type: Object,
required: true
},
model: {
type: Object,
required: true
}
})
const infoItems = computed(() => {
const routeJson = props.run?.route_json || {}
const currentDocument = props.model.documents?.find(
(item) => item.documentId === props.model.currentDocumentId
)
return [
{ label: 'Trace ID', value: props.run?.run_id || '-' },
{ label: '任务类型', value: resolveJobType(routeJson.job_type) },
{ label: '触发来源', value: resolveSource(props.run?.source) },
{ label: '当前阶段', value: props.model.phaseLabel || '-' },
{ label: '开始时间', value: formatDateTime(props.run?.started_at) },
{ label: '结束时间', value: formatDateTime(props.run?.finished_at) },
{ label: '执行耗时', value: formatElapsed(props.run?.started_at, props.run?.finished_at) },
{ label: '当前文件', value: currentDocument?.name || '-' }
]
})
function resolveJobType(value) {
const jobType = String(value || '').trim()
if (jobType === 'knowledge_index_sync') return 'LightRAG 知识归纳'
if (jobType === 'llm_wiki_sync') return 'LLM Wiki 知识归纳'
return jobType || '-'
}
function resolveSource(value) {
const source = String(value || '').trim()
if (source === 'manual') return '手动触发'
if (source === 'scheduled') return '定时任务'
if (source === 'system') return '系统任务'
return source || '-'
}
function formatDateTime(value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function formatElapsed(startedAt, finishedAt) {
const started = new Date(startedAt || '')
const finished = finishedAt ? new Date(finishedAt) : new Date()
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) return '-'
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const restSeconds = seconds % 60
if (minutes < 60) return `${minutes}m ${restSeconds}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
</script>
<style scoped>
.ingest-run-info {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
}
.info-title {
display: flex;
justify-content: space-between;
gap: 16px;
}
.info-title span {
color: #0f766e;
font-size: 12px;
font-weight: 850;
}
.info-title h4 {
margin: 4px 0 0;
color: #0f172a;
font-size: 16px;
}
.info-title > strong {
align-self: flex-start;
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
}
.info-title > strong.success {
background: #dcfce7;
color: #166534;
}
.info-title > strong.warning {
background: #fef3c7;
color: #92400e;
}
.info-title > strong.danger {
background: #fee2e2;
color: #991b1b;
}
.info-title > strong.muted {
background: #eef2f7;
color: #475569;
}
.info-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.info-item {
min-width: 0;
display: grid;
gap: 5px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.info-item span {
color: #64748b;
font-size: 12px;
}
.info-item strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1100px) {
.info-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.info-title {
flex-direction: column;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -16,184 +16,18 @@
<span :style="{ width: `${model.progress.percent}%` }"></span>
</div>
<div class="metric-strip">
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</div>
</div>
<KnowledgeIngestRunInfo :run="props.run" :model="model" />
<div class="ingest-workspace">
<aside class="file-rail">
<button
v-for="document in model.documents"
:key="document.documentId"
type="button"
class="file-item"
:class="{ active: selectedDocumentId === document.documentId }"
@click="selectDocument(document.documentId)"
>
<i :class="documentIcon(document)"></i>
<span class="file-copy">
<strong>{{ document.name }}</strong>
<small>
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
</small>
</span>
<span class="mini-status" :class="document.statusTone">
{{ document.statusLabel }}
</span>
</button>
</aside>
<section v-if="selectedDocument" class="file-detail">
<div class="detail-topline">
<div>
<h4>{{ selectedDocument.name }}</h4>
<p>
{{ selectedDocument.folder || '根目录' }}
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
</p>
</div>
<span class="status-chip" :class="selectedDocument.statusTone">
{{ selectedDocument.statusLabel }}
</span>
</div>
<div class="detail-stats">
<div>
<span>原文字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
</div>
<div>
<span>索引字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
</div>
<div>
<span>Chunk</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
</div>
<div>
<span>实体 / 关系</span>
<strong>
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
/
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
</strong>
</div>
</div>
<p v-if="selectedDocument.error" class="error-note">
{{ selectedDocument.error }}
</p>
<div class="detail-section-grid">
<section class="detail-section">
<div class="section-head">
<h5>Chunk 信息</h5>
<span>{{ selectedDocument.chunks.length }} </span>
</div>
<div v-if="selectedDocument.chunks.length" class="chunk-list">
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
<div>
<strong>{{ compactId(chunk.id) }}</strong>
<p>{{ chunk.summary || '暂无摘要' }}</p>
</div>
<small>{{ chunk.tokens }} tokens</small>
</div>
</div>
<div v-else class="compact-empty">暂无 chunk 明细</div>
</section>
<section class="detail-section">
<div class="section-head">
<h5>章节提取</h5>
<span>{{ selectedDocument.sectionCount }} </span>
</div>
<div v-if="selectedDocument.sections.length" class="section-list">
<div
v-for="section in selectedDocument.sections"
:key="section.title"
class="section-row"
>
<strong>{{ section.title }}</strong>
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
</div>
</div>
<div v-else class="compact-empty">暂无章节信息</div>
</section>
</div>
<section class="detail-section">
<div class="section-head">
<h5>处理事件</h5>
<span>{{ selectedDocument.events.length }} </span>
</div>
<div v-if="selectedDocument.events.length" class="event-list">
<div
v-for="event in selectedDocument.events"
:key="`${event.at}-${event.message}`"
class="event-row"
:class="event.level"
>
<span></span>
<div>
<strong>{{ formatEventTime(event.at) }}</strong>
<p>{{ event.message }}</p>
</div>
</div>
</div>
<div v-else class="compact-empty">暂无处理事件</div>
</section>
</section>
</div>
<section class="graph-section">
<div class="section-head">
<h4>图谱形成</h4>
<span>
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
</span>
</div>
<div class="graph-grid">
<div class="graph-pane">
<h5>实体</h5>
<div v-if="model.graph.entities.length" class="entity-cloud">
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
</div>
<div v-else class="compact-empty">暂无实体</div>
</div>
<div class="graph-pane">
<h5>关系</h5>
<div v-if="model.graph.relations.length" class="relation-list">
<div
v-for="relation in model.graph.relations"
:key="`${relation.source}-${relation.target}-${relation.type}`"
class="relation-row"
>
<strong>{{ relation.source }}</strong>
<i class="mdi mdi-arrow-right-thin"></i>
<strong>{{ relation.target }}</strong>
<span>{{ relation.type }}</span>
</div>
</div>
<div v-else class="compact-empty">暂无关系</div>
</div>
</div>
</section>
<KnowledgeIngestGraphView :graph="model.graph" />
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import {
buildKnowledgeIngestLogModel,
formatKnowledgeMetric
} from '../../utils/knowledgeIngestLogModel.js'
import KnowledgeIngestGraphView from './KnowledgeIngestGraphView.vue'
import KnowledgeIngestRunInfo from './KnowledgeIngestRunInfo.vue'
import { buildKnowledgeIngestLogModel } from '../../utils/knowledgeIngestLogModel.js'
const props = defineProps({
run: {
@@ -202,63 +36,18 @@ const props = defineProps({
}
})
const selectedDocumentId = ref('')
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
const selectedDocument = computed(
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
)
watch(
() => model.value.selectedDocumentId,
(nextDocumentId) => {
if (!nextDocumentId) {
selectedDocumentId.value = ''
return
}
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
selectedDocumentId.value = nextDocumentId
}
},
{ immediate: true }
)
function selectDocument(documentId) {
selectedDocumentId.value = documentId
}
function documentIcon(document) {
const extension = String(document?.extension || '').toLowerCase()
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
return 'mdi mdi-file-document-outline'
}
function compactId(value) {
const text = String(value || '').trim()
if (text.length <= 18) return text || 'chunk'
return `${text.slice(0, 8)}...${text.slice(-6)}`
}
function formatEventTime(value) {
if (!value) return '刚刚'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
</script>
<style scoped>
.knowledge-ingest-panel {
display: grid;
grid-template-rows: auto auto auto minmax(1080px, 1fr);
gap: 14px;
height: clamp(1500px, calc(100dvh + 860px), 1880px);
min-height: 1500px;
padding: 18px;
overflow: hidden;
}
.ingest-head {
@@ -328,381 +117,15 @@ function formatEventTime(value) {
transition: width 0.24s ease;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric-tile {
min-width: 0;
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.metric-tile span,
.metric-tile small {
color: #64748b;
font-size: 12px;
}
.metric-tile strong {
color: #0f172a;
font-size: 18px;
line-height: 1.2;
}
.ingest-workspace {
display: grid;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
gap: 14px;
min-height: 360px;
}
.file-rail {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.file-item {
width: 100%;
min-height: 58px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
text-align: left;
}
.file-item.active {
border-color: rgba(15, 118, 110, 0.38);
background: #f0fdfa;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
}
.file-item > i {
color: #334155;
font-size: 24px;
}
.file-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.file-copy strong,
.file-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-copy strong {
color: #0f172a;
font-size: 13px;
}
.file-copy small {
color: #64748b;
font-size: 12px;
}
.mini-status,
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.mini-status.success,
.status-chip.success {
background: #dcfce7;
color: #166534;
}
.mini-status.warning,
.status-chip.warning {
background: #fef3c7;
color: #92400e;
}
.mini-status.danger,
.status-chip.danger {
background: #fee2e2;
color: #991b1b;
}
.mini-status.muted,
.status-chip.muted {
background: #eef2f7;
color: #475569;
}
.file-detail {
min-width: 0;
display: grid;
align-content: start;
gap: 12px;
}
.detail-topline {
display: flex;
justify-content: space-between;
gap: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #e5edf5;
}
.detail-topline h4 {
margin: 0;
color: #0f172a;
font-size: 16px;
}
.detail-topline p {
margin: 5px 0 0;
color: #64748b;
font-size: 12px;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.detail-stats div {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.detail-stats span {
color: #64748b;
font-size: 12px;
}
.detail-stats strong {
color: #0f172a;
font-size: 13px;
}
.error-note {
margin: 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #991b1b;
font-size: 13px;
}
.detail-section-grid,
.graph-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-section,
.graph-section,
.graph-pane {
min-width: 0;
display: grid;
gap: 10px;
align-content: start;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.section-head h4,
.section-head h5,
.graph-pane h5 {
margin: 0;
color: #0f172a;
font-size: 14px;
}
.section-head span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.chunk-list,
.section-list,
.event-list,
.relation-list {
display: grid;
gap: 8px;
}
.chunk-row,
.section-row,
.event-row,
.relation-row {
min-width: 0;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.chunk-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
}
.chunk-index {
color: #2563eb;
font-size: 12px;
font-weight: 850;
}
.chunk-row strong,
.section-row strong,
.event-row strong,
.relation-row strong {
color: #0f172a;
font-size: 12px;
}
.chunk-row p,
.section-row p,
.event-row p {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.chunk-row small {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}
.section-row {
padding: 10px;
}
.event-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 10px;
}
.event-row > span {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 999px;
background: #2563eb;
}
.event-row.error > span {
background: #dc2626;
}
.entity-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-cloud span {
max-width: 100%;
padding: 5px 9px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 750;
}
.relation-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 9px 10px;
}
.relation-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.relation-row i {
color: #0f766e;
font-size: 18px;
}
.relation-row span {
padding: 3px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
}
.compact-empty {
min-height: 42px;
display: grid;
place-items: center;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 12px;
}
@media (max-width: 980px) {
.metric-strip,
.detail-stats,
.detail-section-grid,
.graph-grid,
.ingest-workspace {
grid-template-columns: 1fr;
}
.file-rail {
max-height: 260px;
overflow: auto;
.knowledge-ingest-panel {
height: clamp(1460px, calc(100dvh + 840px), 1840px);
min-height: 1460px;
}
}
@media (max-width: 620px) {
.ingest-head,
.detail-topline {
.ingest-head {
flex-direction: column;
}
@@ -711,17 +134,9 @@ function formatEventTime(value) {
height: 64px;
}
.file-item {
grid-template-columns: auto minmax(0, 1fr);
}
.mini-status {
grid-column: 2;
justify-self: start;
}
.relation-row {
grid-template-columns: 1fr;
.knowledge-ingest-panel {
height: clamp(1520px, calc(100dvh + 900px), 1900px);
min-height: 1520px;
}
}
</style>

View File

@@ -0,0 +1,415 @@
import { computed, ref, watch } from 'vue'
const MAX_VISIBLE_NODES = 72
const MAX_VISIBLE_EDGES = 180
const MAX_RELATION_PREVIEW = 40
const NODE_TONES = {
hub: {
fill: '#2563eb',
stroke: '#dbeafe',
halo: '#93c5fd',
shadow: 'rgba(37, 99, 235, 0.20)'
},
strong: {
fill: '#0f766e',
stroke: '#ccfbf1',
halo: '#5eead4',
shadow: 'rgba(15, 118, 110, 0.18)'
},
accent: {
fill: '#d97706',
stroke: '#fef3c7',
halo: '#fbbf24',
shadow: 'rgba(217, 119, 6, 0.16)'
},
normal: {
fill: '#4f46e5',
stroke: '#e0e7ff',
halo: '#a5b4fc',
shadow: 'rgba(79, 70, 229, 0.16)'
},
muted: {
fill: '#64748b',
stroke: '#e2e8f0',
halo: '#cbd5e1',
shadow: 'rgba(100, 116, 139, 0.12)'
}
}
export function useKnowledgeIngestGraph(props) {
const graphQuery = ref('')
const activeNodeId = ref('')
const allRelations = computed(() => normalizeRelations(props.graph?.relations))
const rankedNodes = computed(() => buildRankedNodes(props.graph, allRelations.value))
const visibleNodes = computed(() => {
const query = graphQuery.value.toLowerCase()
return rankedNodes.value.slice(0, MAX_VISIBLE_NODES).map((node, index) => ({
...node,
rank: index + 1,
matchesQuery: query ? node.name.toLowerCase().includes(query) : true
}))
})
const visibleNodeNameSet = computed(() => new Set(visibleNodes.value.map((node) => node.name)))
const visibleRelations = computed(() =>
allRelations.value
.filter(
(relation) =>
visibleNodeNameSet.value.has(relation.source) && visibleNodeNameSet.value.has(relation.target)
)
.slice(0, MAX_VISIBLE_EDGES)
)
const nodeIdByName = computed(() =>
new Map(visibleNodes.value.map((node) => [node.name, node.id]))
)
const selectedNode = computed(() => {
if (!visibleNodes.value.length) return null
return visibleNodes.value.find((node) => node.id === activeNodeId.value) || visibleNodes.value[0]
})
const selectedNodeRelations = computed(() => {
if (!selectedNode.value) return []
return allRelations.value
.filter(
(relation) =>
relation.source === selectedNode.value.name || relation.target === selectedNode.value.name
)
.slice(0, MAX_RELATION_PREVIEW)
})
const selectedNodeIncoming = computed(() =>
selectedNodeRelations.value.filter((relation) => relation.target === selectedNode.value?.name)
)
const selectedNodeOutgoing = computed(() =>
selectedNodeRelations.value.filter((relation) => relation.source === selectedNode.value?.name)
)
const graphData = computed(() => ({
nodes: visibleNodes.value.map((node) => toG6Node(node)),
edges: visibleRelations.value
.map((relation, index) => toG6Edge(relation, index, nodeIdByName.value))
.filter(Boolean)
}))
const graphSummary = computed(() => ({
entityCount: formatNumber(resolveCount(props.graph?.entityCount, props.graph?.entity_count, rankedNodes.value.length)),
relationCount: formatNumber(resolveCount(props.graph?.relationCount, props.graph?.relation_count, allRelations.value.length)),
visibleNodeCount: formatNumber(visibleNodes.value.length),
visibleEdgeCount: formatNumber(visibleRelations.value.length)
}))
watch(
visibleNodes,
(nextNodes) => {
if (!nextNodes.length) {
activeNodeId.value = ''
return
}
if (!nextNodes.some((node) => node.id === activeNodeId.value)) {
activeNodeId.value = nextNodes[0].id
}
},
{ immediate: true }
)
function selectNodeById(nodeId) {
const matchedNode = visibleNodes.value.find((node) => node.id === nodeId)
if (!matchedNode) return ''
activeNodeId.value = matchedNode.id
return matchedNode.id
}
function focusFirstMatch() {
const matchedNode = visibleNodes.value.find((node) => node.matchesQuery)
if (!matchedNode) return ''
activeNodeId.value = matchedNode.id
return matchedNode.id
}
function selectRelationPeer(relation) {
if (!selectedNode.value) return ''
const peerName = relation.source === selectedNode.value.name ? relation.target : relation.source
const peerId = nodeIdByName.value.get(peerName)
if (!peerId) return ''
activeNodeId.value = peerId
return peerId
}
return {
activeNodeId,
graphData,
graphQuery,
graphSummary,
selectNodeById,
focusFirstMatch,
selectRelationPeer,
selectedNode,
selectedNodeIncoming,
selectedNodeOutgoing,
selectedNodeRelations,
truncateText,
visibleNodes,
visibleRelations
}
}
function buildRankedNodes(graph, relations) {
const degreeMap = new Map()
for (const relation of relations) {
degreeMap.set(relation.source, (degreeMap.get(relation.source) || 0) + 1)
degreeMap.set(relation.target, (degreeMap.get(relation.target) || 0) + 1)
}
const byName = new Map()
for (const entity of normalizeEntities(graph?.entities)) {
byName.set(entity.name, entity)
}
for (const relation of relations) {
if (!byName.has(relation.source)) byName.set(relation.source, { name: relation.source, type: '关系实体' })
if (!byName.has(relation.target)) byName.set(relation.target, { name: relation.target, type: '关系实体' })
}
return [...byName.values()]
.map((entity) => ({
...entity,
id: toNodeId(entity.name),
degree: degreeMap.get(entity.name) || 0
}))
.sort((left, right) => {
const degreeDelta = right.degree - left.degree
if (degreeDelta !== 0) return degreeDelta
return left.name.localeCompare(right.name, 'zh-CN')
})
}
function toG6Node(node) {
const tone = resolveNodeTone(node)
const palette = NODE_TONES[tone]
const size = clamp(34 + Math.sqrt(Math.max(node.degree, 1)) * 13, 38, node.rank === 1 ? 82 : 70)
const opacity = node.matchesQuery ? 1 : 0.24
return {
id: node.id,
data: {
name: node.name,
type: node.type || '实体',
description: node.description || '',
descriptions: node.descriptions || [],
properties: node.properties || {},
labels: node.labels || [],
degree: node.degree,
rank: node.rank,
matchesQuery: node.matchesQuery,
tone
},
style: {
size,
fill: palette.fill,
stroke: palette.stroke,
lineWidth: 2,
opacity,
shadowColor: palette.shadow,
shadowBlur: 14,
label: true,
labelText: truncateText(node.name, node.rank === 1 ? 16 : 12),
labelFill: '#0f172a',
labelFontSize: node.rank === 1 ? 13 : 12,
labelFontWeight: 850,
labelPlacement: 'bottom',
labelOffsetY: 10,
labelBackground: true,
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
labelBackgroundLineWidth: 1,
labelBackgroundRadius: 4,
labelBackgroundPadding: [2, 6],
halo: true,
haloStroke: palette.halo,
haloLineWidth: 10,
haloStrokeOpacity: 0.16,
badge: node.degree > 0,
badges: [
{
text: String(node.degree),
placement: 'right-top',
fill: 'rgba(255, 255, 255, 0.94)',
stroke: 'rgba(37, 99, 235, 0.28)',
color: '#1e293b',
fontSize: 10,
padding: [2, 5]
}
]
},
states: node.matchesQuery ? [] : ['dimmed']
}
}
function toG6Edge(relation, index, nodeIdByName) {
const sourceId = nodeIdByName.get(relation.source)
const targetId = nodeIdByName.get(relation.target)
if (!sourceId || !targetId || sourceId === targetId) return null
const weight = clamp(Number(relation.weight || relation.confidence || 1), 1, 6)
return {
id: `edge-${index}-${sourceId}-${targetId}`,
source: sourceId,
target: targetId,
data: {
sourceName: relation.source,
targetName: relation.target,
type: relation.type,
description: relation.description || '',
keywords: relation.keywords || [],
properties: relation.properties || {},
weight
},
style: {
stroke: 'rgba(37, 99, 235, 0.34)',
lineWidth: clamp(1 + weight * 0.22, 1.2, 2.6),
opacity: 0.9,
endArrow: true,
endArrowSize: 8,
label: index < 42,
labelText: truncateText(relation.type, 10),
labelFill: '#0f172a',
labelFontSize: 10,
labelFontWeight: 800,
labelBackground: true,
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
labelBackgroundLineWidth: 1,
labelBackgroundRadius: 5,
labelBackgroundPadding: [2, 5]
},
states: []
}
}
function resolveNodeTone(node) {
if (node.rank === 1 || node.degree >= 6) return 'hub'
if (node.degree >= 4) return 'strong'
if (node.degree >= 2 && node.rank % 3 === 0) return 'accent'
if (node.degree === 0) return 'muted'
return 'normal'
}
function normalizeRelations(rawRelations) {
if (!Array.isArray(rawRelations)) return []
const seen = new Set()
return rawRelations
.map((relation) => ({
source: String(relation?.source || relation?.from || relation?.head || '').trim(),
target: String(relation?.target || relation?.to || relation?.tail || '').trim(),
type: String(relation?.type || relation?.relation || relation?.label || '关联').trim(),
description: String(relation?.description || '').trim(),
keywords: dedupeTextList(relation?.keywords || []),
properties: normalizeProperties(relation?.properties),
weight: relation?.weight ?? relation?.confidence ?? 1
}))
.filter((relation) => relation.source && relation.target)
.filter((relation) => {
const key = `${relation.source}\u0000${relation.target}\u0000${relation.type}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function normalizeEntities(rawEntities) {
if (!Array.isArray(rawEntities)) return []
const seen = new Set()
return rawEntities
.map((entity) => {
if (typeof entity === 'string') {
return {
name: entity.trim(),
type: '实体',
description: '',
descriptions: [],
properties: {},
labels: []
}
}
return {
name: String(
entity?.name || entity?.entity || entity?.entity_id || entity?.title || entity?.id || ''
).trim(),
type: String(entity?.type || entity?.entity_type || entity?.category || entity?.kind || '实体').trim(),
description: String(entity?.description || '').trim(),
descriptions: normalizeDescriptions(entity),
properties: normalizeProperties(entity?.properties),
labels: dedupeTextList(entity?.labels || [])
}
})
.filter((entity) => entity.name)
.filter((entity) => {
if (seen.has(entity.name)) return false
seen.add(entity.name)
return true
})
}
function toNodeId(value) {
const text = String(value || '').trim()
let hash = 0
for (let index = 0; index < text.length; index += 1) {
hash = (hash << 5) - hash + text.charCodeAt(index)
hash |= 0
}
return `node-${Math.abs(hash).toString(36)}`
}
function normalizeDescriptions(entity) {
const descriptions = dedupeTextList(entity?.descriptions)
if (descriptions.length) return descriptions
const description = String(entity?.description || '').trim()
return description ? [description] : []
}
function normalizeProperties(rawProperties) {
if (!rawProperties || typeof rawProperties !== 'object' || Array.isArray(rawProperties)) {
return {}
}
const hiddenKeys = new Set(['source_id', 'file_path', 'truncate'])
return Object.fromEntries(
Object.entries(rawProperties)
.filter(([key, value]) => !hiddenKeys.has(key) && String(value || '').trim())
.map(([key, value]) => [key, String(value).trim()])
)
}
function truncateText(value, maxLength) {
const text = String(value || '').trim()
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength - 1)}...`
}
function resolveCount(...values) {
for (const value of values) {
const number = Number(value)
if (Number.isFinite(number) && number >= 0) return number
}
return 0
}
function formatNumber(value) {
return new Intl.NumberFormat('zh-CN').format(resolveCount(value))
}
function dedupeTextList(items) {
const result = []
const seen = new Set()
const sourceItems = Array.isArray(items)
? items
: String(items || '')
.split('<SEP>')
.filter(Boolean)
for (const item of sourceItems) {
const text = String(item || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
result.push(text)
}
return result
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}

View File

@@ -0,0 +1,487 @@
<template>
<section class="risk-rule-flow-figure" aria-label="风险规则流程说明" :style="accentStyle">
<div class="risk-rule-flow-content">
<aside class="risk-rule-flow-explainer">
<div class="risk-rule-section-title risk-rule-flow-copy-head">
<strong>流程解释</strong>
<span>{{ flowModel.severityLabel }}</span>
</div>
<ol class="risk-rule-flow-steps" aria-label="文字流程说明">
<li v-for="(item, index) in flowSteps" :key="item.title">
<span class="risk-rule-step-index">{{ index + 1 }}</span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.text }}</p>
<div v-if="item.fields?.length" class="risk-rule-field-list">
<span v-for="field in item.fields" :key="field">{{ field }}</span>
</div>
</div>
</li>
</ol>
<div class="risk-rule-flow-branches" aria-label="是或否分支">
<div v-for="item in flowBranches" :key="item.answer" class="risk-rule-flow-branch">
<span class="risk-rule-branch-answer" :class="`answer-${item.tone}`">
{{ item.answer }}
</span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.text }}</p>
</div>
</div>
</div>
</aside>
<div class="risk-rule-flow-visual">
<div class="risk-rule-section-title risk-rule-flow-visual-title">
<strong>流程图</strong>
</div>
<img
v-if="src"
class="risk-rule-flow-image"
:src="src"
alt="风险规则流程说明"
draggable="false"
/>
<div
v-else
class="risk-rule-flow-svg"
role="img"
aria-label="风险规则流程说明"
v-html="displaySvg"
></div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
svg: { type: String, default: '' },
src: { type: String, default: '' },
flow: { type: Object, default: () => ({}) },
fields: { type: Array, default: () => [] },
severity: { type: String, default: 'medium' },
severityLabel: { type: String, default: '中风险' }
})
const FONT =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'
const NEUTRAL_BORDER = '#e2e8f0'
const PALETTES = {
low: {
accent: '#2563eb',
accentDark: '#1d4ed8',
border: '#bfdbfe',
surface: '#eff6ff'
},
medium: {
accent: '#f97316',
accentDark: '#c2410c',
border: '#fed7aa',
surface: '#fff7ed'
},
high: {
accent: '#dc2626',
accentDark: '#b91c1c',
border: '#fecaca',
surface: '#fef2f2'
}
}
function normalizeText(value, fallback = '') {
return String(value || fallback || '').trim()
}
function escapeSvg(value) {
return normalizeText(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function isSafeSvg(value) {
const text = normalizeText(value)
if (!text.startsWith('<svg') || !text.endsWith('</svg>')) {
return false
}
return !/(<script|<foreignObject|<iframe|<object|<embed|\son\w+=|javascript:)/i.test(text)
}
function isCurrentDisplaySvg(value) {
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
}
function resolvePalette(severity) {
return PALETTES[normalizeText(severity).toLowerCase()] || PALETTES.medium
}
function formatFieldDisplay(item) {
const key = normalizeText(item?.key)
const label = normalizeText(item?.label || key)
if (label && key && label !== key) {
return `${label}[${key}]`
}
return label || key
}
function wrapText(value, width, maxLines) {
const text = normalizeText(value)
if (!text) {
return ['']
}
const lines = []
for (let index = 0; index < text.length; index += width) {
lines.push(text.slice(index, index + width))
}
if (lines.length > maxLines) {
return [...lines.slice(0, maxLines - 1), `${lines[maxLines - 1].slice(0, width - 1)}`]
}
return lines
}
function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13) {
return lines
.map(
(line, index) =>
`<text x="${x}" y="${y + index * (fontSize + 5)}" text-anchor="${anchor}" fill="${color}" font-family="${FONT}" font-size="${fontSize}" font-weight="400">${escapeSvg(line)}</text>`
)
.join('')
}
function node(title, body, x, y, width, height, currentPalette = null) {
const border = currentPalette?.border || NEUTRAL_BORDER
const stripe = currentPalette?.accent || NEUTRAL_LINE
const surface = currentPalette?.surface || '#ffffff'
return `<g>
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="7" ry="7" fill="${surface}" stroke="${border}" stroke-width="1.2"/>
<rect x="${x}" y="${y}" width="3.5" height="${height}" rx="1.75" ry="1.75" fill="${stripe}"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', MUTED, 11)}
</g>`
}
function diamond(title, body, x, y, width, height) {
const cx = x + width / 2
const cy = y + height / 2
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
return `<g>
<polygon points="${points}" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="${cx}" y="${cy - 10}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 11, 'middle', MUTED, 10.2)}
</g>`
}
function note(body) {
return `<g>
<rect x="214" y="218" width="290" height="36" rx="7" ry="7" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="226" y="240" fill="${MUTED}" font-family="${FONT}" font-size="10" font-weight="500">BASIS</text>
${textLines(wrapText(body, 22, 1), 268, 240, 'start', TEXT, 10.2)}
</g>`
}
const palette = computed(() => resolvePalette(props.severity))
const accentStyle = computed(() => ({
'--risk-flow-accent': palette.value.accent,
'--risk-flow-accent-dark': palette.value.accentDark,
'--risk-flow-border': palette.value.border,
'--risk-flow-surface': palette.value.surface
}))
const fieldDisplays = computed(() =>
(Array.isArray(props.fields) ? props.fields : []).map(formatFieldDisplay).filter(Boolean)
)
const fieldSummary = computed(() => {
const fields = fieldDisplays.value
if (!fields.length) {
return '规则字段'
}
if (fields.length <= 4) {
return fields.join('、')
}
return `${fields.slice(0, 4).join('、')}${fields.length} 项字段`
})
const flowModel = computed(() => {
const severityLabel = normalizeText(props.severityLabel, '中风险')
return {
severityLabel,
start: normalizeText(props.flow?.start, '业务单据提交'),
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
}
})
const flowSteps = computed(() => [
{
title: '业务输入',
text: flowModel.value.start
},
{
title: '字段取数',
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
fields: fieldDisplays.value
},
{
title: '判断依据',
text: flowModel.value.basis || flowModel.value.decision
}
])
const flowBranches = computed(() => [
{
answer: '否',
tone: 'pass',
title: '不命中风险',
text: flowModel.value.pass
},
{
answer: '是',
tone: 'risk',
title: `命中${flowModel.value.severityLabel}`,
text: flowModel.value.fail
}
])
const displaySvg = computed(() => {
const providedSvg = normalizeText(props.svg)
if (isCurrentDisplaySvg(providedSvg)) {
return providedSvg
}
const flow = flowModel.value
const currentPalette = palette.value
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${NEUTRAL_LINE}"/>
</marker>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="${MUTED}" font-family="${FONT}" font-size="11" font-weight="500">RULE FLOW</text>
${node('业务输入', flow.start, 48, 118, 124, 60)}
${node('字段取数', '读取字段证据', 214, 118, 132, 60)}
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
${node('继续流转', flow.pass, 562, 74, 126, 60)}
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
${note(flow.basis)}
<line x1="172" y1="148" x2="214" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="600">是</text>
</svg>`
})
</script>
<style scoped>
.risk-rule-flow-figure {
width: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
padding: 14px;
cursor: default;
}
.risk-rule-flow-content {
width: 100%;
display: grid;
grid-template-columns: minmax(260px, 0.78fr) minmax(0, 1.22fr);
gap: 16px;
align-items: start;
}
.risk-rule-flow-explainer {
min-width: 0;
align-self: stretch;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 16px;
border-right: 1px solid #e2e8f0;
}
.risk-rule-section-title {
min-height: 24px;
display: flex;
align-items: center;
width: 100%;
line-height: 1.4;
}
.risk-rule-flow-copy-head {
justify-content: space-between;
gap: 10px;
}
.risk-rule-section-title strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.4;
}
.risk-rule-flow-copy-head span {
color: var(--risk-flow-accent-dark);
font-size: 12px;
font-weight: 800;
}
.risk-rule-flow-steps {
display: grid;
gap: 11px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-rule-flow-steps li,
.risk-rule-flow-branch {
min-width: 0;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.risk-rule-step-index,
.risk-rule-branch-answer {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.risk-rule-step-index,
.answer-pass {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.answer-risk {
background: var(--risk-flow-surface);
color: var(--risk-flow-accent-dark);
border: 1px solid var(--risk-flow-border);
}
.risk-rule-flow-steps strong,
.risk-rule-flow-branch strong {
display: block;
color: #111827;
font-size: 12.5px;
font-weight: 800;
line-height: 1.35;
}
.risk-rule-flow-explainer p {
margin: 4px 0 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.risk-rule-field-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.risk-rule-field-list span {
max-width: 100%;
padding: 3px 7px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
color: #334155;
font-size: 11px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.risk-rule-flow-branches {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
}
.risk-rule-flow-visual {
min-width: 0;
display: grid;
gap: 8px;
place-items: center;
overflow: hidden;
}
.risk-rule-flow-visual-title {
justify-self: start;
justify-content: flex-start;
}
.risk-rule-flow-image,
.risk-rule-flow-svg {
width: min(760px, 100%);
display: block;
pointer-events: none;
user-select: none;
}
.risk-rule-flow-image {
height: auto;
object-fit: contain;
}
.risk-rule-flow-svg :deep(svg) {
width: 100%;
height: auto;
display: block;
}
.risk-rule-flow-svg :deep(*) {
pointer-events: none !important;
user-select: none;
}
@media (max-width: 980px) {
.risk-rule-flow-content {
grid-template-columns: 1fr;
}
.risk-rule-flow-explainer {
padding-right: 0;
padding-bottom: 12px;
border-right: 0;
border-bottom: 1px solid #e2e8f0;
}
}
@media (max-width: 760px) {
.risk-rule-flow-figure {
padding: 10px;
}
}
</style>

View File

@@ -144,6 +144,16 @@ export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
})
}
export function generateRiskRuleAsset(payload, options = {}) {
return apiRequest('/agent-assets/risk-rules/generate', {
method: 'POST',
body: JSON.stringify(payload),
headers: buildWriteHeaders(options),
timeoutMs: options.timeoutMs || 60000,
timeoutMessage: '风险规则生成时间较长,请稍后查看是否已生成草稿。'
})
}
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`

View File

@@ -110,14 +110,14 @@ export function buildTypeFilterOptions(rows) {
const typeMap = new Map()
for (const row of rows) {
const value = String(row?.typeCode || 'other').trim() || 'other'
const value = String(row?.archiveTypeCode || row?.typeCode || 'other').trim() || 'other'
if (!typeMap.has(value)) {
typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value)
typeMap.set(value, String(row?.archiveType || row?.type || row?.typeLabel || value).trim() || value)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部类型' },
{ value: ARCHIVE_FILTER_ALL, label: '全部归档类型' },
...Array.from(typeMap.entries())
.sort((left, right) => left[1].localeCompare(right[1], 'zh-CN'))
.map(([value, label]) => ({ value, label }))
@@ -176,7 +176,7 @@ export function applyArchiveListFilters(rows, filters) {
}
if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type)
filteredRows = filteredRows.filter((row) => String(row.archiveTypeCode || row.typeCode || '').trim() === filters.type)
}
if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) {
@@ -193,6 +193,7 @@ export function applyArchiveListFilters(rows, filters) {
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.archiveType || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
|| String(row.risk || '').toLowerCase().includes(keyword)

View File

@@ -130,12 +130,14 @@ function normalizeDocument(rawDocument) {
textChars: toNumber(document.text_chars),
indexedTextChars: toNumber(document.indexed_text_chars),
sectionCount: toNumber(document.section_count || sections.length),
sections,
chunkCount: toNumber(document.chunk_count || chunks.length),
chunkIds: normalizeTextList(document.chunk_ids),
chunks,
entityCount: toNumber(document.entity_count || entities.length),
relationCount: toNumber(document.relation_count || relations.length),
entities,
entityChunks: normalizeEntityChunks(document.entity_chunks || document.entityChunks),
relations,
events: normalizeEvents(document.events)
}
@@ -165,7 +167,9 @@ function normalizeProgress(rawProgress, documents) {
function normalizeGraph(rawGraph, documents) {
const graph = asObject(rawGraph)
const fallbackEntities = dedupeTextList(documents.flatMap((item) => item.entities))
const graphEntities = normalizeEntities(graph.entities)
const fallbackEntities = dedupeEntities(documents.flatMap((item) => item.entities))
const graphRelations = normalizeRelations(graph.relations)
const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations))
return {
chunkCount: toNumber(
@@ -177,12 +181,8 @@ function normalizeGraph(rawGraph, documents) {
relationCount: toNumber(
graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0)
),
entities: normalizeTextList(graph.entities).length
? normalizeTextList(graph.entities)
: fallbackEntities,
relations: normalizeRelations(graph.relations).length
? normalizeRelations(graph.relations)
: fallbackRelations
entities: graphEntities.length ? graphEntities : fallbackEntities,
relations: graphRelations.length ? graphRelations : fallbackRelations
}
}
@@ -195,12 +195,28 @@ function normalizeChunks(rawChunks) {
id: String(item.id || item._id || `chunk-${index + 1}`).trim(),
order: toNumber(item.order ?? item.chunk_order_index ?? index),
tokens: toNumber(item.tokens),
summary: String(item.summary || item.content || '').trim()
summary: String(item.summary || item.content || '').trim(),
excerpt: String(item.excerpt || item.content_preview || item.summary || item.content || '').trim()
}
})
.sort((left, right) => left.order - right.order)
}
function normalizeEntityChunks(rawItems) {
if (!Array.isArray(rawItems)) return []
const result = []
const seen = new Set()
for (const rawItem of rawItems) {
const item = asObject(rawItem)
const entity = String(item.entity || item.name || '').trim()
const chunkIds = normalizeTextList(item.chunk_ids || item.chunkIds)
if (!entity || !chunkIds.length || seen.has(entity)) continue
seen.add(entity)
result.push({ entity, chunkIds })
}
return result
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections)) return []
return rawSections.map((section, index) => {
@@ -225,7 +241,8 @@ function normalizeEvents(rawEvents) {
}
function normalizeEntities(rawEntities) {
return normalizeTextList(rawEntities)
if (!Array.isArray(rawEntities)) return []
return dedupeEntities(rawEntities)
}
function normalizeRelations(rawRelations) {
@@ -236,7 +253,11 @@ function normalizeRelations(rawRelations) {
return {
source: String(item.source || item.from || '').trim(),
target: String(item.target || item.to || '').trim(),
type: String(item.type || '关联').trim()
type: String(item.type || '关联').trim(),
description: String(item.description || '').trim(),
keywords: normalizeTextList(item.keywords),
weight: toNumber(item.weight || item.confidence || 1),
properties: asObject(item.properties)
}
})
.filter((item) => item.source && item.target)
@@ -263,15 +284,22 @@ function asObject(value) {
}
function normalizeTextList(value) {
if (!Array.isArray(value)) return []
return dedupeTextList(value)
if (Array.isArray(value)) return dedupeTextList(value)
return dedupeTextList(
String(value || '')
.split('<SEP>')
.filter(Boolean)
)
}
function dedupeTextList(items) {
const result = []
const seen = new Set()
for (const item of items) {
const text = String(item || '').trim()
const text =
typeof item === 'string'
? item.trim()
: String(item?.name || item?.entity || item?.title || item?.id || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
result.push(text)
@@ -279,6 +307,43 @@ function dedupeTextList(items) {
return result
}
function dedupeEntities(items) {
const result = []
const seen = new Set()
for (const rawItem of items) {
const item = asObject(rawItem)
const name =
typeof rawItem === 'string'
? rawItem.trim()
: String(
item.name ||
item.entity ||
item.entity_id ||
item.title ||
item.id ||
''
).trim()
if (!name || seen.has(name)) continue
seen.add(name)
const description = String(item.description || '').trim()
const descriptions = normalizeTextList(item.descriptions).length
? normalizeTextList(item.descriptions)
: description
? [description]
: []
result.push({
...item,
name,
type: String(item.type || item.entity_type || item.category || item.kind || '实体').trim(),
description,
descriptions,
properties: asObject(item.properties),
labels: normalizeTextList(item.labels)
})
}
return result
}
function dedupeRelations(items) {
const result = []
const seen = new Set()
@@ -289,7 +354,7 @@ function dedupeRelations(items) {
const key = `${source}::${target}::${type}`
if (!source || !target || seen.has(key)) continue
seen.add(key)
result.push({ source, target, type })
result.push({ ...item, source, target, type })
}
return result
}

View File

@@ -26,7 +26,7 @@
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
</div>
<div
@@ -102,7 +102,7 @@
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>归档类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>归档节点</th>
@@ -120,7 +120,7 @@
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.archiveType }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td>{{ row.node }}</td>

View File

@@ -13,9 +13,9 @@
<div class="detail-scroll">
<section
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
class="detail-hero panel"
class="detail-hero panel asset-detail-topbar list-toolbar"
>
<div class="hero-title">
<div class="hero-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
@@ -52,7 +52,7 @@
</div>
</div>
<div class="hero-stats">
<div class="hero-stats asset-detail-topbar-meta toolbar-actions">
<div class="hero-stat">
<span>资产编码</span>
<strong>{{ selectedSkill.code }}</strong>
@@ -82,8 +82,8 @@
<TableLoadingState
v-else-if="detailLoading && selectedSkill.loading"
class="detail-inline-state panel"
variant="detail"
class="detail-loading-state panel"
variant="panel"
title="正在加载资产详情"
message="列表数据已就绪,正在补充版本、审核和运行信息"
icon="mdi mdi-file-document-outline"
@@ -94,8 +94,8 @@
v-else-if="selectedSkill.usesSpreadsheetRule"
class="spreadsheet-editor-shell panel"
>
<header class="spreadsheet-editor-head">
<div class="spreadsheet-editor-title">
<header class="spreadsheet-editor-head asset-detail-topbar list-toolbar">
<div class="spreadsheet-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
@@ -103,7 +103,7 @@
</div>
</div>
<div class="spreadsheet-editor-actions">
<div class="spreadsheet-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetModeLabel }}
</span>
@@ -213,21 +213,27 @@
v-else-if="selectedSkill.usesJsonRiskRule"
class="json-risk-editor-shell panel"
>
<header class="json-risk-editor-head">
<div class="json-risk-editor-title">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
</p>
<p v-if="selectedSkill.riskCategory" class="json-risk-head-category">
适用场景{{ selectedSkill.riskCategory }}
</p>
<div class="json-risk-head-meta">
<span v-if="selectedSkill.riskCategory">适用场景{{ selectedSkill.riskCategory }}</span>
<span>业务域{{ selectedSkill.category || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
<div class="json-risk-editor-actions">
<span class="json-risk-mode-pill">JSON 风险规则</span>
</div>
<div class="json-risk-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="json-risk-mode-pill" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel }}
</span>
</div>
</header>
@@ -236,64 +242,34 @@
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>规则摘要</h3>
<p>检查器与字段关系为只读说明实际判断逻辑由平台代码实现</p>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间和使用字段</p>
</div>
</div>
<div v-if="selectedSkill.riskRuleSummary" class="json-risk-summary-grid">
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || '-' }}</span>
<span><strong>检查器</strong>{{ selectedSkill.riskRuleSummary.evaluator || '-' }}</span>
<span><strong>本体信号</strong>{{ selectedSkill.riskRuleSummary.ontologySignal || '-' }}</span>
<div class="json-risk-summary-grid">
<span><strong>业务域</strong>{{ selectedSkill.category || '-' }}</span>
<span><strong>风险等级</strong>{{ selectedSkill.riskRuleSeverityLabel || '-' }}</span>
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
<span><strong>创建时间</strong>{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}</span>
<span><strong>已创建</strong>{{ selectedSkill.riskRuleAgeLabel || '-' }}</span>
<span>
<strong>申报字段</strong>
{{ selectedSkill.riskRuleSummary.inputs?.declared || 'claim.location' }}
<strong>使用字段</strong>
{{ selectedSkill.riskRuleFieldSummary || '-' }}
</span>
<span>
<strong>证据字段</strong>
{{ (selectedSkill.riskRuleSummary.inputs?.evidence || []).join('、') || '-' }}
</span>
</div>
</article>
<article class="detail-card panel json-risk-flow-card">
<div class="card-head">
<div>
<h3>字段关系</h3>
<p>提交报销时从表单与 OCR 组装验审上下文再执行一致性检查</p>
</div>
</div>
<div class="json-risk-flow-diagram">
<div class="json-risk-flow-column">
<span class="json-risk-flow-label">输入</span>
<code>claim.location</code>
<code>attachment.cities[]</code>
<code>item.item_location</code>
</div>
<div class="json-risk-flow-arrow"></div>
<div class="json-risk-flow-column center">
<span class="json-risk-flow-label">检查</span>
<strong>{{ selectedSkill.riskRuleSummary?.evaluator || 'location_consistency' }}</strong>
</div>
<div class="json-risk-flow-arrow"></div>
<div class="json-risk-flow-column">
<span class="json-risk-flow-label">输出</span>
<code>risk_flags_json</code>
<code>severity / message</code>
</div>
</div>
</article>
<article
v-if="selectedSkill.riskRuleDescription"
v-if="selectedSkill.riskRuleBusinessDescription"
class="detail-card panel json-risk-description-card"
>
<div class="card-head">
<div>
<h3>规则说明</h3>
<p>本条风险规则的业务背景识别逻辑与适用场景来自 JSON 契约 description 字段</p>
<h3>业务说明</h3>
<p>面向规则制定者和审核人的自然语言说明</p>
</div>
</div>
<p class="json-risk-description-text">{{ selectedSkill.riskRuleDescription }}</p>
<p class="json-risk-description-text">{{ selectedSkill.riskRuleBusinessDescription }}</p>
<p
v-if="selectedSkill.riskRuleSourceRef"
class="json-risk-description-source"
@@ -302,27 +278,20 @@
</p>
</article>
<article class="detail-card panel json-editor-card">
<article class="detail-card panel json-risk-flow-card">
<div class="card-head">
<div>
<h3>规则 JSON 契约</h3>
<p>保存后写入 server/rules/risk-rules/提交验审与 Agent 风险问答共用同一检查器</p>
<h3>判断流程</h3>
<p>规则从业务单据开始读取字段证据后按判断依据决定是否进入复核</p>
</div>
</div>
<label class="field">
<span>{{ selectedSkill.ruleDocument?.file_name || `${selectedSkill.code}.json` }}</span>
<textarea
v-model="selectedSkill.riskRuleJsonText"
class="json-editor json-risk-editor"
:class="{ disabled: !canEditMarkdown }"
spellcheck="false"
:readonly="!canEditMarkdown || detailBusy"
></textarea>
</label>
<div class="editor-foot">
<span>请勿在 JSON 中配置公司差标evaluator 变更需同步发布服务端检查器</span>
<span>平台内置规则一般不频繁变更直接维护 JSON 契约即可</span>
</div>
<RiskRuleFlowDiagram
:svg="selectedSkill.riskRuleFlowDiagramSvg"
:flow="selectedSkill.riskRuleFlow"
:fields="selectedSkill.riskRuleFields"
:severity="selectedSkill.riskRuleSeverity"
:severity-label="selectedSkill.riskRuleSeverityLabel"
/>
</article>
</section>
</div>
@@ -673,17 +642,7 @@
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
</button>
<button
v-else-if="selectedSkillUsesJsonRisk"
class="minor-action"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@click="saveRiskRuleJson"
>
<i class="mdi mdi-content-save-outline"></i>
<span>{{ actionState === 'save-risk-json' ? '保存中...' : '保存 JSON' }}</span>
</button>
<button
v-else-if="!selectedSkill.usesSpreadsheetRule"
v-else-if="!selectedSkill.usesSpreadsheetRule && !selectedSkillUsesJsonRisk"
class="minor-action"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@@ -893,7 +852,12 @@
<span>清空筛选</span>
</button>
<button class="create-btn" type="button" disabled>
<button
class="create-btn"
type="button"
:disabled="!canCreateRiskRule"
@click="openRiskRuleCreateDialog"
>
<i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span>
</button>
@@ -985,6 +949,58 @@
</article>
</Transition>
<ConfirmDialog
:open="riskRuleCreateOpen"
badge="自然语言规则"
badge-tone="info"
title="新建风险规则"
description="选择业务域和风险等级后,用自然语言描述规则,系统会生成可审核的风险规则草稿。"
cancel-text="取消"
confirm-text="开始生成"
busy-text="生成中..."
confirm-tone="primary"
confirm-icon="mdi mdi-auto-fix"
:busy="riskRuleCreateBusy"
:close-on-mask="!riskRuleCreateBusy"
@close="closeRiskRuleCreateDialog"
@confirm="submitRiskRuleCreate"
>
<div class="risk-rule-create-form">
<label>
<span>业务域</span>
<select v-model="riskRuleCreateForm.business_domain" :disabled="riskRuleCreateBusy">
<option
v-for="option in riskRuleCreateDomainOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label>
<span>风险等级</span>
<select v-model="riskRuleCreateForm.risk_level" :disabled="riskRuleCreateBusy">
<option
v-for="option in riskRuleLevelOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
v-model="riskRuleCreateForm.natural_language"
:disabled="riskRuleCreateBusy"
placeholder="例如:住宿城市必须出现在本次差旅行程城市中,否则提示高风险并要求补充说明。"
></textarea>
</label>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="Boolean(versionSwitchTarget)"
badge="切换版本"

View File

@@ -15,7 +15,7 @@
</article>
<template v-else-if="isHermes && hermesRun">
<article class="detail-hero panel">
<article v-if="!isKnowledgeIngestRunDetail" class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
@@ -48,7 +48,7 @@
:run="hermesRun"
/>
<div class="detail-grid">
<div v-if="!isKnowledgeIngestRunDetail" class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>基本信息</h3>

View File

@@ -82,7 +82,7 @@
/>
</span>
<div class="message-bubble">
<div class="message-bubble" :class="buildMessageBubbleClass(message)">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
@@ -357,6 +357,13 @@
</p>
</template>
<div
v-if="buildReviewNextStepRichCopyForMessage(message)"
class="review-next-step-rich-copy message-answer-markdown"
v-html="renderMarkdown(buildReviewNextStepRichCopyForMessage(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="resolveReviewFooterActions(message.reviewPayload).length"
class="review-footer-actions"
@@ -773,7 +780,11 @@
</div>
<Transition name="insight-switch" mode="out-in">
<div :key="`${activeSessionType}-${currentInsight.intent}-${currentInsight.title}-${reviewDrawerMode}`" class="insight-body">
<div
:key="`${activeSessionType}-${currentInsight.intent}-${currentInsight.title}-${reviewDrawerMode}`"
class="insight-body"
:class="{ 'document-review-body': isReviewDocumentDrawer }"
>
<template v-if="isKnowledgeSession">
<section class="insight-card knowledge-hot-card">
<div class="card-head">
@@ -1145,7 +1156,7 @@
v-model="activeReviewDocument.scene_label"
type="text"
:disabled="submitting || reviewActionBusy"
placeholder="例如:业务招待费 / 差旅费"
placeholder="例如:出租车/网约车票据 / 火车/高铁票"
/>
</label>
@@ -1302,6 +1313,22 @@
@confirm="confirmDeleteCurrentSession"
/>
<ConfirmDialog
:open="nextStepConfirmDialog.open"
badge="提交确认"
badge-tone="primary"
title="确认提交当前单据?"
description="提交后单据将进入审批流程,请确认关键信息、票据和风险提示已经核对无误。"
cancel-text="再检查一下"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-check-outline"
:busy="reviewActionBusy"
@close="closeReviewNextStepConfirm"
@confirm="confirmReviewNextStepSubmit"
/>
<Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal">
@@ -1318,11 +1345,13 @@
<div class="review-preview-body" :class="documentPreviewDialog.kind">
<img
v-if="documentPreviewDialog.kind === 'image'"
:key="documentPreviewDialog.renderKey"
:src="documentPreviewDialog.url"
:alt="documentPreviewDialog.filename"
/>
<iframe
v-else-if="documentPreviewDialog.kind === 'pdf'"
:key="documentPreviewDialog.renderKey"
:src="documentPreviewDialog.url"
title="票据 PDF 原图预览"
></iframe>

View File

@@ -20,7 +20,11 @@ import {
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
const ARCHIVE_TAB_ALL = '全部归档'
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
@@ -40,17 +44,6 @@ function formatCurrency(value) {
}).format(Number.isFinite(amount) ? amount : 0)
}
function resolveArchiveTypeTab(request) {
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
if (expenseType === 'travel') {
return '差旅报销'
}
if (expenseType === 'entertainment') {
return '招待报销'
}
return '其他费用'
}
function buildArchiveRow(request) {
const normalized = normalizeRequestForUi(request)
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
@@ -75,6 +68,8 @@ function buildArchiveRow(request) {
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: normalized.workflowNode || '归档入账',
hasRisk,
riskCount,
@@ -82,7 +77,7 @@ function buildArchiveRow(request) {
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: resolveArchiveTypeTab(normalized)
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
}
}
@@ -98,7 +93,7 @@ export default {
TableEmptyState
},
setup() {
const activeTab = ref('全部归档')
const activeTab = ref(ARCHIVE_TAB_ALL)
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
@@ -115,7 +110,7 @@ export default {
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '归档类型'))
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
@@ -193,8 +188,8 @@ export default {
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `${activeTab.value}”里暂时没有归档单据`,
desc: filtersActive
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
? '可以调整风险、归档类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他归档分类查看,或调整筛选条件后重新检索。',
icon: 'mdi mdi-archive-outline',
actionLabel: null,
actionIcon: null,
@@ -205,7 +200,7 @@ export default {
})
function resetListFilters() {
activeTab.value = '全部归档'
activeTab.value = ARCHIVE_TAB_ALL
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL

View File

@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import RiskRuleFlowDiagram from '../../components/shared/RiskRuleFlowDiagram.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -18,6 +19,7 @@ import {
fetchAgentAssetRuleJson,
fetchAgentAssetVersionTimeline,
fetchAgentRuns,
generateRiskRuleAsset,
saveAgentAssetRuleJson,
importAgentAssetSpreadsheetContent,
restoreAgentAssetVersion,
@@ -58,11 +60,17 @@ import {
parseRuntimeRuleText,
buildMarkdownVersionContent
} from './auditViewModel.js'
import {
createDefaultRiskRuleForm,
RISK_RULE_CREATE_DOMAIN_OPTIONS,
RISK_RULE_LEVEL_OPTIONS
} from './auditViewRiskRuleModel.js'
export default {
name: 'AuditView',
components: {
ConfirmDialog,
RiskRuleFlowDiagram,
TableLoadingState,
TableEmptyState
},
@@ -95,6 +103,8 @@ export default {
const reviewSubmitReviewer = ref('')
const reviewSubmitReviewerLoading = ref(false)
const reviewSubmitReviewerOptions = ref([])
const riskRuleCreateOpen = ref(false)
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
const runLoading = ref(false)
const runs = ref([])
const spreadsheetUploadInput = ref(null)
@@ -152,6 +162,10 @@ export default {
!selectedSkill.value?.isPreviewMock &&
(isAdmin.value || isFinance.value)
)
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
)
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
const isDisplayingWorkingVersion = computed(
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
@@ -492,6 +506,53 @@ export default {
return currentUser.value?.name || currentUser.value?.username || 'system'
}
function openRiskRuleCreateDialog() {
if (activeType.value !== 'riskRules') {
return
}
riskRuleCreateForm.value = createDefaultRiskRuleForm()
riskRuleCreateOpen.value = true
}
function closeRiskRuleCreateDialog() {
if (riskRuleCreateBusy.value) {
return
}
riskRuleCreateOpen.value = false
}
async function submitRiskRuleCreate() {
if (!canCreateRiskRule.value || riskRuleCreateBusy.value) {
return
}
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
if (naturalLanguage.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
}
actionState.value = 'generate-risk-rule'
try {
const detail = await generateRiskRuleAsset(
{
business_domain: riskRuleCreateForm.value.business_domain,
risk_level: riskRuleCreateForm.value.risk_level,
natural_language: naturalLanguage
},
{ actor: resolveActor() }
)
riskRuleCreateOpen.value = false
await refreshCurrentAssets()
selectedSkill.value = buildDetailViewModel(detail, runs.value)
await loadRiskRuleJson(detail.id)
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
} catch (error) {
toast(error?.message || '风险规则生成失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
@@ -1421,6 +1482,7 @@ export default {
activeFilterTokens,
canManageSelected,
canEditSelected,
canCreateRiskRule,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
@@ -1444,6 +1506,11 @@ export default {
reviewSubmitReviewer,
reviewSubmitReviewerLoading,
reviewSubmitReviewerOptions,
riskRuleCreateOpen,
riskRuleCreateForm,
riskRuleCreateBusy,
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
showReviewNote,
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,
@@ -1464,6 +1531,9 @@ export default {
toggleFilterPopover,
selectFilter,
closeFilterPopover,
openRiskRuleCreateDialog,
closeRiskRuleCreateDialog,
submitRiskRuleCreate,
openVersionSwitch,
cancelVersionSwitch,
confirmVersionSwitch,

View File

@@ -11,6 +11,7 @@ import { useTravelReimbursementSessionState } from './useTravelReimbursementSess
import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js'
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
@@ -536,6 +537,7 @@ export default {
currentInsight,
reviewFilePreviews,
composerUploadIntent,
guidedFlowState,
insightPanelCollapsed,
sessionSwitchBusy,
buildEmptySessionState,
@@ -871,7 +873,8 @@ export default {
})
sessionRuntimeRefs = {
attachedFiles,
composerFilesExpanded
composerFilesExpanded,
guidedFlowState
}
const {
confirmPendingAttachmentAssociationInternal,
@@ -961,6 +964,34 @@ export default {
|| composerBusinessTimeTags.value.length
)
)
const {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
resetGuidedFlowState
} = useTravelReimbursementGuidedFlow({
guidedFlowState,
messages,
composerDraft,
attachedFiles,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
fileInputRef,
submitting,
reviewActionBusy,
sessionSwitchBusy,
createMessage,
nextTick,
scrollToBottom,
persistSessionState,
clearAttachedFiles,
adjustComposerTextareaHeight,
buildComposerBusinessTimeContext,
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
toast
})
function toggleTravelCalculator() {
return toggleTravelCalculatorInternal()
}
@@ -1050,6 +1081,7 @@ export default {
reviewFilePreviews: reviewFilePreviews.value,
composerDraft: composerDraft.value,
composerUploadIntent: composerUploadIntent.value,
guidedFlowState: guidedFlowState.value,
insightPanelCollapsed: insightPanelCollapsed.value
}),
() => {
@@ -1168,6 +1200,7 @@ export default {
function resetCurrentSessionState() {
const emptyState = buildEmptySessionState(activeSessionType.value)
sessionSnapshots.value[activeSessionType.value] = emptyState
resetGuidedFlowState()
applySessionState(emptyState)
resetFlowRun({ startedAt: 0, openDrawer: false })
}
@@ -1239,6 +1272,9 @@ export default {
await switchSessionType(shortcut.targetSessionType)
return
}
if (handleGuidedShortcut(shortcut)) {
return
}
const prompt = String(shortcut?.prompt || '').trim()
if (!prompt) return
@@ -1293,6 +1329,7 @@ export default {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (await handleGuidedSuggestedAction(message, action)) return
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
@@ -1746,6 +1783,9 @@ export default {
// submitting.value = true
// recognizeOcrFiles(files)
// submitting.value = false
if (await handleGuidedComposerSubmit(options)) {
return null
}
return submitComposerInternal(options)
}

View File

@@ -87,7 +87,7 @@ export const TAB_META = {
typeKey: 'rules',
label: '风险规则',
typeLabel: '风险规则',
createButtonLabel: '风险规则已接入',
createButtonLabel: '新建风险规则',
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
searchPlaceholder: '搜索风险规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS,

View File

@@ -19,6 +19,17 @@ import {
TYPE_META,
VERSION_STATE_META
} from './auditViewMetadata.js'
import {
buildRiskRuleFieldSummary,
formatRiskRuleAge,
resolveRiskRuleBusinessDescription,
resolveRiskRuleCreatedAt,
resolveRiskRuleFields,
resolveRiskRuleFlow,
resolveRiskRuleFlowDiagramSvg,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
export {
DETAIL_TITLES,
@@ -413,14 +424,26 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
const riskCategory =
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
return {
...target,
riskRuleDescription: fullDescription,
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
riskRuleFields,
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg:
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
riskRuleSummary: {
name: apiPayload?.name || target.name,
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
@@ -1219,6 +1242,7 @@ export function buildDetailViewModel(detail, runs) {
statusValue: detail.status,
statusTone: statusMeta.tone,
hitRate: buildRowMetric(detail, typeKey),
createdAt: detail.created_at,
updatedAt: formatDateTime(detail.updated_at),
badgeTone: tabMeta.badgeTone,
configJson,
@@ -1227,8 +1251,17 @@ export function buildDetailViewModel(detail, runs) {
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleBusinessDescription: '',
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
riskRuleSourceRef: '',
riskRuleSeverity: 'medium',
riskRuleSeverityLabel: '中风险',
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
riskRuleFields: [],
riskRuleFieldSummary: '未识别字段',
riskRuleFlow: resolveRiskRuleFlow({}, []),
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,
scenarioList: typeKey === 'rules' && ruleScenarioCategory

View File

@@ -0,0 +1,166 @@
export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
{ value: 'expense', label: '报销' },
{ value: 'ar', label: '应收' },
{ value: 'ap', label: '应付' }
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'medium', label: '中风险' },
{ value: 'high', label: '高风险' },
{ value: 'low', label: '低风险' }
]
const RISK_LEVEL_LABELS = {
low: '低风险',
medium: '中风险',
high: '高风险'
}
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
risk_level: 'medium',
natural_language: ''
}
}
export function normalizeRiskRuleText(value) {
return String(value || '').trim()
}
export function formatRiskRuleFieldDisplay(field) {
const key = normalizeRiskRuleText(field?.key)
const label = normalizeRiskRuleText(field?.label || key)
if (label && key && label !== key) {
return `${label}[${key}]`
}
return label || key
}
export function resolveRiskRuleSeverity(payload) {
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
}
export function resolveRiskRuleSeverityLabel(payload) {
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
}
export function resolveRiskRuleFields(payload) {
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
if (fieldRows.length) {
return fieldRows
.map((item) => ({
key: normalizeRiskRuleText(item?.key),
label: normalizeRiskRuleText(item?.label || item?.key),
display: formatRiskRuleFieldDisplay(item),
source: normalizeRiskRuleText(item?.source),
type: normalizeRiskRuleText(item?.type)
}))
.filter((item) => item.key || item.label)
}
return Object.entries(inputs)
.map(([label, key]) => ({
key: normalizeRiskRuleText(key),
label: normalizeRiskRuleText(label),
display: formatRiskRuleFieldDisplay({ key, label }),
source: '',
type: ''
}))
.filter((item) => item.key || item.label)
}
export function buildRiskRuleFieldSummary(fields) {
const labels = fields.map(formatRiskRuleFieldDisplay).filter(Boolean)
if (!labels.length) {
return '未识别字段'
}
if (labels.length <= 4) {
return labels.join('、')
}
return `${labels.slice(0, 4).join('、')}${labels.length}`
}
export function resolveRiskRuleCreatedAt(payload, fallback) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
return normalizeRiskRuleText(metadata.created_at || fallback)
}
export function formatRiskRuleAge(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '未记录'
}
const diffMs = Date.now() - date.getTime()
if (diffMs < 0) {
return '刚刚创建'
}
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) {
return '刚刚创建'
}
if (minutes < 60) {
return `${minutes} 分钟`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours} 小时`
}
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}`
}
const months = Math.floor(days / 30)
if (months < 12) {
return `${months} 个月`
}
return `${Math.floor(months / 12)}`
}
export function resolveRiskRuleBusinessDescription(payload, fallback) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
return (
normalizeRiskRuleText(metadata.business_explanation) ||
normalizeRiskRuleText(payload?.description) ||
normalizeRiskRuleText(fallback)
)
}
export function resolveRiskRuleFlowDiagramSvg(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
return (
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
normalizeRiskRuleText(metadata.flow_diagram_svg)
)
}
export function resolveRiskRuleConditionSummary(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
return (
normalizeRiskRuleText(metadata.condition_summary) ||
normalizeRiskRuleText(params.condition_summary) ||
'根据规则字段判断是否命中风险'
)
}
export function resolveRiskRuleFlow(payload, fields) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
const fieldSummary = buildRiskRuleFieldSummary(fields)
const conditionSummary = resolveRiskRuleConditionSummary(payload)
const severityLabel = resolveRiskRuleSeverityLabel(payload)
return {
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
basis: conditionSummary,
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
}
}

View File

@@ -2,6 +2,11 @@ import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY
} from './travelReimbursementGuidedFlowModel.js'
export const SESSION_TYPE_EXPENSE = 'expense'
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
@@ -88,34 +93,19 @@ export const ASSISTANT_DISPLAY_NAME = '财务助手'
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
{
label: '发起差旅报销',
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
icon: 'mdi mdi-bag-suitcase-outline'
label: '快速发起报销',
action: GUIDED_ACTION_START_REIMBURSEMENT,
icon: 'mdi mdi-receipt-text-plus-outline'
},
{
label: '招待费报销',
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
icon: 'mdi mdi-food-fork-drink'
label: '查询单据状态',
action: GUIDED_ACTION_START_STATUS_QUERY,
icon: 'mdi mdi-file-search-outline'
},
{
label: '交通费报销',
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
icon: 'mdi mdi-car-outline'
},
{
label: '上传票据识别',
prompt: '我已准备好票据,请帮我识别并整理报销核对信息。',
icon: 'mdi mdi-file-upload-outline'
},
{
label: '查询近期报销',
prompt: '帮我查询近10天的报销记录和金额汇总。',
icon: 'mdi mdi-chart-timeline-variant'
},
{
label: '解释报销风险',
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
icon: 'mdi mdi-shield-alert-outline'
label: '差旅计算器',
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
icon: 'mdi mdi-calculator-variant-outline'
}
]
@@ -436,22 +426,6 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
}))
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
{
label: '补充当前单据票据',
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
icon: 'mdi mdi-file-plus-outline'
},
{
label: '解释本单风险',
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
icon: 'mdi mdi-shield-alert-outline'
},
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
]
}
return EXPENSE_WELCOME_QUICK_ACTIONS
}

View File

@@ -0,0 +1,508 @@
export const GUIDED_FLOW_MODE_NONE = ''
export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
export const GUIDED_ACTION_SELECT_QUERY_MODE = 'guided_select_query_mode'
export const GUIDED_ACTION_SELECT_QUERY_STATUS = 'guided_select_query_status'
export const GUIDED_EXPENSE_TYPES = [
{ key: 'travel', label: '差旅费', description: '出差、跨城交通、住宿和补贴', icon: 'mdi mdi-bag-suitcase-outline' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车和通行费', icon: 'mdi mdi-car-outline' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店票据', icon: 'mdi mdi-bed-outline' },
{ key: 'meal', label: '业务招待费', description: '客户接待、餐饮和工作招待', icon: 'mdi mdi-food-fork-drink' },
{ key: 'office', label: '办公用品费', description: '办公用品、文具和低值易耗品', icon: 'mdi mdi-briefcase-outline' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
]
const GUIDED_REIMBURSEMENT_STEPS = {
travel: [
{ key: 'reason', summaryLabel: '事由', prompt: '请先告诉我本次出差事由,例如:去上海支持项目部署。' },
{ key: 'location', summaryLabel: '出差地点', prompt: '本次出差地点是哪里?可以回复城市或具体客户地点。' },
{ key: 'time_range', summaryLabel: '出差时间/天数', prompt: '请补充出差时间或天数例如2026-05-20 至 2026-05-23出差 3 天。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充本次预计或实际报销金额。如果还没有汇总,可以回复“待核算”。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '票据可以现在上传,也可以回复“稍后上传”。上传后我会在生成核对信息时一起处理。' }
],
transport: [
{ key: 'reason', summaryLabel: '出行事由', prompt: '请说明本次交通费事由,例如:送客户去机场。' },
{ key: 'time_range', summaryLabel: '出行时间', prompt: '请补充出行时间例如2026-05-20 下午。' },
{ key: 'location', summaryLabel: '路线/地点', prompt: '请补充出行路线或地点,例如:公司至机场。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充交通费金额。如果票据里再识别金额,可以回复“以票据为准”。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传出租车、网约车、停车或通行费等票据;也可以回复“稍后上传”。' }
],
hotel: [
{ key: 'reason', summaryLabel: '住宿事由', prompt: '请说明住宿事由,例如:项目现场支持期间住宿。' },
{ key: 'location', summaryLabel: '城市/酒店地点', prompt: '住宿城市或酒店地点是哪里?' },
{ key: 'time_range', summaryLabel: '入住离店时间', prompt: '请补充入住和离店时间例如2026-05-20 至 2026-05-23。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充住宿金额。如果还没有汇总,可以回复“待核算”。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传酒店发票或住宿水单;也可以回复“稍后上传”。' }
],
meal: [
{ key: 'customer_name', summaryLabel: '客户单位', prompt: '请补充客户单位或接待对象。' },
{ key: 'participants', summaryLabel: '参与人员', prompt: '请补充参与人员,例如:客户 2 人,我方 1 人。' },
{ key: 'time_range', summaryLabel: '招待时间', prompt: '请补充招待时间例如2026-05-20 晚。' },
{ key: 'location', summaryLabel: '招待地点', prompt: '请补充招待地点或商户名称。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充招待金额。' },
{ key: 'reason', summaryLabel: '事由', prompt: '请补充招待事由,例如:项目沟通或客户接待。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传餐饮发票或相关凭证;也可以回复“稍后上传”。' }
],
office: [
{ key: 'reason', summaryLabel: '采购用途', prompt: '请说明采购用途,例如:项目现场临时采购办公用品。' },
{ key: 'location', summaryLabel: '商户/采购地点', prompt: '请补充商户或采购地点。' },
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充办公用品金额。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传办公用品发票或购物凭证;也可以回复“稍后上传”。' }
],
other: [
{ key: 'reason', summaryLabel: '费用说明', prompt: '请说明这笔费用的具体内容和用途。' },
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
{ key: 'location', summaryLabel: '地点/对象', prompt: '请补充费用发生地点或关联对象。' },
{ key: 'amount', summaryLabel: '金额', prompt: '请补充费用金额。' },
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传相关票据;也可以回复“稍后上传”。' }
]
}
export const GUIDED_QUERY_MODES = [
{ key: 'claim_no', label: '按单号', description: '输入报销单号精准查询', icon: 'mdi mdi-pound' },
{ key: 'status', label: '按状态', description: '查询草稿、审批中或已归档单据', icon: 'mdi mdi-list-status' },
{ key: 'time_range', label: '按时间范围', description: '例如上周、去年、2026-05', icon: 'mdi mdi-calendar-search-outline' },
{ key: 'keyword', label: '按地点/事由', description: '例如北京、上海电力、服务器部署', icon: 'mdi mdi-map-search-outline' }
]
export const GUIDED_QUERY_STATUS_OPTIONS = [
{ key: 'draft', label: '草稿', description: '还没有正式提交的单据' },
{ key: 'pending', label: '审批中', description: '正在流转审批的单据' },
{ key: 'returned', label: '已退回', description: '需要补充或修改的单据' },
{ key: 'archived', label: '已归档', description: '已完成归档的单据' },
{ key: 'completed', label: '已完成', description: '已审核完成或已入账的单据' }
]
const NO_ATTACHMENT_TEXT_PATTERN = /^(稍后|暂不|不用|没有|待上传|后面|后续|先不|以票据为准)/u
const INTERRUPTION_PATTERN = /(查一下|查询|状态|报销了吗|报销了么|多少|总额|标准|制度|规则|为什么|怎么|可以吗|能不能|差旅计算器|计算一下|解释|风险|打开|跳转|查看|审批|归档|入账|[?])/u
function uniqueValues(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map((item) => String(item || '').trim()).filter(Boolean)))
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeValues(values) {
if (!values || typeof values !== 'object') {
return {}
}
return Object.entries(values).reduce((result, [key, value]) => {
if (key === 'attachment_names') {
result[key] = uniqueValues(value)
return result
}
result[key] = normalizeText(value)
return result
}, {})
}
export function createEmptyGuidedFlowState() {
return {
mode: GUIDED_FLOW_MODE_NONE,
stepKey: '',
expenseType: '',
values: {},
pendingInterruptionText: ''
}
}
export function normalizeGuidedFlowState(state) {
const source = state && typeof state === 'object' ? state : {}
const mode = normalizeText(source.mode)
const supportedMode = [GUIDED_FLOW_MODE_REIMBURSEMENT, GUIDED_FLOW_MODE_STATUS_QUERY].includes(mode)
? mode
: GUIDED_FLOW_MODE_NONE
if (!supportedMode) {
return createEmptyGuidedFlowState()
}
return {
mode: supportedMode,
stepKey: normalizeText(source.stepKey),
expenseType: normalizeText(source.expenseType),
values: normalizeValues(source.values),
pendingInterruptionText: normalizeText(source.pendingInterruptionText)
}
}
export function isGuidedFlowActive(state) {
return Boolean(normalizeGuidedFlowState(state).mode)
}
export function getGuidedExpenseType(expenseType) {
const key = normalizeText(expenseType)
return GUIDED_EXPENSE_TYPES.find((item) => item.key === key) || null
}
export function getGuidedExpenseTypeLabel(expenseType) {
return getGuidedExpenseType(expenseType)?.label || ''
}
export function buildGuidedExpenseTypeActions() {
return GUIDED_EXPENSE_TYPES.map((option) => ({
label: option.label,
description: option.description,
icon: option.icon,
action_type: GUIDED_ACTION_SELECT_EXPENSE_TYPE,
payload: {
expense_type: option.key,
expense_type_label: option.label
}
}))
}
export function buildGuidedReimbursementStartText() {
return [
'请问你要报销的类型?',
'',
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
].join('\n')
}
export function createGuidedReimbursementState() {
return {
...createEmptyGuidedFlowState(),
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
stepKey: 'expense_type'
}
}
export function selectGuidedExpenseType(state, expenseType) {
const type = getGuidedExpenseType(expenseType)
if (!type) {
return normalizeGuidedFlowState(state)
}
const steps = GUIDED_REIMBURSEMENT_STEPS[type.key] || []
return {
...normalizeGuidedFlowState(state),
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
expenseType: type.key,
stepKey: steps[0]?.key || 'summary',
pendingInterruptionText: ''
}
}
export function getGuidedReimbursementSteps(expenseType) {
const key = normalizeText(expenseType)
return GUIDED_REIMBURSEMENT_STEPS[key] || []
}
export function getCurrentGuidedStep(state) {
const current = normalizeGuidedFlowState(state)
if (current.mode !== GUIDED_FLOW_MODE_REIMBURSEMENT || !current.expenseType) {
return null
}
return getGuidedReimbursementSteps(current.expenseType).find((step) => step.key === current.stepKey) || null
}
export function buildGuidedStepPromptText(state) {
const current = normalizeGuidedFlowState(state)
const step = getCurrentGuidedStep(current)
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType)
if (!step || !typeLabel) {
return buildGuidedReimbursementStartText()
}
const steps = getGuidedReimbursementSteps(current.expenseType)
const stepIndex = Math.max(0, steps.findIndex((item) => item.key === step.key))
return [
`已选择“${typeLabel}”。`,
'',
`${stepIndex + 1} 步:${step.summaryLabel}`,
step.prompt,
'',
'直接回复这一项即可。'
].join('\n')
}
export function resolveGuidedExpenseTypeFromText(text) {
const normalized = normalizeText(text)
if (!normalized) {
return ''
}
const exact = GUIDED_EXPENSE_TYPES.find((item) => normalized === item.label || normalized === item.key)
if (exact) {
return exact.key
}
const matched = GUIDED_EXPENSE_TYPES.find((item) => normalized.includes(item.label))
return matched?.key || ''
}
export function applyGuidedReimbursementAnswer(state, answerText, attachmentNames = []) {
const current = normalizeGuidedFlowState(state)
const step = getCurrentGuidedStep(current)
if (!step) {
return current
}
const answer = normalizeText(answerText)
const nextValues = { ...current.values }
if (step.key === 'attachments') {
const nextAttachmentNames = uniqueValues([
...(Array.isArray(nextValues.attachment_names) ? nextValues.attachment_names : []),
...attachmentNames
])
if (nextAttachmentNames.length) {
nextValues.attachment_names = nextAttachmentNames
}
nextValues.attachments = answer || (nextAttachmentNames.length ? `已选择 ${nextAttachmentNames.length} 份附件` : '稍后上传')
} else {
nextValues[step.key] = answer
}
const steps = getGuidedReimbursementSteps(current.expenseType)
const currentIndex = steps.findIndex((item) => item.key === step.key)
const nextStep = steps[currentIndex + 1]
return {
...current,
values: normalizeValues(nextValues),
stepKey: nextStep?.key || 'summary',
pendingInterruptionText: ''
}
}
export function isGuidedReimbursementReadyForReview(state) {
const current = normalizeGuidedFlowState(state)
return current.mode === GUIDED_FLOW_MODE_REIMBURSEMENT
&& Boolean(current.expenseType)
&& current.stepKey === 'summary'
}
export function buildGuidedReimbursementSummaryText(state) {
const current = normalizeGuidedFlowState(state)
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
const steps = getGuidedReimbursementSteps(current.expenseType)
const lines = [
`已完成“${typeLabel}”的引导填写。`,
'',
'请核查下面的关键信息:'
]
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
? current.values.attachment_names.join('、')
: current.values.attachments || '稍后上传')
: current.values[step.key]
lines.push(`- ${step.summaryLabel}${value || '待补充'}`)
})
lines.push('')
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。')
return lines.join('\n')
}
export function buildGuidedReviewConfirmationActions() {
return [{
label: '生成报销核对信息',
description: '进入现有报销核对流程,不会直接保存草稿',
icon: 'mdi mdi-clipboard-check-outline',
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
}]
}
export function buildGuidedReviewSubmitOptions(state, files = []) {
const current = normalizeGuidedFlowState(state)
const type = getGuidedExpenseType(current.expenseType)
const values = current.values || {}
const typeLabel = type?.label || '其他费用'
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
: values[step.key]
return `${step.summaryLabel}${value || '待补充'}`
})
const rawText = [
`报销类型:${typeLabel}`,
...fieldLines
].join('\n')
const reviewFormValues = {
expense_type: typeLabel,
reimbursement_type: typeLabel,
reason: values.reason || values.customer_name || '',
reason_value: values.reason || '',
customer_name: values.customer_name || '',
participants: values.participants || '',
location: values.location || '',
business_location: values.location || '',
time_range: values.time_range || '',
business_time: values.time_range || '',
amount: values.amount || '',
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : []
}
return {
rawText,
userText: '生成报销核对信息',
pendingText: '正在生成右侧报销核对信息...',
systemGenerated: true,
files,
extraContext: {
draft_claim_id: '',
user_input_text: rawText,
expense_scene_selection: {
expense_type: type?.key || current.expenseType || 'other',
expense_type_label: typeLabel,
original_message: rawText
},
review_form_values: reviewFormValues
}
}
}
export function shouldConfirmGuidedInterruption(text, state) {
const current = normalizeGuidedFlowState(state)
if (!current.mode || current.pendingInterruptionText) {
return false
}
const normalized = normalizeText(text)
if (!normalized || NO_ATTACHMENT_TEXT_PATTERN.test(normalized)) {
return false
}
return INTERRUPTION_PATTERN.test(normalized)
}
export function buildGuidedInterruptionText(text) {
return [
`我看到你刚才输入的是:“${normalizeText(text)}”。`,
'',
'这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
].join('\n')
}
export function buildGuidedInterruptionActions() {
return [
{
label: '继续填写',
description: '保留当前引导,继续回答这一项',
icon: 'mdi mdi-pencil-outline',
action_type: GUIDED_ACTION_CONTINUE_FILLING
},
{
label: '暂停当前引导并处理这个问题',
description: '暂停引导,把刚才输入交给财务助手处理',
icon: 'mdi mdi-chat-processing-outline',
action_type: GUIDED_ACTION_PROCESS_INTERRUPTION
}
]
}
export function createGuidedStatusQueryState() {
return {
...createEmptyGuidedFlowState(),
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
stepKey: 'query_mode'
}
}
export function buildGuidedStatusQueryStartText() {
return [
'你想按什么条件查询单据状态?',
'',
'先选查询方式,我再向你收集对应条件。'
].join('\n')
}
export function buildGuidedQueryModeActions() {
return GUIDED_QUERY_MODES.map((option) => ({
label: option.label,
description: option.description,
icon: option.icon,
action_type: GUIDED_ACTION_SELECT_QUERY_MODE,
payload: {
query_mode: option.key,
query_mode_label: option.label
}
}))
}
export function buildGuidedQueryStatusActions() {
return GUIDED_QUERY_STATUS_OPTIONS.map((option) => ({
label: option.label,
description: option.description,
icon: 'mdi mdi-checkbox-marked-circle-outline',
action_type: GUIDED_ACTION_SELECT_QUERY_STATUS,
payload: {
query_status: option.key,
query_status_label: option.label
}
}))
}
export function resolveGuidedQueryModeFromText(text) {
const normalized = normalizeText(text)
if (!normalized) return ''
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
if (exact) return exact.key
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
return 'keyword'
}
export function selectGuidedQueryMode(state, queryMode) {
const current = normalizeGuidedFlowState(state)
const mode = GUIDED_QUERY_MODES.find((item) => item.key === normalizeText(queryMode))
if (!mode) {
return current
}
return {
...current,
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
stepKey: mode.key === 'status' ? 'status_value' : 'query_value',
values: {
...current.values,
query_mode: mode.key,
query_mode_label: mode.label
},
pendingInterruptionText: ''
}
}
export function buildGuidedQueryPromptText(state) {
const current = normalizeGuidedFlowState(state)
const mode = normalizeText(current.values.query_mode)
if (!mode) {
return buildGuidedStatusQueryStartText()
}
if (mode === 'status') {
return [
'请选择要查询的单据状态。',
'',
'我会按所选状态筛选最近的报销单据。'
].join('\n')
}
const prompts = {
claim_no: '请输入报销单号,例如 EXP-202605-001。',
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
}
return prompts[mode] || '请补充查询条件。'
}
export function buildGuidedStatusQueryText(state, valueText) {
const current = normalizeGuidedFlowState(state)
const mode = normalizeText(current.values.query_mode)
const value = normalizeText(valueText)
if (mode === 'claim_no') {
return `帮我查询单号 ${value} 的报销单状态`
}
if (mode === 'status') {
return `帮我查询${value}的报销单据,筛选最近的 5 条记录`
}
if (mode === 'time_range') {
return `帮我查询${value}提交或发生的报销单据状态,筛选最近的 5 条记录`
}
return `帮我查询地点或事由包含“${value}”的报销单据状态,筛选最近的 5 条记录`
}

View File

@@ -0,0 +1,439 @@
import { ref } from 'vue'
import {
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY,
GUIDED_FLOW_MODE_REIMBURSEMENT,
GUIDED_FLOW_MODE_STATUS_QUERY,
applyGuidedReimbursementAnswer,
buildGuidedExpenseTypeActions,
buildGuidedInterruptionActions,
buildGuidedInterruptionText,
buildGuidedQueryModeActions,
buildGuidedQueryPromptText,
buildGuidedQueryStatusActions,
buildGuidedReimbursementStartText,
buildGuidedReimbursementSummaryText,
buildGuidedReviewConfirmationActions,
buildGuidedReviewSubmitOptions,
buildGuidedStatusQueryStartText,
buildGuidedStatusQueryText,
buildGuidedStepPromptText,
createEmptyGuidedFlowState,
createGuidedReimbursementState,
createGuidedStatusQueryState,
getCurrentGuidedStep,
isGuidedFlowActive,
isGuidedReimbursementReadyForReview,
normalizeGuidedFlowState,
resolveGuidedExpenseTypeFromText,
resolveGuidedQueryModeFromText,
selectGuidedExpenseType,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
} from './travelReimbursementGuidedFlowModel.js'
function normalizeText(value) {
return String(value || '').trim()
}
function buildFileNames(files) {
return Array.from(files || [])
.map((file) => normalizeText(file?.name))
.filter(Boolean)
}
function mergePendingFiles(currentFiles, nextFiles) {
const merged = [...Array.from(currentFiles || [])]
Array.from(nextFiles || []).forEach((file) => {
const name = normalizeText(file?.name)
if (!name) return
const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0))
if (!duplicated) {
merged.push(file)
}
})
return merged
}
export function useTravelReimbursementGuidedFlow({
guidedFlowState,
messages,
composerDraft,
attachedFiles,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
fileInputRef,
submitting,
reviewActionBusy,
sessionSwitchBusy,
createMessage,
nextTick,
scrollToBottom,
persistSessionState,
clearAttachedFiles,
adjustComposerTextareaHeight,
buildComposerBusinessTimeContext,
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer,
toast
}) {
const guidedPendingFiles = ref([])
function persistAndScroll() {
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight?.()
scrollToBottom?.()
})
}
function clearComposerRuntime() {
composerDraft.value = ''
clearAttachedFiles?.()
if (fileInputRef?.value) {
fileInputRef.value.value = ''
}
if (composerBusinessTimeTags) {
composerBusinessTimeTags.value = []
}
if (composerBusinessTimeDraftTouched) {
composerBusinessTimeDraftTouched.value = false
}
}
function pushAssistant(text, extras = {}) {
messages.value.push(createMessage('assistant', text, [], extras))
}
function pushUser(text, attachmentNames = []) {
const normalizedText = normalizeText(text)
messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames))
}
function resetGuidedFlowState() {
guidedFlowState.value = createEmptyGuidedFlowState()
guidedPendingFiles.value = []
}
function startGuidedReimbursement() {
guidedFlowState.value = createGuidedReimbursementState()
guidedPendingFiles.value = []
pushAssistant(buildGuidedReimbursementStartText(), {
meta: ['引导式报销'],
suggestedActions: buildGuidedExpenseTypeActions()
})
persistAndScroll()
}
function startGuidedStatusQuery() {
guidedFlowState.value = createGuidedStatusQueryState()
guidedPendingFiles.value = []
pushAssistant(buildGuidedStatusQueryStartText(), {
meta: ['引导式查询'],
suggestedActions: buildGuidedQueryModeActions()
})
persistAndScroll()
}
function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
startGuidedReimbursement()
return true
}
if (actionType === GUIDED_ACTION_START_STATUS_QUERY) {
startGuidedStatusQuery()
return true
}
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
openTravelCalculator?.()
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
meta: ['差旅计算器']
})
persistAndScroll()
return true
}
return false
}
function buildAnswerText(rawText, state) {
const text = normalizeText(rawText)
if (text) {
return text
}
const currentStep = getCurrentGuidedStep(state)
if (currentStep?.key === 'time_range') {
const businessTimeContext = buildComposerBusinessTimeContext?.()
return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time)
}
return ''
}
function pushNextReimbursementPrompt() {
pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), {
meta: ['引导式报销']
})
}
function pushReimbursementSummary() {
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
meta: ['待生成核对信息'],
suggestedActions: buildGuidedReviewConfirmationActions()
})
}
function handleReimbursementAnswer(answerText, files) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files)
if (currentState.stepKey === 'expense_type') {
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
if (!expenseType) {
pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', {
meta: ['等待选择报销类型'],
suggestedActions: buildGuidedExpenseTypeActions()
})
return
}
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
pushNextReimbursementPrompt()
return
}
if (!currentStep) {
pushAssistant(buildGuidedReimbursementStartText(), {
meta: ['引导式报销'],
suggestedActions: buildGuidedExpenseTypeActions()
})
return
}
if (!answerText && fileNames.length && currentStep.key !== 'attachments') {
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
pushAssistant([
`我已先记录 ${fileNames.length} 份附件。`,
'',
`当前还需要补充:${currentStep.summaryLabel}`,
currentStep.prompt
].join('\n'), {
meta: ['已记录附件']
})
return
}
if (fileNames.length) {
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
}
guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames)
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
pushReimbursementSummary()
return
}
pushNextReimbursementPrompt()
}
async function runStatusQuery(queryText, skipUserMessage = true) {
const normalizedQuery = normalizeText(queryText)
resetGuidedFlowState()
clearComposerRuntime()
persistAndScroll()
if (!normalizedQuery) {
return true
}
await submitExistingComposer({
rawText: normalizedQuery,
userText: normalizedQuery,
pendingText: '正在查询单据状态...',
skipUserMessage
})
return true
}
async function handleStatusQueryAnswer(answerText) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
if (currentState.stepKey === 'query_mode') {
const queryMode = resolveGuidedQueryModeFromText(answerText)
if (!queryMode) {
pushAssistant(buildGuidedStatusQueryStartText(), {
meta: ['引导式查询'],
suggestedActions: buildGuidedQueryModeActions()
})
return true
}
guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode)
const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
meta: ['引导式查询'],
suggestedActions: actions
})
return true
}
const queryText = buildGuidedStatusQueryText(currentState, answerText)
return runStatusQuery(queryText, true)
}
async function handleGuidedComposerSubmit(options = {}) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
if (!isGuidedFlowActive(currentState)) {
return false
}
if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) {
return false
}
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return true
}
const files = Array.from(options.files ?? attachedFiles.value ?? [])
const fileNames = buildFileNames(files)
const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState)
if (!answerText && !fileNames.length) {
return true
}
pushUser(answerText, fileNames)
if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) {
guidedFlowState.value = {
...currentState,
pendingInterruptionText: answerText
}
pushAssistant(buildGuidedInterruptionText(answerText), {
meta: ['等待确认是否打断'],
suggestedActions: buildGuidedInterruptionActions()
})
clearComposerRuntime()
persistAndScroll()
return true
}
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
handleReimbursementAnswer(answerText, files)
clearComposerRuntime()
persistAndScroll()
return true
}
if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
clearComposerRuntime()
persistAndScroll()
await handleStatusQueryAnswer(answerText)
return true
}
return false
}
async function handleGuidedSuggestedAction(message, action) {
const actionType = normalizeText(action?.action_type)
if (!actionType) {
return false
}
const guidedActionTypes = new Set([
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS
])
if (!guidedActionTypes.has(actionType)) {
return false
}
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) {
return true
}
if (!lockSuggestedActionMessage(message, action)) {
return true
}
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
const expenseType = normalizeText(action?.payload?.expense_type)
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
pushNextReimbursementPrompt()
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) {
const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value)
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer(submitOptions)
return true
}
if (actionType === GUIDED_ACTION_CONTINUE_FILLING) {
const pendingState = {
...normalizeGuidedFlowState(guidedFlowState.value),
pendingInterruptionText: ''
}
guidedFlowState.value = pendingState
if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
pushAssistant(buildGuidedQueryPromptText(pendingState), {
meta: ['引导式查询'],
suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
})
} else {
pushNextReimbursementPrompt()
}
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) {
const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText)
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer({
rawText: pendingText,
userText: pendingText,
pendingText: '正在处理你的问题...',
skipUserMessage: true
})
return true
}
if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) {
const queryMode = normalizeText(action?.payload?.query_mode)
const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label)
guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode)
pushUser(`选择${queryModeLabel || '查询方式'}`)
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
meta: ['引导式查询'],
suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
})
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) {
const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label)
pushUser(`选择${statusLabel || '单据状态'}`)
const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel)
await runStatusQuery(queryText, true)
return true
}
return false
}
return {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
resetGuidedFlowState
}
}

View File

@@ -15,6 +15,7 @@ import {
SESSION_TYPE_KNOWLEDGE,
buildInitialInsightFromConversation,
buildWelcomeInsight,
buildWelcomeQuickActions,
createWelcomeAssistantMessage,
hasMeaningfulSessionMessages,
normalizeInitialConversationMessages,
@@ -25,6 +26,10 @@ import {
serializeSessionMessages,
shouldPreferPersistedSessionState
} from './travelReimbursementConversationModel.js'
import {
createEmptyGuidedFlowState,
normalizeGuidedFlowState
} from './travelReimbursementGuidedFlowModel.js'
export function useTravelReimbursementSessionState({
props,
@@ -36,9 +41,26 @@ export function useTravelReimbursementSessionState({
scrollToBottom,
getSessionRuntimeRefs = () => ({})
}) {
function refreshWelcomeQuickActions(messages, sessionType) {
if (!Array.isArray(messages) || !messages.length) {
return []
}
const currentActions = buildWelcomeQuickActions(
sessionType,
currentUser.value,
props.entrySource,
linkedRequest.value
)
return messages.map((message) => (
message?.isWelcome
? { ...message, welcomeQuickActions: currentActions }
: message
))
}
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
const restoredMessages = normalizeInitialConversationMessages(conversation)
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
const initialInsight = buildInitialInsightFromConversation(conversation)
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
@@ -56,6 +78,7 @@ export function useTravelReimbursementSessionState({
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: '',
guidedFlowState: createEmptyGuidedFlowState(),
insightPanelCollapsed: false
}
}
@@ -79,6 +102,7 @@ export function useTravelReimbursementSessionState({
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: '',
guidedFlowState: createEmptyGuidedFlowState(),
insightPanelCollapsed: false
}
}
@@ -90,7 +114,7 @@ export function useTravelReimbursementSessionState({
}
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
const restoredMessages = normalizeSnapshotMessages(state.messages)
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
if (
!hasMeaningfulSessionMessages(restoredMessages)
&& !String(state.conversationId || '').trim()
@@ -114,6 +138,7 @@ export function useTravelReimbursementSessionState({
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
@@ -155,6 +180,7 @@ export function useTravelReimbursementSessionState({
})
const currentInsight = ref(initialSessionState.currentInsight)
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
const insightPanelCollapsed = ref(false)
const sessionSwitchBusy = ref(false)
let knowledgeSessionResetPromise = Promise.resolve()
@@ -170,6 +196,7 @@ export function useTravelReimbursementSessionState({
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''),
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
@@ -209,6 +236,7 @@ export function useTravelReimbursementSessionState({
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
composerUploadIntent: composerUploadIntent.value,
guidedFlowState: runtimeRefs.guidedFlowState?.value ?? guidedFlowState.value,
insightPanelCollapsed: insightPanelCollapsed.value
}
}
@@ -246,6 +274,11 @@ export function useTravelReimbursementSessionState({
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
}
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
const nextGuidedFlowState = normalizeGuidedFlowState(nextState.guidedFlowState)
guidedFlowState.value = nextGuidedFlowState
if (runtimeRefs.guidedFlowState) {
runtimeRefs.guidedFlowState.value = nextGuidedFlowState
}
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
nextTick(() => {
adjustComposerTextareaHeight()
@@ -322,6 +355,7 @@ export function useTravelReimbursementSessionState({
currentInsight,
reviewFilePreviews,
composerUploadIntent,
guidedFlowState,
insightPanelCollapsed,
sessionSwitchBusy,
initialSessionState,

View File

@@ -685,8 +685,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
timeoutMs: 75000,
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
}
: {
timeoutMs: 120000,

View File

@@ -18,10 +18,12 @@ const sampleRows = [
id: 'EXP-001',
typeCode: 'travel',
type: '差旅费',
archiveTypeCode: 'reimbursement',
archiveType: '报销',
department: '研发部',
archiveMonth: '2026-05',
archiveMonthLabel: '2026年05月',
archiveTab: '差旅报销',
archiveTab: '报销归档',
hasRisk: true,
riskTone: 'high',
risk: '2条',
@@ -31,10 +33,12 @@ const sampleRows = [
id: 'EXP-002',
typeCode: 'entertainment',
type: '业务招待费',
archiveTypeCode: 'reimbursement',
archiveType: '报销',
department: '销售部',
archiveMonth: '2026-04',
archiveMonthLabel: '2026年04月',
archiveTab: '招待报销',
archiveTab: '报销归档',
hasRisk: false,
riskTone: 'none',
risk: '0条',
@@ -80,14 +84,27 @@ test('applyArchiveListFilters supports department and archive month', () => {
assert.equal(filtered[0].id, 'EXP-002')
})
test('build filter options are derived from loaded rows', () => {
test('applyArchiveListFilters supports the unified reimbursement archive tab', () => {
const reimbursementRows = applyArchiveListFilters(sampleRows, {
tab: '报销归档'
})
const oldTravelRows = applyArchiveListFilters(sampleRows, {
tab: '差旅报销'
})
assert.equal(reimbursementRows.length, 2)
assert.equal(oldTravelRows.length, 0)
})
test('build filter options are derived from archive types', () => {
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
const typeValues = buildTypeFilterOptions(sampleRows).map((item) => item.value)
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
assert.equal(typeLabels[0], '全部类型')
assert.ok(typeLabels.includes('差旅费'))
assert.ok(typeLabels.includes('业务招待费'))
assert.equal(typeLabels[0], '全部归档类型')
assert.deepEqual(typeValues, ['all', 'reimbursement'])
assert.deepEqual(typeLabels, ['全部归档类型', '报销'])
assert.equal(departmentLabels[0], '全部部门')
assert.ok(departmentLabels.includes('研发部'))
assert.ok(departmentLabels.includes('销售部'))

View File

@@ -32,3 +32,26 @@ test('archive center is wired into navigation and api client', () => {
assert.match(navigationScript, /id:\s*'archive'/)
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
})
test('archive center uses generic archive category and type wording', () => {
const archiveView = readFileSync(
fileURLToPath(new URL('../src/views/ArchiveCenterView.vue', import.meta.url)),
'utf8'
)
const archiveScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ArchiveCenterView.js', import.meta.url)),
'utf8'
)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
assert.doesNotMatch(archiveScript, /'差旅报销'/)
assert.doesNotMatch(archiveScript, /'招待报销'/)
assert.doesNotMatch(archiveScript, /'其他费用'/)
assert.match(archiveView, /placeholder="搜索单号、申请人、部门、归档类型\.\.\."/)
assert.match(archiveView, /<th>归档类型<\/th>/)
assert.match(archiveView, /\{\{\s*row\.archiveType\s*\}\}/)
assert.doesNotMatch(archiveView, /<th>报销类型<\/th>/)
})

View File

@@ -26,8 +26,26 @@ function buildRun() {
chunk_count: 5,
entity_count: 3,
relation_count: 2,
entities: ['远光软件', '支出管理'],
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
entities: [
{
name: '远光软件',
type: 'ORGANIZATION',
description: '远光软件是支出管理制度的公司主体。',
descriptions: ['远光软件是支出管理制度的公司主体。'],
properties: { created_at: '2026-05-23' }
},
'支出管理'
],
relations: [
{
source: '远光软件',
target: '支出管理',
type: '约束',
description: '通过制度约束支出审批。',
keywords: ['制度', '审批'],
weight: 2.5
}
]
},
documents: [
{
@@ -40,7 +58,8 @@ function buildRun() {
chunk_count: 3,
entity_count: 2,
relation_count: 1,
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围', excerpt: '支出管理范围正文' }],
entity_chunks: [{ entity: '支出管理', chunk_ids: ['chunk-1'] }],
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
},
@@ -75,8 +94,18 @@ function testBuildsInteractiveModel() {
assert.equal(model.documents.length, 2)
assert.equal(model.documents[0].statusLabel, '已完成')
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
assert.equal(model.documents[0].chunks[0].excerpt, '支出管理范围正文')
assert.deepEqual(model.documents[0].entityChunks, [{ entity: '支出管理', chunkIds: ['chunk-1'] }])
assert.deepEqual(model.documents[1].chunks, [])
assert.deepEqual(model.documents[1].sections, [])
assert.deepEqual(model.documents[1].events, [])
assert.equal(model.graph.entityCount, 3)
assert.equal(model.graph.entities[0].name, '远光软件')
assert.equal(model.graph.entities[0].type, 'ORGANIZATION')
assert.equal(model.graph.entities[0].descriptions[0], '远光软件是支出管理制度的公司主体。')
assert.equal(model.graph.relations[0].source, '远光软件')
assert.equal(model.graph.relations[0].description, '通过制度约束支出审批。')
assert.deepEqual(model.graph.relations[0].keywords, ['制度', '审批'])
assert.equal(model.metrics[1].value, '5')
}

View File

@@ -38,3 +38,8 @@ test('composer keeps backend raw text but displays structured user message', ()
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
})
test('knowledge questions keep enough request time for LightRAG retrieval', () => {
assert.match(submitComposerScript, /timeoutMs:\s*75000/)
assert.match(submitComposerScript, /知识问答仍在检索整理/)
})

View File

@@ -0,0 +1,189 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
EXPENSE_WELCOME_QUICK_ACTIONS,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildWelcomeQuickActions
} from '../src/views/scripts/travelReimbursementConversationModel.js'
import {
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY,
GUIDED_FLOW_MODE_REIMBURSEMENT,
GUIDED_FLOW_MODE_STATUS_QUERY,
applyGuidedReimbursementAnswer,
buildGuidedExpenseTypeActions,
buildGuidedInterruptionActions,
buildGuidedQueryModeActions,
buildGuidedQueryStatusActions,
buildGuidedReimbursementSummaryText,
buildGuidedReviewConfirmationActions,
buildGuidedReviewSubmitOptions,
buildGuidedStatusQueryText,
buildGuidedStepPromptText,
createEmptyGuidedFlowState,
createGuidedReimbursementState,
createGuidedStatusQueryState,
isGuidedReimbursementReadyForReview,
normalizeGuidedFlowState,
selectGuidedExpenseType,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const guidedFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
'utf8'
)
const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
test('welcome quick actions are reduced to three guided local actions', () => {
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
['快速发起报销', '查询单据状态', '差旅计算器']
)
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.action),
[
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR
]
)
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
assert.notDeepEqual(
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
'knowledge hot questions should stay independent'
)
})
test('guided reimbursement asks type first and walks travel fields in order', () => {
const typeActions = buildGuidedExpenseTypeActions()
assert.deepEqual(
typeActions.map((action) => action.label),
['差旅费', '交通费', '住宿费', '业务招待费', '办公用品费', '其他费用']
)
assert.ok(typeActions.every((action) => action.action_type === GUIDED_ACTION_SELECT_EXPENSE_TYPE))
let state = createGuidedReimbursementState()
assert.equal(state.mode, GUIDED_FLOW_MODE_REIMBURSEMENT)
assert.equal(state.stepKey, 'expense_type')
state = selectGuidedExpenseType(state, 'travel')
assert.equal(state.stepKey, 'reason')
assert.match(buildGuidedStepPromptText(state), /第 1 步:事由/)
state = applyGuidedReimbursementAnswer(state, '去上海支持上海电力部署项目')
assert.equal(state.stepKey, 'location')
state = applyGuidedReimbursementAnswer(state, '上海')
assert.equal(state.stepKey, 'time_range')
state = applyGuidedReimbursementAnswer(state, '2026-05-20 至 2026-05-23出差 3 天')
assert.equal(state.stepKey, 'amount')
state = applyGuidedReimbursementAnswer(state, '待核算')
assert.equal(state.stepKey, 'attachments')
state = applyGuidedReimbursementAnswer(state, '稍后上传')
assert.ok(isGuidedReimbursementReadyForReview(state))
assert.match(buildGuidedReimbursementSummaryText(state), /已完成“差旅费”的引导填写/)
assert.deepEqual(
buildGuidedReviewConfirmationActions().map((action) => action.action_type),
[GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW]
)
const submitOptions = buildGuidedReviewSubmitOptions(state)
assert.equal(submitOptions.systemGenerated, true)
assert.equal(submitOptions.extraContext.expense_scene_selection.expense_type, 'travel')
assert.equal(submitOptions.extraContext.review_form_values.expense_type, '差旅费')
assert.match(submitOptions.rawText, /事由:去上海支持上海电力部署项目/)
assert.match(submitOptions.rawText, /出差时间\/天数2026-05-20 至 2026-05-23出差 3 天/)
})
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
assert.equal(shouldConfirmGuidedInterruption('帮我查询一下上周的报销状态?', state), true)
assert.deepEqual(
buildGuidedInterruptionActions().map((action) => action.action_type),
[GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION]
)
})
test('status query guide collects a query mode before calling existing query flow', () => {
let state = createGuidedStatusQueryState()
assert.equal(state.mode, GUIDED_FLOW_MODE_STATUS_QUERY)
assert.equal(state.stepKey, 'query_mode')
assert.ok(buildGuidedQueryModeActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_MODE))
state = selectGuidedQueryMode(state, 'status')
assert.equal(state.stepKey, 'status_value')
assert.ok(buildGuidedQueryStatusActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_STATUS))
assert.equal(buildGuidedStatusQueryText(state, '已归档'), '帮我查询已归档的报销单据,筛选最近的 5 条记录')
const keywordState = selectGuidedQueryMode(createGuidedStatusQueryState(), 'keyword')
assert.equal(keywordState.stepKey, 'query_value')
assert.equal(
buildGuidedStatusQueryText(keywordState, '上海电力'),
'帮我查询地点或事由包含“上海电力”的报销单据状态,筛选最近的 5 条记录'
)
})
test('guided flow state is serializable and restored through session state', () => {
const empty = createEmptyGuidedFlowState()
assert.deepEqual(normalizeGuidedFlowState({ mode: 'bad' }), empty)
assert.deepEqual(
normalizeGuidedFlowState({
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
stepKey: 'amount',
expenseType: 'travel',
values: {
amount: 200,
attachment_names: ['a.pdf', '', 'a.pdf']
},
pendingInterruptionText: '查询状态?'
}),
{
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
stepKey: 'amount',
expenseType: 'travel',
values: {
amount: '200',
attachment_names: ['a.pdf']
},
pendingInterruptionText: '查询状态?'
}
)
assert.match(sessionStateScript, /guidedFlowState:\s*normalizeGuidedFlowState\(state\.guidedFlowState\)/)
assert.match(sessionStateScript, /runtimeRefs\.guidedFlowState\?\.value/)
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
})
test('guided flow is local until final confirmation or collected query handoff', () => {
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
})