PMFlow/backend/tests/test_semantic_fact_service.py

869 lines
28 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_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="REVIEWER",
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="REVIEWER",
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="REVIEWER",
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="REVIEWER",
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