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