This commit is contained in:
39
app/agent.py
39
app/agent.py
@@ -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"}}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .kab_ingestion.domain.models import SourceDocument
|
||||
from kab_ingestion.models import SourceDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
56
app/main.py
56
app/main.py
@@ -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__":
|
||||
|
||||
@@ -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"]
|
||||
|
||||
41
app/nodes/deployment_node.py
Normal file
41
app/nodes/deployment_node.py
Normal 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",
|
||||
}
|
||||
25
app/nodes/discovery_node.py
Normal file
25
app/nodes/discovery_node.py
Normal 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",
|
||||
}
|
||||
33
app/nodes/generation_node.py
Normal file
33
app/nodes/generation_node.py
Normal 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",
|
||||
}
|
||||
41
app/nodes/validation_node.py
Normal file
41
app/nodes/validation_node.py
Normal 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",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .kab_ingestion.domain.models import IngestionRequest
|
||||
from kab_ingestion.models import IngestionRequest
|
||||
|
||||
|
||||
def ingest_route(request: IngestionRequest, service):
|
||||
|
||||
25
app/states/ingestion_state.py
Normal file
25
app/states/ingestion_state.py
Normal 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]
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .kab_ingestion.domain.models import SourceDocument
|
||||
from kab_ingestion.models import SourceDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .ingestion_workflow import IngestionWorkflow, create_ingestion_workflow
|
||||
|
||||
__all__ = ["IngestionWorkflow", "create_ingestion_workflow"]
|
||||
|
||||
77
app/workflows/ingestion_workflow.py
Normal file
77
app/workflows/ingestion_workflow.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user