feat: 集成Hermes智能体系统,增强聊天和差旅报销功能
This commit is contained in:
1
.env
1
.env
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
main:
|
main:
|
||||||
image: x-financial-dev:latest
|
image: x-financial-dev:latest
|
||||||
container_name: x-financial-main
|
container_name: x-financial-main
|
||||||
@@ -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:
|
||||||
|
|||||||
61
hermes/skills/domain/x-financial-callback/SKILL.md
Normal file
61
hermes/skills/domain/x-financial-callback/SKILL.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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())
|
||||||
134
hermes/skills/domain/x-financial-llm-wiki-ingest/SKILL.md
Normal file
134
hermes/skills/domain/x-financial-llm-wiki-ingest/SKILL.md
Normal 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.
|
||||||
44
server/src/app/api/v1/endpoints/hermes.py
Normal file
44
server/src/app/api/v1/endpoints/hermes.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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、任务及其版本、审核和上线流程。",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
server/src/app/schemas/hermes.py
Normal file
25
server/src/app/schemas/hermes.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
124
server/src/app/services/hermes_callbacks.py
Normal file
124
server/src/app/services/hermes_callbacks.py
Normal 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),
|
||||||
|
)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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_sync,run_id 必须保持不变;\n"
|
||||||
|
"6. 任务成功时 status=succeeded,业务结果放在 payload;失败时 status=failed 并给出 error;\n"
|
||||||
|
"7. 回调成功后,只返回一句简短确认。\n\n"
|
||||||
|
f"{json.dumps(request_payload, ensure_ascii=False, indent=2)}"
|
||||||
|
)
|
||||||
|
return LlmWikiAgentBatchDispatch(
|
||||||
|
prompt=prompt,
|
||||||
|
request_payload=request_payload,
|
||||||
|
changed_document_ids=[item["document_id"] for item in changed_entries],
|
||||||
|
skipped_document_ids=skipped_document_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispatch_agent_batch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
folder: str,
|
||||||
|
document_ids: list[str],
|
||||||
|
force: bool,
|
||||||
|
agent_run_id: str,
|
||||||
|
) -> LlmWikiAgentBatchDispatch:
|
||||||
|
dispatch = self.build_agent_batch_dispatch(
|
||||||
|
folder=folder,
|
||||||
|
document_ids=document_ids,
|
||||||
|
force=force,
|
||||||
|
agent_run_id=agent_run_id,
|
||||||
|
)
|
||||||
|
if not dispatch.changed_document_ids:
|
||||||
|
return dispatch
|
||||||
|
|
||||||
|
self.knowledge_service.set_document_ingest_statuses(
|
||||||
|
dispatch.changed_document_ids,
|
||||||
|
status_code=KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||||
|
agent_run_id=agent_run_id,
|
||||||
|
)
|
||||||
|
process_handle = self.system_hermes_service.start_query_background(
|
||||||
|
dispatch.prompt,
|
||||||
|
source="tool",
|
||||||
|
max_turns=24,
|
||||||
|
skills=("llm-wiki", "x-financial-llm-wiki-ingest", "x-financial-callback"),
|
||||||
|
log_prefix=f"llm-wiki-{agent_run_id}",
|
||||||
|
yolo=True,
|
||||||
|
)
|
||||||
|
dispatch.process_id = process_handle.pid
|
||||||
|
dispatch.stdout_path = process_handle.stdout_path
|
||||||
|
dispatch.stderr_path = process_handle.stderr_path
|
||||||
|
return dispatch
|
||||||
|
|
||||||
|
def finalize_agent_batch_callback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_run_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> LlmWikiSyncRead:
|
||||||
|
documents_payload = list(payload.get("documents") or [])
|
||||||
|
if not bool(payload.get("ok", True)):
|
||||||
|
raise ValueError(str(payload.get("error") or "Hermes LLM Wiki 回调失败。"))
|
||||||
|
if not documents_payload:
|
||||||
|
raise ValueError("Hermes LLM Wiki 回调未返回 documents。")
|
||||||
|
|
||||||
|
callback_route = self._resolve_callback_route(agent_run_id)
|
||||||
|
callback_folder = str(payload.get("folder") or callback_route.get("folder") or "")
|
||||||
|
documents_by_id = {
|
||||||
|
str(item.get("id") or "").strip(): item
|
||||||
|
for item in self.knowledge_service.list_folder_documents()
|
||||||
|
if str(item.get("id") or "").strip()
|
||||||
|
}
|
||||||
|
index = self._load_wiki_index()
|
||||||
|
sync_runs = self._load_sync_runs()
|
||||||
|
existing_by_id = {
|
||||||
|
str(item.get("document_id") or ""): item for item in list(index.get("documents", []))
|
||||||
|
}
|
||||||
|
|
||||||
|
changed_document_count = 0
|
||||||
|
knowledge_candidate_count = 0
|
||||||
|
rule_candidate_count = 0
|
||||||
|
generated_rule_asset_ids: list[str] = []
|
||||||
|
completed_document_ids: list[str] = []
|
||||||
|
sync_summaries: list[str] = []
|
||||||
|
|
||||||
|
current_user = self._resolve_callback_user(agent_run_id)
|
||||||
|
for raw_document in documents_payload:
|
||||||
|
if not isinstance(raw_document, dict):
|
||||||
|
continue
|
||||||
|
document_id = str(raw_document.get("document_id") or "").strip()
|
||||||
|
entry = documents_by_id.get(document_id)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
document_payload = self._persist_agent_document_result(
|
||||||
|
entry=entry,
|
||||||
|
current_user=current_user,
|
||||||
|
raw_document=raw_document,
|
||||||
|
)
|
||||||
|
existing_by_id[document_id] = document_payload["document"]
|
||||||
|
changed_document_count += 1
|
||||||
|
completed_document_ids.append(document_id)
|
||||||
|
knowledge_candidate_count += len(document_payload["knowledge_candidates"])
|
||||||
|
rule_candidate_count += len(document_payload["rule_candidates"])
|
||||||
|
generated_rule_asset_ids.extend(
|
||||||
|
[
|
||||||
|
str(item.get("generated_asset_id") or "").strip()
|
||||||
|
for item in document_payload["rule_candidates"]
|
||||||
|
if str(item.get("generated_asset_id") or "").strip()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sync_summaries.append(
|
||||||
|
f"{entry['original_name']}:agent_batch:知识候选 {len(document_payload['knowledge_candidates'])} 条,"
|
||||||
|
f"规则候选 {len(document_payload['rule_candidates'])} 条。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed_document_count <= 0:
|
||||||
|
raise ValueError("Hermes LLM Wiki 回调没有匹配到可落库的文档。")
|
||||||
|
|
||||||
|
index["documents"] = list(existing_by_id.values())
|
||||||
|
self._write_json_file(self.knowledge_service.llm_wiki_index_path, index)
|
||||||
|
sync_run_id = f"wiki_{uuid4().hex[:12]}"
|
||||||
|
generated_rule_ids = list(dict.fromkeys(generated_rule_asset_ids))
|
||||||
|
summary = str(payload.get("summary") or "").strip() or ";".join(sync_summaries)
|
||||||
|
sync_runs.setdefault("runs", [])
|
||||||
|
sync_runs["runs"].append(
|
||||||
|
{
|
||||||
|
"run_id": sync_run_id,
|
||||||
|
"folder": callback_folder,
|
||||||
|
"requested_document_ids": completed_document_ids,
|
||||||
|
"changed_document_count": changed_document_count,
|
||||||
|
"knowledge_candidate_count": knowledge_candidate_count,
|
||||||
|
"rule_candidate_count": rule_candidate_count,
|
||||||
|
"generated_rule_asset_ids": generated_rule_ids,
|
||||||
|
"created_by": current_user.name,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
"summary": sync_summaries,
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": agent_run_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._write_json_file(self.knowledge_service.llm_wiki_sync_runs_path, sync_runs)
|
||||||
|
self.knowledge_service.refresh_document_ingest_statuses(
|
||||||
|
document_ids=completed_document_ids,
|
||||||
|
preserve_syncing=False,
|
||||||
|
)
|
||||||
|
return LlmWikiSyncRead(
|
||||||
|
ok=True,
|
||||||
|
run_id=sync_run_id,
|
||||||
|
folder=callback_folder,
|
||||||
|
document_count=changed_document_count,
|
||||||
|
knowledge_candidate_count=knowledge_candidate_count,
|
||||||
|
rule_candidate_count=rule_candidate_count,
|
||||||
|
generated_rule_count=len(generated_rule_ids),
|
||||||
|
generated_rule_asset_ids=generated_rule_ids,
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _persist_agent_document_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
entry: dict[str, Any],
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
raw_document: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
document_id = str(entry["id"])
|
||||||
|
document_name = str(entry["original_name"])
|
||||||
|
document_dir = self._document_dir(document_id)
|
||||||
|
document_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
extracted_text = self.knowledge_service.extract_document_text(document_id)
|
||||||
|
text_path = document_dir / "text.md"
|
||||||
|
text_path.write_text(extracted_text, encoding="utf-8")
|
||||||
|
|
||||||
|
source_chunk = {
|
||||||
|
"chunk_id": f"{document_id}-document",
|
||||||
|
"title": document_name,
|
||||||
|
"content": extracted_text,
|
||||||
|
"source_page": None,
|
||||||
|
"word_count": len(re.sub(r"\s+", "", extracted_text)),
|
||||||
|
"tags": self._normalize_tags([], fallback_text=extracted_text),
|
||||||
|
}
|
||||||
|
seen_knowledge_keys: set[str] = set()
|
||||||
|
seen_rule_keys: set[str] = set()
|
||||||
|
knowledge_candidates = self._normalize_knowledge_candidates(
|
||||||
|
raw_items=list(raw_document.get("knowledge_candidates") or []),
|
||||||
|
entry=entry,
|
||||||
|
chunk_group=[source_chunk],
|
||||||
|
seen_keys=seen_knowledge_keys,
|
||||||
|
extraction_mode="hermes",
|
||||||
|
)[:12]
|
||||||
|
rule_candidates = self._normalize_rule_candidates(
|
||||||
|
raw_items=list(raw_document.get("rule_candidates") or []),
|
||||||
|
entry=entry,
|
||||||
|
chunk_group=[source_chunk],
|
||||||
|
current_user=current_user,
|
||||||
|
seen_keys=seen_rule_keys,
|
||||||
|
)[:12]
|
||||||
|
generated_candidates: list[dict[str, Any]] = []
|
||||||
|
for candidate in rule_candidates:
|
||||||
|
if candidate.get("validation_status") == "valid":
|
||||||
|
generated_asset = self._create_or_update_rule_draft(candidate, current_user=current_user)
|
||||||
|
if generated_asset is not None:
|
||||||
|
candidate["generated_asset_id"] = generated_asset["asset_id"]
|
||||||
|
candidate["generated_asset_code"] = generated_asset["asset_code"]
|
||||||
|
candidate["generated_version"] = generated_asset["version"]
|
||||||
|
generated_candidates.append(candidate)
|
||||||
|
|
||||||
|
summary_markdown = str(raw_document.get("knowledge_summary_markdown") or "").strip()
|
||||||
|
if not summary_markdown:
|
||||||
|
summary_markdown = self._build_knowledge_summary_markdown(
|
||||||
|
entry=entry,
|
||||||
|
knowledge_candidates=knowledge_candidates,
|
||||||
|
)
|
||||||
|
knowledge_candidates = self._upgrade_table_candidate_contents_from_summary(
|
||||||
|
knowledge_candidates=knowledge_candidates,
|
||||||
|
summary_markdown=summary_markdown,
|
||||||
|
)
|
||||||
|
knowledge_candidates = self._synthesize_candidates_from_summary(
|
||||||
|
knowledge_candidates=knowledge_candidates,
|
||||||
|
summary_markdown=summary_markdown,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
self._validate_agent_knowledge_candidate_quality(
|
||||||
|
knowledge_candidates,
|
||||||
|
summary_markdown=summary_markdown,
|
||||||
|
)
|
||||||
|
document_record = {
|
||||||
|
"document_id": document_id,
|
||||||
|
"document_name": document_name,
|
||||||
|
"folder": str(entry["folder"]),
|
||||||
|
"document_version": f"v{int(entry.get('version_number', 1))}.0",
|
||||||
|
"checksum": str(entry.get("sha256") or ""),
|
||||||
|
"extracted_text_path": str(text_path),
|
||||||
|
"chunk_count": 1,
|
||||||
|
"candidate_chunk_count": 1,
|
||||||
|
"filtered_chunk_count": 0,
|
||||||
|
"group_count": 1,
|
||||||
|
"successful_group_count": 1,
|
||||||
|
"failed_group_count": 0,
|
||||||
|
"knowledge_candidate_count": len(knowledge_candidates),
|
||||||
|
"formal_knowledge_candidate_count": len(knowledge_candidates),
|
||||||
|
"fallback_knowledge_candidate_count": 0,
|
||||||
|
"rule_candidate_count": len(generated_candidates),
|
||||||
|
"quality_status": "formal" if knowledge_candidates else "failed",
|
||||||
|
"quality_note": (
|
||||||
|
"Hermes 已基于完整原文件完成正式归纳。"
|
||||||
|
if knowledge_candidates
|
||||||
|
else "Hermes 回调未返回可用知识候选。"
|
||||||
|
),
|
||||||
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"signature": self._build_document_signature(entry),
|
||||||
|
"sync_reason": str(raw_document.get("sync_reason") or "agent_batch"),
|
||||||
|
}
|
||||||
|
self._write_json_file(document_dir / "document.json", document_record)
|
||||||
|
self._write_json_file(document_dir / "chunks.json", [source_chunk])
|
||||||
|
self._write_json_file(document_dir / "knowledge_candidates.json", knowledge_candidates)
|
||||||
|
self._write_json_file(document_dir / "rule_candidates.json", generated_candidates)
|
||||||
|
(document_dir / "knowledge_summary.md").write_text(summary_markdown, encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"document": document_record,
|
||||||
|
"knowledge_candidates": knowledge_candidates,
|
||||||
|
"rule_candidates": generated_candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_agent_knowledge_candidate_quality(
|
||||||
|
knowledge_candidates: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
summary_markdown: str = "",
|
||||||
|
) -> None:
|
||||||
|
invalid_titles: list[str] = []
|
||||||
|
for candidate in knowledge_candidates:
|
||||||
|
content = str(candidate.get("content") or "").strip()
|
||||||
|
evidence = " ".join(str(item or "") for item in list(candidate.get("evidence") or []))
|
||||||
|
title = str(candidate.get("title") or "").strip() or "未命名条目"
|
||||||
|
table_backed = "表" in evidence
|
||||||
|
if not table_backed:
|
||||||
|
continue
|
||||||
|
has_markdown_table = "|" in content
|
||||||
|
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
|
||||||
|
summary_has_candidate_table = False
|
||||||
|
if summary_markdown and "|" in summary_markdown:
|
||||||
|
match_terms = [
|
||||||
|
term
|
||||||
|
for term in [title, *[str(item).strip() for item in list(candidate.get("tags") or [])]]
|
||||||
|
if len(term) >= 2
|
||||||
|
]
|
||||||
|
summary_has_candidate_table = any(
|
||||||
|
("|" in section) and any(term in section for term in match_terms)
|
||||||
|
for section in re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
|
||||||
|
if section.strip()
|
||||||
|
)
|
||||||
|
if (not has_markdown_table or has_slash_shorthand) and not summary_has_candidate_table:
|
||||||
|
invalid_titles.append(title)
|
||||||
|
if invalid_titles:
|
||||||
|
joined = "、".join(invalid_titles[:5])
|
||||||
|
raise ValueError(
|
||||||
|
"Hermes 回调中的表格型知识条目既没有可直接复用的 Markdown 表格,"
|
||||||
|
"也没有在 wiki 总结中提供可用于还原表格的结构化表述:"
|
||||||
|
f"{joined}。请补充可还原的 wiki 表述后重新回调。"
|
||||||
|
)
|
||||||
|
LlmWikiService._validate_travel_knowledge_completeness(
|
||||||
|
knowledge_candidates=knowledge_candidates,
|
||||||
|
summary_markdown=summary_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_travel_knowledge_completeness(
|
||||||
|
*,
|
||||||
|
knowledge_candidates: list[dict[str, Any]],
|
||||||
|
summary_markdown: str,
|
||||||
|
) -> None:
|
||||||
|
joined_text = "\n".join(
|
||||||
|
[
|
||||||
|
summary_markdown,
|
||||||
|
*[
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
str(candidate.get("title") or ""),
|
||||||
|
str(candidate.get("content") or ""),
|
||||||
|
" ".join(str(item or "") for item in list(candidate.get("tags") or [])),
|
||||||
|
" ".join(str(item or "") for item in list(candidate.get("evidence") or [])),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in knowledge_candidates
|
||||||
|
if isinstance(candidate, dict)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
normalized = re.sub(r"\s+", "", joined_text)
|
||||||
|
if "差旅费" not in normalized and "出差" not in normalized:
|
||||||
|
return
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
"交通费": ("交通费", "交通工具", "飞机", "火车", "轮船"),
|
||||||
|
"住宿费": ("住宿费", "住宿限额", "酒店住宿", "住 宿", "住 宿费"),
|
||||||
|
"出差补贴": ("出差补贴", "餐补", "基本补助", "补贴标准"),
|
||||||
|
}
|
||||||
|
missing = [
|
||||||
|
label
|
||||||
|
for label, aliases in dimensions.items()
|
||||||
|
if not any(alias.replace(" ", "") in normalized for alias in aliases)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"Hermes 回调中的差旅知识不完整,缺少影响总额计算的关键维度:"
|
||||||
|
f"{'、'.join(missing)}。请补充后重新回调。"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upgrade_table_candidate_contents_from_summary(
|
||||||
|
*,
|
||||||
|
knowledge_candidates: list[dict[str, Any]],
|
||||||
|
summary_markdown: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if "|" not in summary_markdown:
|
||||||
|
return knowledge_candidates
|
||||||
|
|
||||||
|
sections = re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
|
||||||
|
table_sections = [section.strip() for section in sections if "|" in section and section.strip()]
|
||||||
|
if not table_sections:
|
||||||
|
return knowledge_candidates
|
||||||
|
|
||||||
|
upgraded: list[dict[str, Any]] = []
|
||||||
|
for candidate in knowledge_candidates:
|
||||||
|
copied = dict(candidate)
|
||||||
|
content = str(copied.get("content") or "").strip()
|
||||||
|
title = str(copied.get("title") or "").strip()
|
||||||
|
tags = [str(item).strip() for item in list(copied.get("tags") or []) if str(item).strip()]
|
||||||
|
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
|
||||||
|
evidence = " ".join(str(item or "") for item in list(copied.get("evidence") or []))
|
||||||
|
table_backed = "表" in evidence
|
||||||
|
if not (table_backed or has_slash_shorthand) or "|" in content:
|
||||||
|
upgraded.append(copied)
|
||||||
|
continue
|
||||||
|
|
||||||
|
match_terms = [
|
||||||
|
term
|
||||||
|
for term in [title, *tags, *LlmWikiService._extract_table_match_terms(title, evidence)]
|
||||||
|
if len(term) >= 2
|
||||||
|
]
|
||||||
|
ranked_sections = sorted(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
LlmWikiService._score_summary_table_section(
|
||||||
|
section=section,
|
||||||
|
title=title,
|
||||||
|
tags=tags,
|
||||||
|
evidence=evidence,
|
||||||
|
match_terms=match_terms,
|
||||||
|
),
|
||||||
|
max((len(term) for term in match_terms if term in section), default=0),
|
||||||
|
section,
|
||||||
|
)
|
||||||
|
for section in table_sections
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
replacement = ranked_sections[0][2] if ranked_sections and ranked_sections[0][0] > 0 else ""
|
||||||
|
if replacement:
|
||||||
|
intro = ""
|
||||||
|
if content and "|" not in content:
|
||||||
|
prose_lines = [
|
||||||
|
line.strip()
|
||||||
|
for line in content.splitlines()
|
||||||
|
if line.strip() and "|" not in line
|
||||||
|
]
|
||||||
|
intro = "\n\n".join(prose_lines[:2]).strip()
|
||||||
|
copied["content"] = f"{intro}\n\n{replacement}".strip() if intro else replacement
|
||||||
|
quality_flags = list(copied.get("quality_flags") or [])
|
||||||
|
if "table_restored_from_summary" not in quality_flags:
|
||||||
|
quality_flags.append("table_restored_from_summary")
|
||||||
|
copied["quality_flags"] = quality_flags
|
||||||
|
upgraded.append(copied)
|
||||||
|
return upgraded
|
||||||
|
|
||||||
|
def _synthesize_candidates_from_summary(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
knowledge_candidates: list[dict[str, Any]],
|
||||||
|
summary_markdown: str,
|
||||||
|
entry: dict[str, Any],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
sections = re.split(r"(?m)^(?=#{2,6}\s)", summary_markdown)
|
||||||
|
if not sections:
|
||||||
|
return knowledge_candidates
|
||||||
|
|
||||||
|
existing_text = "\n".join(
|
||||||
|
[
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
str(candidate.get("title") or ""),
|
||||||
|
str(candidate.get("content") or ""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in knowledge_candidates
|
||||||
|
if isinstance(candidate, dict)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
normalized_existing = re.sub(r"\s+", "", existing_text)
|
||||||
|
synthesized = list(knowledge_candidates)
|
||||||
|
|
||||||
|
for section in sections:
|
||||||
|
stripped = section.strip()
|
||||||
|
if not stripped.startswith("## "):
|
||||||
|
continue
|
||||||
|
lines = [line.rstrip() for line in stripped.splitlines() if line.strip()]
|
||||||
|
if len(lines) < 2:
|
||||||
|
continue
|
||||||
|
heading = lines[0].lstrip("#").strip()
|
||||||
|
body = "\n".join(lines[1:]).strip()
|
||||||
|
if len(body) < 40:
|
||||||
|
continue
|
||||||
|
if not any(keyword in heading for keyword in ("差旅费", "住宿", "补贴", "审批权限", "归口管理")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
compact_heading = re.sub(r"\s+", "", heading)
|
||||||
|
if compact_heading and compact_heading in normalized_existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "住宿" in heading and "住宿" in normalized_existing:
|
||||||
|
continue
|
||||||
|
if "补贴" in heading and "补贴" in normalized_existing:
|
||||||
|
continue
|
||||||
|
if "交通工具" in heading and "交通工具" in normalized_existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
synthesized.append(
|
||||||
|
{
|
||||||
|
"candidate_id": f"kc_{uuid4().hex[:12]}",
|
||||||
|
"title": heading,
|
||||||
|
"content": body,
|
||||||
|
"domain": "expense",
|
||||||
|
"scenario": self._infer_summary_candidate_scenario(heading),
|
||||||
|
"tags": self._normalize_tags([heading], fallback_text=body),
|
||||||
|
"source_document_id": str(entry["id"]),
|
||||||
|
"source_document_name": str(entry["original_name"]),
|
||||||
|
"source_chunk_ids": [f"{entry['id']}-document"],
|
||||||
|
"evidence": [f"wiki summary section: {heading}"],
|
||||||
|
"confidence": 0.9,
|
||||||
|
"status": "draft",
|
||||||
|
"created_by": "hermes",
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
"extraction_mode": "hermes",
|
||||||
|
"quality_flags": ["summary_synthesized_candidate"],
|
||||||
|
"fallback_reason": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized_existing += compact_heading + re.sub(r"\s+", "", body)
|
||||||
|
|
||||||
|
return synthesized[:12]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_summary_candidate_scenario(heading: str) -> str:
|
||||||
|
compact_heading = re.sub(r"\s+", "", heading)
|
||||||
|
if "差旅费" in compact_heading or "住宿" in compact_heading or "补贴" in compact_heading:
|
||||||
|
return "travel_standard"
|
||||||
|
if "审批权限" in compact_heading:
|
||||||
|
return "expense_amount_limit"
|
||||||
|
return "general_policy"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_table_match_terms(title: str, evidence: str) -> list[str]:
|
||||||
|
seed_text = f"{title} {evidence}"
|
||||||
|
terms = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,}", seed_text)
|
||||||
|
stop_terms = {
|
||||||
|
"单位",
|
||||||
|
"标准",
|
||||||
|
"摘要",
|
||||||
|
"规定",
|
||||||
|
"说明",
|
||||||
|
"国内",
|
||||||
|
"国外",
|
||||||
|
"员工",
|
||||||
|
"表1",
|
||||||
|
"表2",
|
||||||
|
"表3",
|
||||||
|
}
|
||||||
|
unique_terms: list[str] = []
|
||||||
|
for term in terms:
|
||||||
|
if term in stop_terms:
|
||||||
|
continue
|
||||||
|
if term not in unique_terms:
|
||||||
|
unique_terms.append(term)
|
||||||
|
return unique_terms[:8]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _score_summary_table_section(
|
||||||
|
*,
|
||||||
|
section: str,
|
||||||
|
title: str,
|
||||||
|
tags: list[str],
|
||||||
|
evidence: str,
|
||||||
|
match_terms: list[str],
|
||||||
|
) -> int:
|
||||||
|
section_heading = ""
|
||||||
|
for line in section.splitlines():
|
||||||
|
stripped = line.strip().lstrip("#").strip()
|
||||||
|
if stripped:
|
||||||
|
section_heading = stripped
|
||||||
|
break
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
if title and title in section:
|
||||||
|
score += 12
|
||||||
|
if title and title in section_heading:
|
||||||
|
score += 18
|
||||||
|
|
||||||
|
for term in match_terms:
|
||||||
|
if term in section:
|
||||||
|
score += 3
|
||||||
|
if section_heading and term in section_heading:
|
||||||
|
score += 6
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
if len(tag) >= 2 and tag in section_heading:
|
||||||
|
score += 2
|
||||||
|
|
||||||
|
core_terms = LlmWikiService._extract_table_match_terms(title, evidence)
|
||||||
|
if core_terms and not any(term in section_heading for term in core_terms):
|
||||||
|
score -= 10
|
||||||
|
return score
|
||||||
|
|
||||||
|
def _resolve_callback_user(self, agent_run_id: str) -> CurrentUserContext:
|
||||||
|
route_json = self._resolve_callback_route(agent_run_id)
|
||||||
|
return CurrentUserContext(
|
||||||
|
username=str(route_json.get("requested_by_username") or "hermes"),
|
||||||
|
name=str(route_json.get("requested_by_name") or "Hermes"),
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_callback_route(self, agent_run_id: str) -> dict[str, Any]:
|
||||||
|
from app.services.agent_runs import AgentRunService
|
||||||
|
|
||||||
|
run = AgentRunService(self.db).get_run(agent_run_id)
|
||||||
|
if run is None:
|
||||||
|
return {}
|
||||||
|
return dict(run.route_json or {})
|
||||||
|
|
||||||
def sync_folder(
|
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)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -343,6 +343,14 @@ class SettingsService:
|
|||||||
"apiKey": self.load_saved_model_api_key(slot),
|
"apiKey": self.load_saved_model_api_key(slot),
|
||||||
"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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,19 +1,147 @@
|
|||||||
# 远光《公司支出管理办法(2024)》.pdf 知识总结
|
# 知识总结
|
||||||
|
|
||||||
## 概览
|
## 文档概述
|
||||||
|
- **文件名称**:远光软件股份有限公司《公司支出管理办法(2024)》(远光制度〔2024〕14号)
|
||||||
|
- **发文日期**: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类资产、食堂、办公费用、商旅统付结算 |
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -111,6 +111,243 @@
|
|||||||
"summary": [
|
"summary": [
|
||||||
"远光《公司支出管理办法(2024)》.pdf:forced_rebuild,知识候选 1 条,规则候选 0 条,归纳质量 fallback_only。"
|
"远光《公司支出管理办法(2024)》.pdf:forced_rebuild,知识候选 1 条,规则候选 0 条,归纳质量 fallback_only。"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_d714c8ce5b6b",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 10,
|
||||||
|
"rule_candidate_count": 5,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"cfec89e4-335c-45d9-a4e5-b799ba408645",
|
||||||
|
"51338fe7-ec4b-4f4c-91d2-fb9401ee153b",
|
||||||
|
"bcf3cdc6-b6b2-4ada-b9d9-42d6687903cd",
|
||||||
|
"e244c8a4-10cb-47fd-ae51-977ae645c45b",
|
||||||
|
"304c1570-9fe0-4c4b-8950-d55e9aab2565"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-15T10:19:34.663079+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 10 条,规则候选 5 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_4de66ca333574b7e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_e6f8e8b3c137",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 9,
|
||||||
|
"rule_candidate_count": 4,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"c7ea86ba-8b94-4c12-a9fc-d4dd4bff145e",
|
||||||
|
"522c6b6c-bdfd-47b0-9a25-2a6d2da5b5e5",
|
||||||
|
"c7a1a2a4-2b1a-4339-af10-b25e97b0c309",
|
||||||
|
"be9adadd-daa5-4625-8789-0a666090ad57"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-15T10:20:56.773603+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 9 条,规则候选 4 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_48a4c41cca8342b9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_10f632d2c6bd",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 6,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
|
||||||
|
"21a180e1-d760-4ad9-a4f0-ed29463075cd",
|
||||||
|
"019c68be-0c2c-4a84-bca8-0ba8502bcec7",
|
||||||
|
"de95fea3-5b9d-4f61-abb7-ce139a82e844",
|
||||||
|
"95028ad9-5e3d-4281-b062-c3bc05060cb4",
|
||||||
|
"f7ab1491-e121-477a-9023-6d74580ec5ea"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T00:34:48.749438+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 6 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_022ae24990ec4355"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_a9e871817dc5",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 5,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"31b8d05a-c878-42d2-bacc-a6ad09bc6a94",
|
||||||
|
"6cc1b7e0-be52-4bf7-8d9b-06b9f8c5b961",
|
||||||
|
"48e412f3-55d9-480f-b2ad-5240e7a6de0b",
|
||||||
|
"0d6dcdd2-bafd-4e81-9d2c-48f3b84febbe",
|
||||||
|
"29624254-e83a-46a3-b323-117c097a8976"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T00:54:27.313665+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 5 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_d72cf90db5df427f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_5d984aa5564f",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 4,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"02c92cfb-ce24-499d-a051-bf64e481eea1",
|
||||||
|
"fce1bfd6-9e47-48fb-8132-ebc0d3c57c5e",
|
||||||
|
"72f3a5c0-5d46-43dc-8e5c-8f647e0e878f",
|
||||||
|
"1eafd703-a834-4f50-9db6-fe89b7dbd514"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:09:42.078805+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 4 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_4f322b84564b4d25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_f65445f69883",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 4,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"143247d8-83f8-4257-b05f-161e0b264056",
|
||||||
|
"21a180e1-d760-4ad9-a4f0-ed29463075cd",
|
||||||
|
"b12e2d99-2fd9-453a-84a8-7ad52bfab4a7",
|
||||||
|
"af0b4d7c-6b00-4a64-8168-07cae57f80bd"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:17:06.436319+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 4 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_17ff3f4584cb4cfa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_96d5bf187cdc",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 11,
|
||||||
|
"rule_candidate_count": 4,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"e7330580-138b-4e82-b611-2a2b968f1bea",
|
||||||
|
"84c685fc-6b8a-4c44-981b-d8d89c02cadb",
|
||||||
|
"1c93075e-24aa-46ca-a702-f8b640b244cf",
|
||||||
|
"0eef894b-b6c7-4ef4-b133-daee3a5e245e"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:26:09.822587+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 11 条,规则候选 4 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_88d5b60192c9450d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_12d3e3d3a8d8",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 5,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
|
||||||
|
"7c59c499-b416-4c58-8abb-515c76f4dc8a",
|
||||||
|
"51baae43-2cf5-464e-86e3-4eb23b305b3c",
|
||||||
|
"db28bdd5-3d78-4c69-b503-862db57c9cf2",
|
||||||
|
"2afad60c-dce7-4802-bb09-b5d760abc1b4"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:30:07.306867+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 5 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_982a86680f5a44a9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_7f12b25fc9db",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 12,
|
||||||
|
"rule_candidate_count": 6,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"bd6b2ef9-29b6-44ff-9b97-25ba15be4538",
|
||||||
|
"d14051ad-c370-4162-8649-d565d13ef59f",
|
||||||
|
"f8e48e1d-c444-4358-be5d-bc29fb293933",
|
||||||
|
"30aee1d5-e930-4d81-88b6-ccbbac898d6e",
|
||||||
|
"671f8908-c4ed-421e-9b8b-5ce4ca461330",
|
||||||
|
"9beb22d9-8582-4a31-b84a-353467e33485"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:45:59.739933+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 12 条,规则候选 6 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_56b615c81e4c42f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_id": "wiki_3c538493f66e",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"requested_document_ids": [
|
||||||
|
"bf761bd8eccf402bb676423d64401a56"
|
||||||
|
],
|
||||||
|
"changed_document_count": 1,
|
||||||
|
"knowledge_candidate_count": 10,
|
||||||
|
"rule_candidate_count": 4,
|
||||||
|
"generated_rule_asset_ids": [
|
||||||
|
"143247d8-83f8-4257-b05f-161e0b264056",
|
||||||
|
"71088dd7-a2c1-471a-8b96-d722d7c47f6a",
|
||||||
|
"ab6e6849-3c84-4b58-bf5a-e320a5640c68",
|
||||||
|
"86e683fc-27d2-43ec-8e3c-6f1397965266"
|
||||||
|
],
|
||||||
|
"created_by": "admin",
|
||||||
|
"created_at": "2026-05-16T01:52:22.811984+00:00",
|
||||||
|
"summary": [
|
||||||
|
"远光《公司支出管理办法(2024)》.pdf:agent_batch:知识候选 10 条,规则候选 4 条。"
|
||||||
|
],
|
||||||
|
"source": "hermes_callback",
|
||||||
|
"agent_run_id": "run_a7b447f69939442f"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,10 +30,12 @@ def test_employee_can_login_with_seed_default_password() -> None:
|
|||||||
LoginRequest(username=employee.email, password="123456")
|
LoginRequest(username=employee.email, password="123456")
|
||||||
)
|
)
|
||||||
|
|
||||||
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.roleCodes
|
assert result.user.position == employee.position
|
||||||
|
assert result.user.grade == employee.grade
|
||||||
|
assert result.user.roleCodes
|
||||||
assert result.user.isAdmin is False
|
assert result.user.isAdmin is False
|
||||||
|
|
||||||
|
|
||||||
@@ -50,10 +52,11 @@ def test_admin_can_login_with_database_password() -> None:
|
|||||||
LoginRequest(username="superadmin", password="admin123")
|
LoginRequest(username="superadmin", password="admin123")
|
||||||
)
|
)
|
||||||
|
|
||||||
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.roleCodes == ["manager"]
|
assert result.user.position == "系统管理员"
|
||||||
|
assert result.user.roleCodes == ["manager"]
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_employee_cannot_login() -> None:
|
def test_disabled_employee_cannot_login() -> None:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ function readStoredUsername() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAnonymousUser() {
|
function buildAnonymousUser() {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
name: '',
|
name: '',
|
||||||
role: '',
|
role: '',
|
||||||
roleCodes: [],
|
position: '',
|
||||||
|
grade: '',
|
||||||
|
roleCodes: [],
|
||||||
email: '',
|
email: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
@@ -101,11 +103,13 @@ function buildLegacyAdminUser(username = '') {
|
|||||||
const normalized = String(username || '').trim()
|
const normalized = String(username || '').trim()
|
||||||
const name = normalized || DEFAULT_USER_NAME
|
const name = normalized || DEFAULT_USER_NAME
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username: normalized,
|
username: normalized,
|
||||||
name,
|
name,
|
||||||
role: DEFAULT_USER_ROLE,
|
role: DEFAULT_USER_ROLE,
|
||||||
roleCodes: ['manager'],
|
position: DEFAULT_USER_ROLE,
|
||||||
|
grade: '',
|
||||||
|
roleCodes: ['manager'],
|
||||||
email: '',
|
email: '',
|
||||||
avatar: name.slice(0, 1).toUpperCase(),
|
avatar: name.slice(0, 1).toUpperCase(),
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
@@ -127,11 +131,13 @@ function readStoredUser() {
|
|||||||
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
|
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
|
||||||
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
name,
|
name,
|
||||||
role: String(payload.role || DEFAULT_USER_ROLE),
|
role: String(payload.role || DEFAULT_USER_ROLE),
|
||||||
roleCodes,
|
position: String(payload.position || ''),
|
||||||
|
grade: String(payload.grade || ''),
|
||||||
|
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()),
|
||||||
isAdmin: Boolean(payload.isAdmin)
|
isAdmin: Boolean(payload.isAdmin)
|
||||||
|
|||||||
@@ -1,322 +1,333 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="qa-view">
|
<section class="qa-view">
|
||||||
<div class="qa-layout">
|
<div class="qa-layout">
|
||||||
<aside class="left-column">
|
<aside class="left-column">
|
||||||
<article class="panel side-panel conversation-list">
|
<article class="panel side-panel conversation-list">
|
||||||
<header>
|
<header>
|
||||||
<h3>问答会话</h3>
|
<h3>问答会话</h3>
|
||||||
<button class="outline-action" type="button" @click="emit('draft', '')">
|
<button class="outline-action" type="button" @click="emit('draft', '')">
|
||||||
<i class="mdi mdi-plus"></i>
|
<i class="mdi mdi-plus"></i>
|
||||||
<span>新建会话</span>
|
<span>新建会话</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="session-scroll">
|
<div class="session-scroll">
|
||||||
<button
|
<button
|
||||||
v-for="item in sessions"
|
v-for="item in sessions"
|
||||||
:key="item.title"
|
:key="item.title"
|
||||||
class="session-row"
|
class="session-row"
|
||||||
:class="{ active: item.active }"
|
:class="{ active: item.active }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="applyPrompt(item.title)"
|
@click="applyPrompt(item.title)"
|
||||||
>
|
>
|
||||||
<span><i class="mdi mdi-message-processing-outline"></i></span>
|
<span><i class="mdi mdi-message-processing-outline"></i></span>
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<time>{{ item.time }}</time>
|
<time>{{ item.time }}</time>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<article class="panel chat-panel">
|
<article class="panel chat-panel">
|
||||||
<div ref="localMessageList" class="message-stream" aria-live="polite">
|
<div ref="localMessageList" class="message-stream" aria-live="polite">
|
||||||
<div class="talk-row user">
|
<div class="talk-row user">
|
||||||
<span class="avatar user-avatar">张</span>
|
<span class="avatar user-avatar">张</span>
|
||||||
<div class="talk-content">
|
<div class="talk-content">
|
||||||
<header><strong>张明</strong><time>10:32</time></header>
|
<header><strong>张明</strong><time>10:32</time></header>
|
||||||
<p class="user-question">北京出差,酒店超标报销怎么处理?</p>
|
<p class="user-question">北京出差,酒店超标报销怎么处理?</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="talk-row assistant">
|
<div class="talk-row assistant">
|
||||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||||
<div class="talk-content">
|
<div class="talk-content">
|
||||||
<header><strong>财务AI助手</strong><time>10:32</time></header>
|
<header><strong>财务AI助手</strong><time>10:32</time></header>
|
||||||
<div class="answer-card">
|
<div class="answer-card">
|
||||||
<section>
|
<section>
|
||||||
<h4>结论</h4>
|
<h4>结论</h4>
|
||||||
<p>酒店费用超过标准的部分原则上不予报销,特殊情况可申请例外报销。</p>
|
<p>酒店费用超过标准的部分原则上不予报销,特殊情况可申请例外报销。</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h4>处理建议</h4>
|
<h4>处理建议</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>超标部分由个人自理或按制度退回,保留超标说明和相关凭证。</li>
|
<li>超标部分由个人自理或按制度退回,保留超标说明和相关凭证。</li>
|
||||||
<li>符合公司相关政策的,可提交佐证材料,申请例外报销。</li>
|
<li>符合公司相关政策的,可提交佐证材料,申请例外报销。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h4>适用规则</h4>
|
<h4>适用规则</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>《差旅报销管理办法(2024版)》第十二条:住宿标准及超标处理</li>
|
<li>《差旅报销管理办法(2024版)》第十二条:住宿标准及超标处理</li>
|
||||||
<li>《费用报销审批流程》附件1:国内差旅住宿标准</li>
|
<li>《费用报销审批流程》附件1:国内差旅住宿标准</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<footer>
|
<footer>
|
||||||
<span>是否有帮助?</span>
|
<span>是否有帮助?</span>
|
||||||
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
|
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
|
||||||
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
|
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="talk-row user">
|
<div class="talk-row user">
|
||||||
<span class="avatar user-avatar">张</span>
|
<span class="avatar user-avatar">张</span>
|
||||||
<div class="talk-content">
|
<div class="talk-content">
|
||||||
<header><strong>张明</strong><time>10:35</time></header>
|
<header><strong>张明</strong><time>10:35</time></header>
|
||||||
<p class="user-question">如果出差地公司名称不一致还能报销吗?</p>
|
<p class="user-question">如果出差地公司名称不一致还能报销吗?</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="talk-row assistant">
|
<div class="talk-row assistant">
|
||||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||||
<div class="talk-content">
|
<div class="talk-content">
|
||||||
<header><strong>财务AI助手</strong><time>10:35</time></header>
|
<header><strong>财务AI助手</strong><time>10:35</time></header>
|
||||||
<div class="answer-card compact">
|
<div class="answer-card compact">
|
||||||
<section>
|
<section>
|
||||||
<h4>结论</h4>
|
<h4>结论</h4>
|
||||||
<p>一般情况下,差旅地与参会公司名称不一致需按异常处理,建议提供情况说明并加盖公章或补充邀请材料。</p>
|
<p>一般情况下,差旅地与参会公司名称不一致需按异常处理,建议提供情况说明并加盖公章或补充邀请材料。</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h4>适用规则</h4>
|
<h4>适用规则</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>《发票管理规定及失误销细则》第二章:发票基本要求</li>
|
<li>《发票管理规定及失误销细则》第二章:发票基本要求</li>
|
||||||
<li>《差旅报销管理办法》附件1:报销凭证要求</li>
|
<li>《差旅报销管理办法》附件1:报销凭证要求</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
|
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
|
||||||
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
|
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
|
||||||
<template v-if="message.role === 'user'">我</template>
|
<template v-if="message.role === 'user'">我</template>
|
||||||
<i v-else class="mdi mdi-robot-outline"></i>
|
<i v-else class="mdi mdi-robot-outline"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="talk-content">
|
<div class="talk-content">
|
||||||
<header>
|
<header>
|
||||||
<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-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
<div v-else class="agent-answer-content">
|
||||||
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
|
||||||
</div>
|
<p v-if="block.type === 'paragraph'" class="agent-answer">{{ block.text }}</p>
|
||||||
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
<div v-else-if="block.type === 'table'" class="agent-answer-table-wrap">
|
||||||
<strong>风险标签</strong>
|
<table class="agent-answer-table">
|
||||||
<div class="agent-detail-chip-row">
|
<thead>
|
||||||
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
<tr>
|
||||||
</div>
|
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
|
||||||
</div>
|
</tr>
|
||||||
<div v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block">
|
</thead>
|
||||||
<strong>引用依据</strong>
|
<tbody>
|
||||||
<div class="agent-citation-list">
|
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
|
||||||
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
|
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
|
||||||
<header>
|
</tr>
|
||||||
<span>{{ item.title }}</span>
|
</tbody>
|
||||||
<small>{{ item.version || item.source_type }}</small>
|
</table>
|
||||||
</header>
|
</div>
|
||||||
<p>{{ item.excerpt || item.code }}</p>
|
</template>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
||||||
</div>
|
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
||||||
<div v-if="message.role !== 'user' && message.suggestedActions?.length" class="agent-detail-block">
|
</div>
|
||||||
<strong>建议动作</strong>
|
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||||
<div class="agent-detail-chip-row">
|
<strong>风险标签</strong>
|
||||||
<span
|
<div class="agent-detail-chip-row">
|
||||||
v-for="item in message.suggestedActions"
|
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
||||||
:key="`${message.id}-${item.action_type}-${item.label}`"
|
</div>
|
||||||
class="agent-action-chip"
|
</div>
|
||||||
>
|
<details v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block agent-citation-disclosure">
|
||||||
{{ item.label }}
|
<summary>
|
||||||
</span>
|
<strong>引用依据</strong>
|
||||||
</div>
|
<span>{{ message.citations.length }} 项</span>
|
||||||
</div>
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
|
</summary>
|
||||||
<header>
|
<div class="agent-citation-list">
|
||||||
<strong>{{ message.draftPayload.title }}</strong>
|
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
|
||||||
<span>待人工确认</span>
|
<header>
|
||||||
</header>
|
<span>{{ item.title }}</span>
|
||||||
<pre>{{ message.draftPayload.body }}</pre>
|
<small>{{ item.version || item.source_type }}</small>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
<p>{{ item.excerpt || item.code }}</p>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<div class="composer-wrap">
|
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
|
||||||
<div class="prompt-toolbar">
|
<header>
|
||||||
<span>猜你想问</span>
|
<strong>{{ message.draftPayload.title }}</strong>
|
||||||
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
|
<span>待人工确认</span>
|
||||||
<i :class="prompt.icon"></i>
|
</header>
|
||||||
{{ prompt.short }}
|
<pre>{{ message.draftPayload.body }}</pre>
|
||||||
</button>
|
</div>
|
||||||
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
|
</div>
|
||||||
<i class="mdi mdi-refresh"></i>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="composer-wrap">
|
||||||
<div class="composer">
|
<div class="prompt-toolbar">
|
||||||
<textarea
|
<span>猜你想问</span>
|
||||||
:value="draft"
|
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
|
||||||
rows="2"
|
<i :class="prompt.icon"></i>
|
||||||
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
{{ prompt.short }}
|
||||||
:disabled="sending"
|
</button>
|
||||||
@input="emit('draft', $event.target.value)"
|
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
|
||||||
@keydown.ctrl.enter.prevent="emit('send')"
|
<i class="mdi mdi-refresh"></i>
|
||||||
></textarea>
|
</button>
|
||||||
<button
|
</div>
|
||||||
class="send-button"
|
|
||||||
type="button"
|
<div class="composer">
|
||||||
aria-label="发送问题"
|
<textarea
|
||||||
:disabled="sending || !String(draft || '').trim()"
|
:value="draft"
|
||||||
@click="emit('send')"
|
rows="2"
|
||||||
>
|
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
||||||
<i :class="sending ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
:disabled="sending"
|
||||||
</button>
|
@input="emit('draft', $event.target.value)"
|
||||||
</div>
|
@keydown.ctrl.enter.prevent="emit('send')"
|
||||||
</div>
|
></textarea>
|
||||||
</article>
|
<button
|
||||||
|
class="send-button"
|
||||||
<aside class="right-column">
|
type="button"
|
||||||
<article class="panel info-panel semantic-debug-panel">
|
aria-label="发送问题"
|
||||||
<header>
|
:disabled="sending || !String(draft || '').trim()"
|
||||||
<h3><i class="mdi mdi-shape-outline"></i> 语义解析调试</h3>
|
@click="emit('send')"
|
||||||
<button type="button" @click="useDraftAsSemanticInput">带入输入框</button>
|
>
|
||||||
</header>
|
<i :class="sending ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||||
|
</button>
|
||||||
<div class="semantic-debug-body">
|
</div>
|
||||||
<label class="semantic-debug-input">
|
</div>
|
||||||
<span>自然语言问题</span>
|
</article>
|
||||||
<textarea
|
|
||||||
v-model="semanticDraft"
|
<aside class="right-column">
|
||||||
rows="4"
|
<article class="panel info-panel semantic-debug-panel">
|
||||||
placeholder="例如:查一下本周报销超标风险"
|
<header>
|
||||||
@keydown.ctrl.enter.prevent="parseSemanticQuery"
|
<h3><i class="mdi mdi-shape-outline"></i> 语义解析调试</h3>
|
||||||
></textarea>
|
<button type="button" @click="useDraftAsSemanticInput">带入输入框</button>
|
||||||
</label>
|
</header>
|
||||||
|
|
||||||
<div class="semantic-debug-actions">
|
<div class="semantic-debug-body">
|
||||||
<button
|
<label class="semantic-debug-input">
|
||||||
v-for="item in semanticExamples"
|
<span>自然语言问题</span>
|
||||||
:key="item"
|
<textarea
|
||||||
class="semantic-chip"
|
v-model="semanticDraft"
|
||||||
type="button"
|
rows="4"
|
||||||
@click="applySemanticExample(item)"
|
placeholder="例如:查一下本周报销超标风险"
|
||||||
>
|
@keydown.ctrl.enter.prevent="parseSemanticQuery"
|
||||||
{{ item }}
|
></textarea>
|
||||||
</button>
|
</label>
|
||||||
</div>
|
|
||||||
|
<div class="semantic-debug-actions">
|
||||||
<div class="semantic-debug-toolbar">
|
<button
|
||||||
<button class="semantic-parse-btn" type="button" :disabled="semanticLoading" @click="parseSemanticQuery">
|
v-for="item in semanticExamples"
|
||||||
<i :class="semanticLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-play-circle-outline'"></i>
|
:key="item"
|
||||||
<span>{{ semanticLoading ? '解析中...' : '开始解析' }}</span>
|
class="semantic-chip"
|
||||||
</button>
|
type="button"
|
||||||
<span class="semantic-inline-meta">
|
@click="applySemanticExample(item)"
|
||||||
<template v-if="semanticResult">run_id:{{ semanticResult.run_id }}</template>
|
>
|
||||||
<template v-else>支持 Ctrl + Enter</template>
|
{{ item }}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="semanticError" class="semantic-debug-error">{{ semanticError }}</p>
|
<div class="semantic-debug-toolbar">
|
||||||
|
<button class="semantic-parse-btn" type="button" :disabled="semanticLoading" @click="parseSemanticQuery">
|
||||||
<div v-if="semanticResult" class="semantic-result-stack">
|
<i :class="semanticLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-play-circle-outline'"></i>
|
||||||
<div class="semantic-result-grid">
|
<span>{{ semanticLoading ? '解析中...' : '开始解析' }}</span>
|
||||||
<article class="semantic-result-card">
|
</button>
|
||||||
<span>场景</span>
|
<span class="semantic-inline-meta">
|
||||||
<strong>{{ semanticResult.scenario }}</strong>
|
<template v-if="semanticResult">run_id:{{ semanticResult.run_id }}</template>
|
||||||
</article>
|
<template v-else>支持 Ctrl + Enter</template>
|
||||||
<article class="semantic-result-card">
|
</span>
|
||||||
<span>意图</span>
|
</div>
|
||||||
<strong>{{ semanticResult.intent }}</strong>
|
|
||||||
</article>
|
<p v-if="semanticError" class="semantic-debug-error">{{ semanticError }}</p>
|
||||||
<article class="semantic-result-card">
|
|
||||||
<span>权限</span>
|
<div v-if="semanticResult" class="semantic-result-stack">
|
||||||
<strong>{{ semanticResult.permission.level }}</strong>
|
<div class="semantic-result-grid">
|
||||||
</article>
|
<article class="semantic-result-card">
|
||||||
<article class="semantic-result-card">
|
<span>场景</span>
|
||||||
<span>置信度</span>
|
<strong>{{ semanticResult.scenario }}</strong>
|
||||||
<strong>{{ semanticConfidenceLabel }}</strong>
|
</article>
|
||||||
</article>
|
<article class="semantic-result-card">
|
||||||
</div>
|
<span>意图</span>
|
||||||
|
<strong>{{ semanticResult.intent }}</strong>
|
||||||
<div class="semantic-field-list">
|
</article>
|
||||||
<section>
|
<article class="semantic-result-card">
|
||||||
<h4>实体</h4>
|
<span>权限</span>
|
||||||
<p>{{ semanticEntitiesText }}</p>
|
<strong>{{ semanticResult.permission.level }}</strong>
|
||||||
</section>
|
</article>
|
||||||
<section>
|
<article class="semantic-result-card">
|
||||||
<h4>时间</h4>
|
<span>置信度</span>
|
||||||
<p>{{ semanticTimeRangeText }}</p>
|
<strong>{{ semanticConfidenceLabel }}</strong>
|
||||||
</section>
|
</article>
|
||||||
<section>
|
</div>
|
||||||
<h4>指标</h4>
|
|
||||||
<p>{{ semanticMetricsText }}</p>
|
<div class="semantic-field-list">
|
||||||
</section>
|
<section>
|
||||||
<section>
|
<h4>实体</h4>
|
||||||
<h4>约束</h4>
|
<p>{{ semanticEntitiesText }}</p>
|
||||||
<p>{{ semanticConstraintsText }}</p>
|
</section>
|
||||||
</section>
|
<section>
|
||||||
<section>
|
<h4>时间</h4>
|
||||||
<h4>风险</h4>
|
<p>{{ semanticTimeRangeText }}</p>
|
||||||
<p>{{ semanticRiskFlagsText }}</p>
|
</section>
|
||||||
</section>
|
<section>
|
||||||
<section>
|
<h4>指标</h4>
|
||||||
<h4>澄清</h4>
|
<p>{{ semanticMetricsText }}</p>
|
||||||
<p>{{ semanticClarificationText }}</p>
|
</section>
|
||||||
</section>
|
<section>
|
||||||
</div>
|
<h4>约束</h4>
|
||||||
|
<p>{{ semanticConstraintsText }}</p>
|
||||||
<div class="semantic-json-block">
|
</section>
|
||||||
<h4>原始 JSON</h4>
|
<section>
|
||||||
<pre>{{ semanticResultJson }}</pre>
|
<h4>风险</h4>
|
||||||
</div>
|
<p>{{ semanticRiskFlagsText }}</p>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
<section>
|
||||||
</article>
|
<h4>澄清</h4>
|
||||||
|
<p>{{ semanticClarificationText }}</p>
|
||||||
<article class="panel info-panel hot-top-panel">
|
</section>
|
||||||
<header>
|
</div>
|
||||||
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
|
||||||
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
|
<div class="semantic-json-block">
|
||||||
</header>
|
<h4>原始 JSON</h4>
|
||||||
<div class="top-question-list">
|
<pre>{{ semanticResultJson }}</pre>
|
||||||
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
|
</div>
|
||||||
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
|
</div>
|
||||||
<span>{{ item }}</span>
|
</div>
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
</article>
|
||||||
</button>
|
|
||||||
</div>
|
<article class="panel info-panel hot-top-panel">
|
||||||
</article>
|
<header>
|
||||||
|
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
||||||
<article class="panel info-panel similar-panel">
|
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
|
||||||
<header>
|
</header>
|
||||||
<h3>相似历史问题</h3>
|
<div class="top-question-list">
|
||||||
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
|
||||||
</header>
|
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
|
||||||
<div class="similar-scroll">
|
<span>{{ item }}</span>
|
||||||
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
|
</button>
|
||||||
<strong>{{ item.score }}</strong>
|
</div>
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
</article>
|
||||||
</button>
|
|
||||||
</div>
|
<article class="panel info-panel similar-panel">
|
||||||
</article>
|
<header>
|
||||||
</aside>
|
<h3>相似历史问题</h3>
|
||||||
</div>
|
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||||
</section>
|
</header>
|
||||||
</template>
|
<div class="similar-scroll">
|
||||||
|
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
|
||||||
<script src="./scripts/ChatView.js"></script>
|
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
|
||||||
|
<strong>{{ item.score }}</strong>
|
||||||
<style scoped src="../assets/styles/views/chat-view.css"></style>
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./scripts/ChatView.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/chat-view.css"></style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user