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