from __future__ import annotations from datetime import datetime, timedelta import pytest import pytest_asyncio from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.models.ai_orchestration_run import AIOrchestrationRun from app.models.base import Base from app.models.feishu_item import FeishuItem from app.models.ingest_event import IngestEvent from app.models.ingest_offset import IngestOffset from app.models.kg_entity import KGEntity from app.models.kg_evidence import KGEvidence from app.models.kg_relation import KGRelation from app.models.kg_sync_run import KGSyncRun 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.user import User from app.models.work_item import WorkItem from app.services.project_knowledge_graph_service import ProjectKnowledgeGraphService def _use_sqlite_json_types(*tables) -> None: for table in tables: for column in table.columns: if column.type.__class__.__name__ == "JSONB": column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") tables = [ User.__table__, Project.__table__, FeishuItem.__table__, Signal.__table__, IngestEvent.__table__, IngestOffset.__table__, WorkItem.__table__, AIOrchestrationRun.__table__, SemanticFact.__table__, SemanticFactExtractionRun.__table__, KGEntity.__table__, KGRelation.__table__, KGEvidence.__table__, KGSyncRun.__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_enqueue_work_item_delta_events_reads_feishu_work_items_into_kg(db_session) -> None: user = User( tenant_id=1, username="kg_owner", email="kg_owner@example.com", password_hash="hash", name="图谱负责人", role="ADMIN", status="active", is_active=True, ) project = Project( tenant_id=1, name="alpha", title="Alpha 项目", status="active", owner_id=None, assigned_role="backend", ) seed_ingest_event = IngestEvent( tenant_id=1, channel="seed", event_key="seed-1", status="done", payload={}, ) ignored_ingest_event = IngestEvent( tenant_id=1, channel="seed", event_key="seed-2", status="done", payload={}, ) db_session.add_all([user, project, seed_ingest_event, ignored_ingest_event]) await db_session.flush() target_work_item = WorkItem( tenant_id=1, ingest_event_id=int(seed_ingest_event.id), source_system="feishu", source_message_id="om_work_item_1", state="waiting_manual", intent="project.create", project_id=int(project.id), execution_result={"status": "pending_manual"}, review_summary={"verified": False, "issues": ["pending_manual"]}, ) ignored_work_item = WorkItem( tenant_id=1, ingest_event_id=int(ignored_ingest_event.id), source_system="ai_pmo_ui", source_message_id="om_work_item_ignored", state="succeeded", intent="followup.confirm", execution_result={"status": "succeeded"}, review_summary={"verified": True, "issues": []}, ) db_session.add_all([target_work_item, ignored_work_item]) await db_session.flush() run = AIOrchestrationRun( tenant_id=1, trace_id="trace-work-item-1", run_type="ingress", status="completed", source_system="feishu", source_message_id="om_work_item_1", sender="测试用户", channel_ref="chat-alpha", message_text="请创建项目 Alpha", work_item_id=int(target_work_item.id), project_id=int(project.id), ) db_session.add(run) await db_session.commit() service = ProjectKnowledgeGraphService(db_session, tenant_id=1) enqueue_result = await service.enqueue_work_item_delta_events(limit=20) assert enqueue_result["enqueued"] == 1 queued_event = ( await db_session.execute( select(IngestEvent).where( IngestEvent.tenant_id == 1, IngestEvent.channel == "work_item_delta", IngestEvent.event_key == "om_work_item_1", ) ) ).scalar_one() assert queued_event.payload["work_item_id"] == int(target_work_item.id) assert queued_event.payload["project_id"] == int(project.id) assert queued_event.payload["source_system"] == "feishu" assert "feishu_item_id" not in queued_event.payload process_result = await service.process_pending_events(max_batch=20) assert process_result["done"] == 1 work_item_entity = ( await db_session.execute( select(KGEntity).where( KGEntity.tenant_id == 1, KGEntity.entity_type == "work_item", KGEntity.entity_key == f"work_item:{int(target_work_item.id)}", ) ) ).scalar_one() assert work_item_entity.payload["work_item_id"] == int(target_work_item.id) assert work_item_entity.payload["project_id"] == int(project.id) assert work_item_entity.payload["source_system"] == "feishu" evidence = ( await db_session.execute( select(KGEvidence).where( KGEvidence.tenant_id == 1, KGEvidence.evidence_type == "work_item_delta", ) ) ).scalars().all() assert len(evidence) >= 1 assert any("请创建项目 Alpha" in row.content_excerpt for row in evidence) relation = ( await db_session.execute( select(KGRelation).where( KGRelation.tenant_id == 1, KGRelation.relation_type == "belongs_to", ) ) ).scalar_one_or_none() assert relation is not None @pytest.mark.asyncio async def test_process_work_item_event_enqueues_semantic_fact_extract_for_project(db_session) -> None: project = Project( tenant_id=1, name="alpha", title="Alpha 项目", status="active", owner_id=None, assigned_role="backend", ) seed_ingest_event = IngestEvent( tenant_id=1, channel="seed", event_key="seed-semantic-fact", status="done", payload={}, ) db_session.add_all([project, seed_ingest_event]) await db_session.flush() target_work_item = WorkItem( tenant_id=1, ingest_event_id=int(seed_ingest_event.id), source_system="feishu", source_message_id="om_work_item_semantic_fact", state="waiting_manual", intent="project.create", project_id=int(project.id), execution_result={"status": "pending_manual"}, review_summary={"verified": False, "issues": ["pending_manual"]}, ) db_session.add(target_work_item) await db_session.flush() run = AIOrchestrationRun( tenant_id=1, trace_id="trace-semantic-fact-1", run_type="ingress", status="completed", source_system="feishu", source_message_id="om_work_item_semantic_fact", sender="测试用户", channel_ref="chat-alpha", message_text="请创建项目 Alpha", work_item_id=int(target_work_item.id), project_id=int(project.id), ) db_session.add(run) await db_session.commit() service = ProjectKnowledgeGraphService(db_session, tenant_id=1) await service.enqueue_work_item_delta_events(limit=20) process_result = await service.process_pending_events(max_batch=20) semantic_event = ( await db_session.execute( select(IngestEvent).where( IngestEvent.tenant_id == 1, IngestEvent.channel == "semantic_fact_extract", IngestEvent.event_key == f"project:{int(project.id)}", ) ) ).scalar_one_or_none() assert process_result["done"] >= 1 assert semantic_event is not None assert semantic_event.payload["entity_type"] == "project" assert semantic_event.payload["entity_id"] == int(project.id) assert semantic_event.payload["trigger_type"] == "work_item_delta" @pytest.mark.asyncio async def test_enqueue_feishu_delta_events_is_compat_alias_for_work_item_delta(db_session) -> None: project = Project( tenant_id=1, name="alpha", title="Alpha 项目", status="active", owner_id=None, assigned_role="backend", ) seed_ingest_event = IngestEvent( tenant_id=1, channel="seed", event_key="seed-compat", status="done", payload={}, ) db_session.add_all([project, seed_ingest_event]) await db_session.flush() target_work_item = WorkItem( tenant_id=1, ingest_event_id=int(seed_ingest_event.id), source_system="feishu", source_message_id="om_work_item_compat", state="waiting_manual", intent="project.create", project_id=int(project.id), execution_result={"status": "pending_manual"}, review_summary={"verified": False, "issues": ["pending_manual"]}, ) db_session.add(target_work_item) await db_session.commit() service = ProjectKnowledgeGraphService(db_session, tenant_id=1) enqueue_result = await service.enqueue_feishu_delta_events(limit=20) assert enqueue_result["enqueued"] == 1 queued_event = ( await db_session.execute( select(IngestEvent).where( IngestEvent.tenant_id == 1, IngestEvent.channel == "work_item_delta", IngestEvent.event_key == "om_work_item_compat", ) ) ).scalar_one() assert queued_event.payload["work_item_id"] == int(target_work_item.id) offset = ( await db_session.execute( select(IngestOffset).where( IngestOffset.tenant_id == 1, IngestOffset.channel == "work_item_delta", IngestOffset.offset_key == "last_work_item_updated_at", ) ) ).scalar_one() assert offset.offset_value @pytest.mark.asyncio async def test_sync_cycle_and_slo_expose_work_item_delta_naming(db_session, monkeypatch) -> None: service = ProjectKnowledgeGraphService(db_session, tenant_id=1) async def _structured(*, limit: int = 1000) -> dict[str, int]: return {"synced": int(limit)} async def _work_item_enqueue(*, limit: int = 500) -> dict[str, int]: return {"enqueued": int(limit)} async def _process(*, max_batch: int = 200, max_retry: int = 3) -> dict[str, int]: return {"processed": int(max_batch), "done": int(max_batch), "retry": 0, "dead_letter": 0} async def _code_scan(*, base_dir=None, interval_hours: int = 6) -> dict[str, int | str | None]: return {"interval_hours": int(interval_hours), "base_dir": base_dir} async def _symbol_index(*, base_dir=None, interval_hours: int = 6) -> dict[str, int | str | None]: return {"interval_hours": int(interval_hours), "base_dir": base_dir} async def _feature_catalog(*, interval_hours: int = 6) -> dict[str, int]: return {"interval_hours": int(interval_hours)} async def _guardrails(*, interval_hours: int = 6) -> dict[str, int]: return {"interval_hours": int(interval_hours)} monkeypatch.setattr(service, "sync_structured_delta", _structured) monkeypatch.setattr(service, "enqueue_work_item_delta_events", _work_item_enqueue, raising=False) monkeypatch.setattr(service, "process_pending_events", _process) monkeypatch.setattr(service, "sync_codebase_if_due", _code_scan) monkeypatch.setattr(service, "sync_symbol_index_if_due", _symbol_index) monkeypatch.setattr(service, "sync_feature_catalog_if_due", _feature_catalog) monkeypatch.setattr(service, "sync_model_guardrails_if_due", _guardrails) cycle_result = await service.run_incremental_cycle(delta_limit=12, event_batch=5) assert cycle_result["run_status"] == "success" assert cycle_result["work_item_enqueue"] == {"enqueued": 12} assert "feishu_enqueue" not in cycle_result stale_at = (datetime.now() - timedelta(minutes=7)).replace(microsecond=0) await service.set_ingest_offset( channel="feishu_delta", offset_key="last_feishu_item_updated_at", offset_value=stale_at.isoformat(), payload={"compat": True}, ) await db_session.commit() slo_result = await service.get_sync_slo(lookback_hours=24) assert "work_item_delta" in slo_result["freshness_minutes"] assert slo_result["freshness_minutes"]["work_item_delta"] is not None assert "feishu_delta" not in slo_result["freshness_minutes"]