869 lines
28 KiB
Python
869 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from sqlalchemy import JSON, select
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
from app.models.base import Base
|
||
from app.models.knowledge_base_chunk import KnowledgeBaseChunk
|
||
from app.models.knowledge_base_doc import KnowledgeBaseDoc
|
||
from app.models.project import Project
|
||
from app.models.semantic_fact import SemanticFact
|
||
from app.models.semantic_fact_extraction_run import SemanticFactExtractionRun
|
||
from app.models.signal import Signal
|
||
from app.models.task import Task
|
||
from app.models.truth_node import TruthNode
|
||
from app.models.user import User
|
||
from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
|
||
from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1
|
||
from app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1 import AdaptiveRAGFastAnswerSemanticMixinP1
|
||
from app.services.adaptive_rag_service.mixin_fast_answer_project_main import AdaptiveRAGFastAnswerProjectMainMixin
|
||
from app.services.adaptive_rag_service.mixin_fast_answer_project_utils import AdaptiveRAGFastAnswerProjectUtilsMixin
|
||
from app.services.adaptive_rag_service.internal_query_context import InternalContextPacket
|
||
from app.services.adaptive_rag_service.mixin_retrieval_router_db import AdaptiveRAGRetrievalRouterDBMixin
|
||
from app.services.llm_service import StructuredExtractionResult
|
||
from app.services.semantic_fact_service.service import SemanticFactService
|
||
|
||
|
||
def _use_sqlite_json_types(*tables) -> None:
|
||
for table in tables:
|
||
for column in table.columns:
|
||
if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}:
|
||
column.type = JSON()
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def db_session():
|
||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||
tables = [
|
||
User.__table__,
|
||
Project.__table__,
|
||
Signal.__table__,
|
||
TruthNode.__table__,
|
||
Task.__table__,
|
||
KnowledgeBaseDoc.__table__,
|
||
KnowledgeBaseChunk.__table__,
|
||
SemanticFact.__table__,
|
||
SemanticFactExtractionRun.__table__,
|
||
]
|
||
_use_sqlite_json_types(*tables)
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables))
|
||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||
async with maker() as session:
|
||
yield session
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_fact_service_upserts_and_queries_project_fact(db_session) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="a-share-analyzer",
|
||
title="A股趋势打分工具",
|
||
description="面向证券投研团队,聚焦 A 股公告解析。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
service = SemanticFactService(db_session, tenant_id=1)
|
||
await service.upsert_fact(
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
fact_namespace="theme",
|
||
fact_key="market_theme",
|
||
fact_value_text="A股",
|
||
fact_value_norm="a_share",
|
||
value_type="keyword",
|
||
confidence=0.93,
|
||
evidence_source_type="project",
|
||
evidence_source_id=int(project.id),
|
||
evidence_excerpt="聚焦 A 股公告解析",
|
||
extract_version="semantic-facts-v1",
|
||
freshness_at=None,
|
||
)
|
||
await db_session.commit()
|
||
|
||
rows = await service.query_facts(
|
||
entity_type="project",
|
||
query_type="existence",
|
||
filters=[{"fact_key": "market_theme", "value": "A股", "normalized_value": "a_share"}],
|
||
)
|
||
|
||
assert len(rows) == 1
|
||
assert rows[0].fact_key == "market_theme"
|
||
assert rows[0].fact_value_norm == "a_share"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_fact_service_extracts_project_facts_from_llm_payload(monkeypatch, db_session) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="invoice-helper",
|
||
title="开票助手",
|
||
description="面向财务运营,自动生成增值税发票草稿。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.commit()
|
||
|
||
async def _fake_generate_structured_object(prompt: str, **kwargs):
|
||
_ = (prompt, kwargs)
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"facts": [
|
||
{
|
||
"fact_namespace": "domain",
|
||
"fact_key": "business_domain",
|
||
"fact_value_text": "开票系统",
|
||
"fact_value_norm": "invoicing",
|
||
"value_type": "keyword",
|
||
"confidence": 0.94,
|
||
"evidence_excerpt": "自动生成增值税发票草稿",
|
||
},
|
||
{
|
||
"fact_namespace": "alias",
|
||
"fact_key": "aliases",
|
||
"fact_value_text": "开票系统",
|
||
"fact_value_norm": "invoice_system",
|
||
"value_type": "keyword",
|
||
"confidence": 0.9,
|
||
"evidence_excerpt": "开票助手",
|
||
},
|
||
]
|
||
},
|
||
source_field="payload",
|
||
provider="test",
|
||
model="test-model",
|
||
validation="valid",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.llm_service.llm_service.generate_structured_object",
|
||
_fake_generate_structured_object,
|
||
)
|
||
|
||
service = SemanticFactService(db_session, tenant_id=1)
|
||
run = await service.extract_project_facts(project_id=int(project.id), trigger_type="manual_retry")
|
||
await db_session.commit()
|
||
|
||
facts = (
|
||
await db_session.execute(
|
||
select(SemanticFact).where(
|
||
SemanticFact.tenant_id == 1,
|
||
SemanticFact.entity_type == "project",
|
||
SemanticFact.entity_id == int(project.id),
|
||
)
|
||
)
|
||
).scalars().all()
|
||
run_row = (
|
||
await db_session.execute(
|
||
select(SemanticFactExtractionRun).where(
|
||
SemanticFactExtractionRun.tenant_id == 1,
|
||
SemanticFactExtractionRun.id == int(run.id),
|
||
)
|
||
)
|
||
).scalar_one()
|
||
|
||
assert len(facts) == 2
|
||
assert {row.fact_key for row in facts} == {"business_domain", "aliases"}
|
||
assert str(run_row.status) == "completed"
|
||
assert int(run_row.facts_written or 0) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_fact_service_extract_entity_facts_replaces_active_rows_for_requirement(monkeypatch, db_session) -> None:
|
||
user = User(
|
||
tenant_id=1,
|
||
username="semantic_owner",
|
||
email="semantic_owner@example.com",
|
||
password_hash="hash",
|
||
name="语义负责人",
|
||
role="ADMIN",
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
db_session.add(user)
|
||
await db_session.flush()
|
||
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
parent_id=None,
|
||
project_id=None,
|
||
signal_id=None,
|
||
created_by=int(user.id),
|
||
title="开票系统需求",
|
||
content={"summary": "为财务团队建设开票平台"},
|
||
version=1,
|
||
status="draft",
|
||
)
|
||
db_session.add(charter)
|
||
await db_session.commit()
|
||
|
||
payloads = [
|
||
{
|
||
"facts": [
|
||
{
|
||
"fact_namespace": "alias",
|
||
"fact_key": "aliases",
|
||
"fact_value_text": "开票系统",
|
||
"fact_value_norm": "invoice_system",
|
||
"value_type": "keyword",
|
||
"confidence": 0.93,
|
||
"evidence_excerpt": "建设开票平台",
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"facts": [
|
||
{
|
||
"fact_namespace": "alias",
|
||
"fact_key": "aliases",
|
||
"fact_value_text": "财务开票平台",
|
||
"fact_value_norm": "finance_invoice_platform",
|
||
"value_type": "keyword",
|
||
"confidence": 0.95,
|
||
"evidence_excerpt": "建设开票平台",
|
||
}
|
||
]
|
||
},
|
||
]
|
||
|
||
async def _fake_generate_structured_object(prompt: str, **kwargs):
|
||
_ = (prompt, kwargs)
|
||
return StructuredExtractionResult(
|
||
payload=payloads.pop(0),
|
||
source_field="payload",
|
||
provider="test",
|
||
model="test-model",
|
||
validation="valid",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.llm_service.llm_service.generate_structured_object",
|
||
_fake_generate_structured_object,
|
||
)
|
||
|
||
service = SemanticFactService(db_session, tenant_id=1)
|
||
first_run = await service.extract_entity_facts(
|
||
entity_type="requirement",
|
||
entity_id=int(charter.id),
|
||
trigger_type="manual_retry",
|
||
)
|
||
second_run = await service.extract_entity_facts(
|
||
entity_type="requirement",
|
||
entity_id=int(charter.id),
|
||
trigger_type="manual_retry",
|
||
)
|
||
await db_session.commit()
|
||
|
||
all_rows = (
|
||
await db_session.execute(
|
||
select(SemanticFact).where(
|
||
SemanticFact.tenant_id == 1,
|
||
SemanticFact.entity_type == "requirement",
|
||
SemanticFact.entity_id == int(charter.id),
|
||
).order_by(SemanticFact.id.asc())
|
||
)
|
||
).scalars().all()
|
||
active_rows = [row for row in all_rows if row.is_active]
|
||
|
||
assert str(first_run.status) == "completed"
|
||
assert str(second_run.status) == "completed"
|
||
assert len(all_rows) == 2
|
||
assert len(active_rows) == 1
|
||
assert active_rows[0].fact_value_text == "财务开票平台"
|
||
assert all_rows[0].is_active is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_fact_service_deactivate_entity_facts_marks_rows_inactive(db_session) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="market-tool",
|
||
title="趋势分析工具",
|
||
description="这是一个证券行业分析工具。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
service = SemanticFactService(db_session, tenant_id=1)
|
||
await service.upsert_fact(
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
fact_namespace="theme",
|
||
fact_key="market_theme",
|
||
fact_value_text="A股",
|
||
fact_value_norm="a_share",
|
||
value_type="keyword",
|
||
confidence=0.95,
|
||
evidence_source_type="project",
|
||
evidence_source_id=int(project.id),
|
||
evidence_excerpt="A股",
|
||
extract_version="semantic-facts-v1",
|
||
freshness_at=None,
|
||
)
|
||
await db_session.commit()
|
||
|
||
deactivated = await service.deactivate_entity_facts(entity_type="project", entity_id=int(project.id))
|
||
await db_session.commit()
|
||
|
||
rows = (
|
||
await db_session.execute(
|
||
select(SemanticFact).where(
|
||
SemanticFact.tenant_id == 1,
|
||
SemanticFact.entity_type == "project",
|
||
SemanticFact.entity_id == int(project.id),
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
assert deactivated == 1
|
||
assert rows
|
||
assert all(row.is_active is False for row in rows)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_existence_answer_prefers_semantic_facts_before_text_fallback(db_session) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="market-tool",
|
||
title="趋势分析工具",
|
||
description="这是一个证券行业分析工具。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
fact_service = SemanticFactService(db_session, tenant_id=1)
|
||
await fact_service.upsert_fact(
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
fact_namespace="theme",
|
||
fact_key="market_theme",
|
||
fact_value_text="A股",
|
||
fact_value_norm="a_share",
|
||
value_type="keyword",
|
||
confidence=0.95,
|
||
evidence_source_type="project",
|
||
evidence_source_id=int(project.id),
|
||
evidence_excerpt="A股",
|
||
extract_version="semantic-facts-v1",
|
||
freshness_at=None,
|
||
)
|
||
await db_session.commit()
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGFastAnswerProjectMainMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(question: str) -> str: # pragma: no cover - should not be called
|
||
raise AssertionError(f"lexical keyword fallback should be skipped: {question}")
|
||
|
||
@staticmethod
|
||
def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]: # pragma: no cover - should not be called
|
||
raise AssertionError(f"lexical term fallback should be skipped: {question}, {preferred}")
|
||
|
||
service = _FakeService()
|
||
result = await service._try_fast_project_existence_answer(
|
||
clean_q="系统里有没有A股项目?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "project",
|
||
"scope_hint": "global",
|
||
"query_type": "existence",
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"object_mentions": ["A股项目"],
|
||
},
|
||
},
|
||
)
|
||
|
||
assert result is not None
|
||
assert "趋势分析工具" in str(result["answer"])
|
||
assert str(result["sources"][0]["metadata"]["table"]) == "semantic_facts"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_glm_project_semantic_answer_passes_owner_fields_into_prompt(db_session, monkeypatch) -> None:
|
||
owner = User(
|
||
tenant_id=1,
|
||
username="project_owner",
|
||
email="project_owner@example.com",
|
||
password_hash="x",
|
||
name="李雷",
|
||
role="ADMIN",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
db_session.add(owner)
|
||
await db_session.flush()
|
||
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="inventory-reporting",
|
||
title="采购经理每日实时库存上报服务",
|
||
description="采购经理每日查看库存并生成上报。",
|
||
owner_id=int(owner.id),
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
captured: dict[str, str] = {}
|
||
|
||
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
|
||
_ = self
|
||
return str(fallback_template).format(**kwargs).strip()
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
|
||
_fake_render_prompt,
|
||
)
|
||
|
||
class _FakeLLM:
|
||
async def generate(self, prompt: str, **kwargs):
|
||
_ = kwargs
|
||
captured["prompt"] = prompt
|
||
return (
|
||
'{"handled": true, "answer": "有,负责人:李雷。", '
|
||
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
|
||
)
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGFastAnswerSemanticMixinP1,
|
||
AdaptiveRAGConversationUtilsAMixinP1,
|
||
AdaptiveRAGFastAnswerProjectMainMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
service = _FakeService()
|
||
result = await service._try_glm_project_semantic_answer(
|
||
clean_q="系统里有没有采购经理每日实时库存上报服务?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "project",
|
||
"query_type": "existence",
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"answer_contract": "yes_no_with_evidence",
|
||
"object_mentions": ["采购经理每日实时库存上报服务"],
|
||
},
|
||
},
|
||
)
|
||
|
||
assert result is not None
|
||
assert "负责人:李雷" in str(result["answer"])
|
||
assert "owner_name" in captured["prompt"]
|
||
assert "李雷" in captured["prompt"]
|
||
assert "yes_no_with_evidence" in captured["prompt"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_glm_project_semantic_answer_appends_deterministic_attention_points(db_session, monkeypatch) -> None:
|
||
owner = User(
|
||
tenant_id=1,
|
||
username="project_owner_attention",
|
||
email="project_owner_attention@example.com",
|
||
password_hash="x",
|
||
name="李雷",
|
||
role="ADMIN",
|
||
assigned_domains=[],
|
||
workload=0,
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
db_session.add(owner)
|
||
await db_session.flush()
|
||
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="inventory-attention",
|
||
title="采购经理库存预警服务",
|
||
description="采购经理查看库存预警并推进处理。",
|
||
owner_id=int(owner.id),
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
db_session.add(
|
||
Signal(
|
||
tenant_id=1,
|
||
source="feishu",
|
||
title="库存异常待确认",
|
||
summary="库存异常待处理",
|
||
content="库存异常待处理",
|
||
status="待处理",
|
||
processed=False,
|
||
planner_status="pending",
|
||
matched_project_id=int(project.id),
|
||
)
|
||
)
|
||
await db_session.flush()
|
||
|
||
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
|
||
_ = self
|
||
return str(fallback_template).format(**kwargs).strip()
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
|
||
_fake_render_prompt,
|
||
)
|
||
|
||
class _FakeLLM:
|
||
async def generate(self, prompt: str, **kwargs):
|
||
_ = (prompt, kwargs)
|
||
return (
|
||
'{"handled": true, "answer": "有,命中 1 个项目。", '
|
||
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
|
||
)
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGFastAnswerSemanticMixinP1,
|
||
AdaptiveRAGConversationUtilsAMixinP1,
|
||
AdaptiveRAGFastAnswerProjectMainMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
service = _FakeService()
|
||
result = await service._try_glm_project_semantic_answer(
|
||
clean_q="系统里有没有采购经理库存预警服务?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "project",
|
||
"query_type": "existence",
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"answer_contract": "yes_no_with_evidence",
|
||
"object_mentions": ["采购经理库存预警服务"],
|
||
},
|
||
},
|
||
)
|
||
|
||
assert result is not None
|
||
answer = str(result["answer"])
|
||
assert "有,命中 1 个项目。" in answer
|
||
assert "当前判断:" in answer
|
||
assert "当前盘面:" in answer
|
||
assert "建议动作:" in answer
|
||
assert "负责人 李雷" in answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_glm_project_semantic_answer_prefers_internal_context_packet_contract(db_session, monkeypatch) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="inventory-packet",
|
||
title="采购经理库存预警服务",
|
||
description="采购经理查看库存预警并推进处理。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
captured: dict[str, str] = {}
|
||
|
||
async def _fake_render_prompt(self, *, fallback_template: str, **kwargs):
|
||
_ = self
|
||
rendered = str(fallback_template).format(**kwargs).strip()
|
||
captured["prompt"] = rendered
|
||
return rendered
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.adaptive_rag_service.mixin_fast_answer_semantic_p1.PromptReleaseService.render_prompt",
|
||
_fake_render_prompt,
|
||
)
|
||
|
||
class _FakeLLM:
|
||
async def generate(self, prompt: str, **kwargs):
|
||
_ = kwargs
|
||
captured["llm_prompt"] = prompt
|
||
return (
|
||
'{"handled": true, "answer": "有,命中 1 个项目。", '
|
||
f'"matched_project_ids": [{int(project.id)}], "confidence": 0.97}}'
|
||
)
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGFastAnswerSemanticMixinP1,
|
||
AdaptiveRAGConversationUtilsAMixinP1,
|
||
AdaptiveRAGFastAnswerProjectMainMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
service = _FakeService()
|
||
packet = InternalContextPacket.model_validate(
|
||
{
|
||
"packet_version": "v1",
|
||
"question": "系统里有没有采购经理库存预警服务?",
|
||
"scope": {
|
||
"tenant_id": 1,
|
||
"project_id": None,
|
||
"session_id": 101,
|
||
"conversation_scope_key": "scope:chat:semantic",
|
||
"reply_to_message_id": None,
|
||
"root_message_id": None,
|
||
},
|
||
"route_decision": {
|
||
"route_mode": "internal",
|
||
"effective_source_policy": "internal",
|
||
"decision_origin": "query_semantics_contract",
|
||
"reply_contract": {"mode": "explicit_source", "head": "内部知识库查询:"},
|
||
"query_profile": {
|
||
"needs_private_context": True,
|
||
"needs_freshness": False,
|
||
"referenced_objects": ["project_object"],
|
||
"confidence": 0.95,
|
||
},
|
||
},
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"answer_contract": "yes_no_with_evidence",
|
||
"object_mentions": ["采购经理库存预警服务"],
|
||
},
|
||
"target_resolution": {},
|
||
"query_plan": {
|
||
"plan_version": "v2",
|
||
"query_type": "existence",
|
||
"entity_type": "project",
|
||
"answer_contract": "yes_no_with_evidence",
|
||
"target_ref": None,
|
||
"filters": [],
|
||
},
|
||
"executor_summary": {},
|
||
"verifier_summary": {},
|
||
"uncertainties": [],
|
||
}
|
||
)
|
||
|
||
result = await service._try_glm_project_semantic_answer(
|
||
clean_q="系统里有没有采购经理库存预警服务?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "project",
|
||
"query_type": "status",
|
||
"operation_contract": {
|
||
"query_type": "status",
|
||
"answer_contract": "entity_snapshot",
|
||
"object_mentions": ["错误镜像"],
|
||
},
|
||
},
|
||
internal_context_packet=packet,
|
||
)
|
||
|
||
assert result is not None
|
||
assert "有,命中 1 个项目" in str(result["answer"])
|
||
assert "yes_no_with_evidence" in captured["prompt"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_structured_db_retrieval_prefers_semantic_fact_context_for_project_queries(db_session) -> None:
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="market-tool",
|
||
title="趋势分析工具",
|
||
description="这是一个证券行业分析工具。",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add(project)
|
||
await db_session.flush()
|
||
|
||
fact_service = SemanticFactService(db_session, tenant_id=1)
|
||
await fact_service.upsert_fact(
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
fact_namespace="theme",
|
||
fact_key="market_theme",
|
||
fact_value_text="A股",
|
||
fact_value_norm="a_share",
|
||
value_type="keyword",
|
||
confidence=0.95,
|
||
evidence_source_type="project",
|
||
evidence_source_id=int(project.id),
|
||
evidence_excerpt="A股",
|
||
extract_version="semantic-facts-v1",
|
||
freshness_at=None,
|
||
)
|
||
await db_session.commit()
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGRetrievalRouterDBMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
AdaptiveRAGAnswerPayloadMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(question: str) -> str: # pragma: no cover - should not be called
|
||
raise AssertionError(f"lexical keyword fallback should be skipped: {question}")
|
||
|
||
@staticmethod
|
||
def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]: # pragma: no cover - should not be called
|
||
raise AssertionError(f"lexical term fallback should be skipped: {question}, {preferred}")
|
||
|
||
service = _FakeService()
|
||
rows = await service._retrieve_structured_db(
|
||
"系统里有没有A股项目?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "project",
|
||
"scope_hint": "global",
|
||
"query_type": "existence",
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"object_mentions": ["A股项目"],
|
||
},
|
||
},
|
||
)
|
||
|
||
assert rows
|
||
assert rows[0].metadata["table"] == "semantic_facts"
|
||
assert "趋势分析工具" in rows[0].content
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_structured_db_retrieval_prefers_semantic_fact_context_for_task_queries(db_session) -> None:
|
||
user = User(
|
||
tenant_id=1,
|
||
username="task_owner",
|
||
email="task_owner@example.com",
|
||
password_hash="hash",
|
||
name="任务负责人",
|
||
role="ADMIN",
|
||
status="active",
|
||
is_active=True,
|
||
)
|
||
project = Project(
|
||
tenant_id=1,
|
||
name="invoice-project",
|
||
title="开票平台项目",
|
||
description="项目描述",
|
||
owner_id=None,
|
||
status="active",
|
||
assigned_role="backend",
|
||
)
|
||
db_session.add_all([user, project])
|
||
await db_session.flush()
|
||
|
||
charter = TruthNode(
|
||
tenant_id=1,
|
||
node_type="charter",
|
||
parent_id=None,
|
||
project_id=int(project.id),
|
||
signal_id=None,
|
||
created_by=int(user.id),
|
||
title="开票需求",
|
||
content={"summary": "建设开票系统"},
|
||
version=1,
|
||
status="draft",
|
||
)
|
||
db_session.add(charter)
|
||
await db_session.flush()
|
||
|
||
task = Task(
|
||
tenant_id=1,
|
||
truth_node_id=int(charter.id),
|
||
project_id=int(project.id),
|
||
assignee_id=int(user.id),
|
||
ai_recommended_assignees=None,
|
||
ai_breakdown_payload=None,
|
||
manual_overrides=None,
|
||
title="实现开票接口",
|
||
description="对接发票创建能力",
|
||
required_skill_tag_ids=[],
|
||
status="todo",
|
||
priority="high",
|
||
estimated_hours=None,
|
||
actual_hours=None,
|
||
quality_score=None,
|
||
due_date=None,
|
||
completed_at=None,
|
||
)
|
||
db_session.add(task)
|
||
await db_session.flush()
|
||
|
||
fact_service = SemanticFactService(db_session, tenant_id=1)
|
||
await fact_service.upsert_fact(
|
||
entity_type="task",
|
||
entity_id=int(task.id),
|
||
fact_namespace="alias",
|
||
fact_key="aliases",
|
||
fact_value_text="开票任务",
|
||
fact_value_norm="invoice_task",
|
||
value_type="keyword",
|
||
confidence=0.96,
|
||
evidence_source_type="task",
|
||
evidence_source_id=int(task.id),
|
||
evidence_excerpt="实现开票接口",
|
||
extract_version="semantic-facts-v1",
|
||
freshness_at=None,
|
||
)
|
||
await db_session.commit()
|
||
|
||
class _FakeService(
|
||
AdaptiveRAGRetrievalRouterDBMixin,
|
||
AdaptiveRAGFastAnswerProjectUtilsMixin,
|
||
AdaptiveRAGAnswerPayloadMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = db_session
|
||
self.tenant_id = 1
|
||
|
||
service = _FakeService()
|
||
rows = await service._retrieve_structured_db(
|
||
"有没有开票任务?",
|
||
project_id=None,
|
||
query_semantics={
|
||
"subject_focus": "task",
|
||
"scope_hint": "global",
|
||
"query_type": "existence",
|
||
"operation_contract": {
|
||
"query_type": "existence",
|
||
"object_mentions": ["开票任务"],
|
||
},
|
||
},
|
||
)
|
||
|
||
assert rows
|
||
assert rows[0].metadata["table"] == "semantic_facts"
|
||
assert "开票任务" in rows[0].content
|