feat: 集成Hermes智能体系统,增强聊天和差旅报销功能
This commit is contained in:
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 datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||
@@ -26,7 +26,11 @@ from app.schemas.knowledge import (
|
||||
LlmWikiSummaryUpdateWrite,
|
||||
)
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_SYNCING, KnowledgeService
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||
KnowledgeService,
|
||||
)
|
||||
from app.services.llm_wiki import LlmWikiService
|
||||
from app.services.llm_wiki_tasks import llm_wiki_task_manager
|
||||
|
||||
@@ -169,6 +173,78 @@ def sync_llm_wiki(
|
||||
for item in knowledge_service.list_folder_documents(folder=payload.folder)
|
||||
if str(item.get("id") or "").strip() and (not requested_ids or str(item.get("id") or "").strip() in requested_ids)
|
||||
]
|
||||
active_run = None
|
||||
for item in run_service.list_runs(
|
||||
agent=AgentName.HERMES.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
limit=100,
|
||||
):
|
||||
if item.route_json.get("job_type") != "llm_wiki_sync":
|
||||
continue
|
||||
if item.route_json.get("folder") != payload.folder:
|
||||
continue
|
||||
heartbeat_raw = str(item.route_json.get("heartbeat_at") or "").strip()
|
||||
heartbeat_at = None
|
||||
if heartbeat_raw:
|
||||
try:
|
||||
heartbeat_at = datetime.fromisoformat(heartbeat_raw)
|
||||
except ValueError:
|
||||
heartbeat_at = None
|
||||
last_seen_at = heartbeat_at or item.started_at
|
||||
if last_seen_at.tzinfo is None:
|
||||
last_seen_at = last_seen_at.replace(tzinfo=UTC)
|
||||
if datetime.now(UTC) - last_seen_at > timedelta(minutes=30):
|
||||
stale_document_ids = [
|
||||
str(document_id).strip()
|
||||
for document_id in list(item.route_json.get("requested_document_ids") or [])
|
||||
if str(document_id).strip()
|
||||
]
|
||||
if stale_document_ids:
|
||||
knowledge_service.set_document_ingest_statuses(
|
||||
stale_document_ids,
|
||||
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=item.run_id,
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
item.run_id,
|
||||
{
|
||||
"phase": "stale_failed",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
result_summary="Hermes 归纳任务长时间无心跳,已自动标记为失败。",
|
||||
error_message="Hermes callback heartbeat timed out.",
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
continue
|
||||
if (
|
||||
not target_document_ids
|
||||
or not list(item.route_json.get("requested_document_ids") or [])
|
||||
or bool(
|
||||
set(target_document_ids)
|
||||
& {
|
||||
str(document_id).strip()
|
||||
for document_id in list(item.route_json.get("requested_document_ids") or [])
|
||||
if str(document_id).strip()
|
||||
}
|
||||
)
|
||||
):
|
||||
active_run = item
|
||||
break
|
||||
if active_run is not None:
|
||||
return LlmWikiSyncTaskRead(
|
||||
ok=True,
|
||||
agent_run_id=active_run.run_id,
|
||||
folder=payload.folder,
|
||||
document_ids=[
|
||||
str(item).strip()
|
||||
for item in list(active_run.route_json.get("requested_document_ids") or target_document_ids)
|
||||
if str(item).strip()
|
||||
],
|
||||
queued_at=active_run.started_at,
|
||||
status=active_run.status,
|
||||
summary="已有 Hermes 归纳任务正在执行,已复用当前任务而不是重复创建。",
|
||||
)
|
||||
task_asset = db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == "task.hermes.llm_wiki_rule_formation")
|
||||
)
|
||||
@@ -186,6 +262,8 @@ def sync_llm_wiki(
|
||||
"folder": payload.folder,
|
||||
"force": payload.force,
|
||||
"requested_document_ids": target_document_ids,
|
||||
"requested_by_username": current_user.username,
|
||||
"requested_by_name": current_user.name,
|
||||
"progress": {
|
||||
"total_documents": len(target_document_ids),
|
||||
"completed_documents": 0,
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.hermes import router as hermes_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.ocr import router as ocr_router
|
||||
from app.api.v1.endpoints.ontology import router as ontology_router
|
||||
@@ -17,6 +18,7 @@ from app.api.v1.endpoints.system_logs import router as system_logs_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health_router, tags=["health"])
|
||||
router.include_router(hermes_router, tags=["hermes"])
|
||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||
router.include_router(auth_router, tags=["auth"])
|
||||
router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||
|
||||
@@ -19,6 +19,8 @@ X-Financial 后端 OpenAPI 文档。
|
||||
- `X-Request-Id`
|
||||
- Hermes 运行时模型配置接口需要:
|
||||
- `Authorization: Bearer <HERMES_AGENT_SHARED_TOKEN>`
|
||||
- Hermes 通用回调接口同样需要:
|
||||
- `Authorization: Bearer <HERMES_AGENT_SHARED_TOKEN>`
|
||||
|
||||
## 当前模块范围
|
||||
|
||||
@@ -76,6 +78,10 @@ OPENAPI_TAGS = [
|
||||
"name": "settings",
|
||||
"description": "系统设置、模型配置、模型连通性探测和 Hermes 运行时模型配置。",
|
||||
},
|
||||
{
|
||||
"name": "hermes",
|
||||
"description": "Hermes 与服务端之间的通用任务回调入口。",
|
||||
},
|
||||
{
|
||||
"name": "agent-assets",
|
||||
"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.knowledge import prepare_knowledge_library
|
||||
from app.services.llm_wiki_tasks import llm_wiki_task_manager
|
||||
from app.services.hermes_sync import sync_repository_hermes_skills
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -26,6 +27,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
prepare_employee_directory()
|
||||
prepare_agent_foundation()
|
||||
prepare_knowledge_library()
|
||||
sync_repository_hermes_skills()
|
||||
logger.info(
|
||||
"Server ready - host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
|
||||
@@ -12,6 +12,8 @@ class AuthUserRead(BaseModel):
|
||||
username: str
|
||||
name: str
|
||||
role: str
|
||||
position: str = ""
|
||||
grade: str = ""
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
email: EmailStr | str
|
||||
avatar: str
|
||||
|
||||
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
|
||||
name: str
|
||||
role: str
|
||||
position: str
|
||||
grade: str
|
||||
role_codes: list[str]
|
||||
email: str
|
||||
avatar: str
|
||||
@@ -76,6 +78,8 @@ class AuthService:
|
||||
username=admin_username or admin_email,
|
||||
name=display_name,
|
||||
role="管理员",
|
||||
position="系统管理员",
|
||||
grade="",
|
||||
role_codes=["manager"],
|
||||
email=admin_email or f"{admin_username}@local",
|
||||
avatar=display_name[:1].upper(),
|
||||
@@ -116,6 +120,8 @@ class AuthService:
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
role_codes=role_codes or ["user"],
|
||||
email=employee.email,
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
@@ -128,6 +134,8 @@ class AuthService:
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
position=user.position,
|
||||
grade=user.grade,
|
||||
roleCodes=user.role_codes,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
|
||||
@@ -3497,7 +3497,7 @@ class ExpenseClaimService:
|
||||
|
||||
return issues
|
||||
|
||||
def _is_location_required_expense_type(expense_type: str | None) -> bool:
|
||||
def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
|
||||
policy = self._get_expense_scene_policy(expense_type)
|
||||
if policy is None:
|
||||
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
|
||||
|
||||
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
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -8,6 +9,8 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HermesModelRoute:
|
||||
@@ -35,6 +38,26 @@ def get_hermes_config_path() -> Path:
|
||||
return get_hermes_home() / "config.yaml"
|
||||
|
||||
|
||||
def sync_repository_hermes_skills(
|
||||
*,
|
||||
source_root: Path | None = None,
|
||||
target_root: Path | None = None,
|
||||
) -> Path:
|
||||
source = source_root or ROOT_DIR / "hermes" / "skills"
|
||||
target = target_root or get_hermes_home() / "skills"
|
||||
if not source.exists():
|
||||
return target
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for item in source.iterdir():
|
||||
destination = target / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination, dirs_exist_ok=True)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
return target
|
||||
|
||||
|
||||
def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot:
|
||||
target_path = config_path or get_hermes_config_path()
|
||||
if not target_path.exists():
|
||||
|
||||
@@ -72,6 +72,23 @@ STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS
|
||||
INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS
|
||||
ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"}
|
||||
KNOWLEDGE_INGEST_SYNC_STALE_SECONDS = 90
|
||||
KNOWLEDGE_SEARCH_RESULT_LIMIT = 3
|
||||
KNOWLEDGE_SEARCH_STOP_TERMS = {
|
||||
"什么",
|
||||
"怎么",
|
||||
"如何",
|
||||
"多少",
|
||||
"是否",
|
||||
"可以",
|
||||
"一下",
|
||||
"请问",
|
||||
"帮我",
|
||||
"一下子",
|
||||
"这个",
|
||||
"那个",
|
||||
"哪些",
|
||||
"一下吧",
|
||||
}
|
||||
|
||||
KNOWLEDGE_INGEST_STATUS_PUBLISHED = 1
|
||||
KNOWLEDGE_INGEST_STATUS_SYNCING = 2
|
||||
@@ -346,6 +363,156 @@ class KnowledgeService:
|
||||
self.ensure_library_ready()
|
||||
return self.llm_wiki_root
|
||||
|
||||
def search_llm_wiki(self, query: str, *, limit: int = KNOWLEDGE_SEARCH_RESULT_LIMIT) -> dict[str, Any]:
|
||||
self.ensure_library_ready()
|
||||
normalized_query = self._normalize_search_text(query)
|
||||
if not normalized_query:
|
||||
return {
|
||||
"result_type": "knowledge_search",
|
||||
"query": "",
|
||||
"record_count": 0,
|
||||
"hits": [],
|
||||
"references": [],
|
||||
"message": "请先输入要检索的制度或规则问题。",
|
||||
}
|
||||
|
||||
index = self._load_index()
|
||||
if self._reconcile_document_ingest_statuses(index):
|
||||
self._save_index(index)
|
||||
entry_by_id = {
|
||||
str(item.get("id") or "").strip(): item
|
||||
for item in list(index.get("documents") or [])
|
||||
if str(item.get("id") or "").strip()
|
||||
}
|
||||
wiki_index = self._load_llm_wiki_index()
|
||||
query_terms = self._extract_search_terms(query)
|
||||
hits: list[dict[str, Any]] = []
|
||||
|
||||
for wiki_document in list(wiki_index.get("documents") or []):
|
||||
document_id = str(wiki_document.get("document_id") or "").strip()
|
||||
if not document_id:
|
||||
continue
|
||||
entry = entry_by_id.get(document_id)
|
||||
if entry is None or not self._has_matching_llm_wiki_artifact(entry, wiki_document):
|
||||
continue
|
||||
|
||||
quality_status = str(wiki_document.get("quality_status") or "").strip()
|
||||
if quality_status == "failed":
|
||||
continue
|
||||
|
||||
document_name = str(wiki_document.get("document_name") or entry.get("original_name") or "").strip()
|
||||
document_dir = self.llm_wiki_documents_root / document_id
|
||||
candidates = self._load_json_file(document_dir / "knowledge_candidates.json", default=[])
|
||||
matched_in_document = False
|
||||
|
||||
for index, candidate in enumerate(candidates, start=1):
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
title = str(candidate.get("title") or "").strip()
|
||||
content = str(candidate.get("content") or "").strip()
|
||||
tags = [str(item).strip() for item in list(candidate.get("tags") or []) if str(item).strip()]
|
||||
evidence = [
|
||||
str(item).strip() for item in list(candidate.get("evidence") or []) if str(item).strip()
|
||||
]
|
||||
score, matched_terms = self._score_knowledge_search_match(
|
||||
query_text=normalized_query,
|
||||
query_terms=query_terms,
|
||||
title=title,
|
||||
content=content,
|
||||
tags=tags,
|
||||
document_name=document_name,
|
||||
evidence=evidence,
|
||||
)
|
||||
if score <= 0:
|
||||
continue
|
||||
|
||||
matched_in_document = True
|
||||
candidate_id = str(candidate.get("candidate_id") or f"candidate_{index}").strip()
|
||||
hits.append(
|
||||
{
|
||||
"code": f"knowledge.{document_id}.{candidate_id}",
|
||||
"candidate_id": candidate_id,
|
||||
"title": title or document_name or "制度知识条目",
|
||||
"content": content,
|
||||
"excerpt": self._build_search_excerpt(content or title, query_terms),
|
||||
"document_id": document_id,
|
||||
"document_name": document_name,
|
||||
"version": str(wiki_document.get("document_version") or "").strip() or None,
|
||||
"updated_at": self._format_search_timestamp(wiki_document.get("updated_at")),
|
||||
"quality_status": quality_status,
|
||||
"tags": tags,
|
||||
"evidence": evidence,
|
||||
"score": score,
|
||||
"matched_terms": matched_terms,
|
||||
}
|
||||
)
|
||||
|
||||
self._boost_title_family_hits(hits)
|
||||
ranked_hits = sorted(
|
||||
hits,
|
||||
key=lambda item: (
|
||||
-int(item.get("score") or 0),
|
||||
str(item.get("quality_status") or "") != "formal",
|
||||
str(item.get("title") or ""),
|
||||
),
|
||||
)[: max(1, limit)]
|
||||
|
||||
if ranked_hits:
|
||||
titles = "、".join(str(item.get("title") or "") for item in ranked_hits[:2] if str(item.get("title") or "").strip())
|
||||
return {
|
||||
"result_type": "knowledge_search",
|
||||
"query": str(query).strip(),
|
||||
"record_count": len(ranked_hits),
|
||||
"hits": ranked_hits,
|
||||
"references": [str(item.get("code") or "").strip() for item in ranked_hits if str(item.get("code") or "").strip()],
|
||||
"message": (
|
||||
f"已从已归纳制度知识中检索到 {len(ranked_hits)} 条相关内容。"
|
||||
f"{f'优先参考:{titles}。' if titles else ''}"
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"result_type": "knowledge_search",
|
||||
"query": str(query).strip(),
|
||||
"record_count": 0,
|
||||
"hits": [],
|
||||
"references": [],
|
||||
"message": (
|
||||
f"当前未在已归纳制度知识中检索到与“{str(query).strip()}”直接匹配的内容。"
|
||||
"知识问答仅基于 LLM Wiki 已形成的知识条目回答;当前依据不足,不能继续扩展回答。"
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _boost_title_family_hits(hits: list[dict[str, Any]]) -> None:
|
||||
if len(hits) < 2:
|
||||
return
|
||||
preliminary = sorted(
|
||||
hits,
|
||||
key=lambda item: (
|
||||
-int(item.get("score") or 0),
|
||||
str(item.get("quality_status") or "") != "formal",
|
||||
str(item.get("title") or ""),
|
||||
),
|
||||
)
|
||||
primary = preliminary[0]
|
||||
primary_title = str(primary.get("title") or "").strip()
|
||||
primary_document_id = str(primary.get("document_id") or "").strip()
|
||||
if len(primary_title) < 3 or not primary_document_id:
|
||||
return
|
||||
|
||||
family_key = primary_title[:3]
|
||||
family_hits = [
|
||||
item
|
||||
for item in hits
|
||||
if str(item.get("document_id") or "").strip() == primary_document_id
|
||||
and str(item.get("title") or "").strip().startswith(family_key)
|
||||
]
|
||||
if len(family_hits) < 2:
|
||||
return
|
||||
for item in family_hits:
|
||||
item["score"] = int(item.get("score") or 0) + 20
|
||||
|
||||
def extract_document_text(self, document_id: str) -> str:
|
||||
self.ensure_library_ready()
|
||||
entry = self.get_document_entry(document_id)
|
||||
@@ -830,6 +997,151 @@ class KnowledgeService:
|
||||
if str(item.get("document_id") or "").strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _load_json_file(path: Path, *, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _load_text_file(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_search_text(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text)
|
||||
|
||||
@staticmethod
|
||||
def _extract_search_terms(query: str) -> list[str]:
|
||||
normalized = KnowledgeService._normalize_search_text(query)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
terms: set[str] = set()
|
||||
for part in re.findall(r"[0-9a-z]+|[\u4e00-\u9fff]+", normalized):
|
||||
if len(part) <= 1:
|
||||
continue
|
||||
if part not in KNOWLEDGE_SEARCH_STOP_TERMS:
|
||||
terms.add(part)
|
||||
if not re.fullmatch(r"[\u4e00-\u9fff]+", part):
|
||||
continue
|
||||
upper_size = min(4, len(part))
|
||||
for size in range(2, upper_size + 1):
|
||||
for index in range(0, len(part) - size + 1):
|
||||
gram = part[index : index + size]
|
||||
if gram in KNOWLEDGE_SEARCH_STOP_TERMS:
|
||||
continue
|
||||
terms.add(gram)
|
||||
|
||||
return sorted(terms, key=lambda item: (-len(item), item))
|
||||
|
||||
@staticmethod
|
||||
def _score_knowledge_search_match(
|
||||
*,
|
||||
query_text: str,
|
||||
query_terms: list[str],
|
||||
title: str,
|
||||
content: str,
|
||||
tags: list[str],
|
||||
document_name: str,
|
||||
evidence: list[str],
|
||||
) -> tuple[int, list[str]]:
|
||||
normalized_title = KnowledgeService._normalize_search_text(title)
|
||||
normalized_content = KnowledgeService._normalize_search_text(content)
|
||||
normalized_tags = [KnowledgeService._normalize_search_text(item) for item in tags]
|
||||
normalized_document_name = KnowledgeService._normalize_search_text(document_name)
|
||||
normalized_evidence = [KnowledgeService._normalize_search_text(item) for item in evidence]
|
||||
|
||||
score = 0
|
||||
matched_terms: list[str] = []
|
||||
|
||||
if query_text and query_text in normalized_title:
|
||||
score += 140
|
||||
elif query_text and any(query_text in item for item in normalized_tags):
|
||||
score += 120
|
||||
elif query_text and query_text in normalized_content:
|
||||
score += 88
|
||||
|
||||
for phrase in [normalized_title, *normalized_tags, normalized_document_name]:
|
||||
if not phrase:
|
||||
continue
|
||||
if phrase in query_text:
|
||||
score += 24 + min(18, len(phrase) * 2)
|
||||
matched_terms.append(phrase)
|
||||
elif query_text and query_text in phrase:
|
||||
score += 16
|
||||
|
||||
for term in query_terms:
|
||||
if len(term) <= 1:
|
||||
continue
|
||||
term_score = 0
|
||||
if term in normalized_title:
|
||||
term_score = 18 if len(term) >= 4 else 14
|
||||
elif any(term in item for item in normalized_tags):
|
||||
term_score = 16 if len(term) >= 4 else 12
|
||||
elif term in normalized_content:
|
||||
term_score = 10 if len(term) >= 4 else 8
|
||||
elif term in normalized_document_name or any(term in item for item in normalized_evidence):
|
||||
term_score = 6
|
||||
if term_score:
|
||||
score += term_score
|
||||
matched_terms.append(term)
|
||||
|
||||
if score <= 0:
|
||||
return 0, []
|
||||
|
||||
distinct_matches = []
|
||||
for item in matched_terms:
|
||||
if item and item not in distinct_matches:
|
||||
distinct_matches.append(item)
|
||||
score += min(24, len(distinct_matches) * 4)
|
||||
return score, distinct_matches[:6]
|
||||
|
||||
@staticmethod
|
||||
def _build_search_excerpt(text: str, query_terms: list[str], *, max_length: int = 140) -> str:
|
||||
plain_text = re.sub(r"[#*_`>\-\[\]]+", " ", str(text or ""))
|
||||
plain_text = re.sub(r"\s+", " ", plain_text).strip()
|
||||
if not plain_text:
|
||||
return ""
|
||||
|
||||
normalized_text = KnowledgeService._normalize_search_text(plain_text)
|
||||
for term in query_terms:
|
||||
if not term or term not in normalized_text:
|
||||
continue
|
||||
raw_index = plain_text.find(term)
|
||||
if raw_index == -1:
|
||||
continue
|
||||
start = max(0, raw_index - 36)
|
||||
end = min(len(plain_text), raw_index + max_length - 36)
|
||||
snippet = plain_text[start:end].strip(" ,。;:")
|
||||
if start > 0:
|
||||
snippet = f"...{snippet}"
|
||||
if end < len(plain_text):
|
||||
snippet = f"{snippet}..."
|
||||
return snippet
|
||||
|
||||
if len(plain_text) <= max_length:
|
||||
return plain_text
|
||||
return f"{plain_text[: max_length - 3].rstrip()}..."
|
||||
|
||||
@staticmethod
|
||||
def _format_search_timestamp(value: Any) -> str | None:
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw_value)
|
||||
except ValueError:
|
||||
return raw_value or None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC).date().isoformat()
|
||||
|
||||
def _has_ingested_llm_wiki_document(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.core.agent_enums import (
|
||||
AgentAssetType,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
from app.core.config import get_settings
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetCreate, AgentAssetUpdate, AgentAssetVersionCreate
|
||||
from app.schemas.knowledge import (
|
||||
@@ -43,12 +44,15 @@ from app.services.knowledge import (
|
||||
)
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.system_hermes import SystemHermesService
|
||||
from app.services.settings import SettingsService
|
||||
from app.services.hermes_sync import sync_repository_hermes_skills
|
||||
|
||||
logger = get_logger("app.services.llm_wiki")
|
||||
|
||||
HERMES_CANDIDATE_MODEL_TIMEOUT_SECONDS = 10
|
||||
HERMES_CANDIDATE_GROUP_SIZE = 2
|
||||
HERMES_CANDIDATE_CONTENT_LIMIT = 520
|
||||
HERMES_AGENT_BATCH_TIMEOUT_SECONDS = 900
|
||||
LOW_SIGNAL_DOTTED_LINE_PATTERN = re.compile(r"[..。·•]{6,}\s*[0-9]{0,3}$")
|
||||
PAGE_FOOTER_PATTERN = re.compile(r"^第\s*\d+\s*页\s*共\s*\d+\s*页$")
|
||||
POLICY_SUBSTANCE_KEYWORDS = (
|
||||
@@ -106,6 +110,17 @@ class CandidateExtractionStats:
|
||||
quality_status: str = "failed"
|
||||
quality_note: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LlmWikiAgentBatchDispatch:
|
||||
prompt: str
|
||||
request_payload: dict[str, Any]
|
||||
changed_document_ids: list[str]
|
||||
skipped_document_ids: list[str]
|
||||
process_id: int = 0
|
||||
stdout_path: str = ""
|
||||
stderr_path: str = ""
|
||||
|
||||
RULE_TEMPLATE_CATALOG: dict[str, dict[str, str]] = {
|
||||
"travel_standard_v1": {
|
||||
"label": "差旅标准模板",
|
||||
@@ -406,6 +421,649 @@ class LlmWikiService:
|
||||
(document_dir / "knowledge_summary.md").write_text(summary_text, encoding="utf-8")
|
||||
return self.get_document_detail(document_id)
|
||||
|
||||
def build_agent_batch_dispatch(
|
||||
self,
|
||||
*,
|
||||
folder: str,
|
||||
document_ids: list[str],
|
||||
force: bool,
|
||||
agent_run_id: str,
|
||||
) -> LlmWikiAgentBatchDispatch:
|
||||
self.knowledge_service.ensure_library_ready()
|
||||
sync_repository_hermes_skills()
|
||||
SettingsService(self.db).sync_hermes_runtime_model_settings()
|
||||
|
||||
documents = self.knowledge_service.list_folder_documents(folder=folder)
|
||||
allowed_ids = {str(item).strip() for item in document_ids if str(item).strip()}
|
||||
if allowed_ids:
|
||||
documents = [item for item in documents if str(item.get("id") or "").strip() in allowed_ids]
|
||||
|
||||
index = self._load_wiki_index()
|
||||
existing_by_id = {
|
||||
str(item.get("document_id") or ""): item for item in list(index.get("documents", []))
|
||||
}
|
||||
changed_entries: list[dict[str, Any]] = []
|
||||
skipped_document_ids: list[str] = []
|
||||
for entry in documents:
|
||||
document_id = str(entry.get("id") or "").strip()
|
||||
if not document_id:
|
||||
continue
|
||||
sync_reason = self._resolve_sync_reason(
|
||||
entry=entry,
|
||||
existing=existing_by_id.get(document_id),
|
||||
force=force,
|
||||
)
|
||||
if sync_reason == "unchanged_skipped":
|
||||
skipped_document_ids.append(document_id)
|
||||
continue
|
||||
file_path, _, _ = self.knowledge_service.get_document_content(document_id)
|
||||
changed_entries.append(
|
||||
{
|
||||
"document_id": document_id,
|
||||
"document_name": str(entry["original_name"]),
|
||||
"folder": str(entry["folder"]),
|
||||
"absolute_path": str(file_path.resolve()),
|
||||
"document_version": f"v{int(entry.get('version_number', 1))}.0",
|
||||
"signature": self._build_document_signature(entry),
|
||||
"sync_reason": sync_reason,
|
||||
}
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
callback_token = str(settings.hermes_agent_shared_token or "").strip()
|
||||
if not callback_token:
|
||||
raise ValueError("Hermes 回调令牌未配置,无法派发 Hermes 任务。")
|
||||
callback_url = (
|
||||
f"http://127.0.0.1:{settings.app_port}{settings.api_v1_prefix}/hermes/callback"
|
||||
)
|
||||
request_payload = {
|
||||
"type": "llm_wiki_sync",
|
||||
"run_id": agent_run_id,
|
||||
"callback_url": callback_url,
|
||||
"callback_token": callback_token,
|
||||
"folder": folder,
|
||||
"documents": changed_entries,
|
||||
}
|
||||
prompt = (
|
||||
"请执行一次 X-Financial 制度文档知识归集任务。\n"
|
||||
"要求:\n"
|
||||
"1. 这是一个批量任务,但只能作为一次 Hermes 任务整体执行;\n"
|
||||
"2. 直接读取每个 absolute_path 指向的完整原文件,不要要求服务端先切块;\n"
|
||||
"3. 使用 llm-wiki、x-financial-llm-wiki-ingest、x-financial-callback 三个 skill;\n"
|
||||
"4. 完成后必须向 callback_url 主动 POST 一个通用回调请求;\n"
|
||||
"5. 回调 body 的 type 必须保持为 llm_wiki_sync,run_id 必须保持不变;\n"
|
||||
"6. 任务成功时 status=succeeded,业务结果放在 payload;失败时 status=failed 并给出 error;\n"
|
||||
"7. 回调成功后,只返回一句简短确认。\n\n"
|
||||
f"{json.dumps(request_payload, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
return LlmWikiAgentBatchDispatch(
|
||||
prompt=prompt,
|
||||
request_payload=request_payload,
|
||||
changed_document_ids=[item["document_id"] for item in changed_entries],
|
||||
skipped_document_ids=skipped_document_ids,
|
||||
)
|
||||
|
||||
def dispatch_agent_batch(
|
||||
self,
|
||||
*,
|
||||
folder: str,
|
||||
document_ids: list[str],
|
||||
force: bool,
|
||||
agent_run_id: str,
|
||||
) -> LlmWikiAgentBatchDispatch:
|
||||
dispatch = self.build_agent_batch_dispatch(
|
||||
folder=folder,
|
||||
document_ids=document_ids,
|
||||
force=force,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
if not dispatch.changed_document_ids:
|
||||
return dispatch
|
||||
|
||||
self.knowledge_service.set_document_ingest_statuses(
|
||||
dispatch.changed_document_ids,
|
||||
status_code=KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
process_handle = self.system_hermes_service.start_query_background(
|
||||
dispatch.prompt,
|
||||
source="tool",
|
||||
max_turns=24,
|
||||
skills=("llm-wiki", "x-financial-llm-wiki-ingest", "x-financial-callback"),
|
||||
log_prefix=f"llm-wiki-{agent_run_id}",
|
||||
yolo=True,
|
||||
)
|
||||
dispatch.process_id = process_handle.pid
|
||||
dispatch.stdout_path = process_handle.stdout_path
|
||||
dispatch.stderr_path = process_handle.stderr_path
|
||||
return dispatch
|
||||
|
||||
def finalize_agent_batch_callback(
|
||||
self,
|
||||
*,
|
||||
agent_run_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> LlmWikiSyncRead:
|
||||
documents_payload = list(payload.get("documents") or [])
|
||||
if not bool(payload.get("ok", True)):
|
||||
raise ValueError(str(payload.get("error") or "Hermes LLM Wiki 回调失败。"))
|
||||
if not documents_payload:
|
||||
raise ValueError("Hermes LLM Wiki 回调未返回 documents。")
|
||||
|
||||
callback_route = self._resolve_callback_route(agent_run_id)
|
||||
callback_folder = str(payload.get("folder") or callback_route.get("folder") or "")
|
||||
documents_by_id = {
|
||||
str(item.get("id") or "").strip(): item
|
||||
for item in self.knowledge_service.list_folder_documents()
|
||||
if str(item.get("id") or "").strip()
|
||||
}
|
||||
index = self._load_wiki_index()
|
||||
sync_runs = self._load_sync_runs()
|
||||
existing_by_id = {
|
||||
str(item.get("document_id") or ""): item for item in list(index.get("documents", []))
|
||||
}
|
||||
|
||||
changed_document_count = 0
|
||||
knowledge_candidate_count = 0
|
||||
rule_candidate_count = 0
|
||||
generated_rule_asset_ids: list[str] = []
|
||||
completed_document_ids: list[str] = []
|
||||
sync_summaries: list[str] = []
|
||||
|
||||
current_user = self._resolve_callback_user(agent_run_id)
|
||||
for raw_document in documents_payload:
|
||||
if not isinstance(raw_document, dict):
|
||||
continue
|
||||
document_id = str(raw_document.get("document_id") or "").strip()
|
||||
entry = documents_by_id.get(document_id)
|
||||
if entry is None:
|
||||
continue
|
||||
document_payload = self._persist_agent_document_result(
|
||||
entry=entry,
|
||||
current_user=current_user,
|
||||
raw_document=raw_document,
|
||||
)
|
||||
existing_by_id[document_id] = document_payload["document"]
|
||||
changed_document_count += 1
|
||||
completed_document_ids.append(document_id)
|
||||
knowledge_candidate_count += len(document_payload["knowledge_candidates"])
|
||||
rule_candidate_count += len(document_payload["rule_candidates"])
|
||||
generated_rule_asset_ids.extend(
|
||||
[
|
||||
str(item.get("generated_asset_id") or "").strip()
|
||||
for item in document_payload["rule_candidates"]
|
||||
if str(item.get("generated_asset_id") or "").strip()
|
||||
]
|
||||
)
|
||||
sync_summaries.append(
|
||||
f"{entry['original_name']}:agent_batch:知识候选 {len(document_payload['knowledge_candidates'])} 条,"
|
||||
f"规则候选 {len(document_payload['rule_candidates'])} 条。"
|
||||
)
|
||||
|
||||
if changed_document_count <= 0:
|
||||
raise ValueError("Hermes LLM Wiki 回调没有匹配到可落库的文档。")
|
||||
|
||||
index["documents"] = list(existing_by_id.values())
|
||||
self._write_json_file(self.knowledge_service.llm_wiki_index_path, index)
|
||||
sync_run_id = f"wiki_{uuid4().hex[:12]}"
|
||||
generated_rule_ids = list(dict.fromkeys(generated_rule_asset_ids))
|
||||
summary = str(payload.get("summary") or "").strip() or ";".join(sync_summaries)
|
||||
sync_runs.setdefault("runs", [])
|
||||
sync_runs["runs"].append(
|
||||
{
|
||||
"run_id": sync_run_id,
|
||||
"folder": callback_folder,
|
||||
"requested_document_ids": completed_document_ids,
|
||||
"changed_document_count": changed_document_count,
|
||||
"knowledge_candidate_count": knowledge_candidate_count,
|
||||
"rule_candidate_count": rule_candidate_count,
|
||||
"generated_rule_asset_ids": generated_rule_ids,
|
||||
"created_by": current_user.name,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"summary": sync_summaries,
|
||||
"source": "hermes_callback",
|
||||
"agent_run_id": agent_run_id,
|
||||
}
|
||||
)
|
||||
self._write_json_file(self.knowledge_service.llm_wiki_sync_runs_path, sync_runs)
|
||||
self.knowledge_service.refresh_document_ingest_statuses(
|
||||
document_ids=completed_document_ids,
|
||||
preserve_syncing=False,
|
||||
)
|
||||
return LlmWikiSyncRead(
|
||||
ok=True,
|
||||
run_id=sync_run_id,
|
||||
folder=callback_folder,
|
||||
document_count=changed_document_count,
|
||||
knowledge_candidate_count=knowledge_candidate_count,
|
||||
rule_candidate_count=rule_candidate_count,
|
||||
generated_rule_count=len(generated_rule_ids),
|
||||
generated_rule_asset_ids=generated_rule_ids,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
def _persist_agent_document_result(
|
||||
self,
|
||||
*,
|
||||
entry: dict[str, Any],
|
||||
current_user: CurrentUserContext,
|
||||
raw_document: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
document_id = str(entry["id"])
|
||||
document_name = str(entry["original_name"])
|
||||
document_dir = self._document_dir(document_id)
|
||||
document_dir.mkdir(parents=True, exist_ok=True)
|
||||
extracted_text = self.knowledge_service.extract_document_text(document_id)
|
||||
text_path = document_dir / "text.md"
|
||||
text_path.write_text(extracted_text, encoding="utf-8")
|
||||
|
||||
source_chunk = {
|
||||
"chunk_id": f"{document_id}-document",
|
||||
"title": document_name,
|
||||
"content": extracted_text,
|
||||
"source_page": None,
|
||||
"word_count": len(re.sub(r"\s+", "", extracted_text)),
|
||||
"tags": self._normalize_tags([], fallback_text=extracted_text),
|
||||
}
|
||||
seen_knowledge_keys: set[str] = set()
|
||||
seen_rule_keys: set[str] = set()
|
||||
knowledge_candidates = self._normalize_knowledge_candidates(
|
||||
raw_items=list(raw_document.get("knowledge_candidates") or []),
|
||||
entry=entry,
|
||||
chunk_group=[source_chunk],
|
||||
seen_keys=seen_knowledge_keys,
|
||||
extraction_mode="hermes",
|
||||
)[:12]
|
||||
rule_candidates = self._normalize_rule_candidates(
|
||||
raw_items=list(raw_document.get("rule_candidates") or []),
|
||||
entry=entry,
|
||||
chunk_group=[source_chunk],
|
||||
current_user=current_user,
|
||||
seen_keys=seen_rule_keys,
|
||||
)[:12]
|
||||
generated_candidates: list[dict[str, Any]] = []
|
||||
for candidate in rule_candidates:
|
||||
if candidate.get("validation_status") == "valid":
|
||||
generated_asset = self._create_or_update_rule_draft(candidate, current_user=current_user)
|
||||
if generated_asset is not None:
|
||||
candidate["generated_asset_id"] = generated_asset["asset_id"]
|
||||
candidate["generated_asset_code"] = generated_asset["asset_code"]
|
||||
candidate["generated_version"] = generated_asset["version"]
|
||||
generated_candidates.append(candidate)
|
||||
|
||||
summary_markdown = str(raw_document.get("knowledge_summary_markdown") or "").strip()
|
||||
if not summary_markdown:
|
||||
summary_markdown = self._build_knowledge_summary_markdown(
|
||||
entry=entry,
|
||||
knowledge_candidates=knowledge_candidates,
|
||||
)
|
||||
knowledge_candidates = self._upgrade_table_candidate_contents_from_summary(
|
||||
knowledge_candidates=knowledge_candidates,
|
||||
summary_markdown=summary_markdown,
|
||||
)
|
||||
knowledge_candidates = self._synthesize_candidates_from_summary(
|
||||
knowledge_candidates=knowledge_candidates,
|
||||
summary_markdown=summary_markdown,
|
||||
entry=entry,
|
||||
)
|
||||
self._validate_agent_knowledge_candidate_quality(
|
||||
knowledge_candidates,
|
||||
summary_markdown=summary_markdown,
|
||||
)
|
||||
document_record = {
|
||||
"document_id": document_id,
|
||||
"document_name": document_name,
|
||||
"folder": str(entry["folder"]),
|
||||
"document_version": f"v{int(entry.get('version_number', 1))}.0",
|
||||
"checksum": str(entry.get("sha256") or ""),
|
||||
"extracted_text_path": str(text_path),
|
||||
"chunk_count": 1,
|
||||
"candidate_chunk_count": 1,
|
||||
"filtered_chunk_count": 0,
|
||||
"group_count": 1,
|
||||
"successful_group_count": 1,
|
||||
"failed_group_count": 0,
|
||||
"knowledge_candidate_count": len(knowledge_candidates),
|
||||
"formal_knowledge_candidate_count": len(knowledge_candidates),
|
||||
"fallback_knowledge_candidate_count": 0,
|
||||
"rule_candidate_count": len(generated_candidates),
|
||||
"quality_status": "formal" if knowledge_candidates else "failed",
|
||||
"quality_note": (
|
||||
"Hermes 已基于完整原文件完成正式归纳。"
|
||||
if knowledge_candidates
|
||||
else "Hermes 回调未返回可用知识候选。"
|
||||
),
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"signature": self._build_document_signature(entry),
|
||||
"sync_reason": str(raw_document.get("sync_reason") or "agent_batch"),
|
||||
}
|
||||
self._write_json_file(document_dir / "document.json", document_record)
|
||||
self._write_json_file(document_dir / "chunks.json", [source_chunk])
|
||||
self._write_json_file(document_dir / "knowledge_candidates.json", knowledge_candidates)
|
||||
self._write_json_file(document_dir / "rule_candidates.json", generated_candidates)
|
||||
(document_dir / "knowledge_summary.md").write_text(summary_markdown, encoding="utf-8")
|
||||
return {
|
||||
"document": document_record,
|
||||
"knowledge_candidates": knowledge_candidates,
|
||||
"rule_candidates": generated_candidates,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_agent_knowledge_candidate_quality(
|
||||
knowledge_candidates: list[dict[str, Any]],
|
||||
*,
|
||||
summary_markdown: str = "",
|
||||
) -> None:
|
||||
invalid_titles: list[str] = []
|
||||
for candidate in knowledge_candidates:
|
||||
content = str(candidate.get("content") or "").strip()
|
||||
evidence = " ".join(str(item or "") for item in list(candidate.get("evidence") or []))
|
||||
title = str(candidate.get("title") or "").strip() or "未命名条目"
|
||||
table_backed = "表" in evidence
|
||||
if not table_backed:
|
||||
continue
|
||||
has_markdown_table = "|" in content
|
||||
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
|
||||
summary_has_candidate_table = False
|
||||
if summary_markdown and "|" in summary_markdown:
|
||||
match_terms = [
|
||||
term
|
||||
for term in [title, *[str(item).strip() for item in list(candidate.get("tags") or [])]]
|
||||
if len(term) >= 2
|
||||
]
|
||||
summary_has_candidate_table = any(
|
||||
("|" in section) and any(term in section for term in match_terms)
|
||||
for section in re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
|
||||
if section.strip()
|
||||
)
|
||||
if (not has_markdown_table or has_slash_shorthand) and not summary_has_candidate_table:
|
||||
invalid_titles.append(title)
|
||||
if invalid_titles:
|
||||
joined = "、".join(invalid_titles[:5])
|
||||
raise ValueError(
|
||||
"Hermes 回调中的表格型知识条目既没有可直接复用的 Markdown 表格,"
|
||||
"也没有在 wiki 总结中提供可用于还原表格的结构化表述:"
|
||||
f"{joined}。请补充可还原的 wiki 表述后重新回调。"
|
||||
)
|
||||
LlmWikiService._validate_travel_knowledge_completeness(
|
||||
knowledge_candidates=knowledge_candidates,
|
||||
summary_markdown=summary_markdown,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_travel_knowledge_completeness(
|
||||
*,
|
||||
knowledge_candidates: list[dict[str, Any]],
|
||||
summary_markdown: str,
|
||||
) -> None:
|
||||
joined_text = "\n".join(
|
||||
[
|
||||
summary_markdown,
|
||||
*[
|
||||
"\n".join(
|
||||
[
|
||||
str(candidate.get("title") or ""),
|
||||
str(candidate.get("content") or ""),
|
||||
" ".join(str(item or "") for item in list(candidate.get("tags") or [])),
|
||||
" ".join(str(item or "") for item in list(candidate.get("evidence") or [])),
|
||||
]
|
||||
)
|
||||
for candidate in knowledge_candidates
|
||||
if isinstance(candidate, dict)
|
||||
],
|
||||
]
|
||||
)
|
||||
normalized = re.sub(r"\s+", "", joined_text)
|
||||
if "差旅费" not in normalized and "出差" not in normalized:
|
||||
return
|
||||
|
||||
dimensions = {
|
||||
"交通费": ("交通费", "交通工具", "飞机", "火车", "轮船"),
|
||||
"住宿费": ("住宿费", "住宿限额", "酒店住宿", "住 宿", "住 宿费"),
|
||||
"出差补贴": ("出差补贴", "餐补", "基本补助", "补贴标准"),
|
||||
}
|
||||
missing = [
|
||||
label
|
||||
for label, aliases in dimensions.items()
|
||||
if not any(alias.replace(" ", "") in normalized for alias in aliases)
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Hermes 回调中的差旅知识不完整,缺少影响总额计算的关键维度:"
|
||||
f"{'、'.join(missing)}。请补充后重新回调。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upgrade_table_candidate_contents_from_summary(
|
||||
*,
|
||||
knowledge_candidates: list[dict[str, Any]],
|
||||
summary_markdown: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if "|" not in summary_markdown:
|
||||
return knowledge_candidates
|
||||
|
||||
sections = re.split(r"(?m)^(?=#{1,6}\s)", summary_markdown)
|
||||
table_sections = [section.strip() for section in sections if "|" in section and section.strip()]
|
||||
if not table_sections:
|
||||
return knowledge_candidates
|
||||
|
||||
upgraded: list[dict[str, Any]] = []
|
||||
for candidate in knowledge_candidates:
|
||||
copied = dict(candidate)
|
||||
content = str(copied.get("content") or "").strip()
|
||||
title = str(copied.get("title") or "").strip()
|
||||
tags = [str(item).strip() for item in list(copied.get("tags") or []) if str(item).strip()]
|
||||
has_slash_shorthand = bool(re.search(r"\d+\s*/\s*\d+", content))
|
||||
evidence = " ".join(str(item or "") for item in list(copied.get("evidence") or []))
|
||||
table_backed = "表" in evidence
|
||||
if not (table_backed or has_slash_shorthand) or "|" in content:
|
||||
upgraded.append(copied)
|
||||
continue
|
||||
|
||||
match_terms = [
|
||||
term
|
||||
for term in [title, *tags, *LlmWikiService._extract_table_match_terms(title, evidence)]
|
||||
if len(term) >= 2
|
||||
]
|
||||
ranked_sections = sorted(
|
||||
(
|
||||
(
|
||||
LlmWikiService._score_summary_table_section(
|
||||
section=section,
|
||||
title=title,
|
||||
tags=tags,
|
||||
evidence=evidence,
|
||||
match_terms=match_terms,
|
||||
),
|
||||
max((len(term) for term in match_terms if term in section), default=0),
|
||||
section,
|
||||
)
|
||||
for section in table_sections
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
replacement = ranked_sections[0][2] if ranked_sections and ranked_sections[0][0] > 0 else ""
|
||||
if replacement:
|
||||
intro = ""
|
||||
if content and "|" not in content:
|
||||
prose_lines = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if line.strip() and "|" not in line
|
||||
]
|
||||
intro = "\n\n".join(prose_lines[:2]).strip()
|
||||
copied["content"] = f"{intro}\n\n{replacement}".strip() if intro else replacement
|
||||
quality_flags = list(copied.get("quality_flags") or [])
|
||||
if "table_restored_from_summary" not in quality_flags:
|
||||
quality_flags.append("table_restored_from_summary")
|
||||
copied["quality_flags"] = quality_flags
|
||||
upgraded.append(copied)
|
||||
return upgraded
|
||||
|
||||
def _synthesize_candidates_from_summary(
|
||||
self,
|
||||
*,
|
||||
knowledge_candidates: list[dict[str, Any]],
|
||||
summary_markdown: str,
|
||||
entry: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
sections = re.split(r"(?m)^(?=#{2,6}\s)", summary_markdown)
|
||||
if not sections:
|
||||
return knowledge_candidates
|
||||
|
||||
existing_text = "\n".join(
|
||||
[
|
||||
"\n".join(
|
||||
[
|
||||
str(candidate.get("title") or ""),
|
||||
str(candidate.get("content") or ""),
|
||||
]
|
||||
)
|
||||
for candidate in knowledge_candidates
|
||||
if isinstance(candidate, dict)
|
||||
]
|
||||
)
|
||||
normalized_existing = re.sub(r"\s+", "", existing_text)
|
||||
synthesized = list(knowledge_candidates)
|
||||
|
||||
for section in sections:
|
||||
stripped = section.strip()
|
||||
if not stripped.startswith("## "):
|
||||
continue
|
||||
lines = [line.rstrip() for line in stripped.splitlines() if line.strip()]
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
heading = lines[0].lstrip("#").strip()
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
if len(body) < 40:
|
||||
continue
|
||||
if not any(keyword in heading for keyword in ("差旅费", "住宿", "补贴", "审批权限", "归口管理")):
|
||||
continue
|
||||
|
||||
compact_heading = re.sub(r"\s+", "", heading)
|
||||
if compact_heading and compact_heading in normalized_existing:
|
||||
continue
|
||||
|
||||
if "住宿" in heading and "住宿" in normalized_existing:
|
||||
continue
|
||||
if "补贴" in heading and "补贴" in normalized_existing:
|
||||
continue
|
||||
if "交通工具" in heading and "交通工具" in normalized_existing:
|
||||
continue
|
||||
|
||||
synthesized.append(
|
||||
{
|
||||
"candidate_id": f"kc_{uuid4().hex[:12]}",
|
||||
"title": heading,
|
||||
"content": body,
|
||||
"domain": "expense",
|
||||
"scenario": self._infer_summary_candidate_scenario(heading),
|
||||
"tags": self._normalize_tags([heading], fallback_text=body),
|
||||
"source_document_id": str(entry["id"]),
|
||||
"source_document_name": str(entry["original_name"]),
|
||||
"source_chunk_ids": [f"{entry['id']}-document"],
|
||||
"evidence": [f"wiki summary section: {heading}"],
|
||||
"confidence": 0.9,
|
||||
"status": "draft",
|
||||
"created_by": "hermes",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"extraction_mode": "hermes",
|
||||
"quality_flags": ["summary_synthesized_candidate"],
|
||||
"fallback_reason": "",
|
||||
}
|
||||
)
|
||||
normalized_existing += compact_heading + re.sub(r"\s+", "", body)
|
||||
|
||||
return synthesized[:12]
|
||||
|
||||
@staticmethod
|
||||
def _infer_summary_candidate_scenario(heading: str) -> str:
|
||||
compact_heading = re.sub(r"\s+", "", heading)
|
||||
if "差旅费" in compact_heading or "住宿" in compact_heading or "补贴" in compact_heading:
|
||||
return "travel_standard"
|
||||
if "审批权限" in compact_heading:
|
||||
return "expense_amount_limit"
|
||||
return "general_policy"
|
||||
|
||||
@staticmethod
|
||||
def _extract_table_match_terms(title: str, evidence: str) -> list[str]:
|
||||
seed_text = f"{title} {evidence}"
|
||||
terms = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]{2,}", seed_text)
|
||||
stop_terms = {
|
||||
"单位",
|
||||
"标准",
|
||||
"摘要",
|
||||
"规定",
|
||||
"说明",
|
||||
"国内",
|
||||
"国外",
|
||||
"员工",
|
||||
"表1",
|
||||
"表2",
|
||||
"表3",
|
||||
}
|
||||
unique_terms: list[str] = []
|
||||
for term in terms:
|
||||
if term in stop_terms:
|
||||
continue
|
||||
if term not in unique_terms:
|
||||
unique_terms.append(term)
|
||||
return unique_terms[:8]
|
||||
|
||||
@staticmethod
|
||||
def _score_summary_table_section(
|
||||
*,
|
||||
section: str,
|
||||
title: str,
|
||||
tags: list[str],
|
||||
evidence: str,
|
||||
match_terms: list[str],
|
||||
) -> int:
|
||||
section_heading = ""
|
||||
for line in section.splitlines():
|
||||
stripped = line.strip().lstrip("#").strip()
|
||||
if stripped:
|
||||
section_heading = stripped
|
||||
break
|
||||
|
||||
score = 0
|
||||
if title and title in section:
|
||||
score += 12
|
||||
if title and title in section_heading:
|
||||
score += 18
|
||||
|
||||
for term in match_terms:
|
||||
if term in section:
|
||||
score += 3
|
||||
if section_heading and term in section_heading:
|
||||
score += 6
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2 and tag in section_heading:
|
||||
score += 2
|
||||
|
||||
core_terms = LlmWikiService._extract_table_match_terms(title, evidence)
|
||||
if core_terms and not any(term in section_heading for term in core_terms):
|
||||
score -= 10
|
||||
return score
|
||||
|
||||
def _resolve_callback_user(self, agent_run_id: str) -> CurrentUserContext:
|
||||
route_json = self._resolve_callback_route(agent_run_id)
|
||||
return CurrentUserContext(
|
||||
username=str(route_json.get("requested_by_username") or "hermes"),
|
||||
name=str(route_json.get("requested_by_name") or "Hermes"),
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
def _resolve_callback_route(self, agent_run_id: str) -> dict[str, Any]:
|
||||
from app.services.agent_runs import AgentRunService
|
||||
|
||||
run = AgentRunService(self.db).get_run(agent_run_id)
|
||||
if run is None:
|
||||
return {}
|
||||
return dict(run.route_json or {})
|
||||
|
||||
def sync_folder(
|
||||
self,
|
||||
*,
|
||||
@@ -923,8 +1581,15 @@ class LlmWikiService:
|
||||
system_prompt = (
|
||||
"你是企业财务制度知识库的 Hermes 规则形成器。"
|
||||
"你只能基于提供的制度条款生成结构化知识候选和规则候选,不能自由发散。"
|
||||
"后续知识问答系统只能基于你形成的 wiki 知识回答,不能再回原文补猜,因此 knowledge_candidates 不是摘要,"
|
||||
"而是可直接复用的 wiki 片段。"
|
||||
"封面、目录、通知、页眉页脚、密级说明、印发信息不属于知识候选,必须忽略。"
|
||||
"只提炼具有执行意义、审核意义、报销约束意义的条款。"
|
||||
"每条 knowledge_candidate.content 都必须尽量自洽,保留适用对象、适用条件、核心要求、例外、阈值和限制;"
|
||||
"如果原文没有足够信息,也要在 content 中保留该限制,不得自行补全。"
|
||||
"如果原文标准同时依赖多个维度,例如“职级 × 地区”“费用类型 × 金额区间”,必须保留全部判断维度,"
|
||||
"不得把二维或多维标准压扁成单一通用额度;涉及金额表时,优先保留逐档结构或等价的分行表达。"
|
||||
"如果原文是表格且表格结构影响答案,content 优先使用 Markdown 表格保留原始决策结构。"
|
||||
"规则候选必须从允许模板中选 template_key,严禁自创模板。"
|
||||
"runtime_rule 必须严格遵守 runtime_rule_contracts 中对应模板的字段结构和允许值。"
|
||||
"如果条款不适合自动规则化,可以只返回 knowledge_candidates。"
|
||||
@@ -937,7 +1602,8 @@ class LlmWikiService:
|
||||
)
|
||||
user_prompt = (
|
||||
"请根据以下制度分块生成候选。"
|
||||
"每组最多提炼 3 条高价值 knowledge_candidates,优先保留可直接供报销审核、附件校验、审批判断使用的知识。"
|
||||
"每组最多提炼 3 条高价值 knowledge_candidates,优先形成后续可被直接检索和引用的 wiki 片段,"
|
||||
"而不是一句话摘要。"
|
||||
"只返回 JSON 对象,不要输出解释,不要调用工具,不要追加任何其他文本。\n"
|
||||
f"{json.dumps(facts, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import signal
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
@@ -10,7 +14,7 @@ from app.core.logging import get_logger
|
||||
from app.db.session import get_session_factory
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService
|
||||
from app.services.llm_wiki import LlmWikiService
|
||||
from app.services.llm_wiki import HERMES_AGENT_BATCH_TIMEOUT_SECONDS, LlmWikiService
|
||||
|
||||
logger = get_logger("app.services.llm_wiki_tasks")
|
||||
|
||||
@@ -95,46 +99,91 @@ class LlmWikiTaskManager:
|
||||
result_summary="Hermes 后台归纳任务已启动。",
|
||||
)
|
||||
|
||||
result = LlmWikiService(db).sync_folder(
|
||||
dispatch = LlmWikiService(db).dispatch_agent_batch(
|
||||
folder=folder,
|
||||
current_user=current_user,
|
||||
document_ids=document_ids,
|
||||
force=force,
|
||||
agent_run_id=agent_run_id,
|
||||
progress_callback=lambda payload, summary: self._write_progress(
|
||||
run_service=run_service,
|
||||
agent_run_id=agent_run_id,
|
||||
payload=payload,
|
||||
summary=summary,
|
||||
),
|
||||
)
|
||||
if not dispatch.changed_document_ids:
|
||||
knowledge_service.refresh_document_ingest_statuses(
|
||||
document_ids=document_ids,
|
||||
preserve_syncing=False,
|
||||
)
|
||||
run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type="llm",
|
||||
tool_name="system_hermes_llm_wiki_dispatch",
|
||||
request_json=request_payload,
|
||||
response_json={"changed_document_ids": [], "skipped_document_ids": dispatch.skipped_document_ids},
|
||||
status="succeeded",
|
||||
duration_ms=0,
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "succeeded",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"progress": {
|
||||
"total_documents": len(document_ids),
|
||||
"completed_documents": 0,
|
||||
"failed_documents": 0,
|
||||
"skipped_documents": len(dispatch.skipped_document_ids),
|
||||
"percent": 100,
|
||||
},
|
||||
},
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result_summary="本次所选文档均未变化,未重复派发 Hermes 任务。",
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
return
|
||||
|
||||
run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type="llm",
|
||||
tool_name="system_hermes_llm_wiki_sync",
|
||||
tool_name="system_hermes_llm_wiki_dispatch",
|
||||
request_json=request_payload,
|
||||
response_json=result.model_dump(mode="json"),
|
||||
response_json={
|
||||
"changed_document_ids": dispatch.changed_document_ids,
|
||||
"skipped_document_ids": dispatch.skipped_document_ids,
|
||||
"process_id": dispatch.process_id,
|
||||
},
|
||||
status="succeeded",
|
||||
duration_ms=0,
|
||||
)
|
||||
current_run = run_service.get_run(agent_run_id)
|
||||
if current_run is not None and current_run.status in {
|
||||
AgentRunStatus.SUCCEEDED.value,
|
||||
AgentRunStatus.FAILED.value,
|
||||
}:
|
||||
return
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "succeeded",
|
||||
"phase": "awaiting_callback",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"sync_run_id": result.run_id,
|
||||
"sync_result": result.model_dump(mode="json"),
|
||||
"requested_document_ids": dispatch.changed_document_ids,
|
||||
"skipped_document_ids": dispatch.skipped_document_ids,
|
||||
"hermes_process_id": dispatch.process_id,
|
||||
"hermes_stdout_path": dispatch.stdout_path,
|
||||
"hermes_stderr_path": dispatch.stderr_path,
|
||||
"progress": {
|
||||
"total_documents": max(len(document_ids), result.document_count),
|
||||
"completed_documents": result.document_count,
|
||||
"total_documents": len(dispatch.changed_document_ids),
|
||||
"completed_documents": 0,
|
||||
"failed_documents": 0,
|
||||
"skipped_documents": max(0, len(document_ids) - result.document_count),
|
||||
"percent": 100,
|
||||
"skipped_documents": len(dispatch.skipped_document_ids),
|
||||
"percent": 0,
|
||||
},
|
||||
},
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result_summary=result.summary,
|
||||
finished_at=datetime.now(UTC),
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
result_summary="Hermes 任务已派发,等待 Agent 主动回调结果。",
|
||||
)
|
||||
self._start_process_monitor(
|
||||
agent_run_id=agent_run_id,
|
||||
document_ids=dispatch.changed_document_ids,
|
||||
process_id=dispatch.process_id,
|
||||
stderr_path=dispatch.stderr_path,
|
||||
timeout_seconds=HERMES_AGENT_BATCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Background LLM Wiki sync failed run_id=%s", agent_run_id)
|
||||
@@ -177,6 +226,122 @@ class LlmWikiTaskManager:
|
||||
with self._lock:
|
||||
self._threads.pop(agent_run_id, None)
|
||||
|
||||
def _start_process_monitor(
|
||||
self,
|
||||
*,
|
||||
agent_run_id: str,
|
||||
document_ids: list[str],
|
||||
process_id: int,
|
||||
stderr_path: str,
|
||||
timeout_seconds: int,
|
||||
) -> None:
|
||||
worker = threading.Thread(
|
||||
target=self._monitor_process,
|
||||
kwargs={
|
||||
"agent_run_id": agent_run_id,
|
||||
"document_ids": list(document_ids),
|
||||
"process_id": process_id,
|
||||
"stderr_path": stderr_path,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
},
|
||||
daemon=True,
|
||||
name=f"llm-wiki-monitor-{agent_run_id}",
|
||||
)
|
||||
worker.start()
|
||||
|
||||
@staticmethod
|
||||
def _monitor_process(
|
||||
*,
|
||||
agent_run_id: str,
|
||||
document_ids: list[str],
|
||||
process_id: int,
|
||||
stderr_path: str,
|
||||
timeout_seconds: int,
|
||||
) -> None:
|
||||
session_factory = get_session_factory()
|
||||
db = session_factory()
|
||||
run_service = AgentRunService(db)
|
||||
knowledge_service = KnowledgeService()
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3)
|
||||
run = run_service.get_run(agent_run_id)
|
||||
if run is None or run.status != AgentRunStatus.RUNNING.value:
|
||||
return
|
||||
if time.monotonic() - started_at > timeout_seconds:
|
||||
try:
|
||||
os.killpg(process_id, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
error_message = LlmWikiTaskManager._read_process_error(stderr_path)
|
||||
if document_ids:
|
||||
knowledge_service.set_document_ingest_statuses(
|
||||
document_ids,
|
||||
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "failed",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"hermes_process_id": process_id,
|
||||
},
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
result_summary="Hermes 任务执行超时,已自动终止等待。",
|
||||
error_message=error_message or "Hermes process exceeded callback timeout.",
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
return
|
||||
if LlmWikiTaskManager._is_process_alive(process_id):
|
||||
continue
|
||||
|
||||
error_message = LlmWikiTaskManager._read_process_error(stderr_path)
|
||||
if document_ids:
|
||||
knowledge_service.set_document_ingest_statuses(
|
||||
document_ids,
|
||||
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "failed",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"hermes_process_id": process_id,
|
||||
},
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
result_summary="Hermes 进程已退出且未回调结果。",
|
||||
error_message=error_message or "Hermes process exited before callback.",
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def _is_process_alive(process_id: int) -> bool:
|
||||
stat_path = Path(f"/proc/{process_id}/stat")
|
||||
if not stat_path.exists():
|
||||
return False
|
||||
try:
|
||||
parts = stat_path.read_text(encoding="utf-8").split()
|
||||
except OSError:
|
||||
return False
|
||||
return len(parts) > 2 and parts[2] != "Z"
|
||||
|
||||
@staticmethod
|
||||
def _read_process_error(stderr_path: str) -> str:
|
||||
path = Path(stderr_path)
|
||||
if not stderr_path or not path.exists():
|
||||
return ""
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
return content[-1000:]
|
||||
|
||||
@staticmethod
|
||||
def _write_progress(
|
||||
*,
|
||||
|
||||
@@ -335,7 +335,6 @@ class SemanticOntologyService:
|
||||
context_json = payload.context_json or {}
|
||||
reference = self._load_reference_catalog()
|
||||
compact_query = self._compact(query)
|
||||
|
||||
entities = self._extract_entities(query, compact_query, reference)
|
||||
rule_scenario, scenario_score = self._detect_scenario(compact_query)
|
||||
time_range, _time_score = self._extract_time_range(
|
||||
@@ -343,7 +342,11 @@ class SemanticOntologyService:
|
||||
compact_query,
|
||||
context_json=context_json,
|
||||
)
|
||||
session_scenario = self._resolve_session_type_scenario(context_json)
|
||||
context_scenario = self._resolve_context_scenario(context_json)
|
||||
if session_scenario == "knowledge":
|
||||
rule_scenario = "knowledge"
|
||||
scenario_score = max(scenario_score, 0.34)
|
||||
if rule_scenario == "unknown" and context_scenario is not None:
|
||||
rule_scenario = context_scenario
|
||||
scenario_score = max(scenario_score, 0.14)
|
||||
@@ -393,6 +396,8 @@ class SemanticOntologyService:
|
||||
constraints=constraints,
|
||||
)
|
||||
scenario = self._resolve_scenario(rule_scenario, model_parse)
|
||||
if session_scenario == "knowledge":
|
||||
scenario = "knowledge"
|
||||
entities = self._merge_entities(
|
||||
entities,
|
||||
model_parse.entity_hints if model_parse is not None else [],
|
||||
@@ -419,6 +424,14 @@ class SemanticOntologyService:
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
relax_knowledge_follow_up = self._should_relax_knowledge_follow_up_clarification(
|
||||
compact_query=compact_query,
|
||||
scenario=scenario,
|
||||
context_json=context_json,
|
||||
missing_slots=missing_slots,
|
||||
)
|
||||
if relax_knowledge_follow_up:
|
||||
missing_slots = [item for item in missing_slots if item != "expense_type"]
|
||||
ambiguity = self._normalize_short_text_list(
|
||||
model_parse.ambiguity if model_parse is not None else []
|
||||
)
|
||||
@@ -450,12 +463,16 @@ class SemanticOntologyService:
|
||||
intent=intent,
|
||||
),
|
||||
model_clarification_required=bool(
|
||||
model_parse is not None and model_parse.clarification_required
|
||||
model_parse is not None
|
||||
and model_parse.clarification_required
|
||||
),
|
||||
model_clarification_question=(
|
||||
model_parse.clarification_question if model_parse is not None else None
|
||||
),
|
||||
)
|
||||
if relax_knowledge_follow_up:
|
||||
clarification_required = False
|
||||
clarification_question = None
|
||||
fallback_confidence = self._compute_confidence(
|
||||
scenario=scenario,
|
||||
scenario_score=scenario_score,
|
||||
@@ -496,6 +513,30 @@ class SemanticOntologyService:
|
||||
"field_errors": field_errors,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _should_relax_knowledge_follow_up_clarification(
|
||||
*,
|
||||
compact_query: str,
|
||||
scenario: str,
|
||||
context_json: dict[str, Any],
|
||||
missing_slots: list[str],
|
||||
) -> bool:
|
||||
if scenario != "knowledge" or "expense_type" not in missing_slots:
|
||||
return False
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
has_previous_user_turn = any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "user"
|
||||
and str(item.get("content") or "").strip()
|
||||
for item in history
|
||||
)
|
||||
if not has_previous_user_turn:
|
||||
return False
|
||||
follow_up_markers = ("那", "那么", "这个", "这种", "呢", "的话", "p", "P")
|
||||
return any(marker in compact_query for marker in follow_up_markers)
|
||||
|
||||
def _record_semantic_parse(
|
||||
self,
|
||||
*,
|
||||
@@ -599,6 +640,14 @@ class SemanticOntologyService:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_session_type_scenario(context_json: dict[str, Any]) -> str | None:
|
||||
value = str(context_json.get("session_type") or "").strip()
|
||||
if value == "knowledge":
|
||||
return "knowledge"
|
||||
return None
|
||||
|
||||
|
||||
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
||||
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
||||
for scenario, keywords in SCENARIO_KEYWORDS.items():
|
||||
@@ -1593,6 +1642,8 @@ class SemanticOntologyService:
|
||||
) -> tuple[bool, str | None]:
|
||||
if permission.level == AgentPermissionLevel.FORBIDDEN.value:
|
||||
return True, "当前动作超出权限范围。是否改为生成草稿或建议?"
|
||||
if scenario == "knowledge" and intent in {"query", "explain"}:
|
||||
return False, None
|
||||
if model_clarification_required:
|
||||
question = str(model_clarification_question or "").strip()
|
||||
if question:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import re
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
@@ -37,6 +38,7 @@ from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import KnowledgeService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
@@ -62,6 +64,8 @@ class ExecutionOutcome:
|
||||
|
||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
|
||||
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
|
||||
KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS = ("出差", "差旅", "报销多少钱", "能报多少", "一共可以报销", "一共能报销")
|
||||
KNOWLEDGE_TRAVEL_EXPANSION_TERMS = ("差旅费", "住宿费", "出差补贴", "交通费", "酒店住宿限额标准", "出差补贴标准")
|
||||
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 20
|
||||
EXPENSE_STATUS_LABELS = {
|
||||
@@ -100,6 +104,7 @@ class OrchestratorService:
|
||||
self.asset_service = AgentAssetService(db)
|
||||
self.conversation_service = AgentConversationService(db)
|
||||
self.expense_claim_service = ExpenseClaimService(db)
|
||||
self.knowledge_service = KnowledgeService(db=db)
|
||||
self.run_service = AgentRunService(db)
|
||||
self.ontology_service = SemanticOntologyService(db)
|
||||
self.user_agent_service = UserAgentService(db)
|
||||
@@ -574,7 +579,12 @@ class OrchestratorService:
|
||||
tool_name="knowledge.search",
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_knowledge_answer(ontology, capabilities),
|
||||
executor=lambda: self._build_knowledge_answer(
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
capabilities=capabilities,
|
||||
context_json=context_json,
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"知识检索暂时不可用,建议稍后重试:{exc}",
|
||||
"degraded": True,
|
||||
@@ -1348,18 +1358,154 @@ class OrchestratorService:
|
||||
result["review_payload"] = response.review_payload.model_dump()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_knowledge_answer(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
referenced = [item.code for item in capabilities["rules"][:1]] or [
|
||||
"knowledge.policy.default"
|
||||
]
|
||||
return {
|
||||
"message": f"已路由到 User Agent,占位知识结果:建议先查看 {', '.join(referenced)}。",
|
||||
"references": referenced,
|
||||
}
|
||||
payload = self.knowledge_service.search_llm_wiki(message, limit=5)
|
||||
expanded_query = self._build_knowledge_expanded_query(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
)
|
||||
if expanded_query and expanded_query != message:
|
||||
expanded_payload = self.knowledge_service.search_llm_wiki(expanded_query, limit=5)
|
||||
payload = self._merge_knowledge_search_payloads(
|
||||
primary_payload=payload,
|
||||
expanded_payload=expanded_payload,
|
||||
original_query=message,
|
||||
expanded_query=expanded_query,
|
||||
)
|
||||
|
||||
references = [str(item).strip() for item in list(payload.get("references") or []) if str(item).strip()]
|
||||
if references:
|
||||
payload["references"] = references
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _merge_knowledge_search_payloads(
|
||||
*,
|
||||
primary_payload: dict[str, Any],
|
||||
expanded_payload: dict[str, Any],
|
||||
original_query: str,
|
||||
expanded_query: str,
|
||||
) -> dict[str, Any]:
|
||||
merged_by_code: dict[str, dict[str, Any]] = {}
|
||||
for item in [
|
||||
*list(primary_payload.get("hits") or []),
|
||||
*list(expanded_payload.get("hits") or []),
|
||||
]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = str(item.get("code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
existing = merged_by_code.get(code)
|
||||
if existing is None or int(item.get("score") or 0) > int(existing.get("score") or 0):
|
||||
merged_by_code[code] = item
|
||||
|
||||
if not merged_by_code:
|
||||
return primary_payload
|
||||
|
||||
ranked_hits = sorted(
|
||||
merged_by_code.values(),
|
||||
key=lambda item: (
|
||||
-int(item.get("score") or 0),
|
||||
str(item.get("quality_status") or "") != "formal",
|
||||
str(item.get("title") or ""),
|
||||
),
|
||||
)[:5]
|
||||
merged_payload = dict(primary_payload)
|
||||
merged_payload.update(
|
||||
{
|
||||
"query": original_query,
|
||||
"expanded_query": expanded_query,
|
||||
"record_count": len(ranked_hits),
|
||||
"hits": ranked_hits,
|
||||
"references": [
|
||||
str(item.get("code") or "").strip()
|
||||
for item in ranked_hits
|
||||
if str(item.get("code") or "").strip()
|
||||
],
|
||||
}
|
||||
)
|
||||
return merged_payload
|
||||
|
||||
@staticmethod
|
||||
def _build_knowledge_expanded_query(
|
||||
*,
|
||||
message: str,
|
||||
context_json: dict[str, Any],
|
||||
) -> str:
|
||||
expansions: list[str] = []
|
||||
normalized_message = "".join(str(message or "").split())
|
||||
if normalized_message and any(keyword in normalized_message for keyword in KNOWLEDGE_TRAVEL_TRIGGER_KEYWORDS):
|
||||
expansions.extend(KNOWLEDGE_TRAVEL_EXPANSION_TERMS)
|
||||
location = OrchestratorService._extract_knowledge_location(message)
|
||||
if location:
|
||||
expansions.append(location)
|
||||
grade = OrchestratorService._extract_knowledge_grade(message, context_json)
|
||||
if grade:
|
||||
expansions.append(grade)
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
if not expansions:
|
||||
return message
|
||||
return "\n".join([message, " ".join(dict.fromkeys(expansions))])
|
||||
|
||||
previous_user_messages: list[str] = []
|
||||
for item in reversed(history):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = str(item.get("role") or "").strip()
|
||||
content = str(item.get("content") or "").strip()
|
||||
if role != "user" or not content or content == message:
|
||||
continue
|
||||
previous_user_messages.append(content)
|
||||
if len(previous_user_messages) >= 2:
|
||||
break
|
||||
|
||||
query_parts: list[str] = []
|
||||
if previous_user_messages:
|
||||
query_parts.extend(reversed(previous_user_messages))
|
||||
query_parts.append(message)
|
||||
if expansions:
|
||||
query_parts.append(" ".join(dict.fromkeys(expansions)))
|
||||
return "\n".join(query_parts)
|
||||
|
||||
@staticmethod
|
||||
def _extract_knowledge_location(message: str) -> str:
|
||||
patterns = (
|
||||
r"去([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
|
||||
r"到([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
|
||||
r"在([\u4e00-\u9fff]{2,8})(?:出差|开会|培训)",
|
||||
)
|
||||
for pattern in patterns:
|
||||
matched = re.search(pattern, str(message or ""))
|
||||
if matched:
|
||||
return matched.group(1)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_knowledge_grade(message: str, context_json: dict[str, Any]) -> str:
|
||||
matched = re.search(r"\bP[1-9]\d?\b", str(message or ""), re.IGNORECASE)
|
||||
if matched:
|
||||
return matched.group(0).upper()
|
||||
for key in ("grade", "employee_grade", "employeeLevel", "employee_level"):
|
||||
value = str(context_json.get(key) or "").strip()
|
||||
if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE):
|
||||
return value.upper()
|
||||
user = context_json.get("current_user")
|
||||
if isinstance(user, dict):
|
||||
for key in ("grade", "employee_grade", "employeeLevel", "employee_level"):
|
||||
value = str(user.get(key) or "").strip()
|
||||
if re.fullmatch(r"P[1-9]\d?", value, re.IGNORECASE):
|
||||
return value.upper()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_rule_answer(ontology: OntologyParseResult) -> dict[str, Any]:
|
||||
|
||||
@@ -343,6 +343,14 @@ class SettingsService:
|
||||
"apiKey": self.load_saved_model_api_key(slot),
|
||||
"capability": model_row.capability,
|
||||
}
|
||||
|
||||
def sync_hermes_runtime_model_settings(self) -> None:
|
||||
settings_row, secrets_row = self.ensure_settings_ready()
|
||||
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
||||
sync_hermes_model_settings(
|
||||
primary_route=self._build_hermes_model_route(model_rows["main"]),
|
||||
fallback_route=self._build_hermes_model_route(model_rows["backup"]),
|
||||
)
|
||||
|
||||
def get_admin_credentials(self) -> AdminCredentialRecord | None:
|
||||
settings_row, secrets_row = self.ensure_settings_ready()
|
||||
|
||||
@@ -5,6 +5,9 @@ import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from app.core.config import SERVER_DIR
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -14,6 +17,14 @@ class HermesCliResult:
|
||||
command: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HermesProcessHandle:
|
||||
pid: int
|
||||
command: tuple[str, ...] = ()
|
||||
stdout_path: str = ""
|
||||
stderr_path: str = ""
|
||||
|
||||
|
||||
class SystemHermesService:
|
||||
def __init__(self) -> None:
|
||||
configured_bin = str(os.getenv("HERMES_BIN", "")).strip()
|
||||
@@ -29,11 +40,94 @@ class SystemHermesService:
|
||||
source: str = "tool",
|
||||
max_turns: int = 1,
|
||||
timeout_seconds: int = 180,
|
||||
skills: tuple[str, ...] = (),
|
||||
env_overrides: Mapping[str, str] | None = None,
|
||||
yolo: bool = False,
|
||||
) -> HermesCliResult:
|
||||
if not self.is_available():
|
||||
raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}")
|
||||
|
||||
command = (
|
||||
command = self._build_command(
|
||||
query,
|
||||
source=source,
|
||||
max_turns=max_turns,
|
||||
skills=skills,
|
||||
yolo=yolo,
|
||||
)
|
||||
env = os.environ.copy()
|
||||
if env_overrides:
|
||||
env.update({str(key): str(value) for key, value in env_overrides.items()})
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。")
|
||||
|
||||
return self._parse_output(completed.stdout, command=command)
|
||||
|
||||
def start_query_background(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
source: str = "tool",
|
||||
max_turns: int = 1,
|
||||
skills: tuple[str, ...] = (),
|
||||
env_overrides: Mapping[str, str] | None = None,
|
||||
log_prefix: str = "hermes",
|
||||
yolo: bool = False,
|
||||
) -> HermesProcessHandle:
|
||||
if not self.is_available():
|
||||
raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}")
|
||||
|
||||
command = self._build_command(
|
||||
query,
|
||||
source=source,
|
||||
max_turns=max_turns,
|
||||
skills=skills,
|
||||
yolo=yolo,
|
||||
)
|
||||
env = os.environ.copy()
|
||||
if env_overrides:
|
||||
env.update({str(key): str(value) for key, value in env_overrides.items()})
|
||||
log_dir = SERVER_DIR / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_prefix = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in log_prefix)
|
||||
stdout_path = log_dir / f"{safe_prefix}.out.log"
|
||||
stderr_path = log_dir / f"{safe_prefix}.err.log"
|
||||
stdout_file = stdout_path.open("ab")
|
||||
stderr_file = stderr_path.open("ab")
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
return HermesProcessHandle(
|
||||
pid=process.pid,
|
||||
command=command,
|
||||
stdout_path=str(stdout_path),
|
||||
stderr_path=str(stderr_path),
|
||||
)
|
||||
|
||||
def _build_command(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
source: str,
|
||||
max_turns: int,
|
||||
skills: tuple[str, ...],
|
||||
yolo: bool,
|
||||
) -> tuple[str, ...]:
|
||||
command_parts = [
|
||||
self.hermes_bin,
|
||||
"chat",
|
||||
"-Q",
|
||||
@@ -41,21 +135,15 @@ class SystemHermesService:
|
||||
source,
|
||||
"--max-turns",
|
||||
str(max_turns),
|
||||
"-q",
|
||||
query,
|
||||
)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。")
|
||||
|
||||
return self._parse_output(completed.stdout, command=command)
|
||||
]
|
||||
for skill in skills:
|
||||
normalized_skill = str(skill or "").strip()
|
||||
if normalized_skill:
|
||||
command_parts.extend(["--skills", normalized_skill])
|
||||
if yolo:
|
||||
command_parts.append("--yolo")
|
||||
command_parts.extend(["-q", query])
|
||||
return tuple(command_parts)
|
||||
|
||||
@staticmethod
|
||||
def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user