PMFlow/backend/tests/test_project_knowledge_grap...

384 lines
13 KiB
Python

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"]