384 lines
13 KiB
Python
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"]
|