feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能
主要变更: - 移除Hermes智能体及相关回调服务 - 新增知识库RAG、同步、调度、规范化和索引任务服务 - 重构orchestrator服务,增强运行时聊天功能 - 更新前端聊天、政策制度、设置等页面样式和逻辑 - 更新expense_claims和document_intelligence服务 - 删除llm_wiki相关服务和测试文件 - 更新docker-compose配置和启动脚本
This commit is contained in:
@@ -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 == ()
|
||||
|
||||
123
server/tests/test_knowledge_normalizer.py
Normal file
123
server/tests/test_knowledge_normalizer.py
Normal 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
|
||||
95
server/tests/test_knowledge_rag_service.py
Normal file
95
server/tests/test_knowledge_rag_service.py
Normal 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"
|
||||
@@ -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
|
||||
@@ -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
184
server/tests/test_runtime_chat_service.py
Normal file
184
server/tests/test_runtime_chat_service.py
Normal 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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user