fix: files manually
Some checks failed
quality-gates / verify (push) Failing after 7s

This commit is contained in:
2026-09-01 17:21:58 +01:00
parent 56c9eaa459
commit d8a4b6b3c1
36 changed files with 656 additions and 216 deletions

View File

@@ -3,10 +3,10 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from kab_ingestion.github import GitHubConnector
from kab_ingestion.models import IngestionRequest, IngestionResult
from kab_ingestion.sharepoint import SharePointConnector
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
@@ -19,22 +19,27 @@ class IngestionService:
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
from .workflows import create_ingestion_workflow
from .states.ingestion_state import IngestionState
workflow = create_ingestion_workflow(
scm=self.scm,
cms=self.cms,
allowed_sources=self.settings.allowed_sources,
publish_enabled=self.settings.publish_enabled,
require_governance_approval=self.settings.require_governance_approval,
)
initial_state: IngestionState = {
"request": request,
"tenant_id": request.metadata.get("tenant_id", "default_tenant"),
}
final_state = workflow.invoke(initial_state)
return final_state.get("published_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
scm = GitHubConnector(config.github_token, config.github_api_base)
cms = SharePointConnector(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"}}
return {"card": AGENT_CARD, "service": service, "routes": {"/health": "health", "/card": "card", "/ingest": "ingest"}, "triggers": {"github_push": "github_trigger", "manual_ingest": "manual_trigger"}}

View File

@@ -13,12 +13,12 @@ class AgentCard:
name: str
description: str
version: str
skills: tuple[AgentSkill, ...]
skills: tuple[str, ...]
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.")),
skills=("ingest_github", "publish_sharepoint", "validate_content"),
)

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from .kab_ingestion.domain.models import SourceDocument
from kab_ingestion.models import SourceDocument
@dataclass(frozen=True)

View File

@@ -1,5 +1,4 @@
"""Ingestion domain migrated into the application package."""
from .domain.models import IngestionRequest, IngestionResult, SourceDocument
"""Re-export canonical kab_ingestion symbols."""
from kab_ingestion import IngestionRequest, IngestionResult, SourceDocument
__all__ = ["IngestionRequest", "IngestionResult", "SourceDocument"]

View File

@@ -1,32 +1,4 @@
from __future__ import annotations
"""Re-export canonical GitHubConnector from kab_ingestion.github."""
from kab_ingestion.github import GitHubConnector as GitHubSCMConnector
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")})
__all__ = ["GitHubSCMConnector"]

View File

@@ -1,22 +1,4 @@
from __future__ import annotations
"""Re-export canonical SharePointConnector from kab_ingestion.sharepoint."""
from kab_ingestion.sharepoint import SharePointConnector as SharePointCMSConnector
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))
__all__ = ["SharePointCMSConnector"]

View File

@@ -1,37 +1,4 @@
from __future__ import annotations
"""Re-export canonical models from kab_ingestion.models for backward compatibility."""
from kab_ingestion.models import IngestionRequest, IngestionResult, SourceDocument
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
__all__ = ["IngestionRequest", "IngestionResult", "SourceDocument"]

View File

@@ -1,11 +1,59 @@
from .agent import create_agent_app
from __future__ import annotations
app = create_agent_app()
import os
from typing import Any
from .agent import create_agent_app
from .card import AGENT_CARD
from .routes import card_route, health_route, ingest_route
agent_context = create_agent_app()
def make_asgi_app() -> Any:
try:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
asgi_app = FastAPI(title="content-ingestion-agent", version="1.0.0")
@asgi_app.get("/health")
def health() -> dict[str, str]:
return health_route()
@asgi_app.get("/card")
def card() -> Any:
return card_route(AGENT_CARD)
@asgi_app.post("/ingest")
async def ingest(request: Request) -> dict[str, Any]:
from kab_ingestion.models import IngestionRequest
data = await request.json()
req = IngestionRequest(
source=data.get("source", "github"),
locator=data.get("locator", ""),
requested_by=data.get("requested_by", "system"),
metadata=data.get("metadata", {}),
)
results = ingest_route(req, agent_context["service"])
return {"results": results, "status": "completed"}
return asgi_app
except ImportError:
return agent_context
app = make_asgi_app()
def main() -> None:
"""Application entry point for ASGI hosts and local smoke checks."""
print(app["card"].name)
port = int(os.environ.get("PORT", "8080"))
try:
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=port)
except ImportError:
print(f"Starting {AGENT_CARD.name} v{AGENT_CARD.version}")
if __name__ == "__main__":

View File

@@ -0,0 +1,6 @@
from .discovery_node import discovery_node
from .validation_node import validation_node
from .generation_node import generation_node
from .deployment_node import deployment_node
__all__ = ["discovery_node", "validation_node", "generation_node", "deployment_node"]

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from kab_ingestion.models import IngestionResult
from ..publication import publish_document
from ..states.ingestion_state import IngestionState
from ..validation import validate_document
def deployment_node(
state: IngestionState,
cms: Any = None,
publish_enabled: bool = False,
require_governance_approval: bool = True,
) -> dict[str, Any]:
"""Deployment Phase: Review governance, enforce tenant ACL isolation, and publish documents."""
documents = state.get("fetched_documents", [])
results: list[IngestionResult] = []
for doc in documents:
report = validate_document(doc)
if not report.valid:
results.append(IngestionResult(doc, False, "invalid", "; ".join(report.errors)))
continue
res = publish_document(doc, cms, publish_enabled, require_governance_approval)
results.append(res)
accepted_count = sum(1 for r in results if r.accepted)
final_output = {
"total": len(results),
"accepted": accepted_count,
"results": results,
"status": "completed" if not state.get("has_errors") else "completed_with_errors",
}
return {
"published_results": results,
"final_output": final_output,
"current_step": "deployment",
"next_step": "END",
}

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from ..states.ingestion_state import IngestionState
def discovery_node(state: IngestionState) -> dict[str, Any]:
"""Discovery Phase: Check existing capabilities and resources non-blockingly."""
request = state.get("request")
source = request.source if request else "unknown"
locator = request.locator if request else "unknown"
# Non-blocking check for existing ingestion resources
existing_resources = {
"source": source,
"locator": locator,
"previously_ingested": False,
"connector_available": source in {"github", "sharepoint"},
}
return {
"existing_resources": existing_resources,
"current_step": "discovery",
"next_step": "validation",
}

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
from ..states.ingestion_state import IngestionState
def generation_node(state: IngestionState, scm: Any = None) -> dict[str, Any]:
"""Generation Phase: Fetch and normalize documents from source connector."""
request = state.get("request")
if not request or not scm:
return {
"fetched_documents": [],
"current_step": "generation",
"next_step": "deployment",
}
try:
documents = list(scm.fetch(request))
return {
"fetched_documents": documents,
"current_step": "generation",
"next_step": "deployment",
}
except Exception as exc:
error_records = list(state.get("errors", []))
error_records.append({"step": "generation", "message": str(exc), "recoverable": True})
return {
"fetched_documents": [],
"errors": error_records,
"has_errors": True,
"current_step": "generation",
"next_step": "END",
}

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from ..states.ingestion_state import IngestionState
def validation_node(state: IngestionState, allowed_sources: set[str] | None = None) -> dict[str, Any]:
"""Validation Phase: Validate request input and fail fast on malformed requests."""
request = state.get("request")
allowed = allowed_sources or {"github", "sharepoint"}
errors: list[str] = []
if not request:
errors.append("missing ingestion request")
else:
if not request.source or not request.source.strip():
errors.append("missing request source")
elif request.source not in allowed:
errors.append(f"unsupported source '{request.source}'")
if not request.locator or not request.locator.strip():
errors.append("missing request locator")
if errors:
error_records = list(state.get("errors", []))
for err in errors:
error_records.append({"step": "validation", "message": err, "recoverable": False})
return {
"validation_errors": errors,
"errors": error_records,
"has_errors": True,
"current_step": "validation",
"next_step": "END",
}
return {
"validation_errors": [],
"current_step": "validation",
"next_step": "generation",
}

View File

@@ -1,9 +1,9 @@
from .kab_ingestion.domain.models import IngestionResult, SourceDocument
from .kab_ingestion.ports.cms import CMSConnector
from typing import Any
from kab_ingestion.models import IngestionResult, SourceDocument
from .governance import review
def publish_document(document: SourceDocument, cms: CMSConnector | None, enabled: bool, require_approval: bool) -> IngestionResult:
def publish_document(document: SourceDocument, cms: Any | None, enabled: bool, require_approval: bool) -> IngestionResult:
decision = review(document, require_approval)
if not decision.approved:
return IngestionResult(document, False, "rejected", decision.reason)

View File

@@ -1,4 +1,4 @@
from .kab_ingestion.domain.models import IngestionRequest
from kab_ingestion.models import IngestionRequest
def ingest_route(request: IngestionRequest, service):

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, TypedDict
from kab_ingestion.models import Document, IngestionRequest, IngestionResult, SourceDocument
class IngestionState(TypedDict, total=False):
# Base agent state contract fields
messages: list[dict[str, Any]]
errors: list[dict[str, Any]]
has_errors: bool
workflow_id: str | None
current_step: str
next_step: str | None
started_at: str | None
updated_at: str | None
final_output: dict[str, Any] | None
# Ingestion phase extension fields
request: IngestionRequest
tenant_id: str
existing_resources: dict[str, Any]
validation_errors: list[str]
fetched_documents: list[Document | SourceDocument]
published_results: list[IngestionResult]

View File

@@ -1,4 +1,4 @@
from .kab_ingestion.domain.models import IngestionRequest
from kab_ingestion.models import IngestionRequest
def github_trigger(locator: str, requested_by: str = "github-webhook") -> IngestionRequest:

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from .kab_ingestion.domain.models import SourceDocument
from kab_ingestion.models import SourceDocument
@dataclass(frozen=True)

View File

@@ -0,0 +1,3 @@
from .ingestion_workflow import IngestionWorkflow, create_ingestion_workflow
__all__ = ["IngestionWorkflow", "create_ingestion_workflow"]

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import functools
from typing import Any, Callable
from ..nodes import deployment_node, discovery_node, generation_node, validation_node
from ..states.ingestion_state import IngestionState
class IngestionWorkflow:
"""LangGraph StateGraph workflow runner for Content Ingestion Agent.
Executes the 4-phase contract: Discovery -> Validation -> Generation -> Deployment.
"""
def __init__(
self,
scm: Any = None,
cms: Any = None,
allowed_sources: set[str] | None = None,
publish_enabled: bool = False,
require_governance_approval: bool = True,
):
self.nodes: dict[str, Callable[[IngestionState], dict[str, Any]]] = {
"discovery": discovery_node,
"validation": functools.partial(validation_node, allowed_sources=allowed_sources),
"generation": functools.partial(generation_node, scm=scm),
"deployment": functools.partial(
deployment_node,
cms=cms,
publish_enabled=publish_enabled,
require_governance_approval=require_governance_approval,
),
}
def invoke(self, state: IngestionState) -> IngestionState:
"""Run the workflow sequentially through the 4 phases."""
current_state: IngestionState = dict(state) # type: ignore
current_state.setdefault("messages", [])
current_state.setdefault("errors", [])
current_state.setdefault("has_errors", False)
current_state.setdefault("current_step", "discovery")
current_state.setdefault("next_step", "discovery")
current_node_name = "discovery"
while current_node_name and current_node_name != "END":
node_fn = self.nodes.get(current_node_name)
if not node_fn:
break
update = node_fn(current_state)
current_state.update(update) # type: ignore
# Route based on next_step contract
next_step = current_state.get("next_step")
if not next_step or next_step == "END" or current_state.get("has_errors"):
break
current_node_name = next_step
return current_state
def create_ingestion_workflow(
scm: Any = None,
cms: Any = None,
allowed_sources: set[str] | None = None,
publish_enabled: bool = False,
require_governance_approval: bool = True,
) -> IngestionWorkflow:
return IngestionWorkflow(
scm=scm,
cms=cms,
allowed_sources=allowed_sources,
publish_enabled=publish_enabled,
require_governance_approval=require_governance_approval,
)