PMFlow/backend/tests/test_semantic_fact_service.py

576 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_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.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_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