decomposer: fix validation failure for Migrate the kab_ingestion domain models, ports, GitHub SCM connector, and SharePoint CMS connector into the repository's app/ package.; Wire the migrated ingestion components into the agent application entry point, card, settings, triggers, governance, publication, and validation layers.; Populate every remaining placeholder file with working application, package, test, dependency, and Kubernetes content.; Verify the migrated agent and completed repository end to end.
Some checks failed
quality-gates / verify (push) Failing after 7s
Some checks failed
quality-gates / verify (push) Failing after 7s
This commit is contained in:
50
app/agent.py
50
app/agent.py
@@ -1,16 +1,40 @@
|
||||
from .card import CARD
|
||||
from .settings import Settings
|
||||
from .connectors.github import GitHubConnector
|
||||
from .connectors.sharepoint import SharePointConnector
|
||||
from .routes import ingest_route
|
||||
from .triggers import register_triggers
|
||||
from .governance import authorize
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .card import AGENT_CARD, AgentCard
|
||||
from .kab_ingestion.connectors.github import GitHubSCMConnector
|
||||
from .kab_ingestion.connectors.sharepoint import SharePointCMSConnector
|
||||
from .kab_ingestion.domain.models import IngestionRequest, IngestionResult
|
||||
from .publication import publish_document
|
||||
from .settings import Settings, get_settings
|
||||
from .validation import validate_document
|
||||
|
||||
def create_agent_app(settings: Settings | None = None) -> dict:
|
||||
config = settings or Settings.from_env()
|
||||
source, publisher = GitHubConnector(config.github_token), SharePointConnector(config.sharepoint_site_url)
|
||||
app = {"card": CARD, "settings": config, "routes": {"/ingest": lambda payload: ingest_route(payload, source)}, "triggers": {}, "governance": authorize, "publication": lambda doc: publish_document(doc, publisher, config), "validation": validate_document}
|
||||
register_triggers(app, app["routes"]["/ingest"])
|
||||
return app
|
||||
|
||||
@dataclass
|
||||
class IngestionService:
|
||||
settings: Settings
|
||||
scm: Any
|
||||
cms: Any = None
|
||||
|
||||
def ingest(self, request: IngestionRequest) -> list[IngestionResult]:
|
||||
if request.source not in self.settings.allowed_sources:
|
||||
return []
|
||||
results = []
|
||||
for document in self.scm.fetch(request):
|
||||
report = validate_document(document)
|
||||
if not report.valid:
|
||||
from .kab_ingestion.domain.models import IngestionResult
|
||||
results.append(IngestionResult(document, False, "invalid", "; ".join(report.errors)))
|
||||
continue
|
||||
results.append(publish_document(document, self.cms, self.settings.publish_enabled, self.settings.require_governance_approval))
|
||||
return results
|
||||
|
||||
|
||||
def create_agent_app(settings: Settings | None = None) -> dict[str, Any]:
|
||||
config = settings or get_settings()
|
||||
scm = GitHubSCMConnector(config.github_token, config.github_api_base)
|
||||
cms = SharePointCMSConnector(config.sharepoint_site_url, config.sharepoint_access_token) if config.sharepoint_site_url and config.sharepoint_access_token else None
|
||||
service = IngestionService(config, scm, cms)
|
||||
return {"card": AGENT_CARD, "service": service, "routes": {"/health": "health", "/card": "card", "/ingest": "ingest"}, "triggers": {"github": "github_trigger"}}
|
||||
|
||||
20
app/card.py
20
app/card.py
@@ -1,10 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentSkill:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentCard:
|
||||
name: str
|
||||
description: str
|
||||
skills: tuple[str, ...]
|
||||
version: str = "1.0.0"
|
||||
version: str
|
||||
skills: tuple[AgentSkill, ...]
|
||||
|
||||
CARD = AgentCard("content-ingestion-agent", "Ingest governed content from SCM into SharePoint CMS.", ("ingest_github", "publish_sharepoint", "validate_content"))
|
||||
|
||||
AGENT_CARD = AgentCard(
|
||||
name="content-ingestion-agent",
|
||||
description="Ingest governed knowledge content from source control and publish it to a CMS.",
|
||||
version="1.0.0",
|
||||
skills=(AgentSkill("ingest", "Ingest content", "Fetch and validate source documents."), AgentSkill("publish", "Publish content", "Apply governance and publish approved documents.")),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
from .domain.models import Document
|
||||
from .settings import Settings
|
||||
from dataclasses import dataclass
|
||||
|
||||
def authorize(document: Document, settings: Settings) -> tuple[bool, str]:
|
||||
allowed = not settings.allowed_repositories or document.source.repository in settings.allowed_repositories
|
||||
return (allowed, "approved" if allowed else "repository is not allowed")
|
||||
from .kab_ingestion.domain.models import SourceDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceDecision:
|
||||
approved: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def review(document: SourceDocument, require_approval: bool = True) -> GovernanceDecision:
|
||||
if not document.title.strip() or not document.body.strip():
|
||||
return GovernanceDecision(False, "title and body are required")
|
||||
if require_approval and document.metadata.get("governance_approved") is not True:
|
||||
return GovernanceDecision(False, "governance approval is required")
|
||||
return GovernanceDecision(True, "document passed governance")
|
||||
|
||||
5
app/kab_ingestion/__init__.py
Normal file
5
app/kab_ingestion/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Ingestion domain migrated into the application package."""
|
||||
|
||||
from .domain.models import IngestionRequest, IngestionResult, SourceDocument
|
||||
|
||||
__all__ = ["IngestionRequest", "IngestionResult", "SourceDocument"]
|
||||
6
app/kab_ingestion/connectors/__init__.py
Normal file
6
app/kab_ingestion/connectors/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Concrete ingestion connectors."""
|
||||
|
||||
from .github import GitHubSCMConnector
|
||||
from .sharepoint import SharePointCMSConnector
|
||||
|
||||
__all__ = ["GitHubSCMConnector", "SharePointCMSConnector"]
|
||||
32
app/kab_ingestion/connectors/github.py
Normal file
32
app/kab_ingestion/connectors/github.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from ..domain.models import IngestionRequest, SourceDocument
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitHubSCMConnector:
|
||||
token: str | None = None
|
||||
api_base: str = "https://api.github.com"
|
||||
|
||||
def fetch(self, request: IngestionRequest) -> list[SourceDocument]:
|
||||
"""Fetch a file or repository contents using the GitHub contents API."""
|
||||
url = request.locator if request.locator.startswith("http") else f"{self.api_base}/{request.locator.lstrip('/')}"
|
||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": "content-ingestion-agent"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
with urlopen(Request(url, headers=headers), timeout=20) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
items = payload if isinstance(payload, list) else [payload]
|
||||
return [self._document(item, request) for item in items if item.get("type", "file") == "file"]
|
||||
|
||||
@staticmethod
|
||||
def _document(item: dict, request: IngestionRequest) -> SourceDocument:
|
||||
body = item.get("content", "")
|
||||
if item.get("encoding") == "base64":
|
||||
import base64
|
||||
body = base64.b64decode(body).decode("utf-8", errors="replace")
|
||||
return SourceDocument(identifier=str(item.get("sha", item.get("path", "unknown"))), title=item.get("name", item.get("path", "Untitled")), body=body, source=request.source, url=item.get("html_url"), metadata={"path": item.get("path")})
|
||||
22
app/kab_ingestion/connectors/sharepoint.py
Normal file
22
app/kab_ingestion/connectors/sharepoint.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from ..domain.models import SourceDocument
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharePointCMSConnector:
|
||||
site_url: str
|
||||
access_token: str
|
||||
|
||||
def publish(self, document: SourceDocument) -> str:
|
||||
"""Create a SharePoint list item through Microsoft Graph."""
|
||||
endpoint = f"{self.site_url.rstrip('/')}/_api/web/lists/getbytitle('Knowledge')/items"
|
||||
payload = json.dumps({"Title": document.title, "Content": document.body}).encode()
|
||||
request = Request(endpoint, data=payload, method="POST", headers={"Authorization": f"Bearer {self.access_token}", "Accept": "application/json", "Content-Type": "application/json"})
|
||||
with urlopen(request, timeout=20) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
return result.get("d", {}).get("Id", result.get("id", document.identifier))
|
||||
5
app/kab_ingestion/domain/__init__.py
Normal file
5
app/kab_ingestion/domain/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Typed ingestion domain contracts."""
|
||||
|
||||
from .models import IngestionRequest, IngestionResult, SourceDocument
|
||||
|
||||
__all__ = ["IngestionRequest", "IngestionResult", "SourceDocument"]
|
||||
37
app/kab_ingestion/domain/models.py
Normal file
37
app/kab_ingestion/domain/models.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngestionRequest:
|
||||
source: str
|
||||
locator: str
|
||||
requested_by: str = "system"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceDocument:
|
||||
identifier: str
|
||||
title: str
|
||||
body: str
|
||||
source: str
|
||||
url: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
fetched_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngestionResult:
|
||||
document: SourceDocument
|
||||
accepted: bool
|
||||
status: str
|
||||
reason: str | None = None
|
||||
published_url: str | None = None
|
||||
6
app/kab_ingestion/ports/__init__.py
Normal file
6
app/kab_ingestion/ports/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Ports implemented by source and publication adapters."""
|
||||
|
||||
from .cms import CMSConnector
|
||||
from .scm import SCMConnector
|
||||
|
||||
__all__ = ["CMSConnector", "SCMConnector"]
|
||||
9
app/kab_ingestion/ports/cms.py
Normal file
9
app/kab_ingestion/ports/cms.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Protocol
|
||||
|
||||
from ..domain.models import SourceDocument
|
||||
|
||||
|
||||
class CMSConnector(Protocol):
|
||||
"""Publish normalized source documents to a content system."""
|
||||
|
||||
def publish(self, document: SourceDocument) -> str: ...
|
||||
9
app/kab_ingestion/ports/scm.py
Normal file
9
app/kab_ingestion/ports/scm.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Protocol
|
||||
|
||||
from ..domain.models import IngestionRequest, SourceDocument
|
||||
|
||||
|
||||
class SCMConnector(Protocol):
|
||||
"""Retrieve documents from a source-control system."""
|
||||
|
||||
def fetch(self, request: IngestionRequest) -> list[SourceDocument]: ...
|
||||
10
app/main.py
10
app/main.py
@@ -2,5 +2,11 @@ from .agent import create_agent_app
|
||||
|
||||
app = create_agent_app()
|
||||
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
def main() -> None:
|
||||
"""Application entry point for ASGI hosts and local smoke checks."""
|
||||
print(app["card"].name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from .domain.models import Document, IngestionResult
|
||||
from .ports import PublicationPort
|
||||
from .governance import authorize
|
||||
from .settings import Settings
|
||||
from .kab_ingestion.domain.models import IngestionResult, SourceDocument
|
||||
from .kab_ingestion.ports.cms import CMSConnector
|
||||
from .governance import review
|
||||
|
||||
def publish_document(document: Document, publisher: PublicationPort, settings: Settings) -> IngestionResult:
|
||||
ok, reason = authorize(document, settings)
|
||||
return publisher.publish(document) if ok else IngestionResult(False, document, reason)
|
||||
|
||||
def publish_document(document: SourceDocument, cms: CMSConnector | None, enabled: bool, require_approval: bool) -> IngestionResult:
|
||||
decision = review(document, require_approval)
|
||||
if not decision.approved:
|
||||
return IngestionResult(document, False, "rejected", decision.reason)
|
||||
if not enabled or cms is None:
|
||||
return IngestionResult(document, True, "validated", "publication disabled")
|
||||
return IngestionResult(document, True, "published", published_url=str(cms.publish(document)))
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from .domain.models import IngestionRequest, SourceRef
|
||||
from .validation import validate_request
|
||||
from .kab_ingestion.domain.models import IngestionRequest
|
||||
|
||||
def ingest_route(payload: dict, source):
|
||||
request = IngestionRequest(SourceRef(payload.get("repository", ""), payload.get("path", ""), payload.get("revision", "main")), payload.get("destination", "sharepoint"))
|
||||
errors = validate_request(request)
|
||||
if errors: return {"accepted": False, "errors": errors}
|
||||
return {"accepted": True, "documents": len(source.fetch(request))}
|
||||
|
||||
def ingest_route(request: IngestionRequest, service):
|
||||
return service.ingest(request)
|
||||
|
||||
|
||||
def card_route(card):
|
||||
return card
|
||||
|
||||
|
||||
def health_route() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
github_token: str = ""
|
||||
sharepoint_site_url: str = "https://sharepoint.invalid"
|
||||
allowed_repositories: tuple[str, ...] = ()
|
||||
require_approval: bool = True
|
||||
environment: str = "development"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
repos = tuple(x.strip() for x in os.getenv("ALLOWED_REPOSITORIES", "").split(",") if x.strip())
|
||||
return cls(os.getenv("GITHUB_TOKEN", ""), os.getenv("SHAREPOINT_SITE_URL", cls.sharepoint_site_url), repos, os.getenv("REQUIRE_APPROVAL", "true").lower() != "false", os.getenv("ENVIRONMENT", "development"))
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="INGESTION_", case_sensitive=False)
|
||||
github_token: str | None = Field(default=None, repr=False)
|
||||
github_api_base: str = "https://api.github.com"
|
||||
sharepoint_site_url: str | None = None
|
||||
sharepoint_access_token: str | None = Field(default=None, repr=False)
|
||||
require_governance_approval: bool = True
|
||||
publish_enabled: bool = False
|
||||
allowed_sources: tuple[str, ...] = ("github",)
|
||||
service_name: str = "content-ingestion-agent"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from .kab_ingestion.domain.models import IngestionRequest
|
||||
|
||||
def register_triggers(app: dict, handler: Callable) -> None:
|
||||
app["triggers"]["github_push"] = handler
|
||||
app["triggers"]["manual_ingest"] = handler
|
||||
|
||||
def github_trigger(locator: str, requested_by: str = "github-webhook") -> IngestionRequest:
|
||||
return IngestionRequest(source="github", locator=locator, requested_by=requested_by)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
from .domain.models import Document, IngestionRequest
|
||||
from dataclasses import dataclass
|
||||
|
||||
def validate_request(request: IngestionRequest) -> list[str]:
|
||||
errors = []
|
||||
if not request.source.repository.strip(): errors.append("repository is required")
|
||||
if not request.source.path.strip(): errors.append("path is required")
|
||||
if request.destination not in {"sharepoint"}: errors.append("unsupported destination")
|
||||
return errors
|
||||
from .kab_ingestion.domain.models import SourceDocument
|
||||
|
||||
def validate_document(document: Document) -> list[str]:
|
||||
return [] if document.content.strip() else ["document content is empty"]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationReport:
|
||||
valid: bool
|
||||
errors: tuple[str, ...]
|
||||
|
||||
|
||||
def validate_document(document: SourceDocument) -> ValidationReport:
|
||||
errors: list[str] = []
|
||||
if not document.identifier.strip():
|
||||
errors.append("identifier is required")
|
||||
if not document.title.strip():
|
||||
errors.append("title is required")
|
||||
if not document.body.strip():
|
||||
errors.append("body is required")
|
||||
if not document.source.strip():
|
||||
errors.append("source is required")
|
||||
return ValidationReport(not errors, tuple(errors))
|
||||
|
||||
Reference in New Issue
Block a user