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

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

View File

@@ -30,10 +30,12 @@ def test_employee_can_login_with_seed_default_password() -> None:
LoginRequest(username=employee.email, password="123456")
)
assert result.ok is True
assert result.user.username == employee.email
assert result.user.name == employee.name
assert result.user.roleCodes
assert result.ok is True
assert result.user.username == employee.email
assert result.user.name == employee.name
assert result.user.position == employee.position
assert result.user.grade == employee.grade
assert result.user.roleCodes
assert result.user.isAdmin is False
@@ -50,10 +52,11 @@ def test_admin_can_login_with_database_password() -> None:
LoginRequest(username="superadmin", password="admin123")
)
assert result.ok is True
assert result.user.username == "superadmin"
assert result.user.isAdmin is True
assert result.user.roleCodes == ["manager"]
assert result.ok is True
assert result.user.username == "superadmin"
assert result.user.isAdmin is True
assert result.user.position == "系统管理员"
assert result.user.roleCodes == ["manager"]
def test_disabled_employee_cannot_login() -> None:

View File

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

View File

@@ -258,19 +258,57 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
assert result.intent == "query"
assert result.time_range.start_date == "2026-04-01"
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"
for item in result.entities
)
assert any(
item.field == "amount" and item.operator == ">" and item.value == 5000
for item in result.constraints
assert result.scenario == "knowledge"
assert result.intent == "query"
assert result.clarification_required is False
assert result.clarification_question is None
assert result.missing_slots == []
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:
session_factory = build_session_factory()

View File

@@ -1,15 +1,19 @@
from __future__ import annotations
import json
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.api.deps import get_db
from app.core.config import get_settings
from app.db.base import Base
from app.main import create_app
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.services.agent_assets import AgentAssetService
from app.services.knowledge import KnowledgeService
from app.services.settings import SettingsService
@@ -45,6 +50,108 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
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:
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"
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:
client, session_factory = build_client()

File diff suppressed because it is too large Load Diff