feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能

主要变更:
- 移除Hermes智能体及相关回调服务
- 新增知识库RAG、同步、调度、规范化和索引任务服务
- 重构orchestrator服务,增强运行时聊天功能
- 更新前端聊天、政策制度、设置等页面样式和逻辑
- 更新expense_claims和document_intelligence服务
- 删除llm_wiki相关服务和测试文件
- 更新docker-compose配置和启动脚本
This commit is contained in:
caoxiaozhu
2026-05-17 08:38:41 +00:00
parent 212c935308
commit 68f663f2f4
308 changed files with 83729 additions and 13588 deletions

View File

@@ -1,13 +1,10 @@
from __future__ import annotations
import json
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.services.document_intelligence import DocumentIntelligenceService, build_document_insight
from app.services.runtime_chat import RuntimeChatService
def test_build_document_insight_prefers_transport_for_didi_text_with_hotel_noise() -> None:
@@ -23,28 +20,7 @@ def test_build_document_insight_prefers_transport_for_didi_text_with_hotel_noise
assert any(field.label == "金额" and field.value == "48元" for field in insight.fields)
def test_document_intelligence_service_uses_vlm_result_when_preview_available(monkeypatch) -> None:
calls: list[tuple[str, ...]] = []
def fake_complete(self, messages, *, slot_priority=("main", "backup"), max_tokens=500, temperature=0.2):
calls.append(slot_priority)
if slot_priority == ("vlm",):
assert isinstance(messages[1]["content"], list)
return json.dumps(
{
"document_type": "taxi_receipt",
"scene_code": "transport",
"scene_label": "交通票据",
"expense_type": "transport",
"confidence": 0.91,
"evidence": ["图片主体为滴滴行程单OCR 中出现订单号、上车、下车等字段"],
},
ensure_ascii=False,
)
return None
monkeypatch.setattr(RuntimeChatService, "complete", fake_complete)
def test_document_intelligence_service_uses_rule_result_when_preview_available() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
@@ -62,8 +38,7 @@ def test_document_intelligence_service_uses_vlm_result_when_preview_available(mo
session.close()
assert insight.document_type == "taxi_receipt"
assert insight.classification_source == "llm_vision"
assert calls[0] == ("vlm",)
assert insight.classification_source == "rule"
def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_candidates() -> None:
@@ -76,28 +51,7 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
def test_document_intelligence_service_uses_vlm_fields_to_correct_amount(monkeypatch) -> None:
def fake_complete(self, messages, *, slot_priority=("main", "backup"), max_tokens=500, temperature=0.2):
if slot_priority == ("vlm",):
return json.dumps(
{
"document_type": "taxi_receipt",
"scene_code": "transport",
"scene_label": "交通票据",
"expense_type": "transport",
"confidence": 0.89,
"evidence": ["图片主体为滴滴行程单,金额区域显示 13.4 元"],
"fields": [
{"key": "amount", "label": "金额", "value": "13.4"},
{"key": "merchant_name", "label": "商户", "value": "滴滴出行"},
],
},
ensure_ascii=False,
)
return None
monkeypatch.setattr(RuntimeChatService, "complete", fake_complete)
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
@@ -114,5 +68,5 @@ def test_document_intelligence_service_uses_vlm_fields_to_correct_amount(monkeyp
finally:
session.close()
assert any(field.label == "金额" and field.value == "13.4" for field in insight.fields)
assert any("大模型复核结果修正" in warning for warning in insight.warnings)
assert any(field.label == "金额" and field.value == "1元" for field in insight.fields)
assert insight.warnings == ()

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.services.knowledge_normalizer import KnowledgeNormalizationService
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def test_knowledge_normalizer_appends_structured_table(monkeypatch) -> None:
session_factory = build_session_factory()
raw_text = (
"表3 出差补贴标准\n\n"
"单位:人民币元/天\n"
"补助类型 项目 港澳台 直辖市/特区/西藏 其他地区 国外\n"
"餐补 自行解决餐食 75 65 55 140\n"
"基本出差补贴 35 35 35 35\n"
"合计 110 100 90 175\n"
)
with session_factory() as db:
service = KnowledgeNormalizationService(db)
monkeypatch.setattr(
service.runtime_chat_service,
"complete",
lambda *args, **kwargs: (
"| 补助类型 | 港澳台 | 直辖市/特区/西藏 | 其他地区 | 国外 |\n"
"|---|---:|---:|---:|---:|\n"
"| 餐补 | 75 | 65 | 55 | 140 |\n"
"| 基本出差补贴 | 35 | 35 | 35 | 35 |\n"
"| 合计 | 110 | 100 | 90 | 175 |"
),
)
enriched = service.build_enriched_text(raw_text)
assert enriched.startswith("# 结构化表格补充")
assert "| 餐补 | 75 | 65 | 55 | 140 |" in enriched
assert enriched.endswith(raw_text.strip())
def test_knowledge_normalizer_keeps_only_markdown_table_body() -> None:
cleaned = KnowledgeNormalizationService._sanitize_answer(
"## 表3 出差补贴标准\n\n"
"| 补助类型 | 港澳台 | 直辖市/特区/西藏 |\n"
"|---|---:|---:|\n"
"| 餐补 | 75 | 65 |\n\n"
"注:主办方统一安排餐食时,不再报销餐补。"
)
assert cleaned == (
"| 补助类型 | 港澳台 | 直辖市/特区/西藏 |\n"
"|---|---:|---:|\n"
"| 餐补 | 75 | 65 |"
)
def test_knowledge_normalizer_builds_section_navigation_without_table() -> None:
session_factory = build_session_factory()
raw_text = (
"第一章 总则\n"
"本制度适用于员工差旅报销和审批管理。\n\n"
"第二章 住宿费标准\n"
"住宿费按照出差城市档位和职级标准执行。\n\n"
"第三章 交通费标准\n"
"交通费应结合出差工具、舱位和审批要求报销。\n"
)
with session_factory() as db:
service = KnowledgeNormalizationService(db)
enriched = service.build_enriched_text(raw_text)
assert enriched.startswith("# 章节导航")
assert "- 第一章 总则" in enriched
assert "## 第二章 住宿费标准" in enriched
assert "# 问答线索补充" in enriched
assert "- 第二章 住宿费标准:住宿费按照出差城市档位和职级标准执行" in enriched
assert enriched.endswith(raw_text.strip())
def test_knowledge_normalizer_builds_answer_clues_from_lists_and_kv_lines() -> None:
session_factory = build_session_factory()
raw_text = (
"第一章 报销要求\n"
"报销时限:费用发生后 30 日内提交申请。\n"
"- 超过 30 日需补充审批说明。\n"
"第十条 发票遗失的,应先提交遗失说明。\n"
)
with session_factory() as db:
service = KnowledgeNormalizationService(db)
enriched = service.build_enriched_text(raw_text)
assert "# 问答线索补充" in enriched
assert "- 第一章 报销要求:报销时限:费用发生后 30 日内提交申请" in enriched
assert "- 第一章 报销要求:超过 30 日需补充审批说明" in enriched
assert "- 第一章 报销要求:第十条 发票遗失的,应先提交遗失说明" in enriched
def test_knowledge_normalizer_builds_answer_clues_without_section_headings() -> None:
session_factory = build_session_factory()
raw_text = (
"报销时限:费用发生后 30 日内提交申请。\n"
"超过 30 日需补充审批说明。\n"
"审批材料包括发票、行程单和付款凭证。\n"
)
with session_factory() as db:
service = KnowledgeNormalizationService(db)
enriched = service.build_enriched_text(raw_text)
assert "# 问答线索补充" in enriched
assert "- 正文:报销时限:费用发生后 30 日内提交申请" in enriched
assert "- 正文:超过 30 日需补充审批说明" in enriched

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from app.services import knowledge_rag as knowledge_rag_module
from app.services.knowledge_rag import KnowledgeRagService
def test_build_hits_prioritizes_structured_table_evidence_for_standard_queries() -> None:
hits = KnowledgeRagService._build_hits_from_query_data(
query="住宿费标准是多少?",
chunks=[
{
"chunk_id": "plain-1",
"file_path": "/tmp/doc-1__差旅制度.md",
"content": "住宿费说明文字,提到了出差和报销要求,但没有清晰表格。",
},
{
"chunk_id": "table-1",
"file_path": "/tmp/doc-1__差旅制度.md",
"content": "# 结构化表格补充\n\n| 城市 | 住宿费标准 |\n| 北京 | 500 |",
},
],
entities=[],
limit=2,
)
assert [item["candidate_id"] for item in hits] == ["table-1", "plain-1"]
def test_build_hits_boosts_query_term_matches() -> None:
hits = KnowledgeRagService._build_hits_from_query_data(
query="招待费报销标准",
chunks=[
{
"chunk_id": "travel-1",
"file_path": "/tmp/doc-1__费用制度.md",
"content": "差旅费包含交通费、住宿费和餐补标准。",
},
{
"chunk_id": "ent-1",
"file_path": "/tmp/doc-1__费用制度.md",
"content": "业务招待费报销标准:应结合客户接待场景、人数和审批要求执行。",
},
],
entities=[],
limit=2,
)
assert [item["candidate_id"] for item in hits] == ["ent-1", "travel-1"]
def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
hits = KnowledgeRagService._build_hits_from_query_data(
query="报销时限是多少?",
chunks=[
{
"chunk_id": "plain-1",
"file_path": "/tmp/doc-1__费用制度.md",
"content": "本制度用于规范报销流程,员工应遵守公司审批要求。",
},
{
"chunk_id": "clue-1",
"file_path": "/tmp/doc-1__费用制度.md",
"content": (
"# 问答线索补充\n\n"
"- 第二章 报销时限:费用发生后 30 日内提交申请。\n"
"- 第二章 报销时限:超过 30 日需补充审批说明。"
),
},
],
entities=[],
limit=2,
)
assert [item["candidate_id"] for item in hits] == ["clue-1", "plain-1"]
def test_resolve_default_qdrant_url_prefers_container_host(monkeypatch) -> None:
monkeypatch.setattr(
knowledge_rag_module.socket,
"getaddrinfo",
lambda hostname, port: [("family", "type", "proto", "canonname", ("172.21.0.2", 0))]
if hostname == "qdrant"
else [],
)
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://qdrant:6333"
def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
def raise_lookup_error(_hostname, _port):
raise OSError("lookup failed")
monkeypatch.setattr(knowledge_rag_module.socket, "getaddrinfo", raise_lookup_error)
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"

View File

@@ -1,713 +0,0 @@
from __future__ import annotations
import json
from subprocess import TimeoutExpired
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.core.agent_enums import AgentReviewStatus, AgentRunSource, AgentRunStatus
from app.db.base import Base
from app.main import create_app
from app.schemas.agent_asset import AgentAssetReviewCreate
from app.schemas.knowledge import LlmWikiSummaryUpdateWrite
from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED,
KNOWLEDGE_INGEST_STATUS_INGESTED,
KNOWLEDGE_INGEST_STATUS_PUBLISHED,
KnowledgeService,
)
from app.services.llm_wiki import CandidateModelAttempt, LlmWikiService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def build_admin_user() -> CurrentUserContext:
return CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
)
def upload_policy_document(storage_root: Path, *, filename: str = "公司差旅报销制度.txt") -> str:
service = KnowledgeService(storage_root=storage_root)
service.ensure_library_ready()
document = service.upload_document(
folder="报销制度",
filename=filename,
content=(
"第一章 差旅报销\n"
"员工因公出差发生的住宿费应按照公司差旅标准执行。\n"
"住宿费超过标准时,必须升级至总经理审批。\n"
"报销时必须提供发票、行程单和审批说明。\n"
).encode("utf-8"),
current_user=build_admin_user(),
)
return document.id
def upload_multipage_policy_document(storage_root: Path, *, filename: str = "公司支出管理办法.txt") -> str:
service = KnowledgeService(storage_root=storage_root)
service.ensure_library_ready()
document = service.upload_document(
folder="报销制度",
filename=filename,
content=(
"商密【中】\n"
"关于颁布《公司支出管理办法》的通知\n"
"特此通知。\n"
"\f"
"目录\n"
"第一章 总则................................4\n"
"第二章 报销审批................................7\n"
"\f"
"第一条 报销申请\n"
"员工提交报销申请时,应附发票、行程单和审批说明。\n"
"第二条 报销审批\n"
"住宿费超过制度标准时,必须升级至总经理审批。\n"
"第三条 附件补充\n"
"缺少附件时不得提交报销。\n"
"\f"
"第四条 财务复核\n"
"财务复核时应校验预算、发票真伪和审批链完整性。\n"
).encode("utf-8"),
current_user=build_admin_user(),
)
return document.id
def build_candidate_payload(chunk_id: str, *, summary: str = "住宿费超过标准时必须升级审批。") -> dict[str, object]:
return {
"knowledge_candidates": [
{
"title": "住宿费升级审批要求",
"content": summary,
"scenario": "reimbursement_policy",
"tags": ["住宿", "审批"],
"evidence": [summary],
"confidence": 0.91,
"source_chunk_ids": [chunk_id],
}
],
"rule_candidates": [
{
"template_key": "expense_amount_limit_v1",
"suggested_rule_name": "住宿费超标审批规则",
"summary": "当住宿费超过制度标准时触发升级审批。",
"scenario": "travel_standard",
"purpose": "识别差旅住宿费是否超出制度标准。",
"scope": "适用于员工差旅住宿报销场景。",
"inputs": ["expense_type", "amount", "travel_grade"],
"judgement_logic": [summary],
"outputs": ["approval_required=true", "risk_level=medium"],
"admin_note": "上线前需要由财务补充不同职级的金额阈值。",
"runtime_rule": {
"target": {
"expense_types": ["hotel"],
"scene_codes": ["travel_standard"],
"metric": "item_amount",
},
"threshold": {
"currency": "CNY",
"comparator": "gt",
"warn_amount": "450.00",
"block_amount": "600.00",
"source": "document_value",
},
"exception_policy": {
"allow_with_explanation": True,
"keywords": ["超标说明", "协议酒店满房"],
},
"output": {
"risk_code": "travel_hotel_limit",
"action": "review",
"message": "住宿费超过制度标准时需要升级审批。",
},
},
"evidence": [summary],
"confidence": 0.93,
"source_chunk_ids": [chunk_id],
}
],
}
def build_invalid_candidate_payload(chunk_id: str) -> dict[str, object]:
return {
"knowledge_candidates": [],
"rule_candidates": [
{
"template_key": "expense_amount_limit_v1",
"suggested_rule_name": "无效金额规则草稿",
"summary": "用于验证 schema 强校验。",
"scenario": "travel_standard",
"purpose": "验证不合规的 runtime_rule 不会落到规则中心。",
"scope": "测试场景。",
"inputs": ["expense_type", "amount"],
"judgement_logic": ["金额超过标准则需审批。"],
"outputs": ["approval_required=true"],
"admin_note": "此规则故意构造错误阈值。",
"runtime_rule": {
"target": {
"expense_types": ["hotel"],
"scene_codes": ["travel_standard"],
"metric": "item_amount",
},
"threshold": {
"currency": "CNY",
"comparator": "gt",
"warn_amount": "600.00",
"block_amount": "450.00",
"source": "document_value",
},
"output": {
"risk_code": "travel_hotel_limit",
"action": "review",
"message": "无效阈值。",
},
},
"evidence": ["金额阈值配置不应允许 block 小于 warn。"],
"confidence": 0.88,
"source_chunk_ids": [chunk_id],
}
],
}
def update_document_timestamp(storage_root: Path, document_id: str, updated_at: str) -> None:
index_path = storage_root / "knowledge" / ".index.json"
payload = json.loads(index_path.read_text(encoding="utf-8"))
for item in payload["documents"]:
if item["id"] == document_id:
item["updated_at"] = updated_at
break
index_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def test_llm_wiki_sync_creates_artifacts_and_draft_rule(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
result = service.sync_folder(folder="报销制度", current_user=build_admin_user())
assert result.document_count == 1
assert result.knowledge_candidate_count == 1
assert result.rule_candidate_count == 1
assert result.generated_rule_count == 1
assert len(result.generated_rule_asset_ids) == 1
document_dir = tmp_path / "knowledge" / ".llm_wiki" / "documents" / document_id
assert (document_dir / "document.json").exists()
assert (document_dir / "text.md").exists()
assert (document_dir / "chunks.json").exists()
assert (document_dir / "knowledge_candidates.json").exists()
assert (document_dir / "knowledge_summary.md").exists()
assert (document_dir / "rule_candidates.json").exists()
document_payload = json.loads((document_dir / "document.json").read_text(encoding="utf-8"))
assert document_payload["sync_reason"] == "initial_build"
assert document_payload["quality_status"] == "formal"
assert document_payload["formal_knowledge_candidate_count"] == 1
assert document_payload["fallback_knowledge_candidate_count"] == 0
detail = service.get_document_detail(document_id)
assert "公司差旅报销制度.txt 知识总结" in detail.knowledge_summary_markdown
assert "住宿费升级审批要求" in detail.knowledge_summary_markdown
assert detail.quality_status == "formal"
asset = AgentAssetService(db).get_asset(result.generated_rule_asset_ids[0])
assert asset is not None
assert asset.status == "draft"
assert asset.config_json["llm_wiki_managed"] is True
assert asset.config_json["runtime_rule"]["template_key"] == "expense_amount_limit_v1"
assert asset.config_json["runtime_rule"]["threshold"]["block_amount"] == "600.00"
assert "```expense-rule" in str(asset.current_version_content)
def test_llm_wiki_document_summary_can_be_updated(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
service.sync_folder(folder="报销制度", current_user=build_admin_user())
updated = service.update_document_summary(
document_id,
LlmWikiSummaryUpdateWrite(
knowledge_summary_markdown="# 人工修订总结\n\n- 住宿费超标必须升级审批。\n- 报销时必须附发票和审批说明。"
),
)
assert updated.document_id == document_id
assert updated.knowledge_summary_markdown.startswith("# 人工修订总结")
summary_path = tmp_path / "knowledge" / ".llm_wiki" / "documents" / document_id / "knowledge_summary.md"
assert summary_path.read_text(encoding="utf-8").startswith("# 人工修订总结")
def test_llm_wiki_sync_rejects_invalid_runtime_rule_schema(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_invalid_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
result = service.sync_folder(folder="报销制度", current_user=build_admin_user())
assert result.document_count == 1
assert result.rule_candidate_count == 1
assert result.generated_rule_count == 0
document_dir = tmp_path / "knowledge" / ".llm_wiki" / "documents" / document_id
rule_candidates = json.loads((document_dir / "rule_candidates.json").read_text(encoding="utf-8"))
assert rule_candidates[0]["validation_status"] == "invalid"
assert rule_candidates[0]["status"] == "validation_failed"
assert rule_candidates[0]["validation_errors"]
assert "block_amount" in " ".join(rule_candidates[0]["validation_errors"])
def test_knowledge_document_state_changes_with_llm_wiki_sync(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
knowledge_service = KnowledgeService(storage_root=tmp_path)
initial_detail = knowledge_service.get_document_detail(document_id)
assert initial_detail.stateCode == KNOWLEDGE_INGEST_STATUS_PUBLISHED
assert initial_detail.state == "待归纳"
with build_session() as db:
LlmWikiService(db, storage_root=tmp_path).sync_folder(
folder="报销制度",
current_user=build_admin_user(),
document_ids=[document_id],
)
ingested_detail = knowledge_service.get_document_detail(document_id)
assert ingested_detail.stateCode == KNOWLEDGE_INGEST_STATUS_INGESTED
assert ingested_detail.state == "已归纳"
updated_detail = knowledge_service.upload_document(
folder="报销制度",
filename="公司差旅报销制度.txt",
content=(
"第一章 差旅报销\n"
"员工因公出差发生的住宿费应按照公司差旅标准执行。\n"
"新增:超标住宿必须附书面说明。\n"
).encode("utf-8"),
current_user=build_admin_user(),
)
assert updated_detail.id == document_id
assert updated_detail.stateCode == KNOWLEDGE_INGEST_STATUS_PUBLISHED
assert updated_detail.state == "待归纳"
index_payload = json.loads((tmp_path / "knowledge" / ".index.json").read_text(encoding="utf-8"))
stored_entry = next(item for item in index_payload["documents"] if item["id"] == document_id)
assert stored_entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_PUBLISHED
def test_llm_wiki_sync_marks_document_failed_when_ingest_raises(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
raise RuntimeError("simulated llm wiki failure")
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
with pytest.raises(RuntimeError, match="simulated llm wiki failure"):
service.sync_folder(
folder="报销制度",
current_user=build_admin_user(),
document_ids=[document_id],
)
detail = KnowledgeService(storage_root=tmp_path).get_document_detail(document_id)
assert detail.stateCode == KNOWLEDGE_INGEST_STATUS_FAILED
assert detail.state == "归纳失败"
def test_llm_wiki_sync_uses_fallback_candidates_when_system_hermes_times_out(
tmp_path,
monkeypatch,
) -> None:
document_id = upload_policy_document(tmp_path)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
monkeypatch.setattr(service.system_hermes_service, "is_available", lambda: True)
def fake_run_query(*args, **kwargs):
raise TimeoutExpired(cmd="hermes", timeout=1)
monkeypatch.setattr(service.system_hermes_service, "run_query", fake_run_query)
runtime_called = {"count": 0}
def fail_runtime_complete(*args, **kwargs):
runtime_called["count"] += 1
raise AssertionError("system hermes timeout should fall back directly to local candidate builder")
monkeypatch.setattr(service.runtime_chat_service, "complete", fail_runtime_complete)
result = service.sync_folder(
folder="报销制度",
current_user=build_admin_user(),
document_ids=[document_id],
)
assert result.document_count == 1
assert result.knowledge_candidate_count >= 1
assert runtime_called["count"] == 0
knowledge_service = KnowledgeService(storage_root=tmp_path)
detail = knowledge_service.get_document_detail(document_id)
assert detail.stateCode == KNOWLEDGE_INGEST_STATUS_FAILED
assert detail.state == "归纳失败"
assert detail.llmWikiAvailable is True
assert detail.llmWikiQualityStatus == "fallback_only"
document_payload = json.loads(
(
tmp_path
/ "knowledge"
/ ".llm_wiki"
/ "documents"
/ document_id
/ "document.json"
).read_text(encoding="utf-8")
)
assert document_payload["quality_status"] == "fallback_only"
assert document_payload["formal_knowledge_candidate_count"] == 0
assert document_payload["fallback_knowledge_candidate_count"] == 1
candidates_payload = json.loads(
(
tmp_path
/ "knowledge"
/ ".llm_wiki"
/ "documents"
/ document_id
/ "knowledge_candidates.json"
).read_text(encoding="utf-8")
)
assert candidates_payload[0]["extraction_mode"] == "fallback"
assert "fallback_only" in candidates_payload[0]["quality_flags"]
def test_llm_wiki_sync_continues_after_single_group_failure(tmp_path, monkeypatch) -> None:
document_id = upload_multipage_policy_document(tmp_path, filename="多页支出制度.txt")
call_count = {"count": 0}
def fake_call_candidate_model(self, *, entry, chunk_group):
call_count["count"] += 1
if call_count["count"] == 1:
return CandidateModelAttempt(
payload={},
source="hermes",
ok=False,
failure_reason="simulated_timeout",
)
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
result = service.sync_folder(
folder="报销制度",
current_user=build_admin_user(),
document_ids=[document_id],
)
detail = service.get_document_detail(document_id)
assert result.document_count == 1
assert call_count["count"] >= 2
assert detail.quality_status == "partial_degraded"
assert detail.successful_group_count >= 1
assert detail.failed_group_count >= 1
assert detail.formal_knowledge_candidate_count >= 1
knowledge_detail = KnowledgeService(storage_root=tmp_path).get_document_detail(document_id)
assert knowledge_detail.stateCode == KNOWLEDGE_INGEST_STATUS_INGESTED
assert knowledge_detail.llmWikiQualityStatus == "partial_degraded"
def test_llm_wiki_filters_cover_and_catalog_chunks_before_candidate_extraction(tmp_path) -> None:
document_id = upload_multipage_policy_document(tmp_path, filename="封面目录过滤测试.txt")
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
text = service.knowledge_service.extract_document_text(document_id)
chunks = service._build_chunks(document_id=document_id, text=text)
candidate_chunks = service._select_candidate_chunks(chunks)
assert len(chunks) > len(candidate_chunks)
assert candidate_chunks
assert all(int(item.get("source_page") or 0) >= 3 for item in candidate_chunks)
def test_llm_wiki_sync_skips_unchanged_and_rebuilds_on_updated_at_change(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
first = service.sync_folder(folder="报销制度", current_user=build_admin_user())
second = service.sync_folder(folder="报销制度", current_user=build_admin_user())
assert first.document_count == 1
assert second.document_count == 0
assert "未变化,跳过" in second.summary
update_document_timestamp(tmp_path, document_id, "2026-05-15T09:30:00+00:00")
third = service.sync_folder(folder="报销制度", current_user=build_admin_user())
assert third.document_count == 1
document_dir = tmp_path / "knowledge" / ".llm_wiki" / "documents" / document_id
document_payload = json.loads((document_dir / "document.json").read_text(encoding="utf-8"))
assert document_payload["sync_reason"] == "updated_at_changed"
def test_llm_wiki_sync_does_not_overwrite_active_rule(tmp_path, monkeypatch) -> None:
document_id = upload_policy_document(tmp_path)
def fake_call_candidate_model(self, *, entry, chunk_group):
return build_candidate_payload(chunk_group[0]["chunk_id"])
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model)
with build_session() as db:
service = LlmWikiService(db, storage_root=tmp_path)
first = service.sync_folder(folder="报销制度", current_user=build_admin_user())
asset_id = first.generated_rule_asset_ids[0]
asset_service = AgentAssetService(db)
asset_detail = asset_service.get_asset(asset_id)
assert asset_detail is not None
asset_service.create_review(
asset_id,
AgentAssetReviewCreate(
version=asset_detail.current_version or "v1.0.0",
reviewer="管理员",
review_status=AgentReviewStatus.APPROVED,
review_note="允许上线",
),
actor="管理员",
)
activated = asset_service.activate_asset(asset_id, actor="管理员")
assert activated.status == "active"
original_version = activated.current_version
original_content = activated.current_version_content
original_config = activated.config_json
def fake_call_candidate_model_changed(self, *, entry, chunk_group):
return build_candidate_payload(
chunk_group[0]["chunk_id"],
summary="住宿费超过标准时,必须升级审批并记录超标原因。",
)
monkeypatch.setattr(LlmWikiService, "_call_candidate_model", fake_call_candidate_model_changed)
update_document_timestamp(tmp_path, document_id, "2026-05-15T10:00:00+00:00")
second = service.sync_folder(folder="报销制度", current_user=build_admin_user())
refreshed = asset_service.get_asset(asset_id)
assert second.document_count == 1
assert second.generated_rule_count == 0
assert refreshed is not None
assert refreshed.status == "active"
assert refreshed.current_version == original_version
assert refreshed.current_version_content == original_content
assert refreshed.config_json == original_config
def test_llm_wiki_sync_endpoint_records_agent_run(monkeypatch) -> None:
client, session_factory = build_client()
def fake_submit_sync(*, agent_run_id, folder, current_user, document_ids=None, force=False):
with session_factory() as db:
service = AgentRunService(db)
service.record_tool_call(
run_id=agent_run_id,
tool_type="llm",
tool_name="system_hermes_llm_wiki_sync",
request_json={
"folder": folder,
"document_ids": list(document_ids or []),
"force": force,
},
response_json={"run_id": "wiki_test_sync"},
status="succeeded",
duration_ms=0,
)
service.merge_route_json(
agent_run_id,
{
"phase": "succeeded",
"sync_run_id": "wiki_test_sync",
"progress": {
"total_documents": len(document_ids or []),
"completed_documents": len(document_ids or []),
"failed_documents": 0,
"skipped_documents": 0,
"percent": 100,
},
},
status=AgentRunStatus.SUCCEEDED.value,
result_summary="已完成 Hermes LLM Wiki 同步。",
)
monkeypatch.setattr(
"app.services.llm_wiki_tasks.llm_wiki_task_manager.submit_sync",
fake_submit_sync,
)
with session_factory() as db:
before_count = len(AgentRunService(db).list_runs(limit=100))
response = client.post(
"/api/v1/knowledge/llm-wiki/sync",
json={"folder": "报销制度", "force": False},
headers={
"x-auth-username": "admin",
"x-auth-name": "admin",
"x-auth-is-admin": "true",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["agent_run_id"].startswith("run_")
assert payload["status"] == AgentRunStatus.RUNNING.value
with session_factory() as db:
service = AgentRunService(db)
after_runs = service.list_runs(limit=100)
assert len(after_runs) == before_count + 1
latest_run = after_runs[0]
assert latest_run.agent == "hermes"
assert latest_run.source == AgentRunSource.SCHEDULE.value
assert latest_run.status == AgentRunStatus.SUCCEEDED.value
assert latest_run.tool_calls
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

@@ -310,6 +310,44 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu
assert result.clarification_required is False
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = SemanticOntologyService(db)
monkeypatch.setattr(
service,
"_parse_with_model",
lambda **kwargs: LlmOntologyParseResult(
scenario="expense",
intent="draft",
confidence=0.91,
clarification_required=True,
clarification_question="请补充招待对象和票据附件。",
missing_slots=["participants", "attachments"],
ambiguity=[],
entity_hints=[],
),
)
result = service.parse(
OntologyParseRequest(
query="我要去北京出差3天一共可以报销多少钱",
user_id="pytest",
context_json={
"role_codes": ["employee"],
"name": "曹笑竹",
"grade": "P3",
"session_type": "knowledge",
},
)
)
assert result.scenario == "knowledge"
assert result.intent == "query"
assert result.clarification_required is False
assert result.clarification_question is None
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
session_factory = build_session_factory()
with session_factory() as db:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.services import runtime_chat as runtime_chat_module
from app.services.runtime_chat import RuntimeChatService
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _clear_runtime_chat_cooldown() -> None:
runtime_chat_module._slot_failure_until.clear()
def test_runtime_chat_fails_over_to_backup_before_retrying_main(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": "MiniMax" if slot == "main" else "GLM",
"endpoint": "https://example.com/v1",
"model": "main-model" if slot == "main" else "backup-model",
"apiKey": "secret",
}
def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds):
del messages, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
if config["slot"] == "main":
raise RuntimeError("main unavailable")
return "backup answer"
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion)
answer = service.complete([{"role": "user", "content": "hello"}])
assert answer == "backup answer"
assert calls == ["main", "backup"]
def test_runtime_chat_does_not_rehit_failed_slots_during_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": slot,
"endpoint": "https://example.com/v1",
"model": f"{slot}-model",
"apiKey": "secret",
}
def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds):
del messages, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
raise RuntimeError("unavailable")
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion)
monkeypatch.setattr("app.services.runtime_chat.sleep", lambda *_args, **_kwargs: None)
assert service.complete([{"role": "user", "content": "hello"}]) is None
assert calls == ["main", "backup"]
def test_runtime_chat_disables_glm_thinking_for_direct_user_answers(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
captured: dict[str, object] = {}
def fake_send_json_request(method, url, *, headers, payload, timeout_seconds):
captured["method"] = method
captured["url"] = url
captured["headers"] = headers
captured["payload"] = payload
captured["timeout_seconds"] = timeout_seconds
return 200, {"choices": [{"message": {"content": "ok"}}]}
monkeypatch.setattr("app.services.runtime_chat._send_json_request", fake_send_json_request)
answer = service._request_openai_compatible(
provider="GLM",
endpoint="https://open.bigmodel.cn/api/paas/v4/",
model="glm-5.1",
api_key="secret",
messages=[{"role": "user", "content": "hello"}],
max_tokens=32,
temperature=0.2,
timeout_seconds=17,
)
assert answer == "ok"
assert captured["payload"]["thinking"] == {"type": "disabled"}
assert captured["timeout_seconds"] == 17
def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[tuple[str, int]] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": slot,
"endpoint": "https://example.com/v1",
"model": f"{slot}-model",
"apiKey": "secret",
}
def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds):
del messages, max_tokens, temperature
calls.append((config["slot"], timeout_seconds))
raise RuntimeError("unavailable")
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion)
assert (
service.complete(
[{"role": "user", "content": "hello"}],
timeout_seconds=15,
slot_timeouts={"main": 8, "backup": 20},
max_attempts=1,
)
is None
)
assert calls == [("main", 8), ("backup", 20)]
def test_runtime_chat_skips_slot_during_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": slot,
"endpoint": "https://example.com/v1",
"model": f"{slot}-model",
"apiKey": "secret",
}
def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds):
del messages, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
if config["slot"] == "main":
raise RuntimeError("main unavailable")
return "backup answer"
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion)
assert service.complete([{"role": "user", "content": "hello"}], max_attempts=1) == "backup answer"
assert service.complete([{"role": "user", "content": "hello again"}], max_attempts=1) == "backup answer"
assert calls == ["main", "backup", "backup"]

View File

@@ -10,8 +10,9 @@ import yaml
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core import admin_secret
from app.core import secret_box
from app.core import admin_secret
from app.core import secret_box
from app.core.secret_box import encrypt_secret
from app.db.base import Base
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
@@ -245,3 +246,55 @@ def test_blank_secret_input_keeps_synced_hermes_api_key(monkeypatch) -> None:
hermes_config = yaml.safe_load(get_hermes_config_path().read_text(encoding="utf-8"))
assert hermes_config["model"]["default"] == "gpt-5.4-mini"
assert hermes_config["model"]["api_key"] == "persisted-main-key"
def test_settings_service_migrates_legacy_vlm_slot_to_reranker(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
service.get_settings_snapshot()
settings_row = db.get(SystemSetting, "default")
secrets_row = db.get(SystemSettingSecret, "default")
reranker_row = db.get(SystemModelSetting, "reranker")
assert settings_row is not None
assert secrets_row is not None
assert reranker_row is not None
db.delete(reranker_row)
settings_row.reranker_provider = ""
settings_row.reranker_model = ""
settings_row.reranker_endpoint = ""
settings_row.vlm_provider = "Gemini"
settings_row.vlm_model = "legacy-reranker"
settings_row.vlm_endpoint = "https://legacy.example.com/v1"
secrets_row.reranker_api_key_encrypted = ""
secrets_row.vlm_api_key_encrypted = encrypt_secret("legacy-reranker-key")
db.add(
SystemModelSetting(
slot="vlm",
provider="Gemini",
model_name="legacy-reranker",
endpoint="https://legacy.example.com/v1",
capability="chat",
priority=30,
enabled=True,
api_key_encrypted=encrypt_secret("legacy-reranker-key"),
)
)
db.commit()
snapshot = service.get_settings_snapshot()
assert snapshot.llmForm.rerankerProvider == "Gemini"
assert snapshot.llmForm.rerankerModel == "legacy-reranker"
assert snapshot.llmForm.rerankerEndpoint == "https://legacy.example.com/v1"
assert snapshot.llmForm.rerankerApiKeyConfigured is True
assert db.get(SystemModelSetting, "vlm") is None
assert db.get(SystemModelSetting, "reranker") is not None
assert service.load_saved_model_api_key("reranker") == "legacy-reranker-key"

View File

@@ -64,6 +64,39 @@ def test_probe_azure_embedding_model(monkeypatch) -> None:
assert captured["payload"]["input"] == "connectivity test"
def test_probe_openai_compatible_reranker_model(monkeypatch) -> None:
captured: dict[str, object] = {}
def fake_send_json_request(method, url, *, headers, payload):
captured["method"] = method
captured["url"] = url
captured["headers"] = headers
captured["payload"] = payload
return 200, {"results": []}
monkeypatch.setattr("app.services.model_connectivity._send_json_request", fake_send_json_request)
result = probe_model_connectivity(
ModelConnectivityTestRequest(
provider="OpenAI Compatible",
endpoint="https://api.example.com/v1",
model="reranker-v1",
api_key="secret",
capability="reranker",
)
)
assert result.ok is True
assert captured["method"] == "POST"
assert captured["url"] == "https://api.example.com/v1/rerank"
assert captured["headers"]["Authorization"] == "Bearer secret"
assert captured["payload"] == {
"model": "reranker-v1",
"query": "connectivity test",
"documents": ["sample document"],
}
def test_probe_ollama_failure_returns_error_payload(monkeypatch) -> None:
def fake_send_json_request(method, url, *, headers, payload):
raise ConnectivityCheckError("模型不存在或尚未拉取。", status_code=404)

View File

@@ -83,7 +83,7 @@ def test_user_agent_sanitizes_model_thinking_blocks() -> None:
)
def test_user_agent_rejects_visible_reasoning_drafts() -> None:
def test_user_agent_rejects_visible_reasoning_drafts() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = UserAgentService(db)
@@ -93,10 +93,43 @@ def test_user_agent_rejects_visible_reasoning_drafts() -> None:
"用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..."
)
is None
)
def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
tool_payload={"result_type": "knowledge_search", "hits": []},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
assert "只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence" in messages[0]["content"]
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
assert "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
def test_user_agent_knowledge_prompt_enforces_llm_wiki_boundary() -> None:
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
@@ -107,7 +140,48 @@ def test_user_agent_knowledge_prompt_enforces_llm_wiki_boundary() -> None:
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
answer = service._build_knowledge_search_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
context_json={"name": "张三"},
tool_payload={
"result_type": "knowledge_search",
"hits": [{"title": "差旅费制度", "content": "住宿费标准正文"}],
},
),
citations=[],
)
assert answer.startswith("张三,您好。")
assert "答案整理阶段本轮没有及时返回" in answer
assert "先给你当前最直接的依据" in answer
assert "《差旅费制度》" in answer
def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
captured: dict[str, object] = {}
def fake_complete(messages, **kwargs):
captured["messages"] = messages
captured.update(kwargs)
return "测试回答"
monkeypatch.setattr(service.runtime_chat_service, "complete", fake_complete)
answer = service._generate_answer_with_model(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
@@ -122,14 +196,177 @@ def test_user_agent_knowledge_prompt_enforces_llm_wiki_boundary() -> None:
fallback_answer="",
)
assert "只能依据 tool_payload.hits 中的 LLM Wiki 内容作答" in messages[0]["content"]
assert answer == "测试回答"
assert captured["timeout_seconds"] == 5
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
assert captured["max_attempts"] == 1
def test_user_agent_prefers_structured_knowledge_hit_for_answer_generation() -> None:
selected = UserAgentService._select_knowledge_model_hits(
{
"hits": [
{"content": "raw hit 1"},
{"content": "raw hit 2"},
{"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"},
{"content": "# 结构化表格补充\n\n| 项目 | 金额 |"},
]
}
)
assert selected[0]["content"].startswith("# 问答线索补充")
assert selected[1]["content"].startswith("# 结构化表格补充")
assert selected[2]["content"] == "raw hit 1"
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="报销时限是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
monkeypatch.setattr(
service,
"_generate_answer_with_model",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")),
)
response = service.respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="报销时限是多少?",
ontology=ontology,
context_json={
"name": "张三",
"session_type": "knowledge",
"user_input_text": "报销时限是多少?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 问答线索补充\n\n"
"- 第二章 报销时限:员工应在费用发生后 30 日内提交报销申请。\n"
"- 第二章 报销时限:超过 30 日需补充审批说明。"
),
},
],
},
)
)
assert response.answer.startswith("张三,您好。")
assert "当前能直接确认的是" in response.answer
assert "30 日内提交报销申请" in response.answer
assert "答案整理阶段本轮没有及时返回" not in response.answer
def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="餐补标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="餐补标准是多少?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "餐补标准是多少?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 结构化表格补充\n\n"
"## 表3 出差补贴标准\n\n"
"| 项目 | 港澳台 | 其他地区 | 国外 |\n"
"| --- | --- | --- | --- |\n"
"| 餐补 | 75 | 55 | 140 |\n"
"| 住宿补贴 | 35 | 35 | 35 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
assert "| 餐补 | 75 | 55 | 140 |" in answer
def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="前往北京出差的报销标准是什么?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="前往北京出差的报销标准是什么?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "前往北京出差的报销标准是什么?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 结构化表格补充\n\n"
"## 表3 出差补贴标准\n\n"
"| 项目 | 港澳台 | 直辖市/特区/西藏 | 其他地区 |\n"
"| --- | --- | --- | --- |\n"
"| 餐补 | 75 | 65 | 55 |\n"
"| 基本补贴 | 35 | 35 | 35 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我能坐什么舱位?",
user_id="pytest",
)
@@ -159,8 +396,8 @@ def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
system_prompt = messages[0]["content"]
user_prompt = messages[1]["content"]
assert "user_grade" in system_prompt
assert "conversation_history" in system_prompt
assert "context.user_grade" in system_prompt
assert "conversation_history" in user_prompt
assert '"user_name": "张三"' in user_prompt
assert '"user_position": "财务分析师"' in user_prompt
assert '"user_grade": "P5"' in user_prompt