first
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""asf-context: Context Engine — "l'impiegato che raccoglie il fascicolo completo".
|
||||
|
||||
Given an artifact / project / user question, assembles relevant context for
|
||||
AI operations. In the MVP this is pure MongoDB relationship traversal.
|
||||
|
||||
The vector / semantic retrieval logic lives behind ``SemanticStore`` protocol
|
||||
so a vector DB can be plugged in later without changing callers.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .context_engine import ContextEngine, AssemblyOptions, ContextAssembly
|
||||
from .semantic_store import SemanticStore, InMemorySemanticStoreMock
|
||||
|
||||
__all__ = [
|
||||
"ContextEngine",
|
||||
"AssemblyOptions",
|
||||
"ContextAssembly",
|
||||
"SemanticStore",
|
||||
"InMemorySemanticStoreMock",
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Minimal configuration surface for asf-context service.
|
||||
|
||||
Keeps parity with asf-core config. Can be grown independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
rabbitmq_events_exchange: str = "asf.events"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> ContextSettings:
|
||||
return ContextSettings()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""ContextEngine: assembles "il fascicolo completo" for an AI operation.
|
||||
|
||||
Input: an artifact (or project id, or a free-text question)
|
||||
Output: a ``ContextAssembly`` bundling
|
||||
· related requirements
|
||||
· architecture decisions
|
||||
· relevant source files
|
||||
· docs
|
||||
· tests
|
||||
· deployments
|
||||
· previous AI analysis
|
||||
· incidents
|
||||
· runtime info (currently placeholder)
|
||||
|
||||
For MVP it walks the ArtifactRelationship graph in MongoDB. Semantic
|
||||
retrieval is hidden behind ``SemanticStore`` so we can add embeddings later
|
||||
without changing the engine surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import Artifact, ArtifactRelationship, RelationshipType
|
||||
from .semantic_store import SemanticStore, InMemorySemanticStoreMock, SemanticHit
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssemblyOptions:
|
||||
max_depth: int = 2
|
||||
max_nodes: int = 200
|
||||
include_semantic: bool = False
|
||||
semantic_top_k: int = 10
|
||||
relationship_whitelist: Optional[Sequence[RelationshipType]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextAssembly:
|
||||
seed_artifact_id: Optional[UUID]
|
||||
project_id: Optional[UUID]
|
||||
tenant_id: UUID
|
||||
|
||||
# Artifacts grouped by logical concern (all are Artifact objects)
|
||||
requirements: list[Artifact] = field(default_factory=list)
|
||||
architecture_decisions: list[Artifact] = field(default_factory=list)
|
||||
tasks: list[Artifact] = field(default_factory=list)
|
||||
source_files: list[Artifact] = field(default_factory=list)
|
||||
documents: list[Artifact] = field(default_factory=list)
|
||||
tests: list[Artifact] = field(default_factory=list)
|
||||
builds: list[Artifact] = field(default_factory=list)
|
||||
deployments: list[Artifact] = field(default_factory=list)
|
||||
releases: list[Artifact] = field(default_factory=list)
|
||||
incidents: list[Artifact] = field(default_factory=list)
|
||||
previous_ai_analysis: list[Artifact] = field(default_factory=list)
|
||||
security_findings: list[Artifact] = field(default_factory=list)
|
||||
|
||||
# Raw graph for UI / debugging
|
||||
relationships_traversed: list[ArtifactRelationship] = field(default_factory=list)
|
||||
all_artifacts: list[Artifact] = field(default_factory=list)
|
||||
|
||||
semantic_hits: list[SemanticHit] = field(default_factory=list)
|
||||
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"seed_artifact_id": str(self.seed_artifact_id) if self.seed_artifact_id else None,
|
||||
"total_artifacts": len(self.all_artifacts),
|
||||
"total_relationships": len(self.relationships_traversed),
|
||||
"semantic_hits": len(self.semantic_hits),
|
||||
"by_type": {
|
||||
"requirements": len(self.requirements),
|
||||
"architecture_decisions": len(self.architecture_decisions),
|
||||
"tasks": len(self.tasks),
|
||||
"source_files": len(self.source_files),
|
||||
"documents": len(self.documents),
|
||||
"tests": len(self.tests),
|
||||
"builds": len(self.builds),
|
||||
"deployments": len(self.deployments),
|
||||
"releases": len(self.releases),
|
||||
"incidents": len(self.incidents),
|
||||
"previous_ai_analysis": len(self.previous_ai_analysis),
|
||||
"security_findings": len(self.security_findings),
|
||||
},
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class ContextEngine:
|
||||
"""Assembles contextual "fascicolo" given a seed (artifact, project, ...).
|
||||
|
||||
For the bootstrap phase we only accept explicit service-layer objects
|
||||
(relationship service + artifact service) so we can run the engine
|
||||
inside asf-core or standalone. No Mongo driver coupling here.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
relationship_service, # duck-typed: for_artifact, outgoing, incoming
|
||||
artifact_service, # duck-typed: get, get_many, list
|
||||
semantic: SemanticStore | None = None,
|
||||
) -> None:
|
||||
self._rels = relationship_service
|
||||
self._arts = artifact_service
|
||||
self._semantic = semantic or InMemorySemanticStoreMock()
|
||||
|
||||
async def assemble(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
seed_artifact_id: Optional[UUID] = None,
|
||||
project_id: Optional[UUID] = None,
|
||||
user_query: Optional[str] = None,
|
||||
options: Optional[AssemblyOptions] = None,
|
||||
) -> ContextAssembly:
|
||||
opts = options or AssemblyOptions()
|
||||
result = ContextAssembly(
|
||||
seed_artifact_id=seed_artifact_id,
|
||||
project_id=project_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
visited_ids: set[UUID] = set()
|
||||
frontier: set[UUID] = set()
|
||||
|
||||
if seed_artifact_id is not None:
|
||||
frontier.add(seed_artifact_id)
|
||||
elif project_id is not None:
|
||||
arts = await self._arts.list(
|
||||
project_id=project_id, limit=opts.max_nodes
|
||||
)
|
||||
frontier = {a.id for a in arts[: opts.max_nodes]}
|
||||
result.all_artifacts.extend(arts)
|
||||
visited_ids.update(a.id for a in arts)
|
||||
else:
|
||||
result.warnings.append(
|
||||
"No seed_artifact_id nor project_id provided; context will be empty."
|
||||
)
|
||||
return result
|
||||
|
||||
# BFS walk
|
||||
for _ in range(opts.max_depth):
|
||||
current = list(frontier - visited_ids)
|
||||
if not current:
|
||||
break
|
||||
visited_ids.update(current)
|
||||
|
||||
# Load artifacts we haven't loaded yet
|
||||
need = [i for i in current if not any(a.id == i for a in result.all_artifacts)]
|
||||
if need:
|
||||
fetched = await self._arts.get_many(need)
|
||||
result.all_artifacts.extend(fetched)
|
||||
|
||||
for aid in current:
|
||||
if len(result.relationships_traversed) >= opts.max_nodes:
|
||||
break
|
||||
out = await self._rels.outgoing(
|
||||
aid, limit=max(50, opts.max_nodes // 4)
|
||||
)
|
||||
inc = await self._rels.incoming(
|
||||
aid, limit=max(50, opts.max_nodes // 4)
|
||||
)
|
||||
for rel in out + inc:
|
||||
result.relationships_traversed.append(rel)
|
||||
frontier.add(rel.source_artifact_id)
|
||||
frontier.add(rel.target_artifact_id)
|
||||
|
||||
# Ensure all referenced artifacts are loaded
|
||||
referenced: set[UUID] = set()
|
||||
for rel in result.relationships_traversed:
|
||||
referenced.add(rel.source_artifact_id)
|
||||
referenced.add(rel.target_artifact_id)
|
||||
already_have = {a.id for a in result.all_artifacts}
|
||||
missing = list(referenced - already_have)
|
||||
if missing:
|
||||
fetched = await self._arts.get_many(missing)
|
||||
result.all_artifacts.extend(fetched)
|
||||
|
||||
# Bucket artifacts by type
|
||||
_bucket(result)
|
||||
|
||||
# Optional semantic layer
|
||||
if opts.include_semantic and user_query:
|
||||
try:
|
||||
result.semantic_hits = list(
|
||||
await self._semantic.search(
|
||||
tenant_id=tenant_id,
|
||||
query=user_query,
|
||||
project_id=project_id,
|
||||
top_k=opts.semantic_top_k,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("semantic_search_failed")
|
||||
result.warnings.append("Semantic search failed; using structural context only.")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_TYPE_BUCKETS = {
|
||||
"requirement": "requirements",
|
||||
"architecture_decision": "architecture_decisions",
|
||||
"task": "tasks",
|
||||
"story": "tasks",
|
||||
"epic": "tasks",
|
||||
"source_file": "source_files",
|
||||
"document": "documents",
|
||||
"test": "tests",
|
||||
"build": "builds",
|
||||
"deployment": "deployments",
|
||||
"release": "releases",
|
||||
"incident": "incidents",
|
||||
"runtime_event": "incidents",
|
||||
"ai_analysis": "previous_ai_analysis",
|
||||
"security_finding": "security_findings",
|
||||
}
|
||||
|
||||
|
||||
def _bucket(r: ContextAssembly) -> None:
|
||||
for art in r.all_artifacts:
|
||||
t = art.type if isinstance(art.type, str) else art.type.value
|
||||
bucket = _TYPE_BUCKETS.get(t)
|
||||
if bucket is not None:
|
||||
getattr(r, bucket).append(art)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Semantic / vector retrieval abstraction (stub + in-memory mock).
|
||||
|
||||
A real implementation will talk to a vector DB / embedding store and return
|
||||
"semantically similar" artifacts. For bootstrap we keep the Protocol open and
|
||||
provide a deterministic mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class SemanticHit:
|
||||
artifact_id: UUID
|
||||
score: float
|
||||
snippet: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class SemanticStore(Protocol):
|
||||
"""Future vector / embedding store interface."""
|
||||
|
||||
async def search(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
query: str,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
top_k: int = 10,
|
||||
filters: Optional[dict] = None,
|
||||
) -> Sequence[SemanticHit]: ...
|
||||
|
||||
async def index_artifact(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class InMemorySemanticStoreMock(SemanticStore):
|
||||
"""Deterministic, no-op mock.
|
||||
|
||||
Useful for unit tests and for getting the Context Engine scaffolding up
|
||||
before a real vector DB is deployed. Always returns an empty hit list so
|
||||
callers gracefully degrade to structural context only.
|
||||
"""
|
||||
|
||||
async def search(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
query: str,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
top_k: int = 10,
|
||||
filters: Optional[dict] = None,
|
||||
) -> Sequence[SemanticHit]:
|
||||
return tuple()
|
||||
|
||||
async def index_artifact(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None: # pragma: no cover - mock side effect
|
||||
return None
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,15 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
motor>=3.5
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,257 @@
|
||||
"""asf-context tests + relationship_service walk using in-memory repo mocks.
|
||||
|
||||
Covers:
|
||||
* In-memory FakeRelationshipRepo (outgoing / incoming / for_artifact / create / delete)
|
||||
* ContextEngine BFS 2-hop with whitelist + cap via fake services
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactType,
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
|
||||
CONTEXT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(CONTEXT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CONTEXT_ROOT))
|
||||
|
||||
TENANT_ID = UUID("11111111-0000-0000-0000-000000000001")
|
||||
ACTOR_ID = UUID("22222222-0000-0000-0000-000000000002")
|
||||
PROJ_ID = UUID("33333333-0000-0000-0000-000000000003")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# In-memory fake relationship + audit repos (matches interface subset used)
|
||||
# =========================================================================
|
||||
|
||||
class FakeRelationshipRepo:
|
||||
def __init__(self) -> None:
|
||||
self._rels: list[ArtifactRelationship] = []
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
r = ArtifactRelationship(
|
||||
id=uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
relationship_type=body.relationship_type,
|
||||
source_artifact_id=body.source_artifact_id,
|
||||
target_artifact_id=body.target_artifact_id,
|
||||
source_project_id=PROJ_ID,
|
||||
target_project_id=PROJ_ID,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._rels.append(r)
|
||||
return r
|
||||
|
||||
async def outgoing(
|
||||
self, tenant_id, artifact_id, *, relationship_type=None, limit=100
|
||||
) -> list[ArtifactRelationship]:
|
||||
out = [r for r in self._rels if r.tenant_id == tenant_id and r.source_artifact_id == artifact_id]
|
||||
if relationship_type is not None:
|
||||
out = [r for r in out if r.relationship_type == relationship_type]
|
||||
return out[:limit]
|
||||
|
||||
async def incoming(
|
||||
self, tenant_id, artifact_id, *, relationship_type=None, limit=100
|
||||
) -> list[ArtifactRelationship]:
|
||||
out = [r for r in self._rels if r.tenant_id == tenant_id and r.target_artifact_id == artifact_id]
|
||||
if relationship_type is not None:
|
||||
out = [r for r in out if r.relationship_type == relationship_type]
|
||||
return out[:limit]
|
||||
|
||||
async def list_for_artifact(self, tenant_id, artifact_id, *, limit=200) -> list[ArtifactRelationship]:
|
||||
out = [
|
||||
r
|
||||
for r in self._rels
|
||||
if r.tenant_id == tenant_id and (r.source_artifact_id == artifact_id or r.target_artifact_id == artifact_id)
|
||||
]
|
||||
return out[:limit]
|
||||
|
||||
async def delete(self, tenant_id, relationship_id) -> bool:
|
||||
before = len(self._rels)
|
||||
self._rels = [r for r in self._rels if not (r.tenant_id == tenant_id and r.id == relationship_id)]
|
||||
return len(self._rels) < before
|
||||
|
||||
|
||||
class FakeAuditRepo:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[UUID, AuditEventCreate]] = []
|
||||
|
||||
async def append(self, tenant_id: UUID, event: AuditEventCreate) -> None:
|
||||
self.events.append((tenant_id, event))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Lightweight "service-like" wrapper (same signatures as RelationshipService)
|
||||
# =========================================================================
|
||||
|
||||
class FakeRelService:
|
||||
"""Drop-in, dependency-free replacement for RelationshipService used by tests."""
|
||||
|
||||
def __init__(self, rels: FakeRelationshipRepo, audit: FakeAuditRepo) -> None:
|
||||
self._tenant = TENANT_ID
|
||||
self._rels = rels
|
||||
self._audit = audit
|
||||
|
||||
async def create(self, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
r = await self._rels.create(self._tenant, body)
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=r.id,
|
||||
actor_id=ACTOR_ID,
|
||||
metadata={
|
||||
"source": str(r.source_artifact_id),
|
||||
"type": r.relationship_type,
|
||||
"target": str(r.target_artifact_id),
|
||||
},
|
||||
),
|
||||
)
|
||||
return r
|
||||
|
||||
async def outgoing(self, artifact_id, *, relationship_type=None, limit=100):
|
||||
return await self._rels.outgoing(self._tenant, artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
async def incoming(self, artifact_id, *, relationship_type=None, limit=100):
|
||||
return await self._rels.incoming(self._tenant, artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
async def for_artifact(self, artifact_id, *, limit=200):
|
||||
return await self._rels.list_for_artifact(self._tenant, artifact_id, limit=limit)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_rels_repo():
|
||||
return FakeRelationshipRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_audit_repo():
|
||||
return FakeAuditRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rel_service(fake_rels_repo, fake_audit_repo):
|
||||
return FakeRelService(fake_rels_repo, fake_audit_repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chain_artifacts_ids():
|
||||
return uuid4(), uuid4(), uuid4(), uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_service(rel_service, chain_artifacts_ids):
|
||||
"""REQ --implements--> TASK --implements--> PR --generated_from--> BUILD (4 nodes, 3 edges, 2 hops from REQ)."""
|
||||
a, b, c, d = chain_artifacts_ids
|
||||
for rt, src, tgt in [
|
||||
(RelationshipType.IMPLEMENTS, a, b),
|
||||
(RelationshipType.IMPLEMENTS, b, c),
|
||||
(RelationshipType.GENERATED_FROM, c, d),
|
||||
]:
|
||||
await rel_service.create(
|
||||
ArtifactRelationshipCreate(
|
||||
source_artifact_id=src, target_artifact_id=tgt, relationship_type=rt,
|
||||
)
|
||||
)
|
||||
return rel_service, chain_artifacts_ids
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Tests
|
||||
# =========================================================================
|
||||
|
||||
def _mk_artifact(aid: UUID, t: ArtifactType, title: str) -> Artifact:
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
return Artifact(
|
||||
id=aid,
|
||||
tenant_id=TENANT_ID,
|
||||
project_id=PROJ_ID,
|
||||
external_id=t.name + "-" + title,
|
||||
type=t,
|
||||
title=title,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
class TestFakeRelServiceCRUD:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_emits_audit_and_stores(self, rel_service, chain_artifacts_ids, fake_audit_repo):
|
||||
a, b, *_ = chain_artifacts_ids
|
||||
body = ArtifactRelationshipCreate(
|
||||
source_artifact_id=a, target_artifact_id=b, relationship_type=RelationshipType.RELATED_TO,
|
||||
)
|
||||
r = await rel_service.create(body)
|
||||
assert r.relationship_type == RelationshipType.RELATED_TO
|
||||
_, ev = fake_audit_repo.events[0]
|
||||
assert ev.action == AuditAction.CREATED
|
||||
assert ev.resource_type == "artifact_relationship"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_incoming_for_artifact(self, seeded_service):
|
||||
svc, (a, b, c, d) = seeded_service
|
||||
assert len(await svc.outgoing(a)) == 1
|
||||
assert len(await svc.incoming(d)) == 1
|
||||
assert len(await svc.for_artifact(b)) == 2 # a->b + b->c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_relationship_type(self, seeded_service):
|
||||
svc, (a, b, c, d) = seeded_service
|
||||
assert len(await svc.outgoing(c, relationship_type=RelationshipType.GENERATED_FROM)) == 1
|
||||
assert len(await svc.outgoing(c, relationship_type=RelationshipType.IMPLEMENTS)) == 0
|
||||
|
||||
|
||||
class TestContextEngineBFS:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bfs_depth_2_stops_before_third_node(self, seeded_service, chain_artifacts_ids):
|
||||
from app.context_engine import ContextEngine, AssemblyOptions
|
||||
a, b, c, d = chain_artifacts_ids
|
||||
arts = [
|
||||
_mk_artifact(a, ArtifactType.REQUIREMENT, "A"),
|
||||
_mk_artifact(b, ArtifactType.TASK, "B"),
|
||||
_mk_artifact(c, ArtifactType.PULL_REQUEST, "C"),
|
||||
_mk_artifact(d, ArtifactType.BUILD, "D"),
|
||||
]
|
||||
|
||||
class _ArtifactSvc:
|
||||
def __init__(self): self._m = {x.id: x for x in arts}
|
||||
async def get_many(self, ids):
|
||||
return [self._m[i] for i in ids if i in self._m]
|
||||
async def list(self, **kw):
|
||||
return list(self._m.values())
|
||||
|
||||
class _RelSvc:
|
||||
def __init__(self, svc): self._svc = svc
|
||||
async def outgoing(self, aid, *, limit=100):
|
||||
return await self._svc.outgoing(aid, limit=limit)
|
||||
async def incoming(self, aid, *, limit=100):
|
||||
return await self._svc.incoming(aid, limit=limit)
|
||||
|
||||
svc, *_ = seeded_service
|
||||
engine = ContextEngine(relationship_service=_RelSvc(svc), artifact_service=_ArtifactSvc())
|
||||
opts = AssemblyOptions(max_depth=2, max_nodes=50)
|
||||
asm = await engine.assemble(tenant_id=TENANT_ID, seed_artifact_id=a, project_id=PROJ_ID, options=opts)
|
||||
summary = asm.summary()
|
||||
visited = {x.id for x in asm.all_artifacts}
|
||||
# depth=2 from a: visits a (0), b (1), c (2). NOT d (depth 3).
|
||||
assert a in visited and b in visited and c in visited
|
||||
assert d not in visited
|
||||
assert summary["total_relationships"] >= 2 # a->b + b->c traversed
|
||||
assert summary["total_artifacts"] == 3
|
||||
Reference in New Issue
Block a user