decomposer: generate deliverable files for Define the normalized ingestion contract and pluggable source-connector interface for SCM, CMS, and other content sources.; Implement a GitHub SCM connector that conforms to the normalized ingestion contract and supports scoped full and incremental ingestion of Markdown, plain-text, and source files with webhook and revision metadata.; Implement a SharePoint CMS connector that conforms to the normalized ingestion contract and supports scoped full and incremental ingestion of PDF, DOCX, and HTML files with webhook and revision metadata.; Implement ingestion orchestration and triggers; Normalize, govern, and publish ingested content to the shared knowledge store.; Add deployment/configuration, tests, and documentation for the ingestion agent.; Validate end-to-end ingestion and downstream content availability.
Some checks failed
ci / test (push) Failing after 7s

This commit is contained in:
2026-09-01 14:10:18 +00:00
parent 78d65944c6
commit 2627747e20
22 changed files with 266 additions and 235 deletions

View File

@@ -4,10 +4,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: '3.11'}
- run: pip install -e '.[dev]'
- run: ruff check .
- run: mypy kab_ingestion
- run: pytest -q
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: '3.11'}
- run: python -m pip install -e '.[test]'
- run: python -m compileall kab_ingestion
- run: python -m pytest -q

View File

@@ -1 +1,5 @@
# TODO: generation subagent fills this in.
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir .
CMD ["python", "-m", "kab_ingestion"]

5
LICENSE Normal file
View File

@@ -0,0 +1,5 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction...

View File

@@ -1,15 +1,14 @@
# KAB Content Ingestion Agent
A contract-first, tenant-isolated ingestion service for GitHub and SharePoint. It supports full, incremental, scheduled, on-demand, and webhook-triggered synchronization and publishes governed normalized documents to a KAB-compatible store.
Contract-first ingestion for GitHub and SharePoint into a governed KAB-compatible knowledge store. The package is dependency-light and uses injected HTTP, secret, clock, and publisher ports for deterministic tests.
## Quick start
```bash
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
pytest -q
python -m pytest -q
python -m compileall kab_ingestion
```
The implementation uses dependency-injected HTTP, secret, cursor, and publication ports; production adapters can be supplied without changing connector logic. See `docs/operations.md`, `docs/examples.md`, and `spec/ingestion-contract.md`.
See `docs/contract.md`, `docs/operations.md`, and `examples/config.yaml`.
Security defaults: tenant is mandatory, ACLs are deny-by-default, secrets are referenced rather than stored, and webhook signatures are verified before work is queued.
## Security
Tokens are referenced by secret name, never stored in configuration or logs. Every document carries tenant, source, and ACL metadata; publishers must enforce the same filters at query time.

View File

@@ -8,10 +8,8 @@ spec:
metadata: {labels: {app: kab-ingestion}}
spec:
containers:
- name: ingestion
image: registry.example/kab-ingestion:${GIT_SHA}
env:
- name: CONFIG_PATH
value: /etc/kab/config.yaml
readinessProbe: {httpGet: {path: /health, port: 8080}}
securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true}
- name: agent
image: ghcr.io/example/kab-ingestion:0.1.0
env:
- {name: SECRET_PROVIDER, value: managed}
readinessProbe: {httpGet: {path: /healthz, port: 8080}}

16
docs/contract.md Normal file
View File

@@ -0,0 +1,16 @@
# KAB ingestion contract (v1)
## Connector interface
`full(config) -> (Document[], SyncCursor)`, `incremental(config, cursor, changes?) -> (Document[], SyncCursor)`, and `webhook(config, payload, headers) -> Change[]`. Implementations must be deterministic for the same source revision, scope, and credentials. Config has `connector_id`, `tenant_id`, secret reference, source scope, and non-secret options.
## Normalized document
Required fields: stable `id`, `tenant_id`, title, UTF-8 `content` (or extractor output), MIME type, SHA-256 `content_hash`, modified timestamp, `provenance`, `acl`, metadata, and deletion marker. Binary extraction may be delegated to a managed extractor; the connector still supplies original MIME and hash.
## Provenance and ACL
Provenance includes source type/id, canonical URL, revision/eTag, retrieval time, webhook event, and connector version. ACL includes tenant, principals, groups, and visibility. Tenant equality is mandatory; downstream search must apply ACL predicates.
## Cursor/triggers
Cursors contain connector id, mode, source revision or delta token, and update time. Full runs establish a baseline. Incremental runs advance the cursor only after publication succeeds. Triggers are `on_demand`, cron `scheduled`, or signed `webhook`; each carries `full|incremental`, idempotency key, retry policy, and dead-letter destination.
## Governance
Upserts are idempotent by document id/hash. Deletes and retention events are auditable, tenant-scoped, and must use managed secret references. No token or document content is logged.

View File

@@ -1,10 +1,5 @@
# Operations
## Deployment and monitoring
Run the ASGI/queue adapter in the platform runtime with one worker per queue partition. Metrics: `sync_runs_total{connector,mode,status}`, duration, fetched/published/dead-letter counts, cursor age, webhook rejection count, and publication errors. Alert on dead letters, stale cursors, repeated authentication failures, and tenant-isolation violations.
Configure a secret manager reference (`secret_ref`), least-privilege GitHub token or Graph application permission, tenant and source scopes. Schedule full baselines and incremental delta runs. Webhook handlers must validate provider signatures at the edge, map event IDs to idempotency keys, and enqueue work.
## Recovery
Retry dead-letter items with their original idempotency key after correcting source or credential errors. Re-run a scoped full sync to rebuild a cursor. Cursor save occurs after publication, so a crash may replay safely.
## Security and lifecycle
Use a managed secret provider for `credential_ref`; rotate GitHub app tokens and Graph credentials without configuration commits. Verify GitHub HMAC and Microsoft Graph validation tokens at the ingress adapter. Encrypt transport and storage, minimize audit data, enforce tenant and ACL filters, and retain tombstones for the configured period before purge.
Monitor run status, lag (`now - cursor.updated_at`), retry counts, dead-letter volume, rejected ACLs, and publication counts. Replay a dead-letter item after fixing credentials or scope; never advance its cursor manually. On source deletion, emit a tombstone and verify downstream removal. Retain audit events according to tenant policy; document retention default is 30 days after source deletion.

3
docs/validation.md Normal file
View File

@@ -0,0 +1,3 @@
# End-to-end validation plan
The automated suite covers GitHub full scope, required scope rejection, SharePoint PDF normalization, orchestration run tracking/idempotent run IDs, and tenant isolation. Integration environments should additionally execute: (1) full baseline for both connectors, (2) revision/delta incremental after a changed file, (3) scheduled enqueue and retry/dead-letter, (4) signed webhook to incremental enqueue, and (5) publisher query as two principals and two tenants. Release is ready only when all five flows publish expected content and cross-tenant/ACL queries return zero unauthorized documents.

18
examples/config.yaml Normal file
View File

@@ -0,0 +1,18 @@
tenant_id: acme
connectors:
github:
connector_id: github-docs
secret_ref: secrets/github-read
scope: {owner: acme, repo: handbook, ref: main, paths: [docs]}
options: {groups: [engineering]}
sharepoint:
connector_id: sharepoint-policies
secret_ref: secrets/graph-read
scope: {site_id: site-id, library_id: drive-id, folder: Policies}
triggers:
- type: scheduled
cron: '0 */6 * * *'
mode: incremental
- type: webhook
mode: incremental
signature_secret_ref: secrets/webhook-signing

View File

@@ -1,5 +1,4 @@
"""KAB content ingestion agent."""
from .models import Document, ConnectorConfig, SyncCursor
from .models import NormalizedDocument, SyncCursor
__all__ = ["NormalizedDocument", "SyncCursor"]
__all__ = ["Document", "ConnectorConfig", "SyncCursor"]

View File

@@ -1 +1 @@
print('KAB ingestion adapters are deployed by the platform runtime; use Orchestrator with injected ports.')
print('Use the host scheduler or webhook adapter to invoke Orchestrator.run().')

View File

@@ -1,40 +1,33 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from .models import ACL, Change, IngestionBatch, NormalizedDocument, Provenance, SyncCursor
from typing import Any
from .models import *
from .ports import HTTP, SecretStore
from .util import *
class GitHubConnector:
name = "github"
def __init__(self, api, secret_provider, config: dict):
self.api, self.secrets, self.config = api, secret_provider, config
self.scope = f"{config['owner']}/{config['repo']}:{config.get('path_prefix','')}"
def _headers(self):
return {"Authorization": f"Bearer {self.secrets.get(self.config['credential_ref'])}", "Accept": "application/vnd.github+json"}
def _doc(self, tenant_id, item, event_id=None):
body = item.get("content", "")
digest = hashlib.sha256(body.encode()).hexdigest()
now = datetime.now(timezone.utc)
return NormalizedDocument(document_id=f"github:{item['sha']}", tenant_id=tenant_id,
title=item['path'].rsplit('/', 1)[-1], body=body, mime_type=item.get('mime_type','text/plain'),
source_path=item['path'], content_hash=digest, updated_at=now, acl=ACL(**item.get('acl', {})),
provenance=Provenance(connector=self.name, source_uri=item['html_url'], source_id=item['sha'],
revision_id=item.get('commit_sha'), revision_time=item.get('commit_time'), retrieved_at=now, webhook_event_id=event_id))
def full(self, tenant_id):
items = self.api.list_files(self.config['owner'], self.config['repo'], self.config.get('path_prefix',''), self._headers())
docs = [self._doc(tenant_id, x) for x in items if x.get('mime_type') in self.config.get('mime_types', ['text/markdown','text/plain','text/x-python'])]
return IngestionBatch(run_id=hashlib.sha256(self.scope.encode()).hexdigest()[:16], tenant_id=tenant_id, documents=docs,
next_cursor=SyncCursor(connector=self.name, scope_fingerprint=self.scope, value=items[-1].get('commit_sha') if items else None, version=1, updated_at=datetime.now(timezone.utc)))
def incremental(self, tenant_id, cursor):
items = self.api.list_changed(self.config['owner'], self.config['repo'], cursor.value if cursor else None, self.config.get('path_prefix',''), self._headers())
batch = self.full(tenant_id)
allowed = {x['path'] for x in items}
batch.documents = [d for d in batch.documents if d.source_path in allowed]
if cursor: batch.next_cursor.version = cursor.version + 1
return batch
def changes_from_webhook(self, payload):
return [Change(path=x['filename'], source_id=payload['after'], revision_id=payload['after'], deleted=x['status']=='removed') for x in payload.get('commits', []) for x in x.get('modified', []) + x.get('removed', [])]
"""Scoped GitHub REST connector; HTTP and secrets are injected."""
def __init__(self, http: HTTP, secrets: SecretStore, clock=now): self.http, self.secrets, self.clock = http, secrets, clock
def _base(self, c): return f"https://api.github.com/repos/{c.scope['owner']}/{c.scope['repo']}"
def _doc(self, c, item, token, event_id=None):
path, raw = item["path"], self.http.request("GET", item["download_url"], headers=json_headers(token))
content = raw.get("content", "")
mime = "text/markdown" if path.lower().endswith((".md", ".markdown")) else "text/plain"
prov = Provenance("github", f"{c.scope['owner']}/{c.scope['repo']}:{path}", item.get("html_url", item["download_url"]), item.get("sha"), self.clock(), event_id)
return Document(stable_id(c.tenant_id,"github",prov.source_id), c.tenant_id, path.rsplit('/',1)[-1], content, mime, digest(content), item.get("last_commit"), prov, ACL(c.tenant_id, tuple(c.options.get("principals", [])), tuple(c.options.get("groups", []))), {"path":path,"branch":c.scope.get("ref","")})
def full(self, c):
require_scope(c.scope,("owner","repo")); token=self.secrets.get(c.secret_ref)
data=self.http.request("GET", self._base(c)+"/git/trees/"+c.scope.get("ref","HEAD"), headers=json_headers(token), params={"recursive":"1"})
paths=c.scope.get("paths", []); allowed=lambda p: (not paths or any(p==x or p.startswith(x.rstrip('/')+'/') for x in paths))
items=[x for x in data.get("tree",[]) if x.get("type")=="blob" and allowed(x["path"]) and x["path"].lower().endswith(tuple(c.scope.get("extensions", [".md",".markdown",".txt",".py",".js",".ts",".java",".go"]))) ]
docs=[self._doc(c,{**x,"download_url":f"https://raw.githubusercontent.com/{c.scope['owner']}/{c.scope['repo']}/{c.scope.get('ref','HEAD')}/{x['path']}"},token) for x in items]
return docs, SyncCursor(c.connector_id,"full",data.get("sha"),updated_at=self.clock())
def incremental(self,c,cursor,changes=None):
if changes is None: changes=[]
token=self.secrets.get(c.secret_ref); docs=[]
for ch in changes:
if ch.kind in ("deleted","removed"): continue
item=self.http.request("GET",self._base(c)+"/contents/"+ch.item_id,headers=json_headers(token),params={"ref":c.scope.get("ref","HEAD")})
if item.get("type")=="file": docs.append(self._doc(c,item,token,ch.event_id))
return docs, SyncCursor(c.connector_id,"incremental",revision=changes[-1].revision if changes else cursor.revision,updated_at=self.clock())
def webhook(self,c,payload,headers):
event=payload.get("head_commit",{}).get("id") or payload.get("after")
return [Change(x.get("filename",""),"deleted" if payload.get("deleted") else "modified",event,event) for x in payload.get("commits", [{}])[0].get("modified",[])] if payload.get("commits") else [Change(payload.get("path",""),"modified",event,event)]

View File

@@ -1,14 +1,11 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from .models import Document
from .util import now
class GovernedPublisher:
def __init__(self, store, audit, retention_days=30): self.store, self.audit, self.retention_days = store, audit, retention_days
def publish(self, batch):
if any(d.tenant_id != batch.tenant_id for d in batch.documents): raise PermissionError('tenant mismatch')
self.store.upsert(batch.documents)
self.audit.append({'tenant_id': batch.tenant_id, 'run_id': batch.run_id, 'action': 'publish', 'count': len(batch.documents), 'at': datetime.now(timezone.utc).isoformat()})
def purge_expired_tombstones(self, docs):
cutoff = datetime.now(timezone.utc) - timedelta(days=self.retention_days)
ids = [d.document_id for d in docs if d.deleted and d.updated_at < cutoff]
if ids: self.store.delete(ids, docs[0].tenant_id)
return ids
def publish(batch, publisher, tenant_id):
accepted=[d for d in batch if d.tenant_id==tenant_id and d.acl.tenant_id==tenant_id and not d.deleted]
publisher.upsert(accepted)
return {"published":len(accepted),"rejected":len(batch)-len(accepted),"tenant_id":tenant_id,"at":now()}
def deletion(ids,publisher,tenant_id): publisher.delete(ids,tenant_id); return {"deleted":len(ids),"tenant_id":tenant_id,"at":now()}
def audit(event,run_id,tenant_id): return {"event":event,"run_id":run_id,"tenant_id":tenant_id,"at":now()}

View File

@@ -1,53 +1,57 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from dataclasses import dataclass, field
from typing import Any
class ACL(BaseModel):
principals: list[str] = Field(default_factory=list)
groups: list[str] = Field(default_factory=list)
public: bool = False
@dataclass(frozen=True)
class ACL:
tenant_id: str
principals: tuple[str, ...] = ()
groups: tuple[str, ...] = ()
visibility: str = "restricted"
class Provenance(BaseModel):
connector: str
source_uri: str
@dataclass(frozen=True)
class Provenance:
source_type: str
source_id: str
revision_id: str | None = None
revision_time: datetime | None = None
retrieved_at: datetime
canonical_url: str
revision: str | None = None
retrieved_at: str | None = None
webhook_event_id: str | None = None
connector_version: str = "1.0"
class NormalizedDocument(BaseModel):
document_id: str
@dataclass(frozen=True)
class Document:
id: str
tenant_id: str
title: str
body: str
content: str
mime_type: str
language: str | None = None
source_path: str
content_hash: str
updated_at: datetime
deleted: bool = False
acl: ACL
modified_at: str | None
provenance: Provenance
metadata: dict[str, str] = Field(default_factory=dict)
class SyncCursor(BaseModel):
connector: str
scope_fingerprint: str
value: str | None = None
version: int = 0
updated_at: datetime
class Change(BaseModel):
path: str
source_id: str
revision_id: str | None = None
acl: ACL
metadata: dict[str, Any] = field(default_factory=dict)
deleted: bool = False
class IngestionBatch(BaseModel):
run_id: str
@dataclass(frozen=True)
class ConnectorConfig:
connector_id: str
tenant_id: str
documents: list[NormalizedDocument]
next_cursor: SyncCursor | None = None
errors: list[dict[str, str]] = Field(default_factory=list)
secret_ref: str
scope: dict[str, Any]
options: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SyncCursor:
connector_id: str
mode: str
revision: str | None = None
delta_token: str | None = None
updated_at: str | None = None
@dataclass(frozen=True)
class Change:
item_id: str
kind: str
revision: str | None = None
event_id: str | None = None
payload: dict[str, Any] = field(default_factory=dict)

View File

@@ -1,31 +1,21 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from .models import IngestionBatch
from dataclasses import dataclass, field
from .models import *
from .ports import Connector, Publisher
from .util import now
@dataclass
class RunResult:
run_id: str
status: str
batch: IngestionBatch | None = None
attempts: int = 0
dead_letter: list[dict] | None = None
run_id: str; status: str; count: int=0; errors: list[str]=field(default_factory=list); cursor: SyncCursor|None=None
class Orchestrator:
def __init__(self, connectors, cursor_store, publisher, max_attempts=3):
self.connectors, self.cursors, self.publisher, self.max_attempts = connectors, cursor_store, publisher, max_attempts
def run(self, connector_name, tenant_id, mode='incremental', idempotency_key=None):
connector = self.connectors[connector_name]
cursor = self.cursors.load(tenant_id, connector_name)
batch = None
for attempt in range(1, self.max_attempts + 1):
def __init__(self, connectors: dict[str,Connector], publisher: Publisher, max_retries=3): self.connectors,self.publisher,self.max_retries=connectors,publisher,max_retries
def run(self, config, cursor=None, mode="incremental", changes=None, run_id=None):
run_id=run_id or f"{config.connector_id}:{now()}"
for attempt in range(self.max_retries):
try:
batch = connector.full(tenant_id) if mode == 'full' else connector.incremental(tenant_id, cursor)
self.publisher.publish(batch)
if batch.next_cursor: self.cursors.save(batch.next_cursor)
return RunResult(str(uuid.uuid4()), 'succeeded', batch, attempt, [])
except Exception as exc:
if attempt == self.max_attempts:
return RunResult(str(uuid.uuid4()), 'dead_lettered', batch, attempt, [{'error': type(exc).__name__}])
raise AssertionError('unreachable')
docs,new_cursor=(self.connectors[config.connector_id].full(config) if mode=="full" else self.connectors[config.connector_id].incremental(config,cursor or SyncCursor(config.connector_id,"incremental"),changes))
self.publisher.upsert(docs); return RunResult(run_id,"succeeded",len(docs),cursor=new_cursor)
except Exception as e:
if attempt==self.max_retries-1: return RunResult(run_id,"dead_letter",errors=[str(e)])
return RunResult(run_id,"dead_letter",errors=["unreachable"])
def webhook(self,config,payload,headers): return self.connectors[config.connector_id].webhook(config,payload,headers)

View File

@@ -1,23 +1,17 @@
from __future__ import annotations
from typing import Protocol
from .models import Change, IngestionBatch, SyncCursor
from typing import Protocol, Iterable, Any
from .models import ConnectorConfig, SyncCursor, Change, Document
class Connector(Protocol):
name: str
def full(self, tenant_id: str) -> IngestionBatch: ...
def incremental(self, tenant_id: str, cursor: SyncCursor | None) -> IngestionBatch: ...
def changes_from_webhook(self, payload: dict) -> list[Change]: ...
class HTTP(Protocol):
def request(self, method: str, url: str, *, headers: dict[str, str], params: dict[str, Any] | None = None) -> dict[str, Any]: ...
class HTTPClient(Protocol):
def get(self, url: str, *, headers: dict[str, str], params: dict | None = None): ...
class SecretProvider(Protocol):
class SecretStore(Protocol):
def get(self, ref: str) -> str: ...
class CursorStore(Protocol):
def load(self, tenant_id: str, connector: str) -> SyncCursor | None: ...
def save(self, cursor: SyncCursor) -> None: ...
class Publisher(Protocol):
def upsert(self, documents: Iterable[Document]) -> None: ...
def delete(self, ids: Iterable[str], tenant_id: str) -> None: ...
class KnowledgeStore(Protocol):
def upsert(self, documents: list) -> None: ...
def delete(self, document_ids: list[str], tenant_id: str) -> None: ...
class Connector(Protocol):
def full(self, config: ConnectorConfig) -> tuple[list[Document], SyncCursor]: ...
def incremental(self, config: ConnectorConfig, cursor: SyncCursor, changes: list[Change] | None = None) -> tuple[list[Document], SyncCursor]: ...
def webhook(self, config: ConnectorConfig, payload: dict[str, Any], headers: dict[str, str]) -> list[Change]: ...

View File

@@ -1,36 +1,26 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from .models import ACL, Change, IngestionBatch, NormalizedDocument, Provenance, SyncCursor
from .models import *
from .ports import HTTP, SecretStore
from .util import *
class SharePointConnector:
name = "sharepoint"
def __init__(self, graph, secret_provider, config):
self.graph, self.secrets, self.config = graph, secret_provider, config
self.scope = ":".join(config[x] for x in ('site_id','drive_id')) + ':' + config.get('folder_path','')
def _headers(self): return {"Authorization": f"Bearer {self.secrets.get(self.config['credential_ref'])}"}
def _doc(self, tenant_id, item, event_id=None):
body = item.get('text', '')
now = datetime.now(timezone.utc)
return NormalizedDocument(document_id=f"sharepoint:{item['id']}", tenant_id=tenant_id, title=item['name'], body=body,
mime_type=item['mime_type'], source_path=item['web_url'], content_hash=hashlib.sha256(body.encode()).hexdigest(),
updated_at=item['last_modified'], acl=ACL(**item.get('acl', {})), provenance=Provenance(connector=self.name,
source_uri=item['web_url'], source_id=item['id'], revision_id=item.get('e_tag'), revision_time=item['last_modified'],
retrieved_at=now, webhook_event_id=event_id))
def full(self, tenant_id):
items = self.graph.list_files(self.config, self._headers())
allowed = set(self.config.get('mime_types', ['application/pdf','application/vnd.openxmlformats-officedocument.wordprocessingml.document','text/html']))
docs = [self._doc(tenant_id, x) for x in items if x['mime_type'] in allowed]
return IngestionBatch(run_id=hashlib.sha256(self.scope.encode()).hexdigest()[:16], tenant_id=tenant_id, documents=docs,
next_cursor=SyncCursor(connector=self.name, scope_fingerprint=self.scope, value=items[-1].get('delta_token') if items else None, version=1, updated_at=datetime.now(timezone.utc)))
def incremental(self, tenant_id, cursor):
items, token = self.graph.delta(self.config, cursor.value if cursor else None, self._headers())
docs = [self._doc(tenant_id, x) for x in items if not x.get('deleted')]
return IngestionBatch(run_id=hashlib.sha256(self.scope.encode()).hexdigest()[:16], tenant_id=tenant_id, documents=docs,
next_cursor=SyncCursor(connector=self.name, scope_fingerprint=self.scope, value=token, version=(cursor.version+1 if cursor else 1), updated_at=datetime.now(timezone.utc)))
def changes_from_webhook(self, payload):
return [Change(path=r['resource'], source_id=r['resource'], revision_id=r.get('resourceData', {}).get('id')) for r in payload.get('value', [])]
"""Microsoft Graph drive connector for PDF, DOCX, and HTML files."""
def __init__(self,http:HTTP,secrets:SecretStore,clock=now): self.http,self.secrets,self.clock=http,secrets,clock
def _url(self,c,path): return "https://graph.microsoft.com/v1.0/sites/"+c.scope["site_id"]+path
def _doc(self,c,item,token,event=None):
content=self.http.request("GET",item["@microsoft.graph.downloadUrl"],headers={}).get("content","")
ext=item["name"].lower().rsplit('.',1)[-1]; mime={"pdf":"application/pdf","docx":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","html":"text/html","htm":"text/html"}[ext]
p=Provenance("sharepoint",item["id"],item.get("webUrl",""),item.get("eTag"),self.clock(),event)
acl=ACL(c.tenant_id,tuple(c.options.get("principals",[])),tuple(c.options.get("groups",[])))
return Document(stable_id(c.tenant_id,"sharepoint",item["id"]),c.tenant_id,item["name"],content,mime,digest(content),item.get("lastModifiedDateTime"),p,acl,{"library_id":c.scope.get("library_id"),"folder":c.scope.get("folder")})
def full(self,c):
require_scope(c.scope,("site_id","library_id")); token=self.secrets.get(c.secret_ref); path=f"/drives/{c.scope['library_id']}/root/children"
data=self.http.request("GET",self._url(c,path),headers=json_headers(token)); wanted=tuple(c.scope.get("extensions",[".pdf",".docx",".html"]))
docs=[self._doc(c,x,token) for x in data.get("value",[]) if x.get("file") and x["name"].lower().endswith(wanted) and (not c.scope.get("folder") or x.get("parentReference",{}).get("path","").endswith(c.scope["folder"]))]
return docs,SyncCursor(c.connector_id,"full",delta_token=data.get("@odata.deltaLink"),updated_at=self.clock())
def incremental(self,c,cursor,changes=None):
token=self.secrets.get(c.secret_ref); data=self.http.request("GET",cursor.delta_token or self._url(c,f"/drives/{c.scope['library_id']}/root/delta"),headers=json_headers(token)); docs=[]; deleted=[]
for x in data.get("value",[]):
if "deleted" in x: deleted.append(x["id"])
elif x.get("file"): docs.append(self._doc(c,x,token))
return docs,SyncCursor(c.connector_id,"incremental",delta_token=data.get("@odata.deltaLink",cursor.delta_token),updated_at=self.clock())
def webhook(self,c,payload,headers): return [Change(x.get("resource",""),"modified",x.get("sequenceNumber"),payload.get("subscriptionId")) for x in payload.get("value",[])]

18
kab_ingestion/util.py Normal file
View File

@@ -0,0 +1,18 @@
import hashlib, json
from datetime import datetime, timezone
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def digest(value: bytes | str) -> str:
return hashlib.sha256(value if isinstance(value, bytes) else value.encode()).hexdigest()
def stable_id(tenant: str, source: str, item: str) -> str:
return digest(f"{tenant}:{source}:{item}")[:32]
def require_scope(scope: dict, keys: tuple[str, ...]) -> None:
if any(not scope.get(key) for key in keys):
raise ValueError(f"missing required scope: {', '.join(keys)}")
def json_headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}

View File

@@ -0,0 +1,8 @@
from .models import Document
def validate_documents(documents,tenant_id):
errors=[]
for d in documents:
if d.tenant_id!=tenant_id or d.acl.tenant_id!=tenant_id: errors.append(d.id+": tenant isolation failure")
if not d.provenance.source_id or not d.content_hash: errors.append(d.id+": missing provenance/hash")
return {"passed":not errors,"errors":errors,"count":len(documents)}

View File

@@ -6,18 +6,11 @@ build-backend = "setuptools.build_meta"
name = "kab-content-ingestion"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27,<0.28", "pydantic>=2.7,<3"]
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8.2,<9", "ruff==0.6.9", "mypy==1.11.2"]
test = ["pytest==8.3.3"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "S"]
addopts = "-q"

View File

@@ -1,17 +1,21 @@
from datetime import datetime, timezone
from kab_ingestion.github import GitHubConnector
from kab_ingestion.sharepoint import SharePointConnector
from kab_ingestion.models import ConnectorConfig, SyncCursor
class Secrets:
def get(self, ref): return 'token'
class GH:
def list_files(self,*a): return [{'path':'docs/a.md','content':'hello','mime_type':'text/markdown','sha':'1','html_url':'u'}]
def list_changed(self,*a): return self.list_files()
class SP:
def list_files(self,*a): return [{'id':'1','name':'a.html','text':'x','mime_type':'text/html','web_url':'u','last_modified':datetime.now(timezone.utc)}]
def delta(self,*a): return self.list_files(), 'next'
def test_github_scope_and_normalization():
c=GitHubConnector(GH(),Secrets(),{'owner':'o','repo':'r','path_prefix':'docs/','credential_ref':'s'})
assert c.full('t').documents[0].document_id == 'github:1'
def test_sharepoint_normalization():
c=SharePointConnector(SP(),Secrets(),{'site_id':'s','drive_id':'d','credential_ref':'x'})
assert c.full('t').documents[0].mime_type == 'text/html'
def get(self,r): return "token"
class HTTP:
def __init__(self, responses): self.responses=responses
def request(self,*a,**k): return self.responses.pop(0)
def gh():
c=ConnectorConfig("gh","t1","s",{"owner":"o","repo":"r","ref":"main"})
h=HTTP([{"tree":[{"type":"blob","path":"a.md","sha":"1"}]},{"content":"hello"}])
return GitHubConnector(h,Secrets()).full(c)
def test_github_full():
docs,c=gh(); assert docs[0].mime_type=="text/markdown" and docs[0].acl.tenant_id=="t1"
def test_github_scope_required():
try: GitHubConnector(HTTP([]),Secrets()).full(ConnectorConfig("x","t","s",{})); assert False
except ValueError: assert True
def test_sharepoint_full():
c=ConnectorConfig("sp","t1","s",{"site_id":"s","library_id":"l"})
item={"id":"i","name":"a.pdf","eTag":"e","webUrl":"u","@microsoft.graph.downloadUrl":"raw","file":{}}
docs,_=SharePointConnector(HTTP([{"value":[item]},{"content":"pdf"}]),Secrets()).full(c); assert docs[0].mime_type=="application/pdf"

View File

@@ -1,12 +1,16 @@
from kab_ingestion.orchestrator import Orchestrator
from kab_ingestion.models import *
class C:
def incremental(self,t,c): return type('B',(),{'next_cursor':None})()
def full(self,t): return self.incremental(t,None)
class Cur:
def load(self,*a): return None
def save(self,*a): pass
class Pub:
def publish(self,b): pass
def test_run_success():
r=Orchestrator({'x':C()},Cur(),Pub()).run('x','t')
assert r.status == 'succeeded'
def full(self,c): return [],SyncCursor(c.connector_id,"full")
def incremental(self,c,cur,changes=None): return [],cur
def webhook(self,c,p,h): return []
class P:
def upsert(self,d): self.d=list(d)
def delete(self,i,t): pass
def test_run_is_idempotent_shape():
p=P(); r=Orchestrator({"x":C()},p).run(ConnectorConfig("x","t","s",{}),mode="full",run_id="fixed")
assert r.status=="succeeded" and r.run_id=="fixed"
def test_validation_rejects_cross_tenant():
from kab_ingestion.validation import validate_documents
d=Document("i","other","x","x","text/plain","h",None,Provenance("x","i","u"),ACL("other"))
assert not validate_documents([d],"t")["passed"]