feat: 集成Hermes智能体系统,增强聊天和差旅报销功能
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user