feat: 集成Hermes智能体系统,增强聊天和差旅报销功能

This commit is contained in:
caoxiaozhu
2026-05-16 06:14:08 +00:00
parent 763afa0ee2
commit 212c935308
46 changed files with 8802 additions and 5372 deletions

1
.env
View File

@@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=true
ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082 ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082
ONLYOFFICE_BACKEND_URL=http://main:8000 ONLYOFFICE_BACKEND_URL=http://main:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
POSTGRES_HOST=10.10.10.189 POSTGRES_HOST=10.10.10.189
POSTGRES_PORT=5432 POSTGRES_PORT=5432

View File

@@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=false
ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082 ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082
ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000 ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
POSTGRES_HOST=127.0.0.1 POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432 POSTGRES_PORT=5432

View File

@@ -6,6 +6,8 @@ services:
depends_on: depends_on:
onlyoffice: onlyoffice:
condition: service_started condition: service_started
qdrant:
condition: service_started
environment: environment:
WEB_HOST: 0.0.0.0 WEB_HOST: 0.0.0.0
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
@@ -48,6 +50,24 @@ services:
networks: networks:
- financial-internal - financial-internal
qdrant:
image: qdrant/qdrant:latest
container_name: x-financial-qdrant
restart: unless-stopped
ports:
- "${QDRANT_HTTP_PORT:-6333}:6333"
- "${QDRANT_GRPC_PORT:-6334}:6334"
volumes:
- qdrant-storage:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6333/healthz >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
networks:
- financial-internal
onlyoffice: onlyoffice:
image: onlyoffice/documentserver:latest image: onlyoffice/documentserver:latest
container_name: x-financial-onlyoffice container_name: x-financial-onlyoffice
@@ -69,3 +89,6 @@ services:
networks: networks:
financial-internal: financial-internal:
name: financial-internal name: financial-internal
volumes:
qdrant-storage:

View File

@@ -0,0 +1,61 @@
---
name: x-financial-callback
description: Use when a Hermes task for X-Financial must report progress or completion back to the backend through the single generic callback endpoint.
---
# X-Financial Callback
Use this skill for every X-Financial task that must notify the backend after Hermes finishes work.
## Callback contract
Send exactly one HTTP `POST` request to the callback URL provided in the task payload.
Use:
- Header: `Authorization: Bearer <callback_token>`
- Header: `Content-Type: application/json`
- Body:
```json
{
"type": "task_type_from_input",
"run_id": "agent_run_id_from_input",
"status": "succeeded",
"summary": "short human-readable summary",
"payload": {}
}
```
## Rules
- Always preserve the incoming `type` and `run_id`.
- Execute the callback directly for server-dispatched tasks; do not ask the user for a second confirmation.
- Use `scripts/send_callback.py` for the actual HTTP request so large JSON bodies, quotes, and multilingual text are encoded safely.
- For normal tasks, prefer letting the script build the generic envelope for you via
`--type`, `--run-id`, `--status`, and `--summary`; then the JSON file on stdin should contain only the
task-specific business payload.
- Prefer sending a validated JSON file for non-trivial payloads:
`python3 ~/.hermes/skills/domain/x-financial-callback/scripts/send_callback.py --url "$CALLBACK_URL" --token "$CALLBACK_TOKEN" < /tmp/x-financial-callback.json`
- Before sending a large payload, validate it with `python3 -m json.tool /tmp/x-financial-callback.json >/dev/null`.
- For large multilingual payloads, write `/tmp/x-financial-callback.json` with the `write_file` tool first.
Do not generate helper Python source files or shell heredocs merely to build JSON.
- Use `status: "running"` only for optional progress updates.
- Use `status: "succeeded"` once when the task is complete.
- Use `status: "failed"` once when the task cannot be completed, and include an `error` string.
- Put task-specific business data only inside `payload`.
- Do not invent extra callback endpoints. X-Financial accepts Hermes callbacks through one shared endpoint only.
- If the callback fails, retry the same request up to 3 times before returning failure.
## Preferred command
```bash
python3 ~/.hermes/skills/domain/x-financial-callback/scripts/send_callback.py \
--url "$CALLBACK_URL" \
--token "$CALLBACK_TOKEN" \
--type "$TASK_TYPE" \
--run-id "$RUN_ID" \
--status succeeded \
--summary "short human-readable summary" \
< /tmp/x-financial-callback.json
```

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Send one generic X-Financial Hermes callback.")
parser.add_argument("--url", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--type", dest="task_type", default="")
parser.add_argument("--run-id", default="")
parser.add_argument("--status", choices=("running", "succeeded", "failed"), default="")
parser.add_argument("--summary", default="")
parser.add_argument("--error", default="")
return parser.parse_args()
def main() -> int:
args = parse_args()
payload = json.load(sys.stdin)
if args.task_type and args.run_id and args.status:
payload = {
"type": args.task_type,
"run_id": args.run_id,
"status": args.status,
"summary": args.summary,
"payload": payload,
**({"error": args.error} if args.error else {}),
}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
last_error = ""
for attempt in range(1, max(1, args.retries) + 1):
request = Request(
args.url,
data=body,
headers={
"Authorization": f"Bearer {args.token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=30) as response:
response_body = response.read().decode("utf-8")
if 200 <= response.status < 300:
print(response_body)
return 0
last_error = f"HTTP {response.status}: {response_body}"
except HTTPError as exc:
last_error = f"HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}"
except URLError as exc:
last_error = str(exc.reason)
if attempt < max(1, args.retries):
time.sleep(min(attempt, 3))
print(last_error or "callback failed", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
---
name: x-financial-llm-wiki-ingest
description: "Use for X-Financial制度文档归纳任务。Read the full source documents provided by the service, use the llm-wiki workflow for synthesis, and actively callback the X-Financial backend with one structured batch result."
---
# X-Financial LLM Wiki Ingest
Use this skill together with the built-in `llm-wiki` skill for X-Financial制度文档归纳任务。
## Workflow
1. Treat each provided `absolute_path` as the authoritative whole source document.
2. Read the original files directly. Do **not** ask the caller to pre-split them into chunks.
3. If a source is too large for one `read_file` call, do **not** retry the same full read. Continue with
line-ranged reads using `offset` and `limit` until the whole source has been covered. This is still
whole-document processing; it is only an internal reading strategy for the Hermes tool safety limit.
4. Use the built-in `llm-wiki` workflow to synthesize the documents as one batch.
5. Use the `x-financial-callback` skill to POST the completed result back to the callback URL from the task payload.
6. After the callback succeeds, return only a short acknowledgement to the caller.
## Large-file handling
- A `read_file` response such as “exceeds the safety limit” means the document is large, not unreadable.
- Re-read it in bounded windows, for example 300-500 lines per call, until every line range has been examined.
- Keep one running synthesis for the whole document; do not emit one callback per window and do not treat each
window as an independent document.
- For monetary tables and approval matrices, make sure you read enough surrounding content to understand the decision
dimensions before summarizing them. Do not infer missing rows from one partial visible fragment.
- If a PDF table is noisy or flattened, prioritize producing a stable wiki description of:
`who/what it applies to`, `which dimensions affect the answer`, `what values exist`, and `which exceptions apply`.
- If a table contains both domestic and overseas columns, reflect those dimensions clearly in the wiki summary.
The server may reconstruct the final display table from your wiki wording, so the wording must keep the axes clear.
## Callback Payload Contract
Send this object inside the generic callback body's `payload` field:
```json
{
"ok": true,
"summary": "本次批量归纳的简要结果",
"documents": [
{
"document_id": "原样返回输入中的 document_id",
"knowledge_summary_markdown": "# 知识总结\n\n...",
"knowledge_candidates": [
{
"title": "知识点标题",
"content": "可直接作为 wiki 页面片段被问答系统引用的制度知识",
"scenario": "reimbursement_policy",
"tags": ["报销", "审批"],
"evidence": ["来自原文的短证据"],
"confidence": 0.0,
"source_chunk_ids": []
}
],
"rule_candidates": [
{
"template_key": "general_policy_v1",
"suggested_rule_name": "规则草稿名称",
"summary": "规则草稿摘要",
"scenario": "reimbursement_policy",
"purpose": "规则目标",
"scope": "适用范围",
"inputs": ["输入字段"],
"judgement_logic": ["判断逻辑"],
"outputs": ["输出动作"],
"admin_note": "管理员审核备注",
"runtime_rule": {},
"evidence": ["来自原文的短证据"],
"confidence": 0.0,
"source_chunk_ids": []
}
]
}
]
}
```
## Rules
- Preserve every input `document_id` in the callback payload.
- The downstream knowledge assistant is allowed to answer only from the compiled wiki output. Treat every
`knowledge_candidate.content` as a reusable wiki section, not as a loose abstract.
- Prefer fewer, self-contained, reviewable wiki sections over many weak summaries.
- Each wiki section must preserve enough context for a later reader to answer questions without reopening the
raw source document: who it applies to, when it applies, what the rule is, exceptions, thresholds, and required
conditions when those facts exist in the source.
- Preserve the original decision dimensions from the source document. If a policy depends on both `职级` and `地区`,
or any other multi-axis table, keep all axes in `content` instead of collapsing them into a single generic summary.
- If a document contains a travel policy, the wiki output must cover the three core dimensions separately when the
source contains them: `交通费标准``住宿费标准``出差补贴标准`. Do not return only one or two of them.
- If the source contains a table whose rows/columns affect the answer, prefer a Markdown table when you can produce one
confidently. If the PDF extraction is noisy, a structured wiki description is acceptable, but it must keep the
answer dimensions explicit enough for the server to reconstruct a table later.
- Table-backed sections do not need OCR-grade cell preservation. What matters is that the wiki wording keeps:
- the decision axes explicit;
- the row groups explicit;
- the applicable values explicit;
- the exceptions and approval rules explicit.
- For monetary standards, do **not** use slash shorthand such as `700/450/400`, `600/400/350`, or similar compressed
sequences. Write the explicit row/column table so every amount remains bound to its original dimension.
- Do **not** paraphrase a multi-axis source table into prose if that would force the downstream QA model to guess
which number belongs to which row or column.
- Do not replace precise source distinctions with a generic "highest" or "default" amount if the source provides
multiple applicable rows.
- If the source does not contain enough information to answer a likely question safely, preserve that limitation in
`content` instead of silently filling the gap.
- Only emit rules that are supported by the source files.
- `template_key` must be one of:
- `travel_standard_v1`
- `expense_amount_limit_v1`
- `attachment_requirement_v1`
- `general_policy_v1`
- If a document has no reliable rule candidate, return an empty `rule_candidates` list.
- Keep `evidence` short and directly grounded in the original source.
- Never invent missing numeric thresholds or unsupported制度要求.
- If the batch cannot be processed, callback with `status: "failed"` and an `error` string instead of partial prose.
## Safe callback construction
- Do **not** hand-write a Python source file that embeds long Chinese prose inside quoted string literals.
- Do **not** use `execute_code`, inline Python, or shell heredocs to assemble the callback payload.
- Use the `write_file` tool to write the finished payload directly as plain UTF-8 JSON to
`/tmp/x-financial-callback.json`.
- Build the callback as plain JSON, save it to a UTF-8 `.json` file, validate it with
`python3 -m json.tool <payload.json>`, then send that file through the callback skill.
- When prose contains Chinese quotation marks, tables, or long paragraphs, keep them as JSON string values in the
JSON file itself; do not interpolate them into shell commands or Python source code.
- Prefer one validated payload file for the whole batch over piecemeal shell heredocs.
- After `python3 -m json.tool` passes, do one final content review of all table-backed candidates before sending.
- If the callback endpoint returns HTTP 400, read the response body, repair **only** the rejected candidates, validate
the JSON again, and resend. Do not treat the first 400 response as terminal failure when the server has provided
actionable correction feedback.

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.v1.endpoints.settings import require_hermes_agent_token
from app.schemas.common import ErrorResponse
from app.schemas.hermes import HermesCallbackRead, HermesCallbackWrite
from app.services.hermes_callbacks import HermesCallbackService
router = APIRouter(prefix="/hermes")
DbSession = Annotated[Session, Depends(get_db)]
@router.post(
"/callback",
response_model=HermesCallbackRead,
dependencies=[Depends(require_hermes_agent_token)],
summary="接收 Hermes 通用回调",
description="所有 Hermes 任务统一通过该入口回传进度或完成结果,服务端依据 type 分发到对应业务处理器。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "Hermes 回调载荷不合法或任务类型暂不支持。",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "回调引用的 AgentRun 不存在。",
},
},
)
def handle_hermes_callback(
payload: HermesCallbackWrite,
db: DbSession,
) -> HermesCallbackRead:
try:
return HermesCallbackService(db).handle_callback(payload)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
@@ -26,7 +26,11 @@ from app.schemas.knowledge import (
LlmWikiSummaryUpdateWrite, LlmWikiSummaryUpdateWrite,
) )
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_SYNCING, KnowledgeService from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED,
KNOWLEDGE_INGEST_STATUS_SYNCING,
KnowledgeService,
)
from app.services.llm_wiki import LlmWikiService from app.services.llm_wiki import LlmWikiService
from app.services.llm_wiki_tasks import llm_wiki_task_manager from app.services.llm_wiki_tasks import llm_wiki_task_manager
@@ -169,6 +173,78 @@ def sync_llm_wiki(
for item in knowledge_service.list_folder_documents(folder=payload.folder) for item in knowledge_service.list_folder_documents(folder=payload.folder)
if str(item.get("id") or "").strip() and (not requested_ids or str(item.get("id") or "").strip() in requested_ids) if str(item.get("id") or "").strip() and (not requested_ids or str(item.get("id") or "").strip() in requested_ids)
] ]
active_run = None
for item in run_service.list_runs(
agent=AgentName.HERMES.value,
status=AgentRunStatus.RUNNING.value,
limit=100,
):
if item.route_json.get("job_type") != "llm_wiki_sync":
continue
if item.route_json.get("folder") != payload.folder:
continue
heartbeat_raw = str(item.route_json.get("heartbeat_at") or "").strip()
heartbeat_at = None
if heartbeat_raw:
try:
heartbeat_at = datetime.fromisoformat(heartbeat_raw)
except ValueError:
heartbeat_at = None
last_seen_at = heartbeat_at or item.started_at
if last_seen_at.tzinfo is None:
last_seen_at = last_seen_at.replace(tzinfo=UTC)
if datetime.now(UTC) - last_seen_at > timedelta(minutes=30):
stale_document_ids = [
str(document_id).strip()
for document_id in list(item.route_json.get("requested_document_ids") or [])
if str(document_id).strip()
]
if stale_document_ids:
knowledge_service.set_document_ingest_statuses(
stale_document_ids,
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=item.run_id,
)
run_service.merge_route_json(
item.run_id,
{
"phase": "stale_failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
},
status=AgentRunStatus.FAILED.value,
result_summary="Hermes 归纳任务长时间无心跳,已自动标记为失败。",
error_message="Hermes callback heartbeat timed out.",
finished_at=datetime.now(UTC),
)
continue
if (
not target_document_ids
or not list(item.route_json.get("requested_document_ids") or [])
or bool(
set(target_document_ids)
& {
str(document_id).strip()
for document_id in list(item.route_json.get("requested_document_ids") or [])
if str(document_id).strip()
}
)
):
active_run = item
break
if active_run is not None:
return LlmWikiSyncTaskRead(
ok=True,
agent_run_id=active_run.run_id,
folder=payload.folder,
document_ids=[
str(item).strip()
for item in list(active_run.route_json.get("requested_document_ids") or target_document_ids)
if str(item).strip()
],
queued_at=active_run.started_at,
status=active_run.status,
summary="已有 Hermes 归纳任务正在执行,已复用当前任务而不是重复创建。",
)
task_asset = db.scalar( task_asset = db.scalar(
select(AgentAsset).where(AgentAsset.code == "task.hermes.llm_wiki_rule_formation") select(AgentAsset).where(AgentAsset.code == "task.hermes.llm_wiki_rule_formation")
) )
@@ -186,6 +262,8 @@ def sync_llm_wiki(
"folder": payload.folder, "folder": payload.folder,
"force": payload.force, "force": payload.force,
"requested_document_ids": target_document_ids, "requested_document_ids": target_document_ids,
"requested_by_username": current_user.username,
"requested_by_name": current_user.name,
"progress": { "progress": {
"total_documents": len(target_document_ids), "total_documents": len(target_document_ids),
"completed_documents": 0, "completed_documents": 0,

View File

@@ -7,6 +7,7 @@ from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.hermes import router as hermes_router
from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.ontology import router as ontology_router
@@ -17,6 +18,7 @@ from app.api.v1.endpoints.system_logs import router as system_logs_router
router = APIRouter() router = APIRouter()
router.include_router(health_router, tags=["health"]) router.include_router(health_router, tags=["health"])
router.include_router(hermes_router, tags=["hermes"])
router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"]) router.include_router(auth_router, tags=["auth"])
router.include_router(agent_assets_router, tags=["agent-assets"]) router.include_router(agent_assets_router, tags=["agent-assets"])

View File

@@ -19,6 +19,8 @@ X-Financial 后端 OpenAPI 文档。
- `X-Request-Id` - `X-Request-Id`
- Hermes 运行时模型配置接口需要: - Hermes 运行时模型配置接口需要:
- `Authorization: Bearer <HERMES_AGENT_SHARED_TOKEN>` - `Authorization: Bearer <HERMES_AGENT_SHARED_TOKEN>`
- Hermes 通用回调接口同样需要:
- `Authorization: Bearer <HERMES_AGENT_SHARED_TOKEN>`
## 当前模块范围 ## 当前模块范围
@@ -76,6 +78,10 @@ OPENAPI_TAGS = [
"name": "settings", "name": "settings",
"description": "系统设置、模型配置、模型连通性探测和 Hermes 运行时模型配置。", "description": "系统设置、模型配置、模型连通性探测和 Hermes 运行时模型配置。",
}, },
{
"name": "hermes",
"description": "Hermes 与服务端之间的通用任务回调入口。",
},
{ {
"name": "agent-assets", "name": "agent-assets",
"description": "Agent 资产中心覆盖规则、技能、MCP、任务及其版本、审核和上线流程。", "description": "Agent 资产中心覆盖规则、技能、MCP、任务及其版本、审核和上线流程。",

View File

@@ -16,6 +16,7 @@ from app.services.agent_foundation import prepare_agent_foundation
from app.services.employee import prepare_employee_directory from app.services.employee import prepare_employee_directory
from app.services.knowledge import prepare_knowledge_library from app.services.knowledge import prepare_knowledge_library
from app.services.llm_wiki_tasks import llm_wiki_task_manager from app.services.llm_wiki_tasks import llm_wiki_task_manager
from app.services.hermes_sync import sync_repository_hermes_skills
@asynccontextmanager @asynccontextmanager
@@ -26,6 +27,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
prepare_employee_directory() prepare_employee_directory()
prepare_agent_foundation() prepare_agent_foundation()
prepare_knowledge_library() prepare_knowledge_library()
sync_repository_hermes_skills()
logger.info( logger.info(
"Server ready - host=%s port=%s prefix=%s", "Server ready - host=%s port=%s prefix=%s",
settings.app_host, settings.app_host,

View File

@@ -12,6 +12,8 @@ class AuthUserRead(BaseModel):
username: str username: str
name: str name: str
role: str role: str
position: str = ""
grade: str = ""
roleCodes: list[str] = Field(default_factory=list) roleCodes: list[str] = Field(default_factory=list)
email: EmailStr | str email: EmailStr | str
avatar: str avatar: str

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
HermesCallbackStatus = Literal["running", "succeeded", "failed"]
class HermesCallbackWrite(BaseModel):
type: str = Field(min_length=1, max_length=80)
run_id: str = Field(min_length=1, max_length=80)
status: HermesCallbackStatus
summary: str = ""
error: str = ""
payload: dict[str, Any] = Field(default_factory=dict)
class HermesCallbackRead(BaseModel):
ok: bool = True
accepted: bool = True
type: str
run_id: str
status: HermesCallbackStatus

View File

@@ -31,6 +31,8 @@ class AuthenticatedUser:
username: str username: str
name: str name: str
role: str role: str
position: str
grade: str
role_codes: list[str] role_codes: list[str]
email: str email: str
avatar: str avatar: str
@@ -76,6 +78,8 @@ class AuthService:
username=admin_username or admin_email, username=admin_username or admin_email,
name=display_name, name=display_name,
role="管理员", role="管理员",
position="系统管理员",
grade="",
role_codes=["manager"], role_codes=["manager"],
email=admin_email or f"{admin_username}@local", email=admin_email or f"{admin_username}@local",
avatar=display_name[:1].upper(), avatar=display_name[:1].upper(),
@@ -116,6 +120,8 @@ class AuthService:
username=employee.email, username=employee.email,
name=employee.name, name=employee.name,
role=ROLE_LABELS.get(primary_role_code, "使用者"), role=ROLE_LABELS.get(primary_role_code, "使用者"),
position=employee.position,
grade=employee.grade,
role_codes=role_codes or ["user"], role_codes=role_codes or ["user"],
email=employee.email, email=employee.email,
avatar=(employee.name or "?")[:1].upper(), avatar=(employee.name or "?")[:1].upper(),
@@ -128,6 +134,8 @@ class AuthService:
username=user.username, username=user.username,
name=user.name, name=user.name,
role=user.role, role=user.role,
position=user.position,
grade=user.grade,
roleCodes=user.role_codes, roleCodes=user.role_codes,
email=user.email, email=user.email,
avatar=user.avatar, avatar=user.avatar,

View File

@@ -3497,7 +3497,7 @@ class ExpenseClaimService:
return issues return issues
def _is_location_required_expense_type(expense_type: str | None) -> bool: def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
policy = self._get_expense_scene_policy(expense_type) policy = self._get_expense_scene_policy(expense_type)
if policy is None: if policy is None:
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentRunStatus
from app.schemas.hermes import HermesCallbackRead, HermesCallbackWrite
from app.services.agent_runs import AgentRunService
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService
from app.services.llm_wiki import LlmWikiService
class HermesCallbackService:
def __init__(self, db: Session) -> None:
self.db = db
self.run_service = AgentRunService(db)
def handle_callback(self, payload: HermesCallbackWrite) -> HermesCallbackRead:
run = self.run_service.get_run(payload.run_id)
if run is None:
raise LookupError("Hermes 回调引用的 AgentRun 不存在。")
if payload.type == "llm_wiki_sync":
self._handle_llm_wiki_sync(payload)
else:
raise ValueError(f"暂不支持的 Hermes 回调类型:{payload.type}")
return HermesCallbackRead(
type=payload.type,
run_id=payload.run_id,
status=payload.status,
)
def _handle_llm_wiki_sync(self, payload: HermesCallbackWrite) -> None:
run = self.run_service.get_run(payload.run_id)
if run is None:
raise LookupError("Hermes 回调引用的 AgentRun 不存在。")
route_json = dict(run.route_json or {})
document_ids = [
str(item).strip()
for item in list(route_json.get("requested_document_ids") or [])
if str(item).strip()
]
if payload.status == "running":
self.run_service.merge_route_json(
payload.run_id,
{
"phase": "running",
"heartbeat_at": datetime.now(UTC).isoformat(),
"callback_status": payload.status,
"callback_payload": payload.payload,
},
status=AgentRunStatus.RUNNING.value,
result_summary=payload.summary or run.result_summary,
)
return
if payload.status == "failed":
if document_ids:
KnowledgeService().set_document_ingest_statuses(
document_ids,
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=payload.run_id,
)
self.run_service.record_tool_call(
run_id=payload.run_id,
tool_type="http",
tool_name="hermes_callback",
request_json=payload.model_dump(mode="json"),
response_json={},
status="failed",
duration_ms=0,
error_message=payload.error or payload.summary or "Hermes callback failed",
)
self.run_service.merge_route_json(
payload.run_id,
{
"phase": "failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
"callback_status": payload.status,
"callback_payload": payload.payload,
},
status=AgentRunStatus.FAILED.value,
result_summary=payload.summary or payload.error or "Hermes 任务失败。",
error_message=payload.error or payload.summary or "Hermes 任务失败。",
finished_at=datetime.now(UTC),
)
return
result = LlmWikiService(self.db).finalize_agent_batch_callback(
agent_run_id=payload.run_id,
payload=payload.payload,
)
self.run_service.record_tool_call(
run_id=payload.run_id,
tool_type="http",
tool_name="hermes_callback",
request_json=payload.model_dump(mode="json"),
response_json=result.model_dump(mode="json"),
status="succeeded",
duration_ms=0,
)
self.run_service.merge_route_json(
payload.run_id,
{
"phase": "succeeded",
"heartbeat_at": datetime.now(UTC).isoformat(),
"callback_status": payload.status,
"sync_run_id": result.run_id,
"sync_result": result.model_dump(mode="json"),
"progress": {
"total_documents": len(document_ids),
"completed_documents": result.document_count,
"failed_documents": 0,
"skipped_documents": max(0, len(document_ids) - result.document_count),
"percent": 100,
},
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary=payload.summary or result.summary,
finished_at=datetime.now(UTC),
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -8,6 +9,8 @@ from typing import Any
import yaml import yaml
from app.core.config import ROOT_DIR
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class HermesModelRoute: class HermesModelRoute:
@@ -35,6 +38,26 @@ def get_hermes_config_path() -> Path:
return get_hermes_home() / "config.yaml" return get_hermes_home() / "config.yaml"
def sync_repository_hermes_skills(
*,
source_root: Path | None = None,
target_root: Path | None = None,
) -> Path:
source = source_root or ROOT_DIR / "hermes" / "skills"
target = target_root or get_hermes_home() / "skills"
if not source.exists():
return target
target.mkdir(parents=True, exist_ok=True)
for item in source.iterdir():
destination = target / item.name
if item.is_dir():
shutil.copytree(item, destination, dirs_exist_ok=True)
elif item.is_file():
shutil.copy2(item, destination)
return target
def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot: def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot:
target_path = config_path or get_hermes_config_path() target_path = config_path or get_hermes_config_path()
if not target_path.exists(): if not target_path.exists():

View File

@@ -72,6 +72,23 @@ STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS
INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS
ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"} ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"}
KNOWLEDGE_INGEST_SYNC_STALE_SECONDS = 90 KNOWLEDGE_INGEST_SYNC_STALE_SECONDS = 90
KNOWLEDGE_SEARCH_RESULT_LIMIT = 3
KNOWLEDGE_SEARCH_STOP_TERMS = {
"什么",
"怎么",
"如何",
"多少",
"是否",
"可以",
"一下",
"请问",
"帮我",
"一下子",
"这个",
"那个",
"哪些",
"一下吧",
}
KNOWLEDGE_INGEST_STATUS_PUBLISHED = 1 KNOWLEDGE_INGEST_STATUS_PUBLISHED = 1
KNOWLEDGE_INGEST_STATUS_SYNCING = 2 KNOWLEDGE_INGEST_STATUS_SYNCING = 2
@@ -346,6 +363,156 @@ class KnowledgeService:
self.ensure_library_ready() self.ensure_library_ready()
return self.llm_wiki_root return self.llm_wiki_root
def search_llm_wiki(self, query: str, *, limit: int = KNOWLEDGE_SEARCH_RESULT_LIMIT) -> dict[str, Any]:
self.ensure_library_ready()
normalized_query = self._normalize_search_text(query)
if not normalized_query:
return {
"result_type": "knowledge_search",
"query": "",
"record_count": 0,
"hits": [],
"references": [],
"message": "请先输入要检索的制度或规则问题。",
}
index = self._load_index()
if self._reconcile_document_ingest_statuses(index):
self._save_index(index)
entry_by_id = {
str(item.get("id") or "").strip(): item
for item in list(index.get("documents") or [])
if str(item.get("id") or "").strip()
}
wiki_index = self._load_llm_wiki_index()
query_terms = self._extract_search_terms(query)
hits: list[dict[str, Any]] = []
for wiki_document in list(wiki_index.get("documents") or []):
document_id = str(wiki_document.get("document_id") or "").strip()
if not document_id:
continue
entry = entry_by_id.get(document_id)
if entry is None or not self._has_matching_llm_wiki_artifact(entry, wiki_document):
continue
quality_status = str(wiki_document.get("quality_status") or "").strip()
if quality_status == "failed":
continue
document_name = str(wiki_document.get("document_name") or entry.get("original_name") or "").strip()
document_dir = self.llm_wiki_documents_root / document_id
candidates = self._load_json_file(document_dir / "knowledge_candidates.json", default=[])
matched_in_document = False
for index, candidate in enumerate(candidates, start=1):
if not isinstance(candidate, dict):
continue
title = str(candidate.get("title") or "").strip()
content = str(candidate.get("content") or "").strip()
tags = [str(item).strip() for item in list(candidate.get("tags") or []) if str(item).strip()]
evidence = [
str(item).strip() for item in list(candidate.get("evidence") or []) if str(item).strip()
]
score, matched_terms = self._score_knowledge_search_match(
query_text=normalized_query,
query_terms=query_terms,
title=title,
content=content,
tags=tags,
document_name=document_name,
evidence=evidence,
)
if score <= 0:
continue
matched_in_document = True
candidate_id = str(candidate.get("candidate_id") or f"candidate_{index}").strip()
hits.append(
{
"code": f"knowledge.{document_id}.{candidate_id}",
"candidate_id": candidate_id,
"title": title or document_name or "制度知识条目",
"content": content,
"excerpt": self._build_search_excerpt(content or title, query_terms),
"document_id": document_id,
"document_name": document_name,
"version": str(wiki_document.get("document_version") or "").strip() or None,
"updated_at": self._format_search_timestamp(wiki_document.get("updated_at")),
"quality_status": quality_status,
"tags": tags,
"evidence": evidence,
"score": score,
"matched_terms": matched_terms,
}
)
self._boost_title_family_hits(hits)
ranked_hits = sorted(
hits,
key=lambda item: (
-int(item.get("score") or 0),
str(item.get("quality_status") or "") != "formal",
str(item.get("title") or ""),
),
)[: max(1, limit)]
if ranked_hits:
titles = "".join(str(item.get("title") or "") for item in ranked_hits[:2] if str(item.get("title") or "").strip())
return {
"result_type": "knowledge_search",
"query": str(query).strip(),
"record_count": len(ranked_hits),
"hits": ranked_hits,
"references": [str(item.get("code") or "").strip() for item in ranked_hits if str(item.get("code") or "").strip()],
"message": (
f"已从已归纳制度知识中检索到 {len(ranked_hits)} 条相关内容。"
f"{f'优先参考:{titles}' if titles else ''}"
),
}
return {
"result_type": "knowledge_search",
"query": str(query).strip(),
"record_count": 0,
"hits": [],
"references": [],
"message": (
f"当前未在已归纳制度知识中检索到与“{str(query).strip()}”直接匹配的内容。"
"知识问答仅基于 LLM Wiki 已形成的知识条目回答;当前依据不足,不能继续扩展回答。"
),
}
@staticmethod
def _boost_title_family_hits(hits: list[dict[str, Any]]) -> None:
if len(hits) < 2:
return
preliminary = sorted(
hits,
key=lambda item: (
-int(item.get("score") or 0),
str(item.get("quality_status") or "") != "formal",
str(item.get("title") or ""),
),
)
primary = preliminary[0]
primary_title = str(primary.get("title") or "").strip()
primary_document_id = str(primary.get("document_id") or "").strip()
if len(primary_title) < 3 or not primary_document_id:
return
family_key = primary_title[:3]
family_hits = [
item
for item in hits
if str(item.get("document_id") or "").strip() == primary_document_id
and str(item.get("title") or "").strip().startswith(family_key)
]
if len(family_hits) < 2:
return
for item in family_hits:
item["score"] = int(item.get("score") or 0) + 20
def extract_document_text(self, document_id: str) -> str: def extract_document_text(self, document_id: str) -> str:
self.ensure_library_ready() self.ensure_library_ready()
entry = self.get_document_entry(document_id) entry = self.get_document_entry(document_id)
@@ -830,6 +997,151 @@ class KnowledgeService:
if str(item.get("document_id") or "").strip() if str(item.get("document_id") or "").strip()
} }
@staticmethod
def _load_json_file(path: Path, *, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return default
@staticmethod
def _load_text_file(path: Path) -> str:
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return ""
@staticmethod
def _normalize_search_text(value: Any) -> str:
text = str(value or "").strip().lower()
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text)
@staticmethod
def _extract_search_terms(query: str) -> list[str]:
normalized = KnowledgeService._normalize_search_text(query)
if not normalized:
return []
terms: set[str] = set()
for part in re.findall(r"[0-9a-z]+|[\u4e00-\u9fff]+", normalized):
if len(part) <= 1:
continue
if part not in KNOWLEDGE_SEARCH_STOP_TERMS:
terms.add(part)
if not re.fullmatch(r"[\u4e00-\u9fff]+", part):
continue
upper_size = min(4, len(part))
for size in range(2, upper_size + 1):
for index in range(0, len(part) - size + 1):
gram = part[index : index + size]
if gram in KNOWLEDGE_SEARCH_STOP_TERMS:
continue
terms.add(gram)
return sorted(terms, key=lambda item: (-len(item), item))
@staticmethod
def _score_knowledge_search_match(
*,
query_text: str,
query_terms: list[str],
title: str,
content: str,
tags: list[str],
document_name: str,
evidence: list[str],
) -> tuple[int, list[str]]:
normalized_title = KnowledgeService._normalize_search_text(title)
normalized_content = KnowledgeService._normalize_search_text(content)
normalized_tags = [KnowledgeService._normalize_search_text(item) for item in tags]
normalized_document_name = KnowledgeService._normalize_search_text(document_name)
normalized_evidence = [KnowledgeService._normalize_search_text(item) for item in evidence]
score = 0
matched_terms: list[str] = []
if query_text and query_text in normalized_title:
score += 140
elif query_text and any(query_text in item for item in normalized_tags):
score += 120
elif query_text and query_text in normalized_content:
score += 88
for phrase in [normalized_title, *normalized_tags, normalized_document_name]:
if not phrase:
continue
if phrase in query_text:
score += 24 + min(18, len(phrase) * 2)
matched_terms.append(phrase)
elif query_text and query_text in phrase:
score += 16
for term in query_terms:
if len(term) <= 1:
continue
term_score = 0
if term in normalized_title:
term_score = 18 if len(term) >= 4 else 14
elif any(term in item for item in normalized_tags):
term_score = 16 if len(term) >= 4 else 12
elif term in normalized_content:
term_score = 10 if len(term) >= 4 else 8
elif term in normalized_document_name or any(term in item for item in normalized_evidence):
term_score = 6
if term_score:
score += term_score
matched_terms.append(term)
if score <= 0:
return 0, []
distinct_matches = []
for item in matched_terms:
if item and item not in distinct_matches:
distinct_matches.append(item)
score += min(24, len(distinct_matches) * 4)
return score, distinct_matches[:6]
@staticmethod
def _build_search_excerpt(text: str, query_terms: list[str], *, max_length: int = 140) -> str:
plain_text = re.sub(r"[#*_`>\-\[\]]+", " ", str(text or ""))
plain_text = re.sub(r"\s+", " ", plain_text).strip()
if not plain_text:
return ""
normalized_text = KnowledgeService._normalize_search_text(plain_text)
for term in query_terms:
if not term or term not in normalized_text:
continue
raw_index = plain_text.find(term)
if raw_index == -1:
continue
start = max(0, raw_index - 36)
end = min(len(plain_text), raw_index + max_length - 36)
snippet = plain_text[start:end].strip(" ,。;:")
if start > 0:
snippet = f"...{snippet}"
if end < len(plain_text):
snippet = f"{snippet}..."
return snippet
if len(plain_text) <= max_length:
return plain_text
return f"{plain_text[: max_length - 3].rstrip()}..."
@staticmethod
def _format_search_timestamp(value: Any) -> str | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
try:
parsed = datetime.fromisoformat(raw_value)
except ValueError:
return raw_value or None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC).date().isoformat()
def _has_ingested_llm_wiki_document( def _has_ingested_llm_wiki_document(
self, self,
entry: dict[str, Any], entry: dict[str, Any],

View File

@@ -23,6 +23,7 @@ from app.core.agent_enums import (
AgentAssetType, AgentAssetType,
) )
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.config import get_settings
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetCreate, AgentAssetUpdate, AgentAssetVersionCreate from app.schemas.agent_asset import AgentAssetCreate, AgentAssetUpdate, AgentAssetVersionCreate
from app.schemas.knowledge import ( from app.schemas.knowledge import (
@@ -43,12 +44,15 @@ from app.services.knowledge import (
) )
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.system_hermes import SystemHermesService from app.services.system_hermes import SystemHermesService
from app.services.settings import SettingsService
from app.services.hermes_sync import sync_repository_hermes_skills
logger = get_logger("app.services.llm_wiki") logger = get_logger("app.services.llm_wiki")
HERMES_CANDIDATE_MODEL_TIMEOUT_SECONDS = 10 HERMES_CANDIDATE_MODEL_TIMEOUT_SECONDS = 10
HERMES_CANDIDATE_GROUP_SIZE = 2 HERMES_CANDIDATE_GROUP_SIZE = 2
HERMES_CANDIDATE_CONTENT_LIMIT = 520 HERMES_CANDIDATE_CONTENT_LIMIT = 520
HERMES_AGENT_BATCH_TIMEOUT_SECONDS = 900
LOW_SIGNAL_DOTTED_LINE_PATTERN = re.compile(r"[..。·•]{6,}\s*[0-9]{0,3}$") LOW_SIGNAL_DOTTED_LINE_PATTERN = re.compile(r"[..。·•]{6,}\s*[0-9]{0,3}$")
PAGE_FOOTER_PATTERN = re.compile(r"^第\s*\d+\s*页\s*共\s*\d+\s*页$") PAGE_FOOTER_PATTERN = re.compile(r"^第\s*\d+\s*页\s*共\s*\d+\s*页$")
POLICY_SUBSTANCE_KEYWORDS = ( POLICY_SUBSTANCE_KEYWORDS = (
@@ -106,6 +110,17 @@ class CandidateExtractionStats:
quality_status: str = "failed" quality_status: str = "failed"
quality_note: str = "" quality_note: str = ""
@dataclass(slots=True)
class LlmWikiAgentBatchDispatch:
prompt: str
request_payload: dict[str, Any]
changed_document_ids: list[str]
skipped_document_ids: list[str]
process_id: int = 0
stdout_path: str = ""
stderr_path: str = ""
RULE_TEMPLATE_CATALOG: dict[str, dict[str, str]] = { RULE_TEMPLATE_CATALOG: dict[str, dict[str, str]] = {
"travel_standard_v1": { "travel_standard_v1": {
"label": "差旅标准模板", "label": "差旅标准模板",
@@ -406,6 +421,649 @@ class LlmWikiService:
(document_dir / "knowledge_summary.md").write_text(summary_text, encoding="utf-8") (document_dir / "knowledge_summary.md").write_text(summary_text, encoding="utf-8")
return self.get_document_detail(document_id) return self.get_document_detail(document_id)
def build_agent_batch_dispatch(
self,
*,
folder: str,
document_ids: list[str],
force: bool,
agent_run_id: str,
) -> LlmWikiAgentBatchDispatch:
self.knowledge_service.ensure_library_ready()
sync_repository_hermes_skills()
SettingsService(self.db).sync_hermes_runtime_model_settings()
documents = self.knowledge_service.list_folder_documents(folder=folder)
allowed_ids = {str(item).strip() for item in document_ids if str(item).strip()}
if allowed_ids:
documents = [item for item in documents if str(item.get("id") or "").strip() in allowed_ids]
index = self._load_wiki_index()
existing_by_id = {
str(item.get("document_id") or ""): item for item in list(index.get("documents", []))
}
changed_entries: list[dict[str, Any]] = []
skipped_document_ids: list[str] = []
for entry in documents:
document_id = str(entry.get("id") or "").strip()
if not document_id:
continue
sync_reason = self._resolve_sync_reason(
entry=entry,
existing=existing_by_id.get(document_id),
force=force,
)
if sync_reason == "unchanged_skipped":
skipped_document_ids.append(document_id)
continue
file_path, _, _ = self.knowledge_service.get_document_content(document_id)
changed_entries.append(
{
"document_id": document_id,
"document_name": str(entry["original_name"]),
"folder": str(entry["folder"]),
"absolute_path": str(file_path.resolve()),
"document_version": f"v{int(entry.get('version_number', 1))}.0",
"signature": self._build_document_signature(entry),
"sync_reason": sync_reason,
}
)
settings = get_settings()
callback_token = str(settings.hermes_agent_shared_token or "").strip()
if not callback_token:
raise ValueError("Hermes 回调令牌未配置,无法派发 Hermes 任务。")
callback_url = (
f"http://127.0.0.1:{settings.app_port}{settings.api_v1_prefix}/hermes/callback"
)
request_payload = {
"type": "llm_wiki_sync",
"run_id": agent_run_id,
"callback_url": callback_url,
"callback_token": callback_token,
"folder": folder,
"documents": changed_entries,
}
prompt = (
"请执行一次 X-Financial 制度文档知识归集任务。\n"
"要求:\n"
"1. 这是一个批量任务,但只能作为一次 Hermes 任务整体执行;\n"
"2. 直接读取每个 absolute_path 指向的完整原文件,不要要求服务端先切块;\n"
"3. 使用 llm-wiki、x-financial-llm-wiki-ingest、x-financial-callback 三个 skill\n"
"4. 完成后必须向 callback_url 主动 POST 一个通用回调请求;\n"
"5. 回调 body 的 type 必须保持为 llm_wiki_syncrun_id 必须保持不变;\n"
"6. 任务成功时 status=succeeded业务结果放在 payload失败时 status=failed 并给出 error\n"
"7. 回调成功后,只返回一句简短确认。\n\n"
f"{json.dumps(request_payload, ensure_ascii=False, indent=2)}"
)
return LlmWikiAgentBatchDispatch(
prompt=prompt,
request_payload=request_payload,
changed_document_ids=[item["document_id"] for item in changed_entries],
skipped_document_ids=skipped_document_ids,
)
def dispatch_agent_batch(
self,
*,
folder: str,
document_ids: list[str],
force: bool,
agent_run_id: str,
) -> LlmWikiAgentBatchDispatch:
dispatch = self.build_agent_batch_dispatch(
folder=folder,
document_ids=document_ids,
force=force,
agent_run_id=agent_run_id,
)
if not dispatch.changed_document_ids:
return dispatch
self.knowledge_service.set_document_ingest_statuses(
dispatch.changed_document_ids,
status_code=KNOWLEDGE_INGEST_STATUS_SYNCING,
agent_run_id=agent_run_id,
)
process_handle = self.system_hermes_service.start_query_background(
dispatch.prompt,
source="tool",
max_turns=24,
skills=("llm-wiki", "x-financial-llm-wiki-ingest", "x-financial-callback"),
log_prefix=f"llm-wiki-{agent_run_id}",
yolo=True,
)
dispatch.process_id = process_handle.pid
dispatch.stdout_path = process_handle.stdout_path
dispatch.stderr_path = process_handle.stderr_path
return dispatch
def finalize_agent_batch_callback(
self,
*,
agent_run_id: str,
payload: dict[str, Any],
) -> LlmWikiSyncRead:
documents_payload = list(payload.get("documents") or [])
if not bool(payload.get("ok", True)):
raise ValueError(str(payload.get("error") or "Hermes LLM Wiki 回调失败。"))
if not documents_payload:
raise ValueError("Hermes LLM Wiki 回调未返回 documents。")
callback_route = self._resolve_callback_route(agent_run_id)
callback_folder = str(payload.get("folder") or callback_route.get("folder") or "")
documents_by_id = {
str(item.get("id") or "").strip(): item
for item in self.knowledge_service.list_folder_documents()
if str(item.get("id") or "").strip()
}
index = self._load_wiki_index()
sync_runs = self._load_sync_runs()
existing_by_id = {
str(item.get("document_id") or ""): item for item in list(index.get("documents", []))
}
changed_document_count = 0
knowledge_candidate_count = 0
rule_candidate_count = 0
generated_rule_asset_ids: list[str] = []
completed_document_ids: list[str] = []
sync_summaries: list[str] = []
current_user = self._resolve_callback_user(agent_run_id)
for raw_document in documents_payload:
if not isinstance(raw_document, dict):
continue
document_id = str(raw_document.get("document_id") or "").strip()
entry = documents_by_id.get(document_id)
if entry is None:
continue
document_payload = self._persist_agent_document_result(
entry=entry,
current_user=current_user,
raw_document=raw_document,
)
existing_by_id[document_id] = document_payload["document"]
changed_document_count += 1
completed_document_ids.append(document_id)
knowledge_candidate_count += len(document_payload["knowledge_candidates"])
rule_candidate_count += len(document_payload["rule_candidates"])
generated_rule_asset_ids.extend(
[
str(item.get("generated_asset_id") or "").strip()
for item in document_payload["rule_candidates"]
if str(item.get("generated_asset_id") or "").strip()
]
)
sync_summaries.append(
f"{entry['original_name']}agent_batch知识候选 {len(document_payload['knowledge_candidates'])} 条,"
f"规则候选 {len(document_payload['rule_candidates'])} 条。"
)
if changed_document_count <= 0:
raise ValueError("Hermes LLM Wiki 回调没有匹配到可落库的文档。")
index["documents"] = list(existing_by_id.values())
self._write_json_file(self.knowledge_service.llm_wiki_index_path, index)
sync_run_id = f"wiki_{uuid4().hex[:12]}"
generated_rule_ids = list(dict.fromkeys(generated_rule_asset_ids))
summary = str(payload.get("summary") or "").strip() or "".join(sync_summaries)
sync_runs.setdefault("runs", [])
sync_runs["runs"].append(
{
"run_id": sync_run_id,
"folder": callback_folder,
"requested_document_ids": completed_document_ids,
"changed_document_count": changed_document_count,
"knowledge_candidate_count": knowledge_candidate_count,
"rule_candidate_count": rule_candidate_count,
"generated_rule_asset_ids": generated_rule_ids,
"created_by": current_user.name,
"created_at": datetime.now(UTC).isoformat(),
"summary": sync_summaries,
"source": "hermes_callback",
"agent_run_id": agent_run_id,
}
)
self._write_json_file(self.knowledge_service.llm_wiki_sync_runs_path, sync_runs)
self.knowledge_service.refresh_document_ingest_statuses(
document_ids=completed_document_ids,
preserve_syncing=False,
)
return LlmWikiSyncRead(
ok=True,
run_id=sync_run_id,
folder=callback_folder,
document_count=changed_document_count,
knowledge_candidate_count=knowledge_candidate_count,
rule_candidate_count=rule_candidate_count,
generated_rule_count=len(generated_rule_ids),
generated_rule_asset_ids=generated_rule_ids,
summary=summary,
)
def _persist_agent_document_result(
self,
*,
entry: dict[str, Any],
current_user: CurrentUserContext,
raw_document: dict[str, Any],
) -> dict[str, Any]:
document_id = str(entry["id"])
document_name = str(entry["original_name"])
document_dir = self._document_dir(document_id)
document_dir.mkdir(parents=True, exist_ok=True)
extracted_text = self.knowledge_service.extract_document_text(document_id)
text_path = document_dir / "text.md"
text_path.write_text(extracted_text, encoding="utf-8")
source_chunk = {
"chunk_id": f"{document_id}-document",
"title": document_name,
"content": extracted_text,
"source_page": None,
"word_count": len(re.sub(r"\s+", "", extracted_text)),
"tags": self._normalize_tags([], fallback_text=extracted_text),
}
seen_knowledge_keys: set[str] = set()
seen_rule_keys: set[str] = set()
knowledge_candidates = self._normalize_knowledge_candidates(
raw_items=list(raw_document.get("knowledge_candidates") or []),
entry=entry,
chunk_group=[source_chunk],
seen_keys=seen_knowledge_keys,
extraction_mode="hermes",
)[:12]
rule_candidates = self._normalize_rule_candidates(
raw_items=list(raw_document.get("rule_candidates") or []),
entry=entry,
chunk_group=[source_chunk],
current_user=current_user,
seen_keys=seen_rule_keys,
)[:12]
generated_candidates: list[dict[str, Any]] = []
for candidate in rule_candidates:
if candidate.get("validation_status") == "valid":
generated_asset = self._create_or_update_rule_draft(candidate, current_user=current_user)
if generated_asset is not None:
candidate["generated_asset_id"] = generated_asset["asset_id"]
candidate["generated_asset_code"] = generated_asset["asset_code"]
candidate["generated_version"] = generated_asset["version"]
generated_candidates.append(candidate)
summary_markdown = str(raw_document.get("knowledge_summary_markdown") or "").strip()
if not summary_markdown:
summary_markdown = self._build_knowledge_summary_markdown(
entry=entry,
knowledge_candidates=knowledge_candidates,
)
knowledge_candidates = self._upgrade_table_candidate_contents_from_summary(
knowledge_candidates=knowledge_candidates,
summary_markdown=summary_markdown,
)
knowledge_candidates = self._synthesize_candidates_from_summary(
knowledge_candidates=knowledge_candidates,
summary_markdown=summary_markdown,
entry=entry,
)
self._validate_agent_knowledge_candidate_quality(
knowledge_candidates,
summary_markdown=summary_markdown,
)
document_record = {
"document_id": document_id,
"document_name": document_name,
"folder": str(entry["folder"]),
"document_version": f"v{int(entry.get('version_number', 1))}.0",
"checksum": str(entry.get("sha256") or ""),
"extracted_text_path": str(text_path),
"chunk_count": 1,
"candidate_chunk_count": 1,
"filtered_chunk_count": 0,
"group_count": 1,
"successful_group_count": 1,
"failed_group_count": 0,
"knowledge_candidate_count": len(knowledge_candidates),
"formal_knowledge_candidate_count": len(knowledge_candidates),
"fallback_knowledge_candidate_count": 0,
"rule_candidate_count": len(generated_candidates),
"quality_status": "formal" if knowledge_candidates else "failed",
"quality_note": (
"Hermes 已基于完整原文件完成正式归纳。"
if knowledge_candidates
else "Hermes 回调未返回可用知识候选。"
),
"updated_at": datetime.now(UTC).isoformat(),
"signature": self._build_document_signature(entry),
"sync_reason": str(raw_document.get("sync_reason") or "agent_batch"),
}
self._write_json_file(document_dir / "document.json", document_record)
self._write_json_file(document_dir / "chunks.json", [source_chunk])
self._write_json_file(document_dir / "knowledge_candidates.json", knowledge_candidates)
self._write_json_file(document_dir / "rule_candidates.json", generated_candidates)
(document_dir / "knowledge_summary.md").write_text(summary_markdown, encoding="utf-8")
return {
"document": document_record,
"knowledge_candidates": knowledge_candidates,
"rule_candidates": generated_candidates,
}
@staticmethod
def _validate_agent_knowledge_candidate_quality(
knowledge_candidates: list[dict[str, Any]],
*,
summary_markdown: str = "",
) -> None:
invalid_titles: list[str] = []
for candidate in knowledge_candidates:
content = str(candidate.get("content") or "").strip()
evidence = " ".join(str(item or "") for item in list(candidate.get("evidence") or []))
title = str(candidate.get("title") or "").strip() or "未命名条目"
table_backed = "" in evidence
if not table_backed:
continue
has_markdown_table = "|" in content
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
summary_has_candidate_table = False
if summary_markdown and "|" in summary_markdown:
match_terms = [
term
for term in [title, *[str(item).strip() for item in list(candidate.get("tags") or [])]]
if len(term) >= 2
]
summary_has_candidate_table = any(
("|" in section) and any(term in section for term in match_terms)
for section in re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
if section.strip()
)
if (not has_markdown_table or has_slash_shorthand) and not summary_has_candidate_table:
invalid_titles.append(title)
if invalid_titles:
joined = "".join(invalid_titles[:5])
raise ValueError(
"Hermes 回调中的表格型知识条目既没有可直接复用的 Markdown 表格,"
"也没有在 wiki 总结中提供可用于还原表格的结构化表述:"
f"{joined}。请补充可还原的 wiki 表述后重新回调。"
)
LlmWikiService._validate_travel_knowledge_completeness(
knowledge_candidates=knowledge_candidates,
summary_markdown=summary_markdown,
)
@staticmethod
def _validate_travel_knowledge_completeness(
*,
knowledge_candidates: list[dict[str, Any]],
summary_markdown: str,
) -> None:
joined_text = "\n".join(
[
summary_markdown,
*[
"\n".join(
[
str(candidate.get("title") or ""),
str(candidate.get("content") or ""),
" ".join(str(item or "") for item in list(candidate.get("tags") or [])),
" ".join(str(item or "") for item in list(candidate.get("evidence") or [])),
]
)
for candidate in knowledge_candidates
if isinstance(candidate, dict)
],
]
)
normalized = re.sub(r"\s+", "", joined_text)
if "差旅费" not in normalized and "出差" not in normalized:
return
dimensions = {
"交通费": ("交通费", "交通工具", "飞机", "火车", "轮船"),
"住宿费": ("住宿费", "住宿限额", "酒店住宿", "住 宿", "住 宿费"),
"出差补贴": ("出差补贴", "餐补", "基本补助", "补贴标准"),
}
missing = [
label
for label, aliases in dimensions.items()
if not any(alias.replace(" ", "") in normalized for alias in aliases)
]
if missing:
raise ValueError(
"Hermes 回调中的差旅知识不完整,缺少影响总额计算的关键维度:"
f"{''.join(missing)}。请补充后重新回调。"
)
@staticmethod
def _upgrade_table_candidate_contents_from_summary(
*,
knowledge_candidates: list[dict[str, Any]],
summary_markdown: str,
) -> list[dict[str, Any]]:
if "|" not in summary_markdown:
return knowledge_candidates
sections = re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
table_sections = [section.strip() for section in sections if "|" in section and section.strip()]
if not table_sections:
return knowledge_candidates
upgraded: list[dict[str, Any]] = []
for candidate in knowledge_candidates:
copied = dict(candidate)
content = str(copied.get("content") or "").strip()
title = str(copied.get("title") or "").strip()
tags = [str(item).strip() for item in list(copied.get("tags") or []) if str(item).strip()]
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
evidence = " ".join(str(item or "") for item in list(copied.get("evidence") or []))
table_backed = "" in evidence
if not (table_backed or has_slash_shorthand) or "|" in content:
upgraded.append(copied)
continue
match_terms = [
term
for term in [title, *tags, *LlmWikiService._extract_table_match_terms(title, evidence)]
if len(term) >= 2
]
ranked_sections = sorted(
(
(
LlmWikiService._score_summary_table_section(
section=section,
title=title,
tags=tags,
evidence=evidence,
match_terms=match_terms,
),
max((len(term) for term in match_terms if term in section), default=0),
section,
)
for section in table_sections
),
reverse=True,
)
replacement = ranked_sections[0][2] if ranked_sections and ranked_sections[0][0] > 0 else ""
if replacement:
intro = ""
if content and "|" not in content:
prose_lines = [
line.strip()
for line in content.splitlines()
if line.strip() and "|" not in line
]
intro = "\n\n".join(prose_lines[:2]).strip()
copied["content"] = f"{intro}\n\n{replacement}".strip() if intro else replacement
quality_flags = list(copied.get("quality_flags") or [])
if "table_restored_from_summary" not in quality_flags:
quality_flags.append("table_restored_from_summary")
copied["quality_flags"] = quality_flags
upgraded.append(copied)
return upgraded
def _synthesize_candidates_from_summary(
self,
*,
knowledge_candidates: list[dict[str, Any]],
summary_markdown: str,
entry: dict[str, Any],
) -> list[dict[str, Any]]:
sections = re.split(r"(?m)^(?=#{2,6}\s)", summary_markdown)
if not sections:
return knowledge_candidates
existing_text = "\n".join(
[
"\n".join(
[
str(candidate.get("title") or ""),
str(candidate.get("content") or ""),
]
)
for candidate in knowledge_candidates
if isinstance(candidate, dict)
]
)
normalized_existing = re.sub(r"\s+", "", existing_text)
synthesized = list(knowledge_candidates)
for section in sections:
stripped = section.strip()
if not stripped.startswith("## "):
continue
lines = [line.rstrip() for line in stripped.splitlines() if line.strip()]
if len(lines) < 2:
continue
heading = lines[0].lstrip("#").strip()
body = "\n".join(lines[1:]).strip()
if len(body) < 40:
continue
if not any(keyword in heading for keyword in ("差旅费", "住宿", "补贴", "审批权限", "归口管理")):
continue
compact_heading = re.sub(r"\s+", "", heading)
if compact_heading and compact_heading in normalized_existing:
continue
if "住宿" in heading and "住宿" in normalized_existing:
continue
if "补贴" in heading and "补贴" in normalized_existing:
continue
if "交通工具" in heading and "交通工具" in normalized_existing:
continue
synthesized.append(
{
"candidate_id": f"kc_{uuid4().hex[:12]}",
"title": heading,
"content": body,
"domain": "expense",
"scenario": self._infer_summary_candidate_scenario(heading),
"tags": self._normalize_tags([heading], fallback_text=body),
"source_document_id": str(entry["id"]),
"source_document_name": str(entry["original_name"]),
"source_chunk_ids": [f"{entry['id']}-document"],
"evidence": [f"wiki summary section: {heading}"],
"confidence": 0.9,
"status": "draft",
"created_by": "hermes",
"created_at": datetime.now(UTC).isoformat(),
"extraction_mode": "hermes",
"quality_flags": ["summary_synthesized_candidate"],
"fallback_reason": "",
}
)
normalized_existing += compact_heading + re.sub(r"\s+", "", body)
return synthesized[:12]
@staticmethod
def _infer_summary_candidate_scenario(heading: str) -> str:
compact_heading = re.sub(r"\s+", "", heading)
if "差旅费" in compact_heading or "住宿" in compact_heading or "补贴" in compact_heading:
return "travel_standard"
if "审批权限" in compact_heading:
return "expense_amount_limit"
return "general_policy"
@staticmethod
def _extract_table_match_terms(title: str, evidence: str) -> list[str]:
seed_text = f"{title} {evidence}"
terms = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,}", seed_text)
stop_terms = {
"单位",
"标准",
"摘要",
"规定",
"说明",
"国内",
"国外",
"员工",
"表1",
"表2",
"表3",
}
unique_terms: list[str] = []
for term in terms:
if term in stop_terms:
continue
if term not in unique_terms:
unique_terms.append(term)
return unique_terms[:8]
@staticmethod
def _score_summary_table_section(
*,
section: str,
title: str,
tags: list[str],
evidence: str,
match_terms: list[str],
) -> int:
section_heading = ""
for line in section.splitlines():
stripped = line.strip().lstrip("#").strip()
if stripped:
section_heading = stripped
break
score = 0
if title and title in section:
score += 12
if title and title in section_heading:
score += 18
for term in match_terms:
if term in section:
score += 3
if section_heading and term in section_heading:
score += 6
for tag in tags:
if len(tag) >= 2 and tag in section_heading:
score += 2
core_terms = LlmWikiService._extract_table_match_terms(title, evidence)
if core_terms and not any(term in section_heading for term in core_terms):
score -= 10
return score
def _resolve_callback_user(self, agent_run_id: str) -> CurrentUserContext:
route_json = self._resolve_callback_route(agent_run_id)
return CurrentUserContext(
username=str(route_json.get("requested_by_username") or "hermes"),
name=str(route_json.get("requested_by_name") or "Hermes"),
role_codes=["manager"],
is_admin=True,
)
def _resolve_callback_route(self, agent_run_id: str) -> dict[str, Any]:
from app.services.agent_runs import AgentRunService
run = AgentRunService(self.db).get_run(agent_run_id)
if run is None:
return {}
return dict(run.route_json or {})
def sync_folder( def sync_folder(
self, self,
*, *,
@@ -923,8 +1581,15 @@ class LlmWikiService:
system_prompt = ( system_prompt = (
"你是企业财务制度知识库的 Hermes 规则形成器。" "你是企业财务制度知识库的 Hermes 规则形成器。"
"你只能基于提供的制度条款生成结构化知识候选和规则候选,不能自由发散。" "你只能基于提供的制度条款生成结构化知识候选和规则候选,不能自由发散。"
"后续知识问答系统只能基于你形成的 wiki 知识回答,不能再回原文补猜,因此 knowledge_candidates 不是摘要,"
"而是可直接复用的 wiki 片段。"
"封面、目录、通知、页眉页脚、密级说明、印发信息不属于知识候选,必须忽略。" "封面、目录、通知、页眉页脚、密级说明、印发信息不属于知识候选,必须忽略。"
"只提炼具有执行意义、审核意义、报销约束意义的条款。" "只提炼具有执行意义、审核意义、报销约束意义的条款。"
"每条 knowledge_candidate.content 都必须尽量自洽,保留适用对象、适用条件、核心要求、例外、阈值和限制;"
"如果原文没有足够信息,也要在 content 中保留该限制,不得自行补全。"
"如果原文标准同时依赖多个维度,例如“职级 × 地区”“费用类型 × 金额区间”,必须保留全部判断维度,"
"不得把二维或多维标准压扁成单一通用额度;涉及金额表时,优先保留逐档结构或等价的分行表达。"
"如果原文是表格且表格结构影响答案content 优先使用 Markdown 表格保留原始决策结构。"
"规则候选必须从允许模板中选 template_key严禁自创模板。" "规则候选必须从允许模板中选 template_key严禁自创模板。"
"runtime_rule 必须严格遵守 runtime_rule_contracts 中对应模板的字段结构和允许值。" "runtime_rule 必须严格遵守 runtime_rule_contracts 中对应模板的字段结构和允许值。"
"如果条款不适合自动规则化,可以只返回 knowledge_candidates。" "如果条款不适合自动规则化,可以只返回 knowledge_candidates。"
@@ -937,7 +1602,8 @@ class LlmWikiService:
) )
user_prompt = ( user_prompt = (
"请根据以下制度分块生成候选。" "请根据以下制度分块生成候选。"
"每组最多提炼 3 条高价值 knowledge_candidates优先保留可直接供报销审核、附件校验、审批判断使用的知识。" "每组最多提炼 3 条高价值 knowledge_candidates优先形成后续可被直接检索和引用的 wiki 片段,"
"而不是一句话摘要。"
"只返回 JSON 对象,不要输出解释,不要调用工具,不要追加任何其他文本。\n" "只返回 JSON 对象,不要输出解释,不要调用工具,不要追加任何其他文本。\n"
f"{json.dumps(facts, ensure_ascii=False, indent=2)}" f"{json.dumps(facts, ensure_ascii=False, indent=2)}"
) )

View File

@@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
import os
import signal
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
@@ -10,7 +14,7 @@ from app.core.logging import get_logger
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService
from app.services.llm_wiki import LlmWikiService from app.services.llm_wiki import HERMES_AGENT_BATCH_TIMEOUT_SECONDS, LlmWikiService
logger = get_logger("app.services.llm_wiki_tasks") logger = get_logger("app.services.llm_wiki_tasks")
@@ -95,46 +99,91 @@ class LlmWikiTaskManager:
result_summary="Hermes 后台归纳任务已启动。", result_summary="Hermes 后台归纳任务已启动。",
) )
result = LlmWikiService(db).sync_folder( dispatch = LlmWikiService(db).dispatch_agent_batch(
folder=folder, folder=folder,
current_user=current_user,
document_ids=document_ids, document_ids=document_ids,
force=force, force=force,
agent_run_id=agent_run_id, agent_run_id=agent_run_id,
progress_callback=lambda payload, summary: self._write_progress(
run_service=run_service,
agent_run_id=agent_run_id,
payload=payload,
summary=summary,
),
) )
if not dispatch.changed_document_ids:
knowledge_service.refresh_document_ingest_statuses(
document_ids=document_ids,
preserve_syncing=False,
)
run_service.record_tool_call(
run_id=agent_run_id,
tool_type="llm",
tool_name="system_hermes_llm_wiki_dispatch",
request_json=request_payload,
response_json={"changed_document_ids": [], "skipped_document_ids": dispatch.skipped_document_ids},
status="succeeded",
duration_ms=0,
)
run_service.merge_route_json(
agent_run_id,
{
"phase": "succeeded",
"heartbeat_at": datetime.now(UTC).isoformat(),
"progress": {
"total_documents": len(document_ids),
"completed_documents": 0,
"failed_documents": 0,
"skipped_documents": len(dispatch.skipped_document_ids),
"percent": 100,
},
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary="本次所选文档均未变化,未重复派发 Hermes 任务。",
finished_at=datetime.now(UTC),
)
return
run_service.record_tool_call( run_service.record_tool_call(
run_id=agent_run_id, run_id=agent_run_id,
tool_type="llm", tool_type="llm",
tool_name="system_hermes_llm_wiki_sync", tool_name="system_hermes_llm_wiki_dispatch",
request_json=request_payload, request_json=request_payload,
response_json=result.model_dump(mode="json"), response_json={
"changed_document_ids": dispatch.changed_document_ids,
"skipped_document_ids": dispatch.skipped_document_ids,
"process_id": dispatch.process_id,
},
status="succeeded", status="succeeded",
duration_ms=0, duration_ms=0,
) )
current_run = run_service.get_run(agent_run_id)
if current_run is not None and current_run.status in {
AgentRunStatus.SUCCEEDED.value,
AgentRunStatus.FAILED.value,
}:
return
run_service.merge_route_json( run_service.merge_route_json(
agent_run_id, agent_run_id,
{ {
"phase": "succeeded", "phase": "awaiting_callback",
"heartbeat_at": datetime.now(UTC).isoformat(), "heartbeat_at": datetime.now(UTC).isoformat(),
"sync_run_id": result.run_id, "requested_document_ids": dispatch.changed_document_ids,
"sync_result": result.model_dump(mode="json"), "skipped_document_ids": dispatch.skipped_document_ids,
"hermes_process_id": dispatch.process_id,
"hermes_stdout_path": dispatch.stdout_path,
"hermes_stderr_path": dispatch.stderr_path,
"progress": { "progress": {
"total_documents": max(len(document_ids), result.document_count), "total_documents": len(dispatch.changed_document_ids),
"completed_documents": result.document_count, "completed_documents": 0,
"failed_documents": 0, "failed_documents": 0,
"skipped_documents": max(0, len(document_ids) - result.document_count), "skipped_documents": len(dispatch.skipped_document_ids),
"percent": 100, "percent": 0,
}, },
}, },
status=AgentRunStatus.SUCCEEDED.value, status=AgentRunStatus.RUNNING.value,
result_summary=result.summary, result_summary="Hermes 任务已派发,等待 Agent 主动回调结果。",
finished_at=datetime.now(UTC), )
self._start_process_monitor(
agent_run_id=agent_run_id,
document_ids=dispatch.changed_document_ids,
process_id=dispatch.process_id,
stderr_path=dispatch.stderr_path,
timeout_seconds=HERMES_AGENT_BATCH_TIMEOUT_SECONDS,
) )
except Exception as exc: except Exception as exc:
logger.exception("Background LLM Wiki sync failed run_id=%s", agent_run_id) logger.exception("Background LLM Wiki sync failed run_id=%s", agent_run_id)
@@ -177,6 +226,122 @@ class LlmWikiTaskManager:
with self._lock: with self._lock:
self._threads.pop(agent_run_id, None) self._threads.pop(agent_run_id, None)
def _start_process_monitor(
self,
*,
agent_run_id: str,
document_ids: list[str],
process_id: int,
stderr_path: str,
timeout_seconds: int,
) -> None:
worker = threading.Thread(
target=self._monitor_process,
kwargs={
"agent_run_id": agent_run_id,
"document_ids": list(document_ids),
"process_id": process_id,
"stderr_path": stderr_path,
"timeout_seconds": timeout_seconds,
},
daemon=True,
name=f"llm-wiki-monitor-{agent_run_id}",
)
worker.start()
@staticmethod
def _monitor_process(
*,
agent_run_id: str,
document_ids: list[str],
process_id: int,
stderr_path: str,
timeout_seconds: int,
) -> None:
session_factory = get_session_factory()
db = session_factory()
run_service = AgentRunService(db)
knowledge_service = KnowledgeService()
started_at = time.monotonic()
try:
while True:
time.sleep(3)
run = run_service.get_run(agent_run_id)
if run is None or run.status != AgentRunStatus.RUNNING.value:
return
if time.monotonic() - started_at > timeout_seconds:
try:
os.killpg(process_id, signal.SIGTERM)
except OSError:
pass
error_message = LlmWikiTaskManager._read_process_error(stderr_path)
if document_ids:
knowledge_service.set_document_ingest_statuses(
document_ids,
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=agent_run_id,
)
run_service.merge_route_json(
agent_run_id,
{
"phase": "failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
"hermes_process_id": process_id,
},
status=AgentRunStatus.FAILED.value,
result_summary="Hermes 任务执行超时,已自动终止等待。",
error_message=error_message or "Hermes process exceeded callback timeout.",
finished_at=datetime.now(UTC),
)
return
if LlmWikiTaskManager._is_process_alive(process_id):
continue
error_message = LlmWikiTaskManager._read_process_error(stderr_path)
if document_ids:
knowledge_service.set_document_ingest_statuses(
document_ids,
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=agent_run_id,
)
run_service.merge_route_json(
agent_run_id,
{
"phase": "failed",
"heartbeat_at": datetime.now(UTC).isoformat(),
"hermes_process_id": process_id,
},
status=AgentRunStatus.FAILED.value,
result_summary="Hermes 进程已退出且未回调结果。",
error_message=error_message or "Hermes process exited before callback.",
finished_at=datetime.now(UTC),
)
return
finally:
db.close()
@staticmethod
def _is_process_alive(process_id: int) -> bool:
stat_path = Path(f"/proc/{process_id}/stat")
if not stat_path.exists():
return False
try:
parts = stat_path.read_text(encoding="utf-8").split()
except OSError:
return False
return len(parts) > 2 and parts[2] != "Z"
@staticmethod
def _read_process_error(stderr_path: str) -> str:
path = Path(stderr_path)
if not stderr_path or not path.exists():
return ""
try:
content = path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
return content[-1000:]
@staticmethod @staticmethod
def _write_progress( def _write_progress(
*, *,

View File

@@ -335,7 +335,6 @@ class SemanticOntologyService:
context_json = payload.context_json or {} context_json = payload.context_json or {}
reference = self._load_reference_catalog() reference = self._load_reference_catalog()
compact_query = self._compact(query) compact_query = self._compact(query)
entities = self._extract_entities(query, compact_query, reference) entities = self._extract_entities(query, compact_query, reference)
rule_scenario, scenario_score = self._detect_scenario(compact_query) rule_scenario, scenario_score = self._detect_scenario(compact_query)
time_range, _time_score = self._extract_time_range( time_range, _time_score = self._extract_time_range(
@@ -343,7 +342,11 @@ class SemanticOntologyService:
compact_query, compact_query,
context_json=context_json, context_json=context_json,
) )
session_scenario = self._resolve_session_type_scenario(context_json)
context_scenario = self._resolve_context_scenario(context_json) context_scenario = self._resolve_context_scenario(context_json)
if session_scenario == "knowledge":
rule_scenario = "knowledge"
scenario_score = max(scenario_score, 0.34)
if rule_scenario == "unknown" and context_scenario is not None: if rule_scenario == "unknown" and context_scenario is not None:
rule_scenario = context_scenario rule_scenario = context_scenario
scenario_score = max(scenario_score, 0.14) scenario_score = max(scenario_score, 0.14)
@@ -393,6 +396,8 @@ class SemanticOntologyService:
constraints=constraints, constraints=constraints,
) )
scenario = self._resolve_scenario(rule_scenario, model_parse) scenario = self._resolve_scenario(rule_scenario, model_parse)
if session_scenario == "knowledge":
scenario = "knowledge"
entities = self._merge_entities( entities = self._merge_entities(
entities, entities,
model_parse.entity_hints if model_parse is not None else [], model_parse.entity_hints if model_parse is not None else [],
@@ -419,6 +424,14 @@ class SemanticOntologyService:
context_json=context_json, context_json=context_json,
) )
) )
relax_knowledge_follow_up = self._should_relax_knowledge_follow_up_clarification(
compact_query=compact_query,
scenario=scenario,
context_json=context_json,
missing_slots=missing_slots,
)
if relax_knowledge_follow_up:
missing_slots = [item for item in missing_slots if item != "expense_type"]
ambiguity = self._normalize_short_text_list( ambiguity = self._normalize_short_text_list(
model_parse.ambiguity if model_parse is not None else [] model_parse.ambiguity if model_parse is not None else []
) )
@@ -450,12 +463,16 @@ class SemanticOntologyService:
intent=intent, intent=intent,
), ),
model_clarification_required=bool( model_clarification_required=bool(
model_parse is not None and model_parse.clarification_required model_parse is not None
and model_parse.clarification_required
), ),
model_clarification_question=( model_clarification_question=(
model_parse.clarification_question if model_parse is not None else None model_parse.clarification_question if model_parse is not None else None
), ),
) )
if relax_knowledge_follow_up:
clarification_required = False
clarification_question = None
fallback_confidence = self._compute_confidence( fallback_confidence = self._compute_confidence(
scenario=scenario, scenario=scenario,
scenario_score=scenario_score, scenario_score=scenario_score,
@@ -496,6 +513,30 @@ class SemanticOntologyService:
"field_errors": field_errors, "field_errors": field_errors,
} }
@staticmethod
def _should_relax_knowledge_follow_up_clarification(
*,
compact_query: str,
scenario: str,
context_json: dict[str, Any],
missing_slots: list[str],
) -> bool:
if scenario != "knowledge" or "expense_type" not in missing_slots:
return False
history = context_json.get("conversation_history")
if not isinstance(history, list):
return False
has_previous_user_turn = any(
isinstance(item, dict)
and str(item.get("role") or "").strip() == "user"
and str(item.get("content") or "").strip()
for item in history
)
if not has_previous_user_turn:
return False
follow_up_markers = ("", "那么", "这个", "这种", "", "的话", "p", "P")
return any(marker in compact_query for marker in follow_up_markers)
def _record_semantic_parse( def _record_semantic_parse(
self, self,
*, *,
@@ -599,6 +640,14 @@ class SemanticOntologyService:
return value return value
return None return None
@staticmethod
def _resolve_session_type_scenario(context_json: dict[str, Any]) -> str | None:
value = str(context_json.get("session_type") or "").strip()
if value == "knowledge":
return "knowledge"
return None
def _detect_scenario(self, compact_query: str) -> tuple[str, float]: def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
scores = {key: 0.0 for key in SCENARIO_KEYWORDS} scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
for scenario, keywords in SCENARIO_KEYWORDS.items(): for scenario, keywords in SCENARIO_KEYWORDS.items():
@@ -1593,6 +1642,8 @@ class SemanticOntologyService:
) -> tuple[bool, str | None]: ) -> tuple[bool, str | None]:
if permission.level == AgentPermissionLevel.FORBIDDEN.value: if permission.level == AgentPermissionLevel.FORBIDDEN.value:
return True, "当前动作超出权限范围。是否改为生成草稿或建议?" return True, "当前动作超出权限范围。是否改为生成草稿或建议?"
if scenario == "knowledge" and intent in {"query", "explain"}:
return False, None
if model_clarification_required: if model_clarification_required:
question = str(model_clarification_question or "").strip() question = str(model_clarification_question or "").strip()
if question: if question:

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import re
from time import perf_counter from time import perf_counter
from typing import Any from typing import Any
@@ -37,6 +38,7 @@ from app.services.agent_conversations import AgentConversationService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.knowledge import KnowledgeService
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.user_agent import UserAgentService from app.services.user_agent import UserAgentService
@@ -62,6 +64,8 @@ class ExecutionOutcome:
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"} PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请") SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS = ("出差", "差旅", "报销多少钱", "能报多少", "一共可以报销", "一共能报销")
KNOWLEDGE_TRAVEL_EXPANSION_TERMS = ("差旅费", "住宿费", "出差补贴", "交通费", "酒店住宿限额标准", "出差补贴标准")
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10 EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
EXPENSE_QUERY_PREVIEW_LIMIT = 20 EXPENSE_QUERY_PREVIEW_LIMIT = 20
EXPENSE_STATUS_LABELS = { EXPENSE_STATUS_LABELS = {
@@ -100,6 +104,7 @@ class OrchestratorService:
self.asset_service = AgentAssetService(db) self.asset_service = AgentAssetService(db)
self.conversation_service = AgentConversationService(db) self.conversation_service = AgentConversationService(db)
self.expense_claim_service = ExpenseClaimService(db) self.expense_claim_service = ExpenseClaimService(db)
self.knowledge_service = KnowledgeService(db=db)
self.run_service = AgentRunService(db) self.run_service = AgentRunService(db)
self.ontology_service = SemanticOntologyService(db) self.ontology_service = SemanticOntologyService(db)
self.user_agent_service = UserAgentService(db) self.user_agent_service = UserAgentService(db)
@@ -574,7 +579,12 @@ class OrchestratorService:
tool_name="knowledge.search", tool_name="knowledge.search",
request_json=self._build_ontology_json(ontology), request_json=self._build_ontology_json(ontology),
context_json=context_json, context_json=context_json,
executor=lambda: self._build_knowledge_answer(ontology, capabilities), executor=lambda: self._build_knowledge_answer(
message=payload.message or "",
ontology=ontology,
capabilities=capabilities,
context_json=context_json,
),
fallback_factory=lambda exc: { fallback_factory=lambda exc: {
"message": f"知识检索暂时不可用,建议稍后重试:{exc}", "message": f"知识检索暂时不可用,建议稍后重试:{exc}",
"degraded": True, "degraded": True,
@@ -1348,18 +1358,154 @@ class OrchestratorService:
result["review_payload"] = response.review_payload.model_dump() result["review_payload"] = response.review_payload.model_dump()
return result return result
@staticmethod
def _build_knowledge_answer( def _build_knowledge_answer(
self,
*,
message: str,
ontology: OntologyParseResult, ontology: OntologyParseResult,
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]], capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
context_json: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
referenced = [item.code for item in capabilities["rules"][:1]] or [ payload = self.knowledge_service.search_llm_wiki(message, limit=5)
"knowledge.policy.default" expanded_query = self._build_knowledge_expanded_query(
] message=message,
return { context_json=context_json,
"message": f"已路由到 User Agent占位知识结果建议先查看 {', '.join(referenced)}", )
"references": referenced, if expanded_query and expanded_query != message:
} expanded_payload = self.knowledge_service.search_llm_wiki(expanded_query, limit=5)
payload = self._merge_knowledge_search_payloads(
primary_payload=payload,
expanded_payload=expanded_payload,
original_query=message,
expanded_query=expanded_query,
)
references = [str(item).strip() for item in list(payload.get("references") or []) if str(item).strip()]
if references:
payload["references"] = references
return payload
@staticmethod
def _merge_knowledge_search_payloads(
*,
primary_payload: dict[str, Any],
expanded_payload: dict[str, Any],
original_query: str,
expanded_query: str,
) -> dict[str, Any]:
merged_by_code: dict[str, dict[str, Any]] = {}
for item in [
*list(primary_payload.get("hits") or []),
*list(expanded_payload.get("hits") or []),
]:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "").strip()
if not code:
continue
existing = merged_by_code.get(code)
if existing is None or int(item.get("score") or 0) > int(existing.get("score") or 0):
merged_by_code[code] = item
if not merged_by_code:
return primary_payload
ranked_hits = sorted(
merged_by_code.values(),
key=lambda item: (
-int(item.get("score") or 0),
str(item.get("quality_status") or "") != "formal",
str(item.get("title") or ""),
),
)[:5]
merged_payload = dict(primary_payload)
merged_payload.update(
{
"query": original_query,
"expanded_query": expanded_query,
"record_count": len(ranked_hits),
"hits": ranked_hits,
"references": [
str(item.get("code") or "").strip()
for item in ranked_hits
if str(item.get("code") or "").strip()
],
}
)
return merged_payload
@staticmethod
def _build_knowledge_expanded_query(
*,
message: str,
context_json: dict[str, Any],
) -> str:
expansions: list[str] = []
normalized_message = "".join(str(message or "").split())
if normalized_message and any(keyword in normalized_message for keyword in KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS):
expansions.extend(KNOWLEDGE_TRAVEL_EXPANSION_TERMS)
location = OrchestratorService._extract_knowledge_location(message)
if location:
expansions.append(location)
grade = OrchestratorService._extract_knowledge_grade(message, context_json)
if grade:
expansions.append(grade)
history = context_json.get("conversation_history")
if not isinstance(history, list):
if not expansions:
return message
return "\n".join([message, " ".join(dict.fromkeys(expansions))])
previous_user_messages: list[str] = []
for item in reversed(history):
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip()
content = str(item.get("content") or "").strip()
if role != "user" or not content or content == message:
continue
previous_user_messages.append(content)
if len(previous_user_messages) >= 2:
break
query_parts: list[str] = []
if previous_user_messages:
query_parts.extend(reversed(previous_user_messages))
query_parts.append(message)
if expansions:
query_parts.append(" ".join(dict.fromkeys(expansions)))
return "\n".join(query_parts)
@staticmethod
def _extract_knowledge_location(message: str) -> str:
patterns = (
r"去([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
r"到([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
r"在([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
)
for pattern in patterns:
matched = re.search(pattern, str(message or ""))
if matched:
return matched.group(1)
return ""
@staticmethod
def _extract_knowledge_grade(message: str, context_json: dict[str, Any]) -> str:
matched = re.search(r"\bP[1-9]\d?\b", str(message or ""), re.IGNORECASE)
if matched:
return matched.group(0).upper()
for key in ("grade", "employee_grade", "employeeLevel", "employee_level"):
value = str(context_json.get(key) or "").strip()
if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE):
return value.upper()
user = context_json.get("current_user")
if isinstance(user, dict):
for key in ("grade", "employee_grade", "employeeLevel", "employee_level"):
value = str(user.get(key) or "").strip()
if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE):
return value.upper()
return ""
@staticmethod @staticmethod
def _build_rule_answer(ontology: OntologyParseResult) -> dict[str, Any]: def _build_rule_answer(ontology: OntologyParseResult) -> dict[str, Any]:

View File

@@ -344,6 +344,14 @@ class SettingsService:
"capability": model_row.capability, "capability": model_row.capability,
} }
def sync_hermes_runtime_model_settings(self) -> None:
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
sync_hermes_model_settings(
primary_route=self._build_hermes_model_route(model_rows["main"]),
fallback_route=self._build_hermes_model_route(model_rows["backup"]),
)
def get_admin_credentials(self) -> AdminCredentialRecord | None: def get_admin_credentials(self) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()

View File

@@ -5,6 +5,9 @@ import shutil
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Mapping
from app.core.config import SERVER_DIR
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -14,6 +17,14 @@ class HermesCliResult:
command: tuple[str, ...] = () command: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class HermesProcessHandle:
pid: int
command: tuple[str, ...] = ()
stdout_path: str = ""
stderr_path: str = ""
class SystemHermesService: class SystemHermesService:
def __init__(self) -> None: def __init__(self) -> None:
configured_bin = str(os.getenv("HERMES_BIN", "")).strip() configured_bin = str(os.getenv("HERMES_BIN", "")).strip()
@@ -29,11 +40,94 @@ class SystemHermesService:
source: str = "tool", source: str = "tool",
max_turns: int = 1, max_turns: int = 1,
timeout_seconds: int = 180, timeout_seconds: int = 180,
skills: tuple[str, ...] = (),
env_overrides: Mapping[str, str] | None = None,
yolo: bool = False,
) -> HermesCliResult: ) -> HermesCliResult:
if not self.is_available(): if not self.is_available():
raise RuntimeError(f"未找到系统 Hermes CLI{self.hermes_bin}") raise RuntimeError(f"未找到系统 Hermes CLI{self.hermes_bin}")
command = ( command = self._build_command(
query,
source=source,
max_turns=max_turns,
skills=skills,
yolo=yolo,
)
env = os.environ.copy()
if env_overrides:
env.update({str(key): str(value) for key, value in env_overrides.items()})
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
env=env,
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()
raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。")
return self._parse_output(completed.stdout, command=command)
def start_query_background(
self,
query: str,
*,
source: str = "tool",
max_turns: int = 1,
skills: tuple[str, ...] = (),
env_overrides: Mapping[str, str] | None = None,
log_prefix: str = "hermes",
yolo: bool = False,
) -> HermesProcessHandle:
if not self.is_available():
raise RuntimeError(f"未找到系统 Hermes CLI{self.hermes_bin}")
command = self._build_command(
query,
source=source,
max_turns=max_turns,
skills=skills,
yolo=yolo,
)
env = os.environ.copy()
if env_overrides:
env.update({str(key): str(value) for key, value in env_overrides.items()})
log_dir = SERVER_DIR / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
safe_prefix = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in log_prefix)
stdout_path = log_dir / f"{safe_prefix}.out.log"
stderr_path = log_dir / f"{safe_prefix}.err.log"
stdout_file = stdout_path.open("ab")
stderr_file = stderr_path.open("ab")
process = subprocess.Popen(
command,
stdout=stdout_file,
stderr=stderr_file,
env=env,
start_new_session=True,
)
stdout_file.close()
stderr_file.close()
return HermesProcessHandle(
pid=process.pid,
command=command,
stdout_path=str(stdout_path),
stderr_path=str(stderr_path),
)
def _build_command(
self,
query: str,
*,
source: str,
max_turns: int,
skills: tuple[str, ...],
yolo: bool,
) -> tuple[str, ...]:
command_parts = [
self.hermes_bin, self.hermes_bin,
"chat", "chat",
"-Q", "-Q",
@@ -41,21 +135,15 @@ class SystemHermesService:
source, source,
"--max-turns", "--max-turns",
str(max_turns), str(max_turns),
"-q", ]
query, for skill in skills:
) normalized_skill = str(skill or "").strip()
completed = subprocess.run( if normalized_skill:
command, command_parts.extend(["--skills", normalized_skill])
capture_output=True, if yolo:
text=True, command_parts.append("--yolo")
timeout=timeout_seconds, command_parts.extend(["-q", query])
check=False, return tuple(command_parts)
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()
raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。")
return self._parse_output(completed.stdout, command=command)
@staticmethod @staticmethod
def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult: def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult:

View File

@@ -175,7 +175,7 @@ class UserAgentService:
def respond(self, payload: UserAgentRequest) -> UserAgentResponse: def respond(self, payload: UserAgentRequest) -> UserAgentResponse:
AgentFoundationService(self.db).ensure_foundation_ready() AgentFoundationService(self.db).ensure_foundation_ready()
citations = self._build_rule_citations(payload) citations = self._build_citations(payload)
suggested_actions = self._build_suggested_actions(payload) suggested_actions = self._build_suggested_actions(payload)
risk_flags = self._resolve_risk_flags(payload) risk_flags = self._resolve_risk_flags(payload)
query_payload = self._build_query_payload(payload) query_payload = self._build_query_payload(payload)
@@ -201,6 +201,7 @@ class UserAgentService:
citations=citations, citations=citations,
suggested_actions=suggested_actions, suggested_actions=suggested_actions,
query_payload=query_payload, query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload, review_payload=review_payload,
risk_flags=risk_flags, risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation, requires_confirmation=payload.requires_confirmation,
@@ -267,6 +268,9 @@ class UserAgentService:
citations: list[UserAgentCitation], citations: list[UserAgentCitation],
draft_payload: UserAgentDraftPayload | None, draft_payload: UserAgentDraftPayload | None,
) -> str: ) -> str:
if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search":
return self._build_explain_answer(payload, citations)
if payload.ontology.intent in {"query", "compare"}: if payload.ontology.intent in {"query", "compare"}:
return self._build_query_answer(payload) return self._build_query_answer(payload)
@@ -357,13 +361,14 @@ class UserAgentService:
draft_payload=draft_payload, draft_payload=draft_payload,
fallback_answer=fallback_answer, fallback_answer=fallback_answer,
) )
return self._sanitize_model_answer( answer = self._sanitize_model_answer(
self.runtime_chat_service.complete( self.runtime_chat_service.complete(
messages, messages,
max_tokens=420, max_tokens=720 if payload.ontology.scenario == "knowledge" else 420,
temperature=0.2, temperature=0.2,
) )
) )
return self._reject_unsupported_location_inference(payload, answer)
def _sanitize_model_answer(self, answer: str | None) -> str | None: def _sanitize_model_answer(self, answer: str | None) -> str | None:
if not answer: if not answer:
@@ -371,8 +376,53 @@ class UserAgentService:
cleaned = re.sub(r"<think>.*?</think>", "", answer, flags=re.DOTALL | re.IGNORECASE) cleaned = re.sub(r"<think>.*?</think>", "", answer, flags=re.DOTALL | re.IGNORECASE)
cleaned = cleaned.strip() cleaned = cleaned.strip()
leaked_reasoning_markers = (
"用户问的是",
"让我分析一下",
"实体识别",
"从对话历史来看",
"从tool_payload来看",
"现在问题是",
"我需要:",
"关键是我",
)
if any(marker in cleaned[:500] for marker in leaked_reasoning_markers):
return None
return cleaned or None return cleaned or None
@staticmethod
def _extract_query_location(message: str) -> str:
match = re.search(r"(?:去|到)([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)", str(message or ""))
return match.group(1) if match else ""
def _reject_unsupported_location_inference(
self,
payload: UserAgentRequest,
answer: str | None,
) -> str | None:
if not answer or payload.ontology.scenario != "knowledge":
return answer
location = self._extract_query_location(payload.message)
if not location:
return answer
hit_text = "\n".join(
str(item.get("content") or "")
for item in list(payload.tool_payload.get("hits") or [])
if isinstance(item, dict)
)
if location in hit_text:
return answer
inference_markers = (
f"{location}属于",
f"{location}市属于",
f"{location}归入",
f"{location}归属",
f"{location}是省会",
)
if any(marker in answer for marker in inference_markers):
return None
return answer
def _build_model_messages( def _build_model_messages(
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
@@ -391,6 +441,10 @@ class UserAgentService:
"entry_source": payload.context_json.get("entry_source"), "entry_source": payload.context_json.get("entry_source"),
"user_name": payload.context_json.get("name"), "user_name": payload.context_json.get("name"),
"user_role": payload.context_json.get("role"), "user_role": payload.context_json.get("role"),
"user_position": payload.context_json.get("position"),
"user_grade": payload.context_json.get("grade"),
"user_role_codes": payload.context_json.get("role_codes", []),
"is_admin": bool(payload.context_json.get("is_admin")),
"request_context": payload.context_json.get("request_context"), "request_context": payload.context_json.get("request_context"),
"attachment_count": payload.context_json.get("attachment_count"), "attachment_count": payload.context_json.get("attachment_count"),
"attachment_names": self._resolve_attachment_names(payload), "attachment_names": self._resolve_attachment_names(payload),
@@ -418,6 +472,36 @@ class UserAgentService:
"fallback_answer": fallback_answer, "fallback_answer": fallback_answer,
} }
answer_style_instruction = (
"当前是制度知识问答场景。回答要自然、完整、略作展开:先直接回答结论,再补充适用条件、例外或注意事项;"
"若事实中包含分档、金额、时限、条件等结构化信息,优先使用一个 Markdown 表格帮助用户理解;"
"允许使用简短标题、分段和项目符号,但不要为了变长而重复。通常控制在 3 到 6 段。"
"知识问答只能依据 tool_payload.hits 中的 LLM Wiki 内容作答;如果 hits 中没有足够依据,就明确说明当前 LLM Wiki 依据不足,"
"不得用常识、外部知识或未提供的制度内容补答。"
"只能陈述 hits 中明确出现的事实;如果 wiki 没有写出某个维度,就不得自行推断该维度不存在、统一适用或默认适用。"
"如果问题涉及城市、地区、职级、时间或其他适用条件,而 hits 只给出了分类标准、没有给出用户问题所需的映射关系,"
"必须明确说“当前 LLM Wiki 没有提供足以确定该条件归属的依据”,不能用常识把城市自行归类。"
"如果用户消息里出现了具体城市、人员、项目、供应商等专有值,而这些值没有在 hits 的 title/content/evidence 中逐字出现,"
"就只能把它们当作用户提供的条件,不能进一步推断其行政层级、地区类别、组织归属或其他外部属性。"
"如果用户的问题需要把多个相关制度条目合并后才能回答,例如同一问题同时涉及标准、补贴、审批条件、时限或附件,"
"必须综合全部相关 hits 作答,不能只抓第一条命中的内容。"
"如果是追问,要先继承 conversation_history 中仍然有效的事实,再结合本轮新增条件回答;"
"如果用户本轮只改变了一个条件,应当保留前文其他条件,并明确说明本轮替换了什么。"
if payload.ontology.scenario == "knowledge"
else "使用简体中文,控制在 2 到 4 句。"
)
personalization_instruction = (
"如果上下文中提供了 user_name、user_position、user_grade 等用户信息,要把这些信息视为可用事实,"
"但只能在问题与用户身份、职级、权限、适用标准、审批层级或个人处理路径有关时使用。"
"当用户使用“我”“本人”“我能报多少”这类第一人称问法时,若答案依赖职级或身份,必须优先按当前登录用户信息作答;"
"只有当用户明确提出“假设 P3 员工”这类假设场景时,才使用题目中给出的假设职级,并且要明确说明本次是按假设条件计算。"
"在会话刚开始、且本次回答确实需要结合用户身份时,可以自然地用一次称呼开场,例如“张三,您好”,"
"随后说明“根据您当前的 P4 职级……”之类的判断依据。"
"如果用户问的是纯通用制度问题,或身份信息与答案无关,就直接回答制度本身,不要为了显得亲切而生硬插入姓名、岗位或职级。"
"同一会话中不要每一轮都重复称呼用户,也不要在没有明确事实支撑时猜测其职级、权限或可享受标准。"
)
system_prompt = ( system_prompt = (
"你是企业财务共享场景中的中文智能助手,负责和最终用户直接对话。" "你是企业财务共享场景中的中文智能助手,负责和最终用户直接对话。"
"你只能基于提供的事实回答,不能编造制度、流程结果或附件内容。" "你只能基于提供的事实回答,不能编造制度、流程结果或附件内容。"
@@ -426,11 +510,14 @@ class UserAgentService:
"如果上下文里只有附件名称,必须明确说明你只拿到了附件名称," "如果上下文里只有附件名称,必须明确说明你只拿到了附件名称,"
"不能假装已看过图片、PDF 或发票内容。" "不能假装已看过图片、PDF 或发票内容。"
"如果提供了 conversation_history必须结合最近轮次理解追问、代词、省略字段和补充信息。" "如果提供了 conversation_history必须结合最近轮次理解追问、代词、省略字段和补充信息。"
f"{personalization_instruction}"
"绝不能向用户展示中间分析、实体识别过程、工具推理过程、思考草稿或类似“让我分析一下”的自述;"
"只给最终答案。如果问题可以计算,先直接给结论,再列分项、公式和必要说明。"
"不要声称已经提交、审批、付款、入账或真正执行了任何动作;如果只是建议、草稿或待确认,要明确说清楚。" "不要声称已经提交、审批、付款、入账或真正执行了任何动作;如果只是建议、草稿或待确认,要明确说清楚。"
"若给出了风险标签、制度引用或建议动作,可以简洁吸收进回答,但不要新增未提供的事实。" "若给出了风险标签、制度引用或建议动作,可以简洁吸收进回答,但不要新增未提供的事实。"
"只输出最终给用户看的自然语言,不要输出 JSON、Markdown、标题、" "只输出最终给用户看的自然语言,不要输出 JSON、"
"<think> 标签或任何中间推理。" "<think> 标签或任何中间推理。"
"使用简体中文,控制在 2 到 4 句。" f"{answer_style_instruction}"
) )
user_prompt = ( user_prompt = (
"请根据以下事实生成最终答复,优先保持准确、具体、可执行:\n" "请根据以下事实生成最终答复,优先保持准确、具体、可执行:\n"
@@ -598,6 +685,14 @@ class UserAgentService:
payload: UserAgentRequest, payload: UserAgentRequest,
citations: list[UserAgentCitation], citations: list[UserAgentCitation],
) -> str: ) -> str:
if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search":
if citations:
return self._build_knowledge_search_answer(payload, citations)
tool_message = str(payload.tool_payload.get("message") or "").strip()
if tool_message:
return tool_message
if citations: if citations:
titles = "".join(item.title for item in citations[:2]) titles = "".join(item.title for item in citations[:2])
summary = citations[0].excerpt or "请结合制度全文进一步确认。" summary = citations[0].excerpt or "请结合制度全文进一步确认。"
@@ -608,6 +703,43 @@ class UserAgentService:
"强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。" "强匹配的已上线规则引用,建议先人工复核或补充更具体的单据上下文。"
) )
def _build_knowledge_search_answer(
self,
payload: UserAgentRequest,
citations: list[UserAgentCitation],
) -> str:
hits = [item for item in list(payload.tool_payload.get("hits") or []) if isinstance(item, dict)]
primary_hit = hits[0] if hits else {}
title = str(primary_hit.get("title") or citations[0].title or "相关制度").strip()
content = str(primary_hit.get("content") or citations[0].excerpt or "").strip()
query = str(payload.message or "").strip()
paragraph_parts = [
f"根据已归纳的制度条目《{title}》,我先把与你这次问题最相关的要求整理出来。",
]
if content:
paragraph_parts.append(f"核心规定是:{content}")
else:
paragraph_parts.append("我已检索到相关制度知识,但当前条目的摘要信息还不够完整,建议结合制度原文进一步确认。")
if len(hits) > 1:
related_titles = "".join(
str(item.get("title") or "").strip()
for item in hits[1:3]
if str(item.get("title") or "").strip()
)
if related_titles:
paragraph_parts.append(
f"另外,系统还命中了与本问题相关的制度条目:{related_titles}"
"如果你的问题涉及多个环节,通常需要把这些要求一并核对。"
)
paragraph_parts.append(
"如果你希望,我也可以继续按“适用对象、报销标准、审批条件、所需附件”几个维度,"
"把这条制度再展开整理成一版更适合直接执行的说明。"
)
return "\n\n".join(part for part in paragraph_parts if part)
def _build_risk_answer( def _build_risk_answer(
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
@@ -669,6 +801,9 @@ class UserAgentService:
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
) -> list[UserAgentSuggestedAction]: ) -> list[UserAgentSuggestedAction]:
if payload.ontology.scenario == "knowledge":
return []
if self._is_generic_expense_prompt(payload): if self._is_generic_expense_prompt(payload):
return [ return [
UserAgentSuggestedAction( UserAgentSuggestedAction(
@@ -1604,7 +1739,43 @@ class UserAgentService:
or int(payload.context_json.get("attachment_count") or 0) > 0 or int(payload.context_json.get("attachment_count") or 0) > 0
) )
def _build_rule_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]: def _build_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]:
knowledge_citations = self._build_knowledge_citations(payload)
if payload.ontology.scenario == "knowledge":
return knowledge_citations[:3]
rule_citations = self._build_rule_asset_citations(payload)
if knowledge_citations:
return (knowledge_citations + rule_citations)[:3]
return rule_citations
@staticmethod
def _build_knowledge_citations(payload: UserAgentRequest) -> list[UserAgentCitation]:
citations: list[UserAgentCitation] = []
for item in list(payload.tool_payload.get("hits") or [])[:3]:
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("document_name") or "").strip()
code = str(item.get("code") or item.get("candidate_id") or "").strip()
if not title or not code:
continue
citations.append(
UserAgentCitation(
source_type="knowledge",
code=code,
title=title,
version=str(item.get("version") or "").strip() or None,
updated_at=str(item.get("updated_at") or "").strip() or None,
excerpt=(
str(item.get("excerpt") or "").strip()
or str(item.get("content") or "").strip()
or None
),
)
)
return citations
def _build_rule_asset_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]:
domain = self._resolve_domain(payload.ontology.scenario) domain = self._resolve_domain(payload.ontology.scenario)
items = self.asset_service.list_assets( items = self.asset_service.list_assets(
asset_type=AgentAssetType.RULE.value, asset_type=AgentAssetType.RULE.value,

View File

@@ -14,9 +14,9 @@
"updated_at": "2026-05-09T08:39:53.788042+00:00", "updated_at": "2026-05-09T08:39:53.788042+00:00",
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-15T09:37:21.829390+00:00", "ingest_status_updated_at": "2026-05-16T01:48:21.849424+00:00",
"ingest_agent_run_id": "run_ef06cc90605f4bd2" "ingest_agent_run_id": "run_a7b447f69939442f"
} }
] ]
} }

File diff suppressed because one or more lines are too long

View File

@@ -5,19 +5,19 @@
"document_version": "v1.0", "document_version": "v1.0",
"checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md", "extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md",
"chunk_count": 63, "chunk_count": 1,
"candidate_chunk_count": 39, "candidate_chunk_count": 1,
"filtered_chunk_count": 24, "filtered_chunk_count": 0,
"group_count": 20, "group_count": 1,
"successful_group_count": 0, "successful_group_count": 1,
"failed_group_count": 20, "failed_group_count": 0,
"knowledge_candidate_count": 1, "knowledge_candidate_count": 10,
"formal_knowledge_candidate_count": 0, "formal_knowledge_candidate_count": 10,
"fallback_knowledge_candidate_count": 1, "fallback_knowledge_candidate_count": 0,
"rule_candidate_count": 0, "rule_candidate_count": 4,
"quality_status": "fallback_only", "quality_status": "formal",
"quality_note": "Hermes 未形成正式知识候选,当前仅保留降级兜底预览,不能作为正式知识上线。", "quality_note": "Hermes 已基于完整原文件完成正式归纳。",
"updated_at": "2026-05-15T09:37:21.556445+00:00", "updated_at": "2026-05-16T01:52:22.750933+00:00",
"signature": { "signature": {
"document_id": "bf761bd8eccf402bb676423d64401a56", "document_id": "bf761bd8eccf402bb676423d64401a56",
"original_name": "远光《公司支出管理办法2024》.pdf", "original_name": "远光《公司支出管理办法2024》.pdf",
@@ -26,5 +26,5 @@
"version_number": 1, "version_number": 1,
"updated_at": "2026-05-09T08:39:53.788042+00:00" "updated_at": "2026-05-09T08:39:53.788042+00:00"
}, },
"sync_reason": "forced_rebuild" "sync_reason": "agent_batch"
} }

View File

@@ -1,30 +1,297 @@
[ [
{ {
"candidate_id": "kc_e897af4a528d", "candidate_id": "kc_288da32a1cfb",
"title": "第一条", "title": "差旅费交通费标准",
"content": "目的\n为适应远光软件股份有限公司以下简称“公司”业务发展需要优化、完善\n支出和报销标准规范支出业务审批和报销过程防范经营风险依据国家有关法律\n法规参照国家电网公司和国网数科公司有关管理规定结合市场经营环境和公司实\n际情况在广泛征求意见的基础上制定本办法。", "content": "公司领导、高层经理、中层经理P5及以上、外聘专家乘坐飞机经济舱基层经理、其他人员P4及以下乘坐经济舱但P4应选乘6折及以下、P1-P3应选乘5折及以下。火车硬席硬卧、硬座、高铁/动车二等座。超标20%以内部门负责人审批超标20%以上分管领导审批。\n\n#### 交通费标准\n| 员工职级 | 飞机 | 火车 | 轮船 | 其他交通工具 |\n|---------|------|------|------|------------|\n| 公司领导、高层经理、中层经理P5及以上、外聘专家 | 经济舱 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |\n| 基层经理、其他人员P4及以下 | 经济舱注2 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |\n\n**注**\n- 交通工具选乘遵循\"性价比优先\"原则\n- P4及以下人员乘坐飞机需事前报部门负责人审批\n- P4应选乘6折及以下经济舱P1-P3应选乘5折及以下经济舱\n- 夜间乘坐高铁/动车6小时以上可选乘卧铺\n- 超标20%以内部门负责人审批超标20%以上分管领导审批",
"domain": "expense", "domain": "expense",
"scenario": "reimbursement_policy", "scenario": "reimbursement_policy",
"tags": [ "tags": [
"差旅费",
"交通费",
"审批权限",
"差旅",
"交通",
"审批" "审批"
], ],
"source_document_id": "bf761bd8eccf402bb676423d64401a56", "source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf", "source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [ "source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-chunk-023" "bf761bd8eccf402bb676423d64401a56-document"
], ],
"evidence": [ "evidence": [
"目的\n为适应远光软件股份有限公司以下简称“公司”业务发展需要优化、完善\n支出和报销标准规范支出业务审批和报销过程防范经营风险依据国家有关法律\n法规参照国家电网公司和国网数科公司有关管理规定结合市场经营环境和公司实\n际情况在广泛征求意见的基础上制定本办法。" "表1 交通工具等级标准",
"注2基层经理及以下人员P4及以下乘坐飞机需事前报经部门负责人审批。P4应选乘6折及以下经济舱、P1-P3应选乘5折及以下经济舱。"
], ],
"confidence": 0.4, "confidence": 0.95,
"status": "draft", "status": "draft",
"created_by": "hermes", "created_by": "hermes",
"created_at": "2026-05-15T09:37:21.489335+00:00", "created_at": "2026-05-16T01:52:21.714431+00:00",
"extraction_mode": "fallback", "extraction_mode": "hermes",
"quality_flags": [ "quality_flags": [
"fallback_only", "table_restored_from_summary"
"not_formal_ingest"
], ],
"fallback_reason": "Hermes 未能从正文条款中形成正式知识候选。当前结果仅为降级兜底预览,不能视为正式归纳。" "fallback_reason": ""
},
{
"candidate_id": "kc_1d3d64b598cf",
"title": "差旅费住宿费标准",
"content": "国内住宿费限额:直辖市/特区/港澳台800元公司领导、省会城市500元公司领导、其他地区450元公司领导中层经理、基层经理P4-P6在直辖市600元、省会城市400元、其他地区350元其他员工在直辖市500元、省会城市350元、其他地区300元。超标20%以内部门负责人审批超标20%以上分管领导审批。\n\n#### 住宿费标准(国内)\n| 员工职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 |\n|---------|-----------------|---------|---------|\n| 公司领导P8及以上 | 800 | 500 | 450 |\n| 高层经理P7 | 700 | 450 | 400 |\n| 中层经理、基层经理P4-P6、外聘专家 | 600 | 400 | 350 |\n| 其他员工 | 500 | 350 | 300 |\n\n- 超标20%以内部门负责人审批超标20%以上分管领导审批",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"差旅费",
"住宿费",
"报销标准",
"差旅",
"住宿",
"审批"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"表2 酒店住宿限额标准"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714478+00:00",
"extraction_mode": "hermes",
"quality_flags": [
"table_restored_from_summary"
],
"fallback_reason": ""
},
{
"candidate_id": "kc_49cc35a20597",
"title": "出差补贴标准",
"content": "国内直辖市/特区/西藏餐补75元+基本补助35元=110元/天国内其他地区餐补65元+基本补助35元=100元/天港澳台餐补55元+基本补助35元=90元/天。因组织安排、销售、推广、调研、培训、会议、研讨、借调等出差,主办方统一安排餐食的,不再报销餐补。\n\n#### 出差补贴标准\n| 补助类型 | 国内直辖市/特区/西藏 | 国内其他地区 | 港澳台 | 国外 |\n|---------|-------------------|------------|-------|------|\n| 餐补(自行解决) | 75 | 65 | 55 | 140 |\n| 基本补助 | 35 | 35 | 35 | 110/100/90 |\n| 合计 | 110 | 100 | 90 | 175 |",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"差旅费",
"出差补贴",
"报销标准",
"差旅"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"表3 出差补贴标准"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714498+00:00",
"extraction_mode": "hermes",
"quality_flags": [
"table_restored_from_summary"
],
"fallback_reason": ""
},
{
"candidate_id": "kc_63f2f841a3e3",
"title": "备用金借款规定",
"content": "备用金是公司借支给正式员工用于与公司经济业务相关的预支费用。原则:前款不清、后款不借。非正式员工不得申请。按季定期清理,季度不能及时报账核销的应向分管领导申请延期,但不得跨年。借款额度原则上不得超过一万元。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"备用金",
"借款",
"报销规定"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第十一条 备用金借款"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714514+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_bf172f57d0d1",
"title": "市内交通费报销规定",
"content": "市内交通费凭据报销应与工作实际相符严禁报销与工作无关的交通费不接受充值预付费方式的交通费。鼓励选择公交、地铁等公共交通。出租车仅限紧急公务、接送客户、夜间工作至22:00后、以及其他确有需要的特别情形等事项。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"市内交通费",
"报销规定",
"交通"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第十二条 市内交通费"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714527+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_e955e0e59c0e",
"title": "报销申请时限规定",
"content": "公司各类支出报销结算申请时限为三个月(按自然月度计算)。逾期需说明原因,经分管领导审批后方可报销。预付款项原则上应在次月底前完成结算,不得长期挂账。差旅费原则上需在行程结束三个月内提交报销申请,连续出差超过一个月时原则上应按月报销,逾期不予报销出差补贴。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"报销时限",
"申请时限",
"差旅费",
"差旅",
"审批"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第八条 支出报销申请 第2项申请时限"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714541+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_292e06107e25",
"title": "票据要求规定",
"content": "除员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等支出业务外,其他支出均需提交税务机关认可的票据。原则上均应取得增值税专用发票。应取得但未取得增值税专用发票的,经办人应在系统单据中说明原因。汇总开具的增值税发票应附税控系统明细清单。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"票据",
"发票",
"报销规定"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第八条 支出报销申请 第1项申请方式2"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714555+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_827a71a7acf6",
"title": "审批权限基本原则",
"content": "预算内支出按附表1、附表2执行。预算外支出需先经办部门提交预算调整申请经公司总裁批准后再按附表1、附表2执行。各级管理人员原则上应在待批单据流转至本岗位后三个工作日内完成审批。审批权限与职级待遇分离按管理岗位执行。批办分离经办人和审批人不得为同一人。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"审批权限",
"管理原则",
"报销规定",
"审批",
"预算"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第三条 管理原则",
"第九条 支出报销审批"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714567+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_ddf19230b6eb",
"title": "对外捐赠支出规定",
"content": "对外捐赠支出由品牌及市场运营中心归口管理,严格预算单控,未纳入对外捐赠预算的不得对外捐赠。预算内捐赠:业务部门申请→部门负责人审批→品牌及市场运营中心审核→分管领导审批执行。未纳入预算的需履行预算调整决策程序并纳入预算范围后方可实施。完成后应将凭据资料报品牌及市场运营中心备案。",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"对外捐赠",
"审批权限",
"报销规定",
"审批",
"预算"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"第二十一条 对外捐赠支出"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714581+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": ""
},
{
"candidate_id": "kc_39b392741c79",
"title": "支出归口管理部门划分",
"content": "计划财务部备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估营销中心投标业务、机构营销业务品牌及市场运营中心广告费、业务宣传费、对外捐赠组织人事部薪酬福利、外聘劳务、员工保险信息管理部IT类资产、网络使用费后勤服务部非IT类资产、食堂、办公费用。\n\n## 附表3支出归口管理部门\n| 归口管理部门 | 归口业务范围 |\n|-------------|-------------|\n| 办公室(党委办公室) | 党建支出、公务车支出 |\n| 工会委员会 | 工会支出 |\n| 营销中心 | 投标业务、机构营销业务、客户培训、销售退款 |\n| 品牌及市场运营中心 | 广告费、业务宣传费、对外捐赠 |\n| 组织人事部 | 薪酬福利、外聘劳务、员工保险、探亲差旅、其他福利 |\n| 人力资源服务部 | 招聘业务、员工教育培训 |\n| 计划财务部 | 备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估 |\n| 产业投资部 | 股权投资、并购业务 |\n| 证券与法律事务部 | 法律事务、上市信披、商标注册 |\n| 产品规划设计部 | 知识产权、信息技术咨询、研发活动 |\n| DAP研发中心 | 研发过程中对外单位的检测、评测、测试、化验 |\n| 信息管理部 | IT类资产购置/租入/运维、网络使用费 |\n| 后勤服务部 | 非IT类资产、食堂、办公费用、商旅统付结算 |",
"domain": "expense",
"scenario": "reimbursement_policy",
"tags": [
"归口管理",
"职责分工",
"报销规定",
"差旅",
"交通"
],
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"evidence": [
"附表3支出归口管理部门与归口业务范围"
],
"confidence": 0.95,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714595+00:00",
"extraction_mode": "hermes",
"quality_flags": [
"table_restored_from_summary"
],
"fallback_reason": ""
} }
] ]

View File

@@ -1,19 +1,147 @@
# 远光《公司支出管理办法2024》.pdf 知识总结 # 知识总结
## 概览 ## 文档概述
- **文件名称**远光软件股份有限公司《公司支出管理办法2024远光制度202414号
- **发文日期**2024年4月17日
- **密级**:商密【中】
- **适用范围**:公司各部门、分支机构(非独立法人)、全资子公司;控股子公司参照执行
- 来源文档远光《公司支出管理办法2024》.pdf ## 第一章 总则
- 知识条目数1 - **目的**:适应业务发展需要,优化支出和报销标准,规范审批和报销过程,防范经营风险
- **管理原则**:预算先行、厉行节约、效益优先、分级授权、分类控制、批办分离
- **核心原则**
1. 预算先行:应在预算目标范围内支出,预算外支出需履行预算审批程序
2. 厉行节约、效益优先
3. 审批权限按管理岗位执行,与职级待遇分离
4. 经办人和审批人不得为同一人(批办分离)
## 核心知识 ## 第二章 职责分工
- **归口管理部门**:确定支出业务的开支范围、标准、方式和管理流程
- **计划财务部**:明确审批流程、审核要点、报销资料规范;负责财务审核和结算支付
- **经办部门/个人**:遵循开支范围和标准,遵循"发票、资金、物资"三流一致原则
- **各级管理人员**:第一审批人全面审核;后续审批人审核必要性和合理性
### 1. 第一条 ## 第三章 支出报销申请与审批
- **申请方式**:通过公司财务信息化系统(系统单据),不接受纸质申请
- **票据要求**:原则上需取得增值税专用发票(除工会经费、员工福利、职工活动、业务招待、车票、政府规费外)
- **申请时限**:业务完成日至附件影像资料挂接系统单据日,三个月内;逾期需分管领导审批
- **结算方式**:员工支出"公对私";岗位支出"公对公"结算起点1000元
- **审批时限**:各级管理人员三个工作日内完成审批
- **财务审核时限**:影像扫描一个工作日;审核与支付三个工作日
目的 ## 第四章 重点支出管理规定
为适应远光软件股份有限公司(以下简称“公司”)业务发展需要,优化、完善
支出和报销标准,规范支出业务审批和报销过程,防范经营风险,依据国家有关法律
法规,参照国家电网公司和国网数科公司有关管理规定,结合市场经营环境和公司实
际情况,在广泛征求意见的基础上,制定本办法。
- 适用场景reimbursement_policy ### 第十一条 备用金借款
- 标签:审批 - 正式员工可申请,用于与公司经济业务相关的预支费用
- 遵循"前款不清、后款不借"原则
- 非正式员工不得申请
- 按季定期清理,不可跨年
- 借款额度原则上不得超过一万元
### 第十二条 市内交通费
- 凭据报销,应与工作实际相符
- 鼓励选择公交、地铁等公共交通
- 出租车仅限紧急公务、接送客户、夜间工作至22:00后等情形
### 第十三条 差旅费
#### 交通费标准
| 员工职级 | 飞机 | 火车 | 轮船 | 其他交通工具 |
|---------|------|------|------|------------|
| 公司领导、高层经理、中层经理P5及以上、外聘专家 | 经济舱 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |
| 基层经理、其他人员P4及以下 | 经济舱注2 | 火车硬席、高铁/动车二等座 | 三等舱 | 凭据报销 |
**注**
- 交通工具选乘遵循"性价比优先"原则
- P4及以下人员乘坐飞机需事前报部门负责人审批
- P4应选乘6折及以下经济舱P1-P3应选乘5折及以下经济舱
- 夜间乘坐高铁/动车6小时以上可选乘卧铺
- 超标20%以内部门负责人审批超标20%以上分管领导审批
#### 住宿费标准(国内)
| 员工职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 |
|---------|-----------------|---------|---------|
| 公司领导P8及以上 | 800 | 500 | 450 |
| 高层经理P7 | 700 | 450 | 400 |
| 中层经理、基层经理P4-P6、外聘专家 | 600 | 400 | 350 |
| 其他员工 | 500 | 350 | 300 |
- 超标20%以内部门负责人审批超标20%以上分管领导审批
#### 出差补贴标准
| 补助类型 | 国内直辖市/特区/西藏 | 国内其他地区 | 港澳台 | 国外 |
|---------|-------------------|------------|-------|------|
| 餐补(自行解决) | 75 | 65 | 55 | 140 |
| 基本补助 | 35 | 35 | 35 | 110/100/90 |
| 合计 | 110 | 100 | 90 | 175 |
### 第十四条 业务招待费
- 用于接待客户和相关单位的餐饮等合理支出
- 未能"公对公"结算的,应附向供应商付款凭据佐证
### 第十五条 会议费
- 不得列支旅游观光、宴请、礼品馈赠等无关支出
- 经费预算30000元及以上的公司内部会议需事前报请公司总裁审批
- 报销应附会议申请与预算、会议通知、参会人员签到表、会议开支明细等
### 第十六条 广告宣传费
- 广告费:各种公开媒体宣传费用
- 业务宣传费:公司自身宣传与营销活动、印有公司标志的礼品、纪念品等
### 第十七条 培训费
- 报销资格按《公司员工教育培训管理办法》认定
- 培训期间交通费、住宿费按出差规定标准执行
### 第十八条 通信费
- 员工通讯费按《公司员工因公通讯费用实施细则》执行
### 第十九条 邮递费
- 邮件费、托运费、快递费等
- 员工报销应附快递底单;单位统一结算应附寄件明细清单与支出分摊表
### 第二十条 薪酬福利支出
- 按公司相关制度规定执行
- 临时奖励及福利支出需事先计划并报公司总裁审批
- 职工福利费由组织人事部年度计划管理
### 第二十一条 对外捐赠支出
- 由品牌及市场运营中心归口管理,严格预算单控
- 预算内捐赠:业务部门申请→部门负责人审批→品牌及市场运营中心审核→分管领导审批
- 未纳入预算:需履行预算调整决策程序后方可实施
### 第二十二条 涉外业务汇率标准
- 人民币结算:凭据报销
- 外币结算:按支付凭据所载汇率折算;未载明汇率的按业务发生月第一个交易日"中国银行外汇折算价"执行
## 审批权限(摘要)
### 员工支出审批权限(单位:万元)
| 支出项目 | 部门经理/机构总经理/事业部总经理 | 总监/副总裁/高级副总裁/一级部门总经理 | 总裁 |
|---------|------------------------------|------------------------------------|------|
| 业务招待 | 0.5 | 1-3 | 15 |
| 因公借款 | 0.5 | 1-3 | 15 |
| 其他支出(差旅费、市内交通等) | 1 | 2-5 | 50 |
### 岗位支出审批权限(单位:万元)
| 支出项目 | 部门经理/机构总经理/事业部总经理 | 总监/一级部门总经理 | 高级副总裁/副总裁 | 总裁 |
|---------|------------------------------|-------------------|-----------------|------|
| 资产采购 | 1-2 | 10 | 15 | 200 |
| 基建工程 | —— | 5 | 10 | 50 |
| 市场营销(广告费、业务宣传费) | 1 | 3-5 | 10 | 50 |
## 附表3支出归口管理部门
| 归口管理部门 | 归口业务范围 |
|-------------|-------------|
| 办公室(党委办公室) | 党建支出、公务车支出 |
| 工会委员会 | 工会支出 |
| 营销中心 | 投标业务、机构营销业务、客户培训、销售退款 |
| 品牌及市场运营中心 | 广告费、业务宣传费、对外捐赠 |
| 组织人事部 | 薪酬福利、外聘劳务、员工保险、探亲差旅、其他福利 |
| 人力资源服务部 | 招聘业务、员工教育培训 |
| 计划财务部 | 备用金、差旅费、市内交通费、出国经费、税金及附加、审计评估 |
| 产业投资部 | 股权投资、并购业务 |
| 证券与法律事务部 | 法律事务、上市信披、商标注册 |
| 产品规划设计部 | 知识产权、信息技术咨询、研发活动 |
| DAP研发中心 | 研发过程中对外单位的检测、评测、测试、化验 |
| 信息管理部 | IT类资产购置/租入/运维、网络使用费 |
| 后勤服务部 | 非IT类资产、食堂、办公费用、商旅统付结算 |

View File

@@ -1 +1,339 @@
[] [
{
"candidate_id": "rc_1eeb0b1b066e",
"source_type": "policy_document",
"template_key": "travel_standard_v1",
"template_label": "差旅标准模板",
"domain": "expense",
"scenario": "reimbursement_policy",
"suggested_rule_name": "差旅费报销标准规则",
"summary": "员工因公出差发生的交通费、住宿费、出差补贴按职级和地区分类计算标准。",
"template_sections": {
"purpose": "规范差旅费报销标准,控制差旅成本",
"scope": "公司全体员工因公出差报销",
"inputs": [
"员工职级",
"出差目的地(直辖市/省会/其他地区/港澳台/国外)",
"交通工具类型",
"住宿天数"
],
"judgement_logic": [
"根据职级确定交通费标准(飞机舱位要求)",
"根据职级和地区确定住宿费上限",
"根据地区确定出差补贴(餐补+基本补助)",
"超标部分需额外审批"
],
"outputs": [
"交通费报销金额",
"住宿费报销上限",
"出差补贴金额"
],
"admin_note": "P4及以下人员乘坐飞机需事前审批P4应选乘6折及以下P1-P3应选乘5折及以下"
},
"rule_markdown_draft": "# 差旅费报销标准规则\n\n## 模板信息\n\n- 模板键:`travel_standard_v1`\n- 来源文档远光《公司支出管理办法2024》.pdf\n- Hermes 置信度0.95\n- 审核人admin\n\n## 目标\n\n规范差旅费报销标准控制差旅成本\n\n## 适用范围\n\n公司全体员工因公出差报销\n\n## 输入字段\n\n- 员工职级\n- 出差目的地(直辖市/省会/其他地区/港澳台/国外)\n- 交通工具类型\n- 住宿天数\n\n## 判断规则\n\n- 根据职级确定交通费标准(飞机舱位要求)\n- 根据职级和地区确定住宿费上限\n- 根据地区确定出差补贴(餐补+基本补助)\n- 超标部分需额外审批\n\n## 输出\n\n- 交通费报销金额\n- 住宿费报销上限\n- 出差补贴金额\n\n## 来源依据\n\n- 第十三条 差旅费\n- 表1-表3\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\nP4及以下人员乘坐飞机需事前审批P4应选乘6折及以下P1-P3应选乘5折及以下\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"travel_standard_v1\",\n \"rule_name\": \"差旅费报销标准规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法2024》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [\n \"travel\",\n \"hotel\",\n \"transport\"\n ],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"control_points\": [\n {\n \"control_code\": \"route_closed_loop\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"destination_match\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"hotel_city_match\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"hotel_limit\",\n \"severity\": \"high\",\n \"enabled\": true\n },\n {\n \"control_code\": \"transport_class_limit\",\n \"severity\": \"high\",\n \"enabled\": true\n }\n ],\n \"exception_policy\": {\n \"allow_with_explanation\": true,\n \"keywords\": []\n },\n \"output\": {\n \"risk_code\": \"travel_policy_review\",\n \"action\": \"review\",\n \"message\": \"差旅制度命中后需要人工复核。\"\n }\n}\n```",
"runtime_rule": {
"kind": "policy_rule_draft",
"version": 1,
"template_key": "travel_standard_v1",
"rule_name": "差旅费报销标准规则",
"scenario": "reimbursement_policy",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"review_required": true,
"target": {
"expense_types": [
"travel",
"hotel",
"transport"
],
"scene_codes": [
"reimbursement_policy"
]
},
"control_points": [
{
"control_code": "route_closed_loop",
"severity": "high",
"enabled": true
},
{
"control_code": "destination_match",
"severity": "high",
"enabled": true
},
{
"control_code": "hotel_city_match",
"severity": "high",
"enabled": true
},
{
"control_code": "hotel_limit",
"severity": "high",
"enabled": true
},
{
"control_code": "transport_class_limit",
"severity": "high",
"enabled": true
}
],
"exception_policy": {
"allow_with_explanation": true,
"keywords": []
},
"output": {
"risk_code": "travel_policy_review",
"action": "review",
"message": "差旅制度命中后需要人工复核。"
}
},
"evidence": [
"第十三条 差旅费",
"表1-表3"
],
"confidence": 0.95,
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"validation_status": "valid",
"validation_errors": [],
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714835+00:00",
"generated_asset_id": "143247d8-83f8-4257-b05f-161e0b264056",
"generated_asset_code": "rule.expense.hermes.travel_standard_v1.34eb3bb565",
"generated_version": "v1.0.1"
},
{
"candidate_id": "rc_c41885da6b01",
"source_type": "policy_document",
"template_key": "expense_amount_limit_v1",
"template_label": "金额上限模板",
"domain": "expense",
"scenario": "reimbursement_policy",
"suggested_rule_name": "备用金借款限额规则",
"summary": "正式员工备用金借款额度上限一万元,按季清理,不得跨年。",
"template_sections": {
"purpose": "控制备用金风险,规范借款管理",
"scope": "公司正式员工备用金借款",
"inputs": [
"借款人身份(正式/非正式)",
"借款金额",
"借款时间"
],
"judgement_logic": [
"非正式员工不得申请备用金借款",
"借款金额不得超过一万元",
"按季定期清理,跨年需延期审批"
],
"outputs": [
"是否可借款",
"借款额度上限",
"清理要求"
],
"admin_note": "遵循\"前款不清、后款不借\"原则"
},
"rule_markdown_draft": "# 备用金借款限额规则\n\n## 模板信息\n\n- 模板键:`expense_amount_limit_v1`\n- 来源文档远光《公司支出管理办法2024》.pdf\n- Hermes 置信度0.95\n- 审核人admin\n\n## 目标\n\n控制备用金风险规范借款管理\n\n## 适用范围\n\n公司正式员工备用金借款\n\n## 输入字段\n\n- 借款人身份(正式/非正式)\n- 借款金额\n- 借款时间\n\n## 判断规则\n\n- 非正式员工不得申请备用金借款\n- 借款金额不得超过一万元\n- 按季定期清理,跨年需延期审批\n\n## 输出\n\n- 是否可借款\n- 借款额度上限\n- 清理要求\n\n## 来源依据\n\n- 第十一条 备用金借款\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n遵循\"前款不清、后款不借\"原则\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"expense_amount_limit_v1\",\n \"rule_name\": \"备用金借款限额规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法2024》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ],\n \"metric\": \"claim_total\"\n },\n \"threshold\": {\n \"currency\": \"CNY\",\n \"comparator\": \"gt\",\n \"warn_amount\": null,\n \"block_amount\": null,\n \"source\": \"manual_fill_required\"\n },\n \"exception_policy\": {\n \"allow_with_explanation\": true,\n \"keywords\": []\n },\n \"output\": {\n \"risk_code\": \"amount_limit_review\",\n \"action\": \"review\",\n \"message\": \"金额超标后需要人工复核。\"\n }\n}\n```",
"runtime_rule": {
"kind": "policy_rule_draft",
"version": 1,
"template_key": "expense_amount_limit_v1",
"rule_name": "备用金借款限额规则",
"scenario": "reimbursement_policy",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"review_required": true,
"target": {
"expense_types": [],
"scene_codes": [
"reimbursement_policy"
],
"metric": "claim_total"
},
"threshold": {
"currency": "CNY",
"comparator": "gt",
"warn_amount": null,
"block_amount": null,
"source": "manual_fill_required"
},
"exception_policy": {
"allow_with_explanation": true,
"keywords": []
},
"output": {
"risk_code": "amount_limit_review",
"action": "review",
"message": "金额超标后需要人工复核。"
}
},
"evidence": [
"第十一条 备用金借款"
],
"confidence": 0.95,
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"validation_status": "valid",
"validation_errors": [],
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714926+00:00",
"generated_asset_id": "71088dd7-a2c1-471a-8b96-d722d7c47f6a",
"generated_asset_code": "rule.expense.hermes.expense_amount_limit_v1.8562a39b81",
"generated_version": "v1.0.0"
},
{
"candidate_id": "rc_231e079a910a",
"source_type": "policy_document",
"template_key": "attachment_requirement_v1",
"template_label": "附件要求模板",
"domain": "expense",
"scenario": "reimbursement_policy",
"suggested_rule_name": "票据附件要求规则",
"summary": "除特定支出类型外均需取得增值税专用发票,汇总开具的发票需附税控系统明细清单。",
"template_sections": {
"purpose": "确保票据合规完整",
"scope": "公司各类支出报销",
"inputs": [
"支出业务类型"
],
"judgement_logic": [
"员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等不需发票",
"其他支出原则上需取得增值税专用发票",
"汇总开具的增值税发票需附税控系统明细清单",
"未取得专票需在系统单据中说明原因"
],
"outputs": [
"所需票据类型",
"附件要求"
],
"admin_note": ""
},
"rule_markdown_draft": "# 票据附件要求规则\n\n## 模板信息\n\n- 模板键:`attachment_requirement_v1`\n- 来源文档远光《公司支出管理办法2024》.pdf\n- Hermes 置信度0.95\n- 审核人admin\n\n## 目标\n\n确保票据合规完整\n\n## 适用范围\n\n公司各类支出报销\n\n## 输入字段\n\n- 支出业务类型\n\n## 判断规则\n\n- 员工薪酬、个人劳务报酬、出差补贴、专项补贴、往来款项支付等不需发票\n- 其他支出原则上需取得增值税专用发票\n- 汇总开具的增值税发票需附税控系统明细清单\n- 未取得专票需在系统单据中说明原因\n\n## 输出\n\n- 所需票据类型\n- 附件要求\n\n## 来源依据\n\n- 第八条 支出报销申请 第1项申请方式\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n待审核人补充备注。\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"attachment_requirement_v1\",\n \"rule_name\": \"票据附件要求规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法2024》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"attachment_requirements\": {\n \"min_attachment_count\": 1,\n \"items\": [],\n \"manual_fill_required\": true\n },\n \"missing_attachment_action\": \"block\",\n \"output\": {\n \"risk_code\": \"invoice_anomaly\",\n \"action\": \"block\",\n \"message\": \"附件或单据不完整,需补件后再提交。\"\n }\n}\n```",
"runtime_rule": {
"kind": "policy_rule_draft",
"version": 1,
"template_key": "attachment_requirement_v1",
"rule_name": "票据附件要求规则",
"scenario": "reimbursement_policy",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"review_required": true,
"target": {
"expense_types": [],
"scene_codes": [
"reimbursement_policy"
]
},
"attachment_requirements": {
"min_attachment_count": 1,
"items": [],
"manual_fill_required": true
},
"missing_attachment_action": "block",
"output": {
"risk_code": "invoice_anomaly",
"action": "block",
"message": "附件或单据不完整,需补件后再提交。"
}
},
"evidence": [
"第八条 支出报销申请 第1项申请方式"
],
"confidence": 0.95,
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"validation_status": "valid",
"validation_errors": [],
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.714991+00:00",
"generated_asset_id": "ab6e6849-3c84-4b58-bf5a-e320a5640c68",
"generated_asset_code": "rule.expense.hermes.attachment_requirement_v1.945712a66b",
"generated_version": "v1.0.0"
},
{
"candidate_id": "rc_b5173ec16d21",
"source_type": "policy_document",
"template_key": "general_policy_v1",
"template_label": "通用制度模板",
"domain": "expense",
"scenario": "reimbursement_policy",
"suggested_rule_name": "报销申请时限规则",
"summary": "各类支出报销申请时限为三个月,逾期需分管领导审批。差旅费逾期不予报销出差补贴。",
"template_sections": {
"purpose": "规范报销时效性管理",
"scope": "公司各类支出报销申请",
"inputs": [
"业务完成日期",
"报销提交日期"
],
"judgement_logic": [
"计算业务完成日至报销提交日期间",
"超过三个月需说明原因并经分管领导审批",
"差旅费行程结束超过三个月不报销出差补贴",
"连续出差超一个月应按月报销"
],
"outputs": [
"是否逾期",
"是否可报销",
"所需额外审批"
],
"admin_note": "预付款项应在次月底前完成结算"
},
"rule_markdown_draft": "# 报销申请时限规则\n\n## 模板信息\n\n- 模板键:`general_policy_v1`\n- 来源文档远光《公司支出管理办法2024》.pdf\n- Hermes 置信度0.95\n- 审核人admin\n\n## 目标\n\n规范报销时效性管理\n\n## 适用范围\n\n公司各类支出报销申请\n\n## 输入字段\n\n- 业务完成日期\n- 报销提交日期\n\n## 判断规则\n\n- 计算业务完成日至报销提交日期间\n- 超过三个月需说明原因并经分管领导审批\n- 差旅费行程结束超过三个月不报销出差补贴\n- 连续出差超一个月应按月报销\n\n## 输出\n\n- 是否逾期\n- 是否可报销\n- 所需额外审批\n\n## 来源依据\n\n- 第八条 支出报销申请 第2项申请时限\n\n## 审核约束\n\n- 当前规则由 Hermes 自动生成,默认仅为 draft 草稿。\n- 规则上线前必须人工审核、补测样例、确认回滚方案。\n- JSON 运行时配置需要与 Markdown 说明保持一致。\n\n## 管理员备注\n\n预付款项应在次月底前完成结算\n\n```expense-rule\n{\n \"kind\": \"policy_rule_draft\",\n \"version\": 1,\n \"template_key\": \"general_policy_v1\",\n \"rule_name\": \"报销申请时限规则\",\n \"scenario\": \"reimbursement_policy\",\n \"source_document_name\": \"远光《公司支出管理办法2024》.pdf\",\n \"review_required\": true,\n \"target\": {\n \"expense_types\": [],\n \"scene_codes\": [\n \"reimbursement_policy\"\n ]\n },\n \"control_points\": [\n \"计算业务完成日至报销提交日期间\",\n \"超过三个月需说明原因并经分管领导审批\",\n \"差旅费行程结束超过三个月不报销出差补贴\",\n \"连续出差超一个月应按月报销\"\n ],\n \"review_checklist\": [\n \"是否逾期\",\n \"是否可报销\",\n \"所需额外审批\"\n ],\n \"output\": {\n \"risk_code\": \"general_policy_review\",\n \"action\": \"review\",\n \"message\": \"通用制度草稿需由人工补充执行细节。\"\n }\n}\n```",
"runtime_rule": {
"kind": "policy_rule_draft",
"version": 1,
"template_key": "general_policy_v1",
"rule_name": "报销申请时限规则",
"scenario": "reimbursement_policy",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"review_required": true,
"target": {
"expense_types": [],
"scene_codes": [
"reimbursement_policy"
]
},
"control_points": [
"计算业务完成日至报销提交日期间",
"超过三个月需说明原因并经分管领导审批",
"差旅费行程结束超过三个月不报销出差补贴",
"连续出差超一个月应按月报销"
],
"review_checklist": [
"是否逾期",
"是否可报销",
"所需额外审批"
],
"output": {
"risk_code": "general_policy_review",
"action": "review",
"message": "通用制度草稿需由人工补充执行细节。"
}
},
"evidence": [
"第八条 支出报销申请 第2项申请时限"
],
"confidence": 0.95,
"source_document_id": "bf761bd8eccf402bb676423d64401a56",
"source_document_name": "远光《公司支出管理办法2024》.pdf",
"source_chunk_ids": [
"bf761bd8eccf402bb676423d64401a56-document"
],
"validation_status": "valid",
"validation_errors": [],
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-16T01:52:21.715034+00:00",
"generated_asset_id": "86e683fc-27d2-43ec-8e3c-6f1397965266",
"generated_asset_code": "rule.expense.hermes.general_policy_v1.88ae87de96",
"generated_version": "v1.0.0"
}
]

View File

@@ -7,19 +7,19 @@
"document_version": "v1.0", "document_version": "v1.0",
"checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "checksum": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md", "extracted_text_path": "/app/server/storage/knowledge/.llm_wiki/documents/bf761bd8eccf402bb676423d64401a56/text.md",
"chunk_count": 63, "chunk_count": 1,
"candidate_chunk_count": 39, "candidate_chunk_count": 1,
"filtered_chunk_count": 24, "filtered_chunk_count": 0,
"group_count": 20, "group_count": 1,
"successful_group_count": 0, "successful_group_count": 1,
"failed_group_count": 20, "failed_group_count": 0,
"knowledge_candidate_count": 1, "knowledge_candidate_count": 10,
"formal_knowledge_candidate_count": 0, "formal_knowledge_candidate_count": 10,
"fallback_knowledge_candidate_count": 1, "fallback_knowledge_candidate_count": 0,
"rule_candidate_count": 0, "rule_candidate_count": 4,
"quality_status": "fallback_only", "quality_status": "formal",
"quality_note": "Hermes 未形成正式知识候选,当前仅保留降级兜底预览,不能作为正式知识上线。", "quality_note": "Hermes 已基于完整原文件完成正式归纳。",
"updated_at": "2026-05-15T09:37:21.556445+00:00", "updated_at": "2026-05-16T01:52:22.750933+00:00",
"signature": { "signature": {
"document_id": "bf761bd8eccf402bb676423d64401a56", "document_id": "bf761bd8eccf402bb676423d64401a56",
"original_name": "远光《公司支出管理办法2024》.pdf", "original_name": "远光《公司支出管理办法2024》.pdf",
@@ -28,7 +28,7 @@
"version_number": 1, "version_number": 1,
"updated_at": "2026-05-09T08:39:53.788042+00:00" "updated_at": "2026-05-09T08:39:53.788042+00:00"
}, },
"sync_reason": "forced_rebuild" "sync_reason": "agent_batch"
} }
] ]
} }

View File

@@ -111,6 +111,243 @@
"summary": [ "summary": [
"远光《公司支出管理办法2024》.pdfforced_rebuild知识候选 1 条,规则候选 0 条,归纳质量 fallback_only。" "远光《公司支出管理办法2024》.pdfforced_rebuild知识候选 1 条,规则候选 0 条,归纳质量 fallback_only。"
] ]
},
{
"run_id": "wiki_d714c8ce5b6b",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 10,
"rule_candidate_count": 5,
"generated_rule_asset_ids": [
"cfec89e4-335c-45d9-a4e5-b799ba408645",
"51338fe7-ec4b-4f4c-91d2-fb9401ee153b",
"bcf3cdc6-b6b2-4ada-b9d9-42d6687903cd",
"e244c8a4-10cb-47fd-ae51-977ae645c45b",
"304c1570-9fe0-4c4b-8950-d55e9aab2565"
],
"created_by": "admin",
"created_at": "2026-05-15T10:19:34.663079+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 10 条,规则候选 5 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_4de66ca333574b7e"
},
{
"run_id": "wiki_e6f8e8b3c137",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 9,
"rule_candidate_count": 4,
"generated_rule_asset_ids": [
"c7ea86ba-8b94-4c12-a9fc-d4dd4bff145e",
"522c6b6c-bdfd-47b0-9a25-2a6d2da5b5e5",
"c7a1a2a4-2b1a-4339-af10-b25e97b0c309",
"be9adadd-daa5-4625-8789-0a666090ad57"
],
"created_by": "admin",
"created_at": "2026-05-15T10:20:56.773603+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 9 条,规则候选 4 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_48a4c41cca8342b9"
},
{
"run_id": "wiki_10f632d2c6bd",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 6,
"generated_rule_asset_ids": [
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
"21a180e1-d760-4ad9-a4f0-ed29463075cd",
"019c68be-0c2c-4a84-bca8-0ba8502bcec7",
"de95fea3-5b9d-4f61-abb7-ce139a82e844",
"95028ad9-5e3d-4281-b062-c3bc05060cb4",
"f7ab1491-e121-477a-9023-6d74580ec5ea"
],
"created_by": "admin",
"created_at": "2026-05-16T00:34:48.749438+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 6 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_022ae24990ec4355"
},
{
"run_id": "wiki_a9e871817dc5",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 5,
"generated_rule_asset_ids": [
"31b8d05a-c878-42d2-bacc-a6ad09bc6a94",
"6cc1b7e0-be52-4bf7-8d9b-06b9f8c5b961",
"48e412f3-55d9-480f-b2ad-5240e7a6de0b",
"0d6dcdd2-bafd-4e81-9d2c-48f3b84febbe",
"29624254-e83a-46a3-b323-117c097a8976"
],
"created_by": "admin",
"created_at": "2026-05-16T00:54:27.313665+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 5 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_d72cf90db5df427f"
},
{
"run_id": "wiki_5d984aa5564f",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 4,
"generated_rule_asset_ids": [
"02c92cfb-ce24-499d-a051-bf64e481eea1",
"fce1bfd6-9e47-48fb-8132-ebc0d3c57c5e",
"72f3a5c0-5d46-43dc-8e5c-8f647e0e878f",
"1eafd703-a834-4f50-9db6-fe89b7dbd514"
],
"created_by": "admin",
"created_at": "2026-05-16T01:09:42.078805+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 4 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_4f322b84564b4d25"
},
{
"run_id": "wiki_f65445f69883",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 4,
"generated_rule_asset_ids": [
"143247d8-83f8-4257-b05f-161e0b264056",
"21a180e1-d760-4ad9-a4f0-ed29463075cd",
"b12e2d99-2fd9-453a-84a8-7ad52bfab4a7",
"af0b4d7c-6b00-4a64-8168-07cae57f80bd"
],
"created_by": "admin",
"created_at": "2026-05-16T01:17:06.436319+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 4 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_17ff3f4584cb4cfa"
},
{
"run_id": "wiki_96d5bf187cdc",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 11,
"rule_candidate_count": 4,
"generated_rule_asset_ids": [
"e7330580-138b-4e82-b611-2a2b968f1bea",
"84c685fc-6b8a-4c44-981b-d8d89c02cadb",
"1c93075e-24aa-46ca-a702-f8b640b244cf",
"0eef894b-b6c7-4ef4-b133-daee3a5e245e"
],
"created_by": "admin",
"created_at": "2026-05-16T01:26:09.822587+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 11 条,规则候选 4 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_88d5b60192c9450d"
},
{
"run_id": "wiki_12d3e3d3a8d8",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 5,
"generated_rule_asset_ids": [
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
"7c59c499-b416-4c58-8abb-515c76f4dc8a",
"51baae43-2cf5-464e-86e3-4eb23b305b3c",
"db28bdd5-3d78-4c69-b503-862db57c9cf2",
"2afad60c-dce7-4802-bb09-b5d760abc1b4"
],
"created_by": "admin",
"created_at": "2026-05-16T01:30:07.306867+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 5 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_982a86680f5a44a9"
},
{
"run_id": "wiki_7f12b25fc9db",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 12,
"rule_candidate_count": 6,
"generated_rule_asset_ids": [
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
"d14051ad-c370-4162-8649-d565d13ef59f",
"f8e48e1d-c444-4358-be5d-bc29fb293933",
"30aee1d5-e930-4d81-88b6-ccbbac898d6e",
"671f8908-c4ed-421e-9b8b-5ce4ca461330",
"9beb22d9-8582-4a31-b84a-353467e33485"
],
"created_by": "admin",
"created_at": "2026-05-16T01:45:59.739933+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 12 条,规则候选 6 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_56b615c81e4c42f3"
},
{
"run_id": "wiki_3c538493f66e",
"folder": "报销制度",
"requested_document_ids": [
"bf761bd8eccf402bb676423d64401a56"
],
"changed_document_count": 1,
"knowledge_candidate_count": 10,
"rule_candidate_count": 4,
"generated_rule_asset_ids": [
"143247d8-83f8-4257-b05f-161e0b264056",
"71088dd7-a2c1-471a-8b96-d722d7c47f6a",
"ab6e6849-3c84-4b58-bf5a-e320a5640c68",
"86e683fc-27d2-43ec-8e3c-6f1397965266"
],
"created_by": "admin",
"created_at": "2026-05-16T01:52:22.811984+00:00",
"summary": [
"远光《公司支出管理办法2024》.pdfagent_batch知识候选 10 条,规则候选 4 条。"
],
"source": "hermes_callback",
"agent_run_id": "run_a7b447f69939442f"
} }
] ]
} }

View File

@@ -33,6 +33,8 @@ def test_employee_can_login_with_seed_default_password() -> None:
assert result.ok is True assert result.ok is True
assert result.user.username == employee.email assert result.user.username == employee.email
assert result.user.name == employee.name assert result.user.name == employee.name
assert result.user.position == employee.position
assert result.user.grade == employee.grade
assert result.user.roleCodes assert result.user.roleCodes
assert result.user.isAdmin is False assert result.user.isAdmin is False
@@ -53,6 +55,7 @@ def test_admin_can_login_with_database_password() -> None:
assert result.ok is True assert result.ok is True
assert result.user.username == "superadmin" assert result.user.username == "superadmin"
assert result.user.isAdmin is True assert result.user.isAdmin is True
assert result.user.position == "系统管理员"
assert result.user.roleCodes == ["manager"] assert result.user.roleCodes == ["manager"]

View File

@@ -662,3 +662,52 @@ def test_llm_wiki_sync_endpoint_records_agent_run(monkeypatch) -> None:
assert latest_run.tool_calls[0].tool_name == "system_hermes_llm_wiki_sync" assert latest_run.tool_calls[0].tool_name == "system_hermes_llm_wiki_sync"
assert latest_run.tool_calls[0].status == "succeeded" assert latest_run.tool_calls[0].status == "succeeded"
assert latest_run.route_json["sync_run_id"] == "wiki_test_sync" assert latest_run.route_json["sync_run_id"] == "wiki_test_sync"
def test_llm_wiki_callback_finalizes_one_whole_document_result(tmp_path) -> None:
document_id = upload_policy_document(tmp_path)
with build_session() as db:
run = AgentRunService(db).create_run(
agent="hermes",
source=AgentRunSource.SCHEDULE.value,
user_id="admin",
route_json={
"job_type": "llm_wiki_sync",
"folder": "报销制度",
"requested_document_ids": [document_id],
"requested_by_username": "admin",
"requested_by_name": "管理员",
},
)
service = LlmWikiService(db, storage_root=tmp_path)
candidate_payload = build_candidate_payload(f"{document_id}-document")
result = service.finalize_agent_batch_callback(
agent_run_id=run.run_id,
payload={
"ok": True,
"summary": "Hermes 已完成整文档归纳。",
"folder": "报销制度",
"documents": [
{
"document_id": document_id,
"knowledge_summary_markdown": "# Hermes 整文档归纳结果",
**candidate_payload,
}
],
},
)
detail = service.get_document_detail(document_id)
assert result.document_count == 1
assert result.knowledge_candidate_count == 1
assert result.rule_candidate_count == 1
assert detail.chunk_count == 1
assert len(detail.chunks) == 1
assert detail.chunks[0].chunk_id == f"{document_id}-document"
assert detail.knowledge_summary_markdown == "# Hermes 整文档归纳结果"
assert detail.quality_status == "formal"
knowledge_detail = KnowledgeService(storage_root=tmp_path).get_document_detail(document_id)
assert knowledge_detail.stateCode == KNOWLEDGE_INGEST_STATUS_INGESTED

View File

@@ -258,19 +258,57 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
assert result.intent == "query" assert result.intent == "query"
assert result.time_range.start_date == "2026-04-01" assert result.time_range.start_date == "2026-04-01"
assert result.time_range.end_date == "2026-04-30" assert result.time_range.end_date == "2026-04-30"
assert any(
item.type == "employee" and item.normalized_value == "张三"
for item in result.entities def test_semantic_ontology_service_treats_travel_amount_question_as_knowledge_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我要去武汉出差3天请问我一共可以报销多少费用",
user_id="pytest",
context_json={
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
)
) )
assert any(
item.type == "expense_type" and item.normalized_value == "travel" assert result.scenario == "knowledge"
for item in result.entities assert result.intent == "query"
) assert result.clarification_required is False
assert any( assert result.clarification_question is None
item.field == "amount" and item.operator == ">" and item.value == 5000 assert result.missing_slots == []
for item in result.constraints
def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="那P4员工可以报销多少钱",
user_id="pytest",
context_json={
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
"conversation_history": [
{
"role": "user",
"content": "我要去武汉出差3天请问我一共可以报销多少费用",
}
],
},
)
) )
assert result.scenario == "knowledge"
assert result.intent == "query"
assert result.clarification_required is False
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None: def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()

View File

@@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.api.deps import get_db from app.api.deps import get_db
from app.core.config import get_settings
from app.db.base import Base from app.db.base import Base
from app.main import create_app from app.main import create_app
from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_conversation import AgentConversation, AgentConversationMessage
@@ -21,6 +25,7 @@ from app.models.financial_record import (
) )
from app.schemas.settings import SettingsWrite from app.schemas.settings import SettingsWrite
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.knowledge import KnowledgeService
from app.services.settings import SettingsService from app.services.settings import SettingsService
@@ -45,6 +50,108 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
return TestClient(app), session_factory return TestClient(app), session_factory
def seed_llm_wiki_knowledge(storage_root: Path) -> str:
service = KnowledgeService(storage_root=storage_root)
detail = service.upload_document(
folder="报销制度",
filename="公司差旅制度.txt",
content=(
"差旅住宿标准:直辖市和特区住宿费最高 500 元,"
"省会城市 450 元,其他地区 400 元。"
).encode("utf-8"),
current_user=CurrentUserContext(
username="admin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
),
)
entry = service.get_document_entry(detail.id)
document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / detail.id
document_dir.mkdir(parents=True, exist_ok=True)
knowledge_candidates = [
{
"candidate_id": "kc_travel_standard",
"title": "差旅费报销标准",
"content": (
"住宿费限额:国内直辖市或特区最高 500 元,"
"省会城市 450 元,其他地区 400 元。"
"出差补贴:国内餐补 75/65/55 元/天(按地区),"
"基本补助 35 元/天,合计 110/100/90 元/天。"
),
"domain": "expense",
"scenario": "expense_reimbursement",
"tags": ["差旅", "住宿费", "标准"],
"source_document_id": detail.id,
"source_document_name": entry["original_name"],
"source_chunk_ids": [f"{detail.id}-document"],
"evidence": ["第八条 差旅住宿标准"],
"confidence": 0.96,
"status": "draft",
"created_by": "hermes",
"created_at": "2026-05-15T10:20:55+00:00",
"extraction_mode": "hermes",
"quality_flags": [],
"fallback_reason": "",
}
]
(document_dir / "knowledge_candidates.json").write_text(
json.dumps(knowledge_candidates, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(document_dir / "knowledge_summary.md").write_text(
(
"# 公司差旅制度知识总结\n\n"
"## 差旅住宿标准\n"
"- 国内直辖市和特区住宿费最高 500 元。\n"
"- 省会城市 450 元,其他地区 400 元。\n"
),
encoding="utf-8",
)
(storage_root / "knowledge" / ".llm_wiki" / "index.json").write_text(
json.dumps(
{
"documents": [
{
"document_id": detail.id,
"document_name": entry["original_name"],
"folder": entry["folder"],
"document_version": "v1.0",
"checksum": entry["sha256"],
"extracted_text_path": str(document_dir / "text.md"),
"chunk_count": 1,
"candidate_chunk_count": 1,
"filtered_chunk_count": 0,
"group_count": 1,
"successful_group_count": 1,
"failed_group_count": 0,
"knowledge_candidate_count": 1,
"formal_knowledge_candidate_count": 1,
"fallback_knowledge_candidate_count": 0,
"rule_candidate_count": 0,
"quality_status": "formal",
"quality_note": "Hermes 已基于完整原文件完成正式归纳。",
"updated_at": "2026-05-15T10:20:56+00:00",
"signature": {
"document_id": entry["id"],
"original_name": entry["original_name"],
"stored_name": entry["stored_name"],
"sha256": entry["sha256"],
"version_number": int(entry["version_number"]),
"updated_at": entry["updated_at"],
},
}
]
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
return detail.id
def test_orchestrator_routes_user_query_to_user_agent() -> None: def test_orchestrator_routes_user_query_to_user_agent() -> None:
client, _ = build_client() client, _ = build_client()
@@ -75,6 +182,288 @@ def test_orchestrator_routes_user_query_to_user_agent() -> None:
assert run_detail["tool_calls"][0]["tool_type"] == "database" assert run_detail["tool_calls"][0]["tool_type"] == "database"
def test_orchestrator_answers_knowledge_question_from_llm_wiki(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
document_id = seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "差旅住宿标准按什么规则执行?",
"context_json": {"role_codes": ["employee"], "name": "测试用户"},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["selected_agent"] == "user_agent"
assert payload["status"] == "succeeded"
assert "差旅费报销标准" in payload["result"]["answer"]
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
assert payload["result"]["citations"][0]["title"] == "差旅费报销标准"
assert "500 元" in payload["result"]["citations"][0]["excerpt"]
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
assert tool_response["hits"][0]["title"] == "差旅费报销标准"
assert tool_response["hits"][0]["document_id"] == document_id
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_session_forces_llm_wiki_search(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["trace_summary"]["scenario"] == "knowledge"
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
assert "差旅费报销标准" in payload["result"]["answer"]
assert "核心规定是:" in payload["result"]["answer"]
assert "住宿费限额" in payload["result"]["answer"]
assert payload["result"]["suggested_actions"] == []
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_session_does_not_answer_from_summary_fallback(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
document_id = seed_llm_wiki_knowledge(storage_root)
document_dir = storage_root / "knowledge" / ".llm_wiki" / "documents" / document_id
(document_dir / "knowledge_candidates.json").write_text("[]", encoding="utf-8")
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["citations"] == []
assert "知识问答仅基于 LLM Wiki 已形成的知识条目回答" in payload["result"]["answer"]
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_follow_up_reuses_recent_context(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "住宿费报销标准是多少?",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
conversation_id = first_response.json()["conversation_id"]
follow_up_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"conversation_id": conversation_id,
"message": "假设p3员工去武汉出差3天一共可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "测试用户",
"session_type": "knowledge",
},
},
)
assert follow_up_response.status_code == 200
payload = follow_up_response.json()
assert "差旅费报销标准" in payload["result"]["answer"]
assert "住宿费限额" in payload["result"]["answer"]
assert payload["result"]["citations"][0]["source_type"] == "knowledge"
run_detail = client.get(f"/api/v1/agent-runs/{payload['run_id']}").json()
tool_response = run_detail["tool_calls"][0]["response_json"]
assert tool_response["result_type"] == "knowledge_search"
assert tool_response["record_count"] == 1
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_answer_does_not_invent_missing_grade_detail(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我去武汉出差3天一共可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
answer = response.json()["result"]["answer"]
assert "住宿费限额" in answer
assert "350 × 3" not in answer
assert "1320 元" not in answer
finally:
get_settings.cache_clear()
def test_orchestrator_answers_direct_travel_amount_question_without_clarification(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我要去武汉出差3天请问我一共可以报销多少费用",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["trace_summary"]["scenario"] == "knowledge"
assert payload["status"] == "succeeded"
assert payload["result"].get("clarification_required") is not True
assert "差旅费报销标准" in payload["result"]["answer"]
assert "1320 元" not in payload["result"]["answer"]
finally:
get_settings.cache_clear()
def test_orchestrator_knowledge_follow_up_inherits_trip_conditions(tmp_path, monkeypatch) -> None:
storage_root = tmp_path / "storage"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(storage_root))
get_settings.cache_clear()
try:
seed_llm_wiki_knowledge(storage_root)
client, _ = build_client()
first_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"message": "我要去武汉出差3天请问我一共可以报销多少费用",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
conversation_id = first_response.json()["conversation_id"]
follow_up_response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "pytest",
"conversation_id": conversation_id,
"message": "那P4员工可以报销多少钱",
"context_json": {
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
},
)
assert follow_up_response.status_code == 200
answer = follow_up_response.json()["result"]["answer"]
assert "差旅费报销标准" in answer
assert "1470 元" not in answer
finally:
get_settings.cache_clear()
def test_orchestrator_does_not_auto_seed_demo_financial_records() -> None: def test_orchestrator_does_not_auto_seed_demo_financial_records() -> None:
client, session_factory = build_client() client, session_factory = build_client()

View File

@@ -83,6 +83,89 @@ def test_user_agent_sanitizes_model_thinking_blocks() -> None:
) )
def test_user_agent_rejects_visible_reasoning_drafts() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = UserAgentService(db)
assert (
service._sanitize_model_answer(
"用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..."
)
is None
)
def test_user_agent_knowledge_prompt_enforces_llm_wiki_boundary() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
tool_payload={"result_type": "knowledge_search", "hits": []},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
assert "只能依据 tool_payload.hits 中的 LLM Wiki 内容作答" in messages[0]["content"]
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我能坐什么舱位?",
user_id="pytest",
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我能坐什么舱位?",
ontology=ontology,
context_json={
"name": "张三",
"position": "财务分析师",
"grade": "P5",
"role": "财务人员",
"role_codes": ["finance"],
},
tool_payload={},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
system_prompt = messages[0]["content"]
user_prompt = messages[1]["content"]
assert "user_grade" in system_prompt
assert "conversation_history" in system_prompt
assert '"user_name": "张三"' in user_prompt
assert '"user_position": "财务分析师"' in user_prompt
assert '"user_grade": "P5"' in user_prompt
def test_user_agent_guides_generic_expense_request() -> None: def test_user_agent_guides_generic_expense_request() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -37,10 +37,26 @@
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; } .answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; } .answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; } .agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
.agent-answer-content { max-width: 760px; display: grid; gap: 10px; }
.agent-answer-table-wrap { overflow-x: auto; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.agent-answer-table { width: 100%; min-width: 360px; border-collapse: collapse; font-size: 13px; }
.agent-answer-table th, .agent-answer-table td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; }
.agent-answer-table th { background: #eef7f2; color: #0f172a; font-weight: 800; }
.agent-answer-table td { color: #334155; font-weight: 650; }
.agent-answer-table tbody tr:last-child td { border-bottom: 0; }
.agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; } .agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; }
.agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: #eef7f2; color: #0f766e; font-size: 12px; font-weight: 760; } .agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: #eef7f2; color: #0f766e; font-size: 12px; font-weight: 760; }
.agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; } .agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; }
.agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; } .agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; }
.agent-citation-disclosure { overflow: hidden; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; }
.agent-citation-disclosure summary { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: #0f172a; cursor: pointer; list-style: none; }
.agent-citation-disclosure summary::-webkit-details-marker { display: none; }
.agent-citation-disclosure summary strong { font-size: 12px; font-weight: 820; }
.agent-citation-disclosure summary span { color: #64748b; font-size: 12px; font-weight: 720; }
.agent-citation-disclosure summary i { margin-left: auto; color: #64748b; transition: transform .18s ease; }
.agent-citation-disclosure[open] summary { border-bottom: 1px solid #eef2f7; }
.agent-citation-disclosure[open] summary i { transform: rotate(180deg); }
.agent-citation-disclosure .agent-citation-list { padding: 10px; }
.agent-detail-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } .agent-detail-chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.agent-risk-chip, .agent-action-chip { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 760; } .agent-risk-chip, .agent-action-chip { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 760; }
.agent-risk-chip { background: #fff1f2; color: #be123c; } .agent-risk-chip { background: #fff1f2; color: #be123c; }

View File

@@ -379,6 +379,52 @@
font-size: 14px; font-size: 14px;
} }
.message-answer-content {
display: grid;
gap: 12px;
}
.message-answer-content p {
margin: 0;
}
.message-answer-table-wrap {
overflow-x: auto;
border: 1px solid #dbe4ee;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.message-answer-table {
width: 100%;
min-width: 360px;
border-collapse: collapse;
overflow: hidden;
font-size: 13px;
}
.message-answer-table th,
.message-answer-table td {
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
.message-answer-table th {
background: #eff6ff;
color: #0f172a;
font-weight: 850;
}
.message-answer-table td {
color: #334155;
font-weight: 650;
}
.message-answer-table tbody tr:last-child td {
border-bottom: 0;
}
.message-meta-row { .message-meta-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -429,6 +475,58 @@
font-weight: 850; font-weight: 850;
} }
.message-citation-disclosure {
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 16px;
background: #fbfdff;
}
.message-citation-disclosure summary {
min-height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
color: #0f172a;
cursor: pointer;
list-style: none;
}
.message-citation-disclosure summary::-webkit-details-marker {
display: none;
}
.message-citation-disclosure summary strong {
font-size: 12px;
font-weight: 850;
}
.message-citation-disclosure summary span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.message-citation-disclosure summary i {
margin-left: auto;
color: #64748b;
font-size: 16px;
transition: transform 0.18s ease;
}
.message-citation-disclosure[open] summary {
border-bottom: 1px solid #e2e8f0;
}
.message-citation-disclosure[open] summary i {
transform: rotate(180deg);
}
.message-citation-disclosure .message-citation-list {
padding: 12px;
}
.expense-query-block { .expense-query-block {
gap: 10px; gap: 10px;
} }

View File

@@ -142,6 +142,8 @@ export function useChat(activeView) {
is_admin: Boolean(user.isAdmin), is_admin: Boolean(user.isAdmin),
name: user.name || '', name: user.name || '',
role: user.role || '', role: user.role || '',
position: user.position || '',
grade: user.grade || '',
active_case_id: activeCase.value?.id || '' active_case_id: activeCase.value?.id || ''
} }
}) })

View File

@@ -90,6 +90,8 @@ function buildAnonymousUser() {
username: '', username: '',
name: '', name: '',
role: '', role: '',
position: '',
grade: '',
roleCodes: [], roleCodes: [],
email: '', email: '',
avatar: '', avatar: '',
@@ -105,6 +107,8 @@ function buildLegacyAdminUser(username = '') {
username: normalized, username: normalized,
name, name,
role: DEFAULT_USER_ROLE, role: DEFAULT_USER_ROLE,
position: DEFAULT_USER_ROLE,
grade: '',
roleCodes: ['manager'], roleCodes: ['manager'],
email: '', email: '',
avatar: name.slice(0, 1).toUpperCase(), avatar: name.slice(0, 1).toUpperCase(),
@@ -131,6 +135,8 @@ function readStoredUser() {
username, username,
name, name,
role: String(payload.role || DEFAULT_USER_ROLE), role: String(payload.role || DEFAULT_USER_ROLE),
position: String(payload.position || ''),
grade: String(payload.grade || ''),
roleCodes, roleCodes,
email: String(payload.email || ''), email: String(payload.email || ''),
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),

View File

@@ -108,7 +108,26 @@
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong> <strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
<time>刚刚</time> <time>刚刚</time>
</header> </header>
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p> <p v-if="message.role === 'user'" class="user-question">{{ message.text }}</p>
<div v-else class="agent-answer-content">
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
<p v-if="block.type === 'paragraph'" class="agent-answer">{{ block.text }}</p>
<div v-else-if="block.type === 'table'" class="agent-answer-table-wrap">
<table class="agent-answer-table">
<thead>
<tr>
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row"> <div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span> <span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
</div> </div>
@@ -118,8 +137,12 @@
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span> <span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
</div> </div>
</div> </div>
<div v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block"> <details v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block agent-citation-disclosure">
<strong>引用依据</strong> <summary>
<strong>引用依据</strong>
<span>{{ message.citations.length }} </span>
<i class="mdi mdi-chevron-down"></i>
</summary>
<div class="agent-citation-list"> <div class="agent-citation-list">
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card"> <article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
<header> <header>
@@ -129,19 +152,7 @@
<p>{{ item.excerpt || item.code }}</p> <p>{{ item.excerpt || item.code }}</p>
</article> </article>
</div> </div>
</div> </details>
<div v-if="message.role !== 'user' && message.suggestedActions?.length" class="agent-detail-block">
<strong>建议动作</strong>
<div class="agent-detail-chip-row">
<span
v-for="item in message.suggestedActions"
:key="`${message.id}-${item.action_type}-${item.label}`"
class="agent-action-chip"
>
{{ item.label }}
</span>
</div>
</div>
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card"> <div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
<header> <header>
<strong>{{ message.draftPayload.title }}</strong> <strong>{{ message.draftPayload.title }}</strong>

View File

@@ -82,7 +82,35 @@
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong> <strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
<time>{{ message.time }}</time> <time>{{ message.time }}</time>
</header> </header>
<p v-if="message.text" :class="{ 'review-summary': message.role === 'assistant' && message.reviewPayload }">{{ message.text }}</p> <p
v-if="message.text && (message.role !== 'assistant' || message.reviewPayload)"
:class="{ 'review-summary': message.role === 'assistant' && message.reviewPayload }"
>
{{ message.text }}
</p>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content"
>
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<div v-else-if="block.type === 'table'" class="message-answer-table-wrap">
<table class="message-answer-table">
<thead>
<tr>
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row"> <div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span> <span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
@@ -95,8 +123,15 @@
</div> </div>
</div> </div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length" class="message-detail-block"> <details
<strong>引用依据</strong> v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure"
>
<summary>
<strong>引用依据</strong>
<span>{{ message.citations.length }} </span>
<i class="mdi mdi-chevron-down"></i>
</summary>
<div class="message-citation-list"> <div class="message-citation-list">
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card"> <article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
<header> <header>
@@ -106,20 +141,7 @@
<p>{{ item.excerpt || item.code }}</p> <p>{{ item.excerpt || item.code }}</p>
</article> </article>
</div> </div>
</div> </details>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length" class="message-detail-block">
<strong>建议动作</strong>
<div class="message-detail-chip-row">
<span
v-for="item in message.suggestedActions"
:key="`${message.id}-${item.action_type}-${item.label}`"
class="message-action-chip"
>
{{ item.label }}
</span>
</div>
</div>
<div <div
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'" v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"

View File

@@ -3,6 +3,69 @@
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { fetchOntologyParse } from '../../services/ontology.js' import { fetchOntologyParse } from '../../services/ontology.js'
function isMarkdownTableDivider(line = '') {
const value = String(line || '').trim()
if (!value.includes('|')) return false
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
}
function splitMarkdownTableRow(line = '') {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function buildAnswerBlocks(text = '') {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index].trim()
if (!line) {
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
) {
const headers = splitMarkdownTableRow(line)
const rows = []
index += 2
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
rows.push(splitMarkdownTableRow(lines[index]))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const paragraphLines = [line]
index += 1
while (
index < lines.length &&
lines[index].trim() &&
!(
lines[index].includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
)
) {
paragraphLines.push(lines[index].trim())
index += 1
}
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
}
return blocks
}
export default { export default {
name: 'ChatView', name: 'ChatView',
props: { props: {
@@ -170,7 +233,9 @@ export default {
role_codes: currentUser.value?.roleCodes || [], role_codes: currentUser.value?.roleCodes || [],
is_admin: Boolean(currentUser.value?.isAdmin), is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '', name: currentUser.value?.name || '',
role: currentUser.value?.role || '' role: currentUser.value?.role || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || ''
} }
}) })
} catch (error) { } catch (error) {
@@ -212,6 +277,7 @@ export default {
semanticRiskFlagsText, semanticRiskFlagsText,
semanticClarificationText, semanticClarificationText,
semanticResultJson, semanticResultJson,
buildAnswerBlocks,
applySemanticExample, applySemanticExample,
useDraftAsSemanticInput, useDraftAsSemanticInput,
parseSemanticQuery parseSemanticQuery

View File

@@ -228,6 +228,69 @@ function createMessage(role, text, attachments = [], extras = {}) {
} }
} }
function isMarkdownTableDivider(line = '') {
const value = String(line || '').trim()
if (!value.includes('|')) return false
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
}
function splitMarkdownTableRow(line = '') {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function buildAnswerBlocks(text = '') {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index].trim()
if (!line) {
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
) {
const headers = splitMarkdownTableRow(line)
const rows = []
index += 2
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
rows.push(splitMarkdownTableRow(lines[index]))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const paragraphLines = [line]
index += 1
while (
index < lines.length &&
lines[index].trim() &&
!(
lines[index].includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
)
) {
paragraphLines.push(lines[index].trim())
index += 1
}
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
}
return blocks
}
function formatMessageTime(value) { function formatMessageTime(value) {
if (!value) { if (!value) {
return nowTime() return nowTime()
@@ -3371,6 +3434,8 @@ export default {
is_admin: Boolean(user.isAdmin), is_admin: Boolean(user.isAdmin),
name: user.name || '', name: user.name || '',
role: user.role || '', role: user.role || '',
position: user.position || '',
grade: user.grade || '',
...buildClientTimeContext(), ...buildClientTimeContext(),
session_type: activeSessionType.value, session_type: activeSessionType.value,
entry_source: props.entrySource, entry_source: props.entrySource,
@@ -3787,6 +3852,7 @@ export default {
buildReviewRiskHint, buildReviewRiskHint,
buildReviewActionHint, buildReviewActionHint,
buildReviewStatusTag, buildReviewStatusTag,
buildAnswerBlocks,
buildExpenseQueryWindowLabel, buildExpenseQueryWindowLabel,
buildExpenseQueryHint, buildExpenseQueryHint,
getExpenseQueryActivePage, getExpenseQueryActivePage,