feat: implement GEWU service and local agent workflows
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-14 19:21:35 +08:00
1 parent 4db3f45164
commit d25c04cdd2
175 files changed
+56243 -15

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""GEWU knowledge service."""
+1
View File
@@ -0,0 +1 @@
"""Replaceable infrastructure adapters."""
+125
View File
@@ -0,0 +1,125 @@
"""Offline, CPU Sentence Transformers adapter for derived retrieval indexes."""
from __future__ import annotations
import hashlib
import importlib
import math
from pathlib import Path
from typing import Any, Protocol
class EmbeddingProviderError(RuntimeError):
"""Safe failure while loading a local model or encoding text."""
class _Encoder(Protocol):
def encode(
self,
sentences: list[str],
*,
normalize_embeddings: bool,
convert_to_numpy: bool,
show_progress_bar: bool,
) -> Any: ...
def model_artifact_sha256(path: Path) -> str:
"""Hash a local model tree deterministically, including file names and bytes."""
root = path.resolve(strict=True)
if not root.is_dir():
raise EmbeddingProviderError("Embedding model path must be a directory")
digest = hashlib.sha256()
files = sorted(item for item in root.rglob("*") if item.is_file())
if not files:
raise EmbeddingProviderError("Embedding model directory is empty")
for item in files:
resolved = item.resolve(strict=True)
if not resolved.is_relative_to(root):
raise EmbeddingProviderError("Embedding model contains an external file")
relative = item.relative_to(root).as_posix().encode("utf-8")
digest.update(len(relative).to_bytes(4, "big"))
digest.update(relative)
with resolved.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
class LocalSentenceTransformer:
"""Loads only a preinstalled model directory; never fetches weights at runtime."""
def __init__(
self,
model_path: Path,
*,
model_id: str,
expected_sha256: str,
dimensions: int = 384,
encoder: _Encoder | None = None,
) -> None:
self._path = model_path.resolve(strict=True)
self._model_id = model_id
self._dimensions = dimensions
actual_sha256 = model_artifact_sha256(self._path)
if actual_sha256 != expected_sha256:
raise EmbeddingProviderError("Embedding model artifact digest does not match")
self._artifact_sha256 = actual_sha256
if dimensions < 1:
raise EmbeddingProviderError("Embedding dimensions must be positive")
if encoder is None:
try:
sentence_transformers = importlib.import_module("sentence_transformers")
encoder = sentence_transformers.SentenceTransformer(
str(self._path),
device="cpu",
local_files_only=True,
trust_remote_code=False,
)
except Exception as error: # optional runtime dependency / invalid model
raise EmbeddingProviderError("Local embedding model could not be loaded") from error
self._encoder = encoder
@property
def dimensions(self) -> int:
return self._dimensions
@property
def model_id(self) -> str:
return self._model_id
@property
def artifact_sha256(self) -> str:
return self._artifact_sha256
def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
return self._encode(texts, prefix="query: ")
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
return self._encode(texts, prefix="passage: ")
def _encode(self, texts: tuple[str, ...], *, prefix: str) -> tuple[tuple[float, ...], ...]:
if not texts:
return ()
if any(not text.strip() for text in texts):
raise EmbeddingProviderError("Embedding input cannot be blank")
try:
encoded = self._encoder.encode(
[prefix + text for text in texts],
normalize_embeddings=True,
convert_to_numpy=True,
show_progress_bar=False,
)
rows = tuple(tuple(float(value) for value in row) for row in encoded)
except Exception as error:
raise EmbeddingProviderError("Text could not be embedded") from error
if len(rows) != len(texts):
raise EmbeddingProviderError("Embedding provider returned an invalid row count")
for row in rows:
if len(row) != self._dimensions or not all(math.isfinite(value) for value in row):
raise EmbeddingProviderError("Embedding provider returned an invalid vector")
norm = math.sqrt(sum(value * value for value in row))
if not math.isclose(norm, 1.0, rel_tol=1e-4, abs_tol=1e-4):
raise EmbeddingProviderError("Embedding provider returned a non-normalized vector")
return rows
+199
View File
@@ -0,0 +1,199 @@
"""Private bare-Git adapter with expected-head publication."""
from __future__ import annotations
import os
import re
import secrets
import subprocess
from collections.abc import Mapping
from pathlib import Path
from gewu.application.ports import (
CanonicalConflictError,
CanonicalRepositoryError,
validate_canonical_path,
)
class GitRepositoryError(CanonicalRepositoryError):
"""Safe repository operation failure."""
class GitConflictError(CanonicalConflictError, GitRepositoryError):
"""Expected-head compare-and-swap did not match."""
class GitCanonicalRepository:
"""Store accepted records in one authoritative Git ref without a worktree."""
branch_ref = "refs/heads/main"
def __init__(self, path: Path) -> None:
self.path = path.expanduser().resolve()
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.path.parent.chmod(0o700)
if self.path.exists():
if not self.path.is_dir() or not (self.path / "HEAD").is_file():
raise GitRepositoryError("Canonical repository path is not a bare Git repository")
else:
self.path.mkdir(mode=0o700)
self._run(["init", "--bare", "--initial-branch=main", str(self.path)], git_dir=False)
self.path.chmod(0o700)
tree = self._run(["mktree"], input_bytes=b"").strip()
empty_commit = self._run(
["commit-tree", tree.decode("ascii"), "-m", "Initialize GEWU knowledge repository"],
env={
"GIT_AUTHOR_NAME": "GEWU service",
"GIT_AUTHOR_EMAIL": "service@gewu.invalid",
"GIT_COMMITTER_NAME": "GEWU service",
"GIT_COMMITTER_EMAIL": "service@gewu.invalid",
},
).strip()
zero = "0" * len(empty_commit.strip())
self._run(["update-ref", self.branch_ref, empty_commit.decode("ascii"), zero])
def _run(
self,
arguments: list[str],
*,
input_bytes: bytes | None = None,
env: Mapping[str, str] | None = None,
git_dir: bool = True,
) -> bytes:
command = ["git"]
if git_dir:
command.extend([f"--git-dir={self.path}"])
command.extend(arguments)
process_env = os.environ.copy()
process_env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"})
if env:
process_env.update(env)
result = subprocess.run(
command,
input=input_bytes,
capture_output=True,
env=process_env,
check=False,
)
if result.returncode != 0:
# Keep command details and repository paths out of client-facing errors.
raise GitRepositoryError("Canonical repository operation failed")
return result.stdout
def head(self) -> str:
return self._run(["rev-parse", self.branch_ref]).decode("ascii").strip()
def read_file(self, revision: str, path: str) -> bytes | None:
self._validate_revision(revision)
self._validate_path(path)
result = subprocess.run(
["git", f"--git-dir={self.path}", "show", f"{revision}:{path}"],
capture_output=True,
env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"},
check=False,
)
if result.returncode != 0:
return None
return result.stdout
def list_files(self, revision: str, prefix: str) -> tuple[str, ...]:
self._validate_revision(revision)
normalized_prefix = prefix.rstrip("/")
self._validate_path(normalized_prefix)
result = self._run(
["ls-tree", "-r", "--name-only", "--full-tree", revision, "--", normalized_prefix]
)
paths = tuple(
sorted(
path
for path in result.decode("utf-8").splitlines()
if path.startswith(normalized_prefix + "/")
)
)
for path in paths:
self._validate_path(path)
return paths
def publish_files(
self,
*,
expected_head: str,
files: Mapping[str, bytes],
message: str,
index_directory: Path,
) -> str:
"""Create a commit containing only named files and CAS-publish the branch."""
if not re.fullmatch(r"(?:[a-f0-9]{40}|[a-f0-9]{64})", expected_head):
raise GitRepositoryError("Expected canonical revision is invalid")
if not files:
raise GitRepositoryError("Canonical publication requires at least one file")
for path in files:
self._validate_path(path)
index_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
if index_directory.is_symlink():
raise GitRepositoryError("Private publication directory is unavailable")
index_path = index_directory / f"index-{secrets.token_hex(16)}"
env = {
"GIT_INDEX_FILE": str(index_path),
"GIT_AUTHOR_NAME": "GEWU service",
"GIT_AUTHOR_EMAIL": "service@gewu.invalid",
"GIT_COMMITTER_NAME": "GEWU service",
"GIT_COMMITTER_EMAIL": "service@gewu.invalid",
}
try:
self._run(["read-tree", expected_head], env=env)
for path, content in sorted(files.items()):
object_id = (
self._run(["hash-object", "-w", "--stdin"], input_bytes=content)
.decode("ascii")
.strip()
)
self._run(
["update-index", "--add", "--cacheinfo", f"100644,{object_id},{path}"], env=env
)
tree = self._run(["write-tree"], env=env).decode("ascii").strip()
commit = (
self._run(
["commit-tree", tree, "-p", expected_head, "-m", message[:200]],
env=env,
)
.decode("ascii")
.strip()
)
try:
self._run(["update-ref", self.branch_ref, commit, expected_head])
except GitRepositoryError as error:
raise GitConflictError("Canonical branch changed before publication") from error
return commit
finally:
index_path.unlink(missing_ok=True)
def introducing_commit(self, revision: str, path: str) -> str | None:
self._validate_revision(revision)
self._validate_path(path)
result = self._run(["log", "--reverse", "--format=%H", revision, "--", path])
commits = result.decode("ascii").splitlines()
return commits[0] if commits else None
def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]:
self._validate_revision(revision)
self._validate_path(path)
if not 1 <= limit <= 50:
raise GitRepositoryError("Canonical history limit is invalid")
result = self._run(["log", "--format=%H", f"-n{limit}", revision, "--", path])
return tuple(line for line in result.decode("ascii").splitlines() if line)
@staticmethod
def _validate_path(path: str) -> None:
try:
validate_canonical_path(path)
except CanonicalRepositoryError as error:
raise GitRepositoryError(str(error)) from error
@staticmethod
def _validate_revision(revision: str) -> None:
if not re.fullmatch(r"(?:[a-f0-9]{40}|[a-f0-9]{64})", revision):
raise GitRepositoryError("Canonical revision is invalid")
+211
View File
@@ -0,0 +1,211 @@
"""Text extraction from PDFs through a bounded, networkless Linux sandbox."""
from __future__ import annotations
import hashlib
import os
import shutil
import signal
import subprocess
import tempfile
from pathlib import Path
from typing import Protocol
from gewu.adapters.text_extractor import (
NORMALIZATION_PROFILE,
TextMarkdownExtractor,
)
from gewu.domain.contracts import ExtractionRecord
from gewu.domain.validation import canonical_json, extraction_digest
MAX_PDF_BYTES = 50 * 1024 * 1024
MAX_TEXT_BYTES = 50 * 1024 * 1024
MAX_PAGES = 10_000
TIMEOUT_SECONDS = 30
PARSER_NAME = "gewu-poppler-pdftotext"
PARSER_VERSION = "sandboxed-system-poppler-v1"
class SourceExtractor(Protocol):
def extract(
self, source_revision_id: str, content: bytes, media_type: str
) -> ExtractionRecord: ...
class PdfExtractionError(ValueError):
"""PDF could not be safely or reliably converted to text."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class PdfTextExtractor:
"""Run Poppler with only system libraries visible and resource limits enforced."""
def __init__(self) -> None:
self._bwrap = shutil.which("bwrap")
self._pdftotext = shutil.which("pdftotext")
self._prlimit = shutil.which("prlimit")
def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord:
if media_type != "application/pdf":
raise PdfExtractionError(
"UNSUPPORTED_MEDIA_TYPE", "PDF extractor requires application/pdf"
)
if not content or len(content) > MAX_PDF_BYTES or not content.startswith(b"%PDF-"):
raise PdfExtractionError("PDF_INVALID", "PDF signature or size is invalid")
if (
not self._bwrap
or not self._pdftotext
or not self._prlimit
or not Path("/proc/sys/kernel/ostype").exists()
):
raise PdfExtractionError(
"PARSER_UNAVAILABLE", "Sandboxed PDF extraction is unavailable"
)
assert self._pdftotext is not None
binary_digest = hashlib.sha256(Path(self._pdftotext).read_bytes()).hexdigest()
config = {
"sandbox": "bubblewrap-unshare-all-readonly-system-empty-env-tmpfs-v1",
"limits": {
"address_space": 1_073_741_824,
"cpu_seconds": 20,
"file_bytes": 104_857_600,
},
"media_type": media_type,
"max_pages": MAX_PAGES,
"max_text_bytes": MAX_TEXT_BYTES,
"normalization": NORMALIZATION_PROFILE,
"poppler_binary_sha256": binary_digest,
"version": PARSER_VERSION,
}
try:
with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
process = subprocess.Popen(
self._command(),
stdin=subprocess.PIPE,
stdout=output,
stderr=errors,
env={},
start_new_session=True,
close_fds=True,
)
try:
process.communicate(input=content, timeout=TIMEOUT_SECONDS)
except subprocess.TimeoutExpired as error:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
raise PdfExtractionError(
"PDF_TIMEOUT", "PDF extraction exceeded its time limit"
) from error
if process.returncode != 0:
raise PdfExtractionError(
"PDF_UNSUPPORTED", "PDF is encrypted, malformed or unreadable"
)
output.seek(0, os.SEEK_END)
if output.tell() > MAX_TEXT_BYTES:
raise PdfExtractionError(
"PDF_TEXT_TOO_LARGE", "Extracted PDF text exceeds its limit"
)
output.seek(0)
extracted = output.read(MAX_TEXT_BYTES + 1).decode("utf-8", errors="strict")
except PdfExtractionError:
raise
except (OSError, UnicodeDecodeError, subprocess.SubprocessError) as error:
raise PdfExtractionError(
"PARSER_UNAVAILABLE", "Sandboxed PDF extraction failed"
) from error
return self._record(
source_revision_id, extracted, hashlib.sha256(canonical_json(config)).hexdigest()
)
def _command(self) -> list[str]:
assert self._bwrap and self._pdftotext and self._prlimit
command = [
self._bwrap,
"--unshare-all",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/bin",
"/bin",
"--ro-bind",
"/lib",
"/lib",
"--ro-bind",
"/lib64",
"/lib64",
"--dev",
"/dev",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
"--chdir",
"/tmp",
"--clearenv",
"--setenv",
"LANG",
"C.UTF-8",
"--setenv",
"HOME",
"/tmp",
"--",
self._prlimit,
"--as=1073741824",
"--cpu=20",
"--fsize=104857600",
"--nofile=64",
"--",
self._pdftotext,
"-enc",
"UTF-8",
"-layout",
"-",
"-",
]
return command
@staticmethod
def _record(source_revision_id: str, text: str, config_digest: str) -> ExtractionRecord:
pages = text.replace("\r\n", "\n").replace("\r", "\n").split("\f")
if pages and not pages[-1].strip():
pages.pop()
if not pages or len(pages) > MAX_PAGES:
raise PdfExtractionError(
"PDF_PAGE_LIMIT", "PDF page count is outside the supported limit"
)
blocks = []
ordinal = 0
empty_pages = 0
for page_number, page_text in enumerate(pages, start=1):
if not page_text.strip():
empty_pages += 1
continue
for _, chunk in TextMarkdownExtractor._split(page_text):
block = TextMarkdownExtractor._block(ordinal, chunk).model_copy(
update={"page": page_number}
)
blocks.append(block)
ordinal += 1
if not blocks:
raise PdfExtractionError("PDF_NO_TEXT", "PDF contains no extractable text")
candidate = ExtractionRecord(
extraction_id="0" * 64,
source_revision_id=source_revision_id,
parser_name=PARSER_NAME,
parser_version=PARSER_VERSION,
parser_config_sha256=config_digest,
normalization_profile=NORMALIZATION_PROFILE,
status="partial" if empty_pages else "complete",
blocks=tuple(blocks),
warnings=(f"{empty_pages} page(s) contained no extractable text",)
if empty_pages
else (),
)
return candidate.model_copy(update={"extraction_id": extraction_digest(candidate)})
+23
View File
@@ -0,0 +1,23 @@
"""Select the versioned extractor appropriate for each archived source type."""
from __future__ import annotations
from gewu.adapters.pdf_extractor import PdfTextExtractor, SourceExtractor
from gewu.adapters.text_extractor import TextMarkdownExtractor
from gewu.domain.contracts import ExtractionRecord
class LocalSourceExtractor:
"""Compose local text and sandboxed PDF parsers behind one extraction contract."""
def __init__(self) -> None:
self.text = TextMarkdownExtractor()
self.pdf = PdfTextExtractor()
def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord:
if media_type == "application/pdf":
return self.pdf.extract(source_revision_id, content, media_type)
return self.text.extract(source_revision_id, content, media_type)
__all__ = ["LocalSourceExtractor", "SourceExtractor"]
File diff suppressed because it is too large. Load diff
+572
View File
@@ -0,0 +1,572 @@
"""Rebuildable SQLite FTS5 index over accepted Git nodes."""
from __future__ import annotations
import hashlib
import re
import sqlite3
from collections.abc import Callable
from contextlib import closing
from datetime import UTC, date, datetime
from pathlib import Path
import yaml
from pydantic import ValidationError
from gewu.application.paths import reject_symlink_components
from gewu.application.ports import CanonicalRepository
from gewu.application.search import (
IndexedNode,
SearchEdgeHit,
SearchEdgeResult,
SearchIndexResult,
SourceIndexResult,
)
from gewu.application.serialization import safe_yaml_record
from gewu.domain.contracts import Confidence, KnowledgeNode, SourceRevisionRecord, TypedEdge
MAX_INDEX_NODES = 100_000
MAX_INDEX_EDGES = 500_000
MAX_INDEX_RECORD_BYTES = 1_048_576
INDEX_SCHEMA_VERSION = 4
_WORD = re.compile(r"[^\W_]+", flags=re.UNICODE)
class SearchIndexError(RuntimeError):
"""Safe failure while rebuilding or querying the derived index."""
class SQLiteSearchIndex:
"""SQLite FTS5 index that can be deleted and reconstructed from Git."""
def __init__(self, path: Path, repository: CanonicalRepository) -> None:
self.path = path.expanduser().absolute()
reject_symlink_components(self.path)
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.path.parent.chmod(0o700)
if self.path.is_symlink():
raise RuntimeError("Search index path must not be a symbolic link")
self.repository = repository
self._vector_extension_loader: Callable[[sqlite3.Connection], None] | None = None
self._vector_generation_builder: (
Callable[[sqlite3.Connection, str, tuple[KnowledgeNode, ...]], None] | None
) = None
self._vector_generation_is_current: Callable[[str], bool] | None = None
self._initialize()
def configure_vector_generation(
self,
*,
extension_loader: Callable[[sqlite3.Connection], None],
builder: Callable[[sqlite3.Connection, str, tuple[KnowledgeNode, ...]], None],
is_current: Callable[[str], bool],
) -> None:
"""Install the optional vector publisher before serving reads."""
self._vector_extension_loader = extension_loader
self._vector_generation_builder = builder
self._vector_generation_is_current = is_current
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=5000")
connection.execute("PRAGMA synchronous=NORMAL")
if self._vector_extension_loader is not None:
try:
self._vector_extension_loader(connection)
except (OSError, RuntimeError, sqlite3.Error) as error:
connection.close()
raise SearchIndexError("SQLite vector extension could not be loaded") from error
return connection
def _initialize(self) -> None:
with closing(self._connect()) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"CREATE TABLE IF NOT EXISTS index_state ("
"singleton INTEGER PRIMARY KEY CHECK(singleton=1), "
"schema_version INTEGER NOT NULL, revision TEXT NOT NULL)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS indexed_nodes ("
"node_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, node_json TEXT NOT NULL)"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS indexed_nodes_project "
"ON indexed_nodes(project_id, node_id)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS indexed_sources ("
"source_revision_id TEXT PRIMARY KEY, source_json TEXT NOT NULL)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS indexed_source_grants ("
"source_revision_id TEXT NOT NULL, project_id TEXT NOT NULL, "
"PRIMARY KEY(source_revision_id, project_id))"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS indexed_source_grants_project "
"ON indexed_source_grants(project_id, source_revision_id)"
)
connection.execute(
"CREATE TABLE IF NOT EXISTS indexed_edges ("
"edge_id TEXT PRIMARY KEY, source_kind TEXT NOT NULL, source_id TEXT NOT NULL, "
"target_kind TEXT NOT NULL, target_id TEXT NOT NULL, edge_type TEXT NOT NULL, "
"edge_json TEXT NOT NULL)"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS indexed_edges_source "
"ON indexed_edges(source_kind, source_id, edge_type, edge_id)"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS indexed_edges_target "
"ON indexed_edges(target_kind, target_id, edge_type, edge_id)"
)
try:
connection.execute(
"CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5("
"node_id UNINDEXED, project_id UNINDEXED, title, summary, claims, tags, "
"tokenize='unicode61 remove_diacritics 2')"
)
except sqlite3.OperationalError as error:
raise SearchIndexError("SQLite FTS5 is unavailable in this runtime") from error
self.path.chmod(0o600)
def _indexed_revision(self) -> str | None:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?",
(INDEX_SCHEMA_VERSION,),
).fetchone()
return str(row[0]) if row is not None else None
def diagnostic_status(self) -> tuple[str | None, int, bool]:
try:
return self._indexed_revision(), INDEX_SCHEMA_VERSION, True
except sqlite3.Error:
return None, INDEX_SCHEMA_VERSION, False
@staticmethod
def _node_paths(repository: CanonicalRepository, revision: str) -> tuple[str, ...]:
return tuple(
path
for path in repository.list_files(revision, "projects")
if "/nodes/" in path and path.endswith(".yaml")
)
def _load_nodes(self, revision: str) -> tuple[KnowledgeNode, ...]:
nodes: list[KnowledgeNode] = []
seen: set[str] = set()
for path in self._node_paths(self.repository, revision):
raw = self.repository.read_file(revision, path)
if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES:
raise SearchIndexError("Canonical search record is unavailable or too large")
try:
node = safe_yaml_record(raw, KnowledgeNode)
except (ValueError, yaml.YAMLError, ValidationError) as error:
raise SearchIndexError("Canonical search record failed validation") from error
node_key = hashlib.sha256(node.node_id.encode("utf-8")).hexdigest()
project_key = hashlib.sha256(node.project_id.encode("utf-8")).hexdigest()
if node.node_id in seen or path != f"projects/{project_key}/nodes/{node_key}.yaml":
raise SearchIndexError("Canonical search identity does not match its path")
seen.add(node.node_id)
nodes.append(node)
if len(nodes) > MAX_INDEX_NODES:
raise SearchIndexError("Canonical search index exceeds its node limit")
return tuple(nodes)
def _load_edges(self, revision: str) -> tuple[TypedEdge, ...]:
edges: list[TypedEdge] = []
seen: set[str] = set()
for path in self.repository.list_files(revision, "edges"):
if not path.endswith(".yaml"):
continue
raw = self.repository.read_file(revision, path)
if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES:
raise SearchIndexError("Canonical relationship is unavailable or too large")
try:
edge = safe_yaml_record(raw, TypedEdge)
except (ValueError, yaml.YAMLError, ValidationError) as error:
raise SearchIndexError("Canonical relationship failed validation") from error
edge_key = hashlib.sha256(edge.edge_id.encode("utf-8")).hexdigest()
if edge.edge_id in seen or path != f"edges/{edge_key}.yaml":
raise SearchIndexError("Canonical relationship identity does not match its path")
seen.add(edge.edge_id)
edges.append(edge)
if len(edges) > MAX_INDEX_EDGES:
raise SearchIndexError("Canonical relationship index exceeds its edge limit")
return tuple(edges)
def _load_sources(self, revision: str) -> tuple[SourceRevisionRecord, ...]:
sources: list[SourceRevisionRecord] = []
seen: set[str] = set()
for path in self.repository.list_files(revision, "sources"):
if not path.endswith(".yaml"):
continue
raw = self.repository.read_file(revision, path)
if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES:
raise SearchIndexError("Canonical source record is unavailable or too large")
try:
source = safe_yaml_record(raw, SourceRevisionRecord)
except (ValueError, yaml.YAMLError, ValidationError) as error:
raise SearchIndexError("Canonical source record failed validation") from error
source_key = hashlib.sha256(source.source_id.encode("utf-8")).hexdigest()
revision_key = hashlib.sha256(source.revision_id.encode("utf-8")).hexdigest()
if source.revision_id in seen or path != f"sources/{source_key}/{revision_key}.yaml":
raise SearchIndexError("Canonical source identity does not match its path")
seen.add(source.revision_id)
sources.append(source)
return tuple(sources)
def _rebuild_if_needed(self) -> str:
revision = self.repository.head()
indexed_revision = self._indexed_revision()
vector_is_current = (
self._vector_generation_is_current(revision)
if self._vector_generation_is_current is not None
else True
)
if indexed_revision == revision and vector_is_current:
return revision
nodes = self._load_nodes(revision)
edges = self._load_edges(revision)
sources = self._load_sources(revision)
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM node_fts")
connection.execute("DELETE FROM indexed_nodes")
connection.execute("DELETE FROM indexed_edges")
connection.execute("DELETE FROM indexed_sources")
connection.execute("DELETE FROM indexed_source_grants")
for node in nodes:
node_json = node.model_dump_json()
claims = "\n".join(claim.text for claim in node.evidence_claims)
connection.execute(
"INSERT INTO indexed_nodes(node_id, project_id, node_json) "
"VALUES (?, ?, ?)",
(node.node_id, node.project_id, node_json),
)
connection.execute(
"INSERT INTO node_fts(node_id, project_id, title, summary, claims, tags) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
node.node_id,
node.project_id,
node.title,
node.summary,
claims,
" ".join((*node.tags, *node.aliases)),
),
)
for edge in edges:
connection.execute(
"INSERT INTO indexed_edges(edge_id, source_kind, source_id, target_kind, "
"target_id, edge_type, edge_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
edge.edge_id,
edge.source.kind,
edge.source.id,
edge.target.kind,
edge.target.id,
edge.edge_type,
edge.model_dump_json(),
),
)
for source in sources:
connection.execute(
"INSERT INTO indexed_sources(source_revision_id, source_json) "
"VALUES (?, ?)",
(source.revision_id, source.model_dump_json()),
)
connection.executemany(
"INSERT INTO indexed_source_grants(source_revision_id, project_id) "
"VALUES (?, ?)",
((source.revision_id, project_id) for project_id in source.ingested_for),
)
if self._vector_generation_builder is not None:
self._vector_generation_builder(connection, revision, nodes)
connection.execute(
"INSERT INTO index_state(singleton, schema_version, revision) "
"VALUES (1, ?, ?) "
"ON CONFLICT(singleton) DO UPDATE SET schema_version=excluded.schema_version, "
"revision=excluded.revision",
(INDEX_SCHEMA_VERSION, revision),
)
connection.commit()
except (sqlite3.Error, OSError) as error:
raise SearchIndexError("Derived search index could not be rebuilt") from error
self.path.chmod(0o600)
return revision
def canonical_nodes(self) -> tuple[str, tuple[KnowledgeNode, ...]]:
"""Return the complete validated node set at the current indexed revision."""
revision = self._rebuild_if_needed()
return revision, self._load_nodes(revision)
@staticmethod
def _match_expression(query: str) -> str:
terms = _WORD.findall(query)
return " AND ".join('"' + term.replace('"', '""') + '"' for term in terms)
def search(
self,
*,
query: str,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
node_types: tuple[str, ...] = (),
tags: tuple[str, ...] = (),
audience: tuple[str, ...] = (),
use_for: tuple[str, ...] = (),
domain: tuple[str, ...] = (),
confidences: tuple[Confidence, ...] = (),
as_of_from: date | None = None,
as_of_to: date | None = None,
stale_only: bool = False,
candidate_node_ids: tuple[str, ...] | None = None,
offset: int = 0,
) -> SearchIndexResult:
revision = self._rebuild_if_needed()
match = self._match_expression(query)
project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set())))
if not project_scope:
return SearchIndexResult(revision, ())
placeholders = ", ".join("?" for _ in project_scope)
filters: list[str] = []
filter_parameters: list[str] = []
if node_types:
filters.append(
"json_extract(indexed_nodes.node_json, '$.node_type') IN ("
+ ", ".join("?" for _ in node_types)
+ ")"
)
filter_parameters.extend(node_types)
for field, values in (
("tags", tags),
("audience", audience),
("use_for", use_for),
("domain", domain),
):
if values:
value_slots = ", ".join("?" for _ in values)
filters.append(
"EXISTS (SELECT 1 FROM json_each(indexed_nodes.node_json, '$."
+ field
+ "') AS facet WHERE facet.value IN ("
+ value_slots
+ "))"
)
filter_parameters.extend(values)
if confidences:
filters.append(
"json_extract(indexed_nodes.node_json, '$.confidence') IN ("
+ ", ".join("?" for _ in confidences)
+ ")"
)
filter_parameters.extend(item.value for item in confidences)
if as_of_from is not None:
filters.append("substr(json_extract(indexed_nodes.node_json, '$.as_of'), 1, 10) >= ?")
filter_parameters.append(as_of_from.isoformat())
if as_of_to is not None:
filters.append("substr(json_extract(indexed_nodes.node_json, '$.as_of'), 1, 10) <= ?")
filter_parameters.append(as_of_to.isoformat())
if stale_only:
filters.append("json_extract(indexed_nodes.node_json, '$.stale_after') <= ?")
filter_parameters.append(datetime.now(UTC).date().isoformat() + "T00:00:00Z")
if candidate_node_ids is not None:
if not candidate_node_ids:
return SearchIndexResult(revision, ())
filters.append(
"indexed_nodes.node_id IN (" + ", ".join("?" for _ in candidate_node_ids) + ")"
)
filter_parameters.extend(candidate_node_ids)
extra_filter = " AND " + " AND ".join(filters) if filters else ""
if match:
statement = (
"SELECT indexed_nodes.node_json, bm25(node_fts) AS score "
"FROM node_fts JOIN indexed_nodes USING(node_id) "
"WHERE node_fts MATCH ? AND indexed_nodes.project_id IN ("
+ placeholders
+ ")"
+ extra_filter
+ " ORDER BY score ASC, indexed_nodes.node_id ASC LIMIT ? OFFSET ?"
)
parameters: tuple[object, ...] = (
match,
*project_scope,
*filter_parameters,
limit + 1,
offset,
)
else:
statement = (
"SELECT indexed_nodes.node_json, 0.0 AS score FROM indexed_nodes "
"WHERE indexed_nodes.project_id IN ("
+ placeholders
+ ")"
+ extra_filter
+ " ORDER BY indexed_nodes.node_id ASC LIMIT ? OFFSET ?"
)
parameters = (*project_scope, *filter_parameters, limit + 1, offset)
try:
with closing(self._connect()) as connection:
rows = connection.execute(statement, parameters).fetchall()
except sqlite3.Error as error:
raise SearchIndexError("Search query could not be evaluated") from error
results: list[IndexedNode] = []
has_more = len(rows) > limit
for node_json, score in rows[:limit]:
try:
node = KnowledgeNode.model_validate_json(node_json, strict=True)
except ValidationError as error:
raise SearchIndexError("Indexed canonical search record is invalid") from error
results.append(IndexedNode(node=node, score=float(score)))
return SearchIndexResult(revision, tuple(results), has_more)
def neighbors(
self,
*,
node_id: str,
edge_types: tuple[str, ...],
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SearchEdgeResult:
self._rebuild_if_needed()
project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set())))
if not project_scope:
raise SearchIndexError("Neighbor scope is empty")
scope_placeholders = ", ".join("?" for _ in project_scope)
def eligible(kind_column: str, id_column: str) -> str:
return (
f"(({kind_column}='node' AND EXISTS (SELECT 1 FROM indexed_nodes eligible_node "
f"WHERE eligible_node.node_id=e.{id_column} AND eligible_node.project_id IN "
f"({scope_placeholders}))) OR "
f"({kind_column}='project' AND e.{id_column} IN ({scope_placeholders})) OR "
f"({kind_column}='source' AND EXISTS (SELECT 1 FROM indexed_source_grants grant "
f"WHERE grant.source_revision_id=e.{id_column} AND grant.project_id IN "
f"({scope_placeholders}))))"
)
target_eligible = eligible("e.target_kind", "target_id")
source_eligible = eligible("e.source_kind", "source_id")
edge_filter = ""
edge_parameters: tuple[str, ...] = ()
if edge_types:
edge_filter = " AND edge_type IN (" + ", ".join("?" for _ in edge_types) + ")"
edge_parameters = edge_types
statement = (
"SELECT e.edge_json FROM indexed_edges e WHERE "
f"((e.source_kind='node' AND e.source_id=? AND {target_eligible}) OR "
f"(e.target_kind='node' AND e.target_id=? AND {source_eligible}))"
+ edge_filter
+ " ORDER BY e.edge_id LIMIT ? OFFSET ?"
)
scope_parameters = (*project_scope, *project_scope, *project_scope)
parameters = (
node_id,
*scope_parameters,
node_id,
*scope_parameters,
*edge_parameters,
limit + 1,
offset,
)
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN")
state = connection.execute(
"SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?",
(INDEX_SCHEMA_VERSION,),
).fetchone()
if state is None:
raise SearchIndexError("Search index revision is unavailable")
revision = str(state[0])
origin_row = connection.execute(
"SELECT node_json FROM indexed_nodes WHERE node_id=?", (node_id,)
).fetchone()
origin = (
KnowledgeNode.model_validate_json(origin_row[0], strict=True)
if origin_row is not None
else None
)
rows = connection.execute(statement, parameters).fetchall()
hits: list[SearchEdgeHit] = []
for (edge_json,) in rows[:limit]:
edge = TypedEdge.model_validate_json(edge_json, strict=True)
if edge.source.kind == "node" and edge.source.id == node_id:
endpoint = edge.target
elif edge.target.kind == "node" and edge.target.id == node_id:
endpoint = edge.source
else:
continue
neighbor_node = None
neighbor_source = None
if endpoint.kind == "node":
neighbor_row = connection.execute(
"SELECT node_json FROM indexed_nodes WHERE node_id=?", (endpoint.id,)
).fetchone()
if neighbor_row is not None:
neighbor_node = KnowledgeNode.model_validate_json(
neighbor_row[0], strict=True
)
elif endpoint.kind == "source":
source_row = connection.execute(
"SELECT source_json FROM indexed_sources WHERE source_revision_id=?",
(endpoint.id,),
).fetchone()
if source_row is not None:
neighbor_source = SourceRevisionRecord.model_validate_json(
source_row[0], strict=True
)
hits.append(SearchEdgeHit(edge, endpoint, neighbor_node, neighbor_source))
connection.commit()
except (sqlite3.Error, ValidationError) as error:
raise SearchIndexError("Neighbor query could not be evaluated") from error
return SearchEdgeResult(revision, origin, tuple(hits), len(rows) > limit)
def list_sources(
self,
*,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SourceIndexResult:
self._rebuild_if_needed()
project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set())))
if not project_scope:
raise SearchIndexError("Source scope is empty")
placeholders = ", ".join("?" for _ in project_scope)
statement = (
"SELECT DISTINCT s.source_json FROM indexed_sources s "
"JOIN indexed_source_grants g USING(source_revision_id) "
f"WHERE g.project_id IN ({placeholders}) "
"ORDER BY json_extract(s.source_json, '$.captured_at') DESC, "
"s.source_revision_id ASC LIMIT ? OFFSET ?"
)
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN")
state = connection.execute(
"SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?",
(INDEX_SCHEMA_VERSION,),
).fetchone()
if state is None:
raise SearchIndexError("Search index revision is unavailable")
revision = str(state[0])
rows = connection.execute(statement, (*project_scope, limit + 1, offset)).fetchall()
sources = tuple(
SourceRevisionRecord.model_validate_json(source_json, strict=True)
for (source_json,) in rows[:limit]
)
connection.commit()
except (sqlite3.Error, ValidationError) as error:
raise SearchIndexError("Source query could not be evaluated") from error
return SourceIndexResult(revision, sources, len(rows) > limit)
+462
View File
@@ -0,0 +1,462 @@
"""Scoped sqlite-vec storage with immutable generations and atomic activation."""
from __future__ import annotations
import hashlib
import importlib
import math
import sqlite3
import struct
from collections.abc import Iterable
from contextlib import closing
from pathlib import Path
from typing import Any
from gewu.application.paths import reject_symlink_components
from gewu.application.ports import VectorGeneration, VectorHit
class VectorIndexError(RuntimeError):
"""Safe vector-index operation failure."""
class SQLiteVectorIndex:
"""SQLite vector partitions isolate candidate search by authorized project."""
def __init__(self, path: Path) -> None:
self.path = path.expanduser().absolute()
reject_symlink_components(self.path)
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.path.parent.chmod(0o700)
if self.path.is_symlink():
raise VectorIndexError("Vector index path must not be a symbolic link")
try:
self._sqlite_vec = importlib.import_module("sqlite_vec")
except ImportError as error:
raise VectorIndexError("Install the optional retrieval dependencies") from error
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=5000")
connection.execute("PRAGMA synchronous=NORMAL")
try:
self.load_extension(connection)
except VectorIndexError:
connection.close()
raise
return connection
def load_extension(self, connection: sqlite3.Connection) -> None:
"""Load the packaged extension for a caller-owned transaction connection."""
try:
connection.enable_load_extension(True)
self._sqlite_vec.load(connection)
except (AttributeError, sqlite3.Error, OSError) as error:
raise VectorIndexError("SQLite vector extension could not be loaded") from error
finally:
try:
connection.enable_load_extension(False)
except sqlite3.Error:
pass
def _initialize(self) -> None:
try:
with closing(self._connect()) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(
"CREATE TABLE IF NOT EXISTS vector_generations ("
"generation_id TEXT PRIMARY KEY, revision TEXT NOT NULL, "
"model_id TEXT NOT NULL, "
"artifact_sha256 TEXT NOT NULL, content_profile TEXT NOT NULL, "
"dimensions INTEGER NOT NULL, "
"node_count INTEGER NOT NULL);"
"CREATE TABLE IF NOT EXISTS vector_partitions ("
"generation_id TEXT NOT NULL REFERENCES vector_generations(generation_id) "
"ON DELETE CASCADE, project_id TEXT NOT NULL, table_name TEXT NOT NULL UNIQUE, "
"PRIMARY KEY(generation_id, project_id));"
"CREATE TABLE IF NOT EXISTS vector_node_ids ("
"generation_id TEXT NOT NULL, project_id TEXT NOT NULL, "
"vector_rowid INTEGER NOT NULL, "
"node_id TEXT NOT NULL, PRIMARY KEY(generation_id, project_id, vector_rowid), "
"UNIQUE(generation_id, project_id, node_id), "
"FOREIGN KEY(generation_id, project_id) "
"REFERENCES vector_partitions(generation_id, project_id) ON DELETE CASCADE);"
"CREATE TABLE IF NOT EXISTS active_vector_generation ("
"singleton INTEGER PRIMARY KEY CHECK(singleton=1), "
"generation_id TEXT NOT NULL REFERENCES vector_generations(generation_id));"
)
generation_columns = {
str(row[1])
for row in connection.execute("PRAGMA table_info(vector_generations)")
}
if "content_profile" not in generation_columns:
connection.execute(
"ALTER TABLE vector_generations ADD COLUMN content_profile "
"TEXT NOT NULL DEFAULT 'legacy-unqualified'"
)
except sqlite3.Error as error:
raise VectorIndexError("Vector index schema could not be initialized") from error
self.path.chmod(0o600)
@staticmethod
def _table_name(generation_id: str, project_id: str) -> str:
digest = hashlib.sha256(f"{generation_id}\0{project_id}".encode()).hexdigest()
return f"vec_{digest}"
@staticmethod
def _vector_bytes(vector: tuple[float, ...], dimensions: int) -> bytes:
if len(vector) != dimensions or not all(math.isfinite(value) for value in vector):
raise VectorIndexError("Vector dimensions or values are invalid")
norm = math.sqrt(sum(value * value for value in vector))
if not math.isclose(norm, 1.0, rel_tol=1e-4, abs_tol=1e-4):
raise VectorIndexError("Vectors must be normalized before indexing")
try:
return struct.pack(f"{dimensions}f", *vector)
except (OverflowError, struct.error) as error:
raise VectorIndexError("Vector cannot be stored") from error
def build_generation(
self,
*,
generation_id: str,
revision: str,
model_id: str,
artifact_sha256: str,
dimensions: int,
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
content_profile: str = "knowledge-node-v1",
) -> None:
"""Build a complete inactive generation in one transaction."""
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
self.build_generation_in_transaction(
connection,
generation_id=generation_id,
revision=revision,
model_id=model_id,
artifact_sha256=artifact_sha256,
dimensions=dimensions,
vectors=vectors,
content_profile=content_profile,
)
connection.commit()
except VectorIndexError:
raise
except (sqlite3.Error, OSError) as error:
raise VectorIndexError("Vector generation could not be built") from error
self.path.chmod(0o600)
def build_generation_in_transaction(
self,
connection: sqlite3.Connection,
*,
generation_id: str,
revision: str,
model_id: str,
artifact_sha256: str,
dimensions: int,
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
content_profile: str = "knowledge-node-v1",
) -> None:
"""Build into a caller's active transaction so FTS and vectors commit together."""
if not generation_id or not model_id or not content_profile or dimensions < 1:
raise VectorIndexError("Vector generation metadata is invalid")
if connection.execute(
"SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,)
).fetchone():
raise VectorIndexError("Vector generation already exists")
connection.execute(
"INSERT INTO vector_generations(generation_id, revision, model_id, "
"artifact_sha256, content_profile, dimensions, node_count) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(generation_id, revision, model_id, artifact_sha256, content_profile, dimensions, 0),
)
current_project = ""
current_table = ""
rowid = 0
node_ids: set[str] = set()
node_count = 0
for project_id, node_id, vector in vectors:
if node_id in node_ids:
raise VectorIndexError("Vector generation contains duplicate node IDs")
node_ids.add(node_id)
if project_id < current_project:
raise VectorIndexError("Vector records must be ordered by project ID")
if project_id != current_project:
current_project = project_id
current_table = self._table_name(generation_id, project_id)
rowid = 0
connection.execute(
"INSERT INTO vector_partitions VALUES (?, ?, ?)",
(generation_id, project_id, current_table),
)
connection.execute(
f'CREATE VIRTUAL TABLE "{current_table}" USING vec0('
f"embedding float[{dimensions}] distance_metric=cosine)"
)
rowid += 1
node_count += 1
connection.execute(
f'INSERT INTO "{current_table}"(rowid, embedding) VALUES (?, ?)',
(rowid, self._vector_bytes(vector, dimensions)),
)
connection.execute(
"INSERT INTO vector_node_ids VALUES (?, ?, ?, ?)",
(generation_id, project_id, rowid, node_id),
)
connection.execute(
"UPDATE vector_generations SET node_count=? WHERE generation_id=?",
(node_count, generation_id),
)
def activate_in_transaction(
self,
connection: sqlite3.Connection,
generation_id: str,
*,
expected_active: str | None,
) -> str | None:
"""CAS the generation pointer on a caller-owned transaction."""
active_row = connection.execute(
"SELECT generation_id FROM active_vector_generation WHERE singleton=1"
).fetchone()
active = str(active_row[0]) if active_row else None
if active != expected_active:
raise VectorIndexError("Active vector generation changed")
if (
connection.execute(
"SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,)
).fetchone()
is None
):
raise VectorIndexError("Vector generation is unavailable")
connection.execute(
"INSERT INTO active_vector_generation(singleton, generation_id) VALUES (1, ?) "
"ON CONFLICT(singleton) DO UPDATE SET generation_id=excluded.generation_id",
(generation_id,),
)
return active
def deactivate_in_transaction(self, connection: sqlite3.Connection) -> None:
connection.execute("DELETE FROM active_vector_generation WHERE singleton=1")
@staticmethod
def active_generation_in_transaction(
connection: sqlite3.Connection,
) -> VectorGeneration | None:
row = connection.execute(
"SELECT generation_id, revision, model_id, artifact_sha256, "
"content_profile, dimensions, node_count "
"FROM active_vector_generation JOIN vector_generations USING(generation_id) "
"WHERE singleton=1"
).fetchone()
return SQLiteVectorIndex._generation_from_row(row)
@staticmethod
def generation_in_transaction(
connection: sqlite3.Connection, generation_id: str
) -> VectorGeneration | None:
row = connection.execute(
"SELECT generation_id, revision, model_id, artifact_sha256, "
"content_profile, dimensions, node_count "
"FROM vector_generations WHERE generation_id=?",
(generation_id,),
).fetchone()
return SQLiteVectorIndex._generation_from_row(row)
@staticmethod
def _generation_from_row(row: tuple[Any, ...] | None) -> VectorGeneration | None:
return (
VectorGeneration(
generation_id=str(row[0]),
revision=str(row[1]),
model_id=str(row[2]),
artifact_sha256=str(row[3]),
content_profile=str(row[4]),
dimensions=int(row[5]),
node_count=int(row[6]),
)
if row
else None
)
def activate(self, generation_id: str, *, expected_active: str | None) -> str | None:
"""Switch the generation pointer with compare-and-swap semantics."""
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
active_row = connection.execute(
"SELECT generation_id FROM active_vector_generation WHERE singleton=1"
).fetchone()
active = str(active_row[0]) if active_row else None
if active != expected_active:
raise VectorIndexError("Active vector generation changed")
if (
connection.execute(
"SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,)
).fetchone()
is None
):
raise VectorIndexError("Vector generation is unavailable")
connection.execute(
"INSERT INTO active_vector_generation(singleton, generation_id) VALUES (1, ?) "
"ON CONFLICT(singleton) DO UPDATE SET generation_id=excluded.generation_id",
(generation_id,),
)
connection.commit()
return active
except sqlite3.Error as error:
raise VectorIndexError("Vector generation could not be activated") from error
def active_generation(self) -> VectorGeneration | None:
try:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT generation_id, revision, model_id, artifact_sha256, "
"content_profile, dimensions, node_count "
"FROM active_vector_generation JOIN vector_generations USING(generation_id) "
"WHERE singleton=1"
).fetchone()
except sqlite3.Error as error:
raise VectorIndexError("Active vector generation could not be read") from error
return (
VectorGeneration(
generation_id=str(row[0]),
revision=str(row[1]),
model_id=str(row[2]),
artifact_sha256=str(row[3]),
content_profile=str(row[4]),
dimensions=int(row[5]),
node_count=int(row[6]),
)
if row
else None
)
def generation(self, generation_id: str) -> VectorGeneration | None:
try:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT generation_id, revision, model_id, artifact_sha256, "
"content_profile, dimensions, node_count "
"FROM vector_generations WHERE generation_id=?",
(generation_id,),
).fetchone()
except sqlite3.Error as error:
raise VectorIndexError("Vector generation metadata could not be read") from error
return (
VectorGeneration(
generation_id=str(row[0]),
revision=str(row[1]),
model_id=str(row[2]),
artifact_sha256=str(row[3]),
content_profile=str(row[4]),
dimensions=int(row[5]),
node_count=int(row[6]),
)
if row
else None
)
def search(
self,
*,
query: tuple[float, ...],
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
expected_generation_id: str | None = None,
) -> tuple[VectorHit, ...]:
if not project_ids or limit < 1:
return ()
if limit > 100:
raise VectorIndexError("Vector candidate limit exceeds the configured maximum")
try:
with closing(self._connect()) as connection:
generation = connection.execute(
"SELECT generation_id, dimensions FROM active_vector_generation "
"JOIN vector_generations USING(generation_id) WHERE singleton=1"
).fetchone()
if generation is None:
return ()
generation_id, dimensions = str(generation[0]), int(generation[1])
if expected_generation_id is not None and generation_id != expected_generation_id:
raise VectorIndexError("Active vector generation changed during search")
query_bytes = self._vector_bytes(query, dimensions)
scope = tuple(
dict.fromkeys((*project_ids, *(("shared",) if include_shared else ())))
)
hits: list[VectorHit] = []
for project_id in scope:
partition = connection.execute(
"SELECT table_name FROM vector_partitions "
"WHERE generation_id=? AND project_id=?",
(generation_id, project_id),
).fetchone()
if partition is None:
continue
table_name = str(partition[0])
if table_name != self._table_name(generation_id, project_id):
raise VectorIndexError("Vector partition identity is invalid")
rows = connection.execute(
f'SELECT rowid, distance FROM "{table_name}" '
"WHERE embedding MATCH ? AND k = ? ORDER BY distance",
(query_bytes, limit),
).fetchall()
rowids = [int(row[0]) for row in rows]
if not rowids:
continue
marks = ", ".join("?" for _ in rowids)
identifiers = connection.execute(
"SELECT vector_rowid, node_id FROM vector_node_ids "
"WHERE generation_id=? AND project_id=? AND vector_rowid IN ("
+ marks
+ ")",
(generation_id, project_id, *rowids),
).fetchall()
id_by_rowid = {int(rowid): str(node_id) for rowid, node_id in identifiers}
hits.extend(
VectorHit(id_by_rowid[int(rowid)], project_id, float(distance))
for rowid, distance in rows
if int(rowid) in id_by_rowid
)
except sqlite3.Error as error:
raise VectorIndexError("Vector query could not be evaluated") from error
return tuple(
sorted(hits, key=lambda item: (item.distance, item.project_id, item.node_id))[:limit]
)
def delete_generation(self, generation_id: str) -> None:
"""Remove an inactive generation; an active generation cannot be deleted."""
try:
with closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT 1 FROM active_vector_generation WHERE generation_id=?",
(generation_id,),
).fetchone()
if active:
raise VectorIndexError("Active vector generation cannot be deleted")
tables = connection.execute(
"SELECT project_id, table_name FROM vector_partitions WHERE generation_id=?",
(generation_id,),
).fetchall()
for project_id, table_name in tables:
if str(table_name) != self._table_name(generation_id, str(project_id)):
raise VectorIndexError("Vector partition identity is invalid")
connection.execute(f'DROP TABLE "{table_name}"')
connection.execute(
"DELETE FROM vector_generations WHERE generation_id=?", (generation_id,)
)
connection.commit()
except sqlite3.Error as error:
raise VectorIndexError("Vector generation could not be deleted") from error
+276
View File
@@ -0,0 +1,276 @@
"""Versioned UTF-8 HTML/text/Markdown extraction with code-point location maps."""
from __future__ import annotations
import hashlib
import re
import unicodedata
from html.parser import HTMLParser
from typing import Final, Literal
import regex
from gewu.domain.contracts import ExtractionBlock, ExtractionRecord
from gewu.domain.validation import canonical_json, extraction_digest
MAX_BLOCK_CODEPOINTS = 80_000
MAX_SOURCE_BYTES = 50 * 1024 * 1024
PARSER_NAME = "gewu-text-html-markdown"
PARSER_VERSION = "0.3.0"
NORMALIZATION_PROFILE: Final[Literal["unicode-nfc-lf-v1"]] = "unicode-nfc-lf-v1"
PARSER_CONFIG = {
"max_block_codepoints": MAX_BLOCK_CODEPOINTS,
"media_types": [
"text/html",
"text/markdown",
"text/plain",
"text/vtt",
"application/x-subrip",
],
"normalization": NORMALIZATION_PROFILE,
"unicode_database_version": unicodedata.unidata_version,
"regex_version": regex.__version__,
"paragraph_split": "preserve-whole-text-in-bounded-blocks",
"transcript_split": "blank-separated-cues-with-exact-millisecond-ranges-v1",
}
PARSER_CONFIG_SHA256 = hashlib.sha256(canonical_json(PARSER_CONFIG)).hexdigest()
class TextExtractionError(ValueError):
"""Unsupported or invalid text input."""
class TextMarkdownExtractor:
"""Extract bounded blocks while preserving offsets into original decoded text."""
def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord:
supported_media = {
"text/html",
"text/markdown",
"text/plain",
"text/vtt",
"application/x-subrip",
}
if media_type not in supported_media:
raise TextExtractionError(
"Text, Markdown, HTML and timestamped transcripts are supported"
)
if not content or len(content) > MAX_SOURCE_BYTES:
raise TextExtractionError("Source size is outside the supported limit")
try:
original = content.decode("utf-8", errors="strict")
except UnicodeDecodeError as error:
raise TextExtractionError("Text artifacts must use UTF-8") from error
original = original.replace("\r\n", "\n").replace("\r", "\n")
if media_type == "text/html":
original = _html_to_text(original)
if not original:
raise TextExtractionError("HTML document contains no visible text")
if media_type in {"text/vtt", "application/x-subrip"}:
blocks = tuple(
self._block(ordinal, text, time_start_ms, time_end_ms)
for ordinal, text, time_start_ms, time_end_ms in self._split_transcript(original)
)
else:
blocks = tuple(self._block(ordinal, text) for ordinal, text in self._split(original))
candidate = ExtractionRecord(
extraction_id="0" * 64,
source_revision_id=source_revision_id,
parser_name=PARSER_NAME,
parser_version=PARSER_VERSION,
parser_config_sha256=PARSER_CONFIG_SHA256,
normalization_profile=NORMALIZATION_PROFILE,
status="complete",
blocks=blocks,
warnings=(),
)
extraction_id = extraction_digest(candidate)
return candidate.model_copy(update={"extraction_id": extraction_id})
@staticmethod
def _split(original: str) -> tuple[tuple[int, str], ...]:
chunks: list[tuple[int, str]] = []
offset = 0
ordinal = 0
while offset < len(original):
end = min(offset + MAX_BLOCK_CODEPOINTS, len(original))
if end < len(original):
boundary = original.rfind("\n", offset + 1, end)
if boundary > offset:
end = boundary + 1
chunk = original[offset:end]
if not chunk:
raise TextExtractionError("Parser failed to advance through source text")
chunks.append((ordinal, chunk))
offset = end
ordinal += 1
return tuple(chunks)
@staticmethod
def _split_transcript(
original: str,
) -> tuple[tuple[int, str, int | None, int | None], ...]:
chunks: list[tuple[int, str, int | None, int | None]] = []
lines = original.splitlines(keepends=True)
group: list[str] = []
def append_group() -> None:
if not group:
return
text = "".join(group)
group.clear()
timings = [line for line in text.splitlines() if "-->" in line]
if len(timings) > 1:
raise TextExtractionError("Transcript cue contains multiple timing lines")
if not timings:
chunks.append((len(chunks), text, None, None))
return
match = re.fullmatch(
r"\s*((?:\d+:)?\d{2}:\d{2}[.,]\d{3})\s+-->\s+"
r"((?:\d+:)?\d{2}:\d{2}[.,]\d{3})(?:\s+.*)?\s*",
timings[0],
)
if match is None:
raise TextExtractionError("Transcript cue timestamp is malformed")
start_ms = _timestamp_milliseconds(match.group(1))
end_ms = _timestamp_milliseconds(match.group(2))
if end_ms <= start_ms:
raise TextExtractionError("Transcript cue end must follow its start")
chunks.append((len(chunks), text, start_ms, end_ms))
for line in lines:
group.append(line)
if not line.strip():
append_group()
append_group()
if not chunks or not any(start is not None for _, _, start, _ in chunks):
raise TextExtractionError("Transcript contains no timestamped cues")
return tuple(chunks)
@staticmethod
def _block(
ordinal: int,
original_text: str,
time_start_ms: int | None = None,
time_end_ms: int | None = None,
) -> ExtractionBlock:
normalized_parts: list[str] = []
mapping: list[tuple[int, int]] = []
for match in regex.finditer(r"\X", original_text):
normalized_cluster = unicodedata.normalize("NFC", match.group())
normalized_parts.append(normalized_cluster)
mapping.extend((match.start(), match.end()) for _ in normalized_cluster)
normalized_text = "".join(normalized_parts)
source_digest = hashlib.sha256(original_text.encode("utf-8")).hexdigest()[:16]
return ExtractionBlock(
block_id=f"block-{ordinal:05d}-{source_digest}",
ordinal=ordinal,
original_text=original_text,
normalized_text=normalized_text,
normalization_map=tuple(mapping),
page=None,
time_start_ms=time_start_ms,
time_end_ms=time_end_ms,
)
def _timestamp_milliseconds(value: str) -> int:
components = value.split(":")
if len(components) == 3:
hours, minutes, seconds_and_millis = components
elif len(components) == 2:
hours = "0"
minutes, seconds_and_millis = components
else:
raise TextExtractionError("Transcript timestamp is malformed")
seconds, separator, milliseconds = seconds_and_millis.partition(".")
if not separator:
seconds, separator, milliseconds = seconds_and_millis.partition(",")
if not separator:
raise TextExtractionError("Transcript timestamp is malformed")
minute_count = int(minutes)
hour_count = int(hours)
second_count = int(seconds)
if minute_count > 59 or second_count > 59 or len(milliseconds) != 3:
raise TextExtractionError("Transcript timestamp is outside the supported range")
return (((hour_count * 60) + minute_count) * 60 + second_count) * 1000 + int(milliseconds)
class _VisibleHtmlText(HTMLParser):
"""Collect visible page text and discard non-content active elements."""
_BLOCK_TAGS = {
"address",
"article",
"blockquote",
"br",
"dd",
"div",
"dl",
"dt",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"li",
"main",
"ol",
"p",
"pre",
"section",
"table",
"tbody",
"td",
"th",
"tr",
"ul",
}
_HIDDEN_TAGS = {"script", "style", "template", "noscript"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.hidden_tag: str | None = None
self.total_characters = 0
def handle_starttag(self, tag: str, _attrs: list[tuple[str, str | None]]) -> None:
if tag in self._HIDDEN_TAGS:
self.hidden_tag = tag
elif self.hidden_tag is None and tag in self._BLOCK_TAGS:
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if self.hidden_tag == tag:
self.hidden_tag = None
elif self.hidden_tag is None and tag in self._BLOCK_TAGS:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if self.hidden_tag is None:
self.total_characters += len(data)
if self.total_characters > 10_000_000:
raise TextExtractionError("HTML visible text exceeds the supported limit")
self.parts.append(data)
def _html_to_text(html: str) -> str:
parser = _VisibleHtmlText()
try:
for offset in range(0, len(html), 64 * 1024):
parser.feed(html[offset : offset + 64 * 1024])
parser.close()
except TextExtractionError:
raise
except Exception as error:
raise TextExtractionError("HTML document could not be parsed") from error
joined = "".join(parser.parts).replace("\xa0", " ")
joined = re.sub(r"[\t\f\v ]+", " ", joined)
joined = re.sub(r" *\n *", "\n", joined)
joined = re.sub(r"\n{3,}", "\n\n", joined)
return joined.strip()
+321
View File
@@ -0,0 +1,321 @@
"""Bounded public-URL capture with explicit host and address validation."""
from __future__ import annotations
import http.client
import ipaddress
import socket
import ssl
import threading
import time
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as ResolverTimeout
from dataclasses import dataclass
from typing import Protocol, cast
from urllib.parse import SplitResult, urljoin, urlsplit
MAX_CAPTURE_BYTES = 50 * 1024 * 1024
MAX_REDIRECTS = 3
CONNECT_TIMEOUT_SECONDS = 5
DNS_TIMEOUT_SECONDS = 5
READ_TIMEOUT_SECONDS = 15
MAX_CAPTURE_SECONDS = 60
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
SUPPORTED_MEDIA_TYPES = {
"application/pdf",
"text/html",
"text/markdown",
"text/plain",
}
class UrlCaptureError(ValueError):
"""Safe, stable failure returned for URL capture requests."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True, slots=True)
class UrlCapture:
original_url: str
final_url: str
media_type: str
title: str | None
content: bytes
Resolver = Callable[..., Sequence[tuple[int, int, int, str, tuple[object, ...]]]]
class ResponseLike(Protocol):
status: int
def getheader(self, name: str) -> str | None: ...
def read(self, amount: int) -> bytes: ...
class ConnectionLike(Protocol):
def request(self, method: str, path: str, headers: dict[str, str]) -> None: ...
def getresponse(self) -> ResponseLike: ...
def close(self) -> None: ...
ConnectionFactory = Callable[[str, str, int, str, int], ConnectionLike]
def _resolve_public_addresses(host: str, port: int, resolver: Resolver) -> tuple[str, ...]:
try:
results = resolver(host, port, type=socket.SOCK_STREAM)
except OSError as error:
raise UrlCaptureError("FETCH_UNAVAILABLE", "Source host could not be resolved") from error
addresses = tuple(dict.fromkeys(str(result[4][0]) for result in results))
if not addresses:
raise UrlCaptureError("FETCH_UNAVAILABLE", "Source host has no usable address")
for address in addresses:
try:
parsed = ipaddress.ip_address(address)
except ValueError as error:
raise UrlCaptureError(
"FETCH_BLOCKED", "Source host resolved to an invalid address"
) from error
if not parsed.is_global:
raise UrlCaptureError(
"FETCH_BLOCKED", "Private or reserved network targets are blocked"
)
return addresses
def _parse_url(value: str) -> tuple[SplitResult, str, int]:
if len(value) > 4_000:
raise UrlCaptureError("URL_INVALID", "Source URL exceeds the supported length")
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
raise UrlCaptureError("URL_INVALID", "Source URL contains a control character")
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as error:
raise UrlCaptureError("URL_INVALID", "Source URL is invalid") from error
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise UrlCaptureError("URL_INVALID", "Only HTTP and HTTPS source URLs are supported")
if parsed.username is not None or parsed.password is not None:
raise UrlCaptureError("URL_INVALID", "Credentials in source URLs are not supported")
raw_host = parsed.hostname
try:
host = ipaddress.ip_address(raw_host).compressed
except ValueError:
try:
host = raw_host.encode("idna").decode("ascii").lower().rstrip(".")
except UnicodeError as error:
raise UrlCaptureError("URL_INVALID", "Source URL hostname is invalid") from error
if not host or len(host) > 253:
raise UrlCaptureError("URL_INVALID", "Source URL hostname is invalid")
selected_port = port or (443 if parsed.scheme == "https" else 80)
if (
selected_port not in {80, 443}
or (parsed.scheme == "https" and selected_port != 443)
or (parsed.scheme == "http" and selected_port != 80)
):
raise UrlCaptureError("URL_INVALID", "Only standard HTTP and HTTPS ports are supported")
return parsed, host, selected_port
def _pinned_connection(
scheme: str, host: str, port: int, address: str, timeout: int
) -> http.client.HTTPConnection:
class PinnedHTTPConnection(http.client.HTTPConnection):
def connect(self) -> None:
self.sock = socket.create_connection((address, port), timeout=timeout)
self.sock.settimeout(READ_TIMEOUT_SECONDS)
class PinnedHTTPSConnection(http.client.HTTPSConnection):
def connect(self) -> None:
raw_socket = socket.create_connection((address, port), timeout=timeout)
raw_socket.settimeout(READ_TIMEOUT_SECONDS)
context = ssl.create_default_context()
self.sock = context.wrap_socket(raw_socket, server_hostname=host)
if scheme == "https":
return PinnedHTTPSConnection(
host, port, timeout=timeout, context=ssl.create_default_context()
)
return PinnedHTTPConnection(host, port, timeout=timeout)
class PublicUrlFetcher:
"""Fetch only explicitly allowed public hosts, pinning each request to checked DNS."""
def __init__(
self,
allowed_hosts: set[str] | frozenset[str],
*,
resolver: Resolver = socket.getaddrinfo,
connection_factory: ConnectionFactory | None = None,
) -> None:
normalized_hosts: set[str] = set()
for host in allowed_hosts:
try:
normalized_hosts.add(ipaddress.ip_address(host).compressed)
except ValueError:
normalized_hosts.add(host.encode("idna").decode("ascii").lower().rstrip("."))
self.allowed_hosts = frozenset(normalized_hosts)
self._resolver = resolver
self._connection_factory = (
connection_factory
if connection_factory is not None
else cast(ConnectionFactory, _pinned_connection)
)
self._capture_slots = threading.BoundedSemaphore(2)
self._resolver_slots = threading.BoundedSemaphore(2)
self._resolver_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="gewu-dns")
def _bounded_resolve(
self, host: str, port: int, *, type: int
) -> Sequence[tuple[int, int, int, str, tuple[object, ...]]]:
if not self._resolver_slots.acquire(blocking=False):
raise UrlCaptureError("FETCH_BUSY", "URL resolver capacity is temporarily full")
future = self._resolver_executor.submit(self._resolver, host, port, type=type)
future.add_done_callback(lambda _future: self._resolver_slots.release())
try:
return future.result(timeout=DNS_TIMEOUT_SECONDS)
except ResolverTimeout as error:
future.cancel()
raise UrlCaptureError("FETCH_TIMEOUT", "Source host resolution timed out") from error
def fetch(self, url: str) -> UrlCapture:
if not self._capture_slots.acquire(blocking=False):
raise UrlCaptureError("FETCH_BUSY", "URL capture capacity is temporarily full")
try:
return self._fetch(url)
finally:
self._capture_slots.release()
def _fetch(self, url: str) -> UrlCapture:
original_url = url
current_url = url
deadline = time.monotonic() + MAX_CAPTURE_SECONDS
for redirect_count in range(MAX_REDIRECTS + 1):
if time.monotonic() >= deadline:
raise UrlCaptureError("FETCH_TIMEOUT", "Source capture exceeded its time limit")
parsed, host, port = _parse_url(current_url)
if host not in self.allowed_hosts:
raise UrlCaptureError("FETCH_BLOCKED", "Source host is not allowlisted")
addresses = _resolve_public_addresses(host, port, self._bounded_resolve)
if time.monotonic() >= deadline:
raise UrlCaptureError("FETCH_TIMEOUT", "Source capture exceeded its time limit")
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
host_header = f"[{host}]" if ":" in host else host
if port != (443 if parsed.scheme == "https" else 80):
host_header += f":{port}"
response: ResponseLike | None = None
connection: ConnectionLike | None = None
last_error: OSError | http.client.HTTPException | ssl.SSLError | None = None
for address in addresses:
try:
connection = self._connection_factory(
parsed.scheme, host, port, address, CONNECT_TIMEOUT_SECONDS
)
connection.request(
"GET",
path,
headers={
"Host": host_header,
"User-Agent": "GEWU-source-capture/1.0",
"Accept": "application/pdf, text/html, text/plain, text/markdown",
"Accept-Encoding": "identity",
"Connection": "close",
},
)
response = connection.getresponse()
break
except (OSError, http.client.HTTPException, ssl.SSLError) as error:
last_error = error
if connection is not None:
connection.close()
if response is None or connection is None:
raise UrlCaptureError(
"FETCH_UNAVAILABLE", "Source host did not return a response"
) from last_error
try:
if response.status in REDIRECT_STATUSES:
location = response.getheader("Location")
if redirect_count >= MAX_REDIRECTS or not location:
raise UrlCaptureError(
"FETCH_REDIRECT_LIMIT", "Source redirects exceed the supported limit"
)
current_url = urljoin(current_url, location)
continue
if response.status < 200 or response.status >= 300:
raise UrlCaptureError(
"FETCH_FAILED", f"Source returned HTTP status {response.status}"
)
media_type = (response.getheader("Content-Type") or "").split(";", 1)[0]
media_type = media_type.strip().lower()
if media_type not in SUPPORTED_MEDIA_TYPES:
raise UrlCaptureError(
"UNSUPPORTED_MEDIA_TYPE", "Source format is not supported for capture"
)
if media_type == "application/pdf" and response.getheader(
"Content-Encoding"
) not in {None, "", "identity"}:
raise UrlCaptureError(
"UNSUPPORTED_ENCODING", "Compressed HTTP responses are not supported"
)
content_encoding = (response.getheader("Content-Encoding") or "identity").lower()
if content_encoding not in {"", "identity"}:
raise UrlCaptureError(
"UNSUPPORTED_ENCODING", "Compressed HTTP responses are not supported"
)
declared_length = response.getheader("Content-Length")
if declared_length is not None:
try:
declared_size = int(declared_length)
except ValueError as error:
raise UrlCaptureError(
"FETCH_FAILED", "Source returned an invalid content length"
) from error
if declared_size < 0:
raise UrlCaptureError(
"FETCH_FAILED", "Source returned an invalid content length"
)
if declared_size > MAX_CAPTURE_BYTES:
raise UrlCaptureError(
"CAPTURE_TOO_LARGE", "Source exceeds the capture size limit"
)
chunks: list[bytes] = []
total = 0
while block := response.read(min(64 * 1024, MAX_CAPTURE_BYTES - total + 1)):
if time.monotonic() >= deadline:
raise UrlCaptureError(
"FETCH_TIMEOUT", "Source capture exceeded its time limit"
)
total += len(block)
if total > MAX_CAPTURE_BYTES:
raise UrlCaptureError(
"CAPTURE_TOO_LARGE", "Source exceeds the capture size limit"
)
chunks.append(block)
if total == 0:
raise UrlCaptureError("FETCH_FAILED", "Source response was empty")
title = response.getheader("X-Source-Title")
return UrlCapture(
original_url=original_url,
final_url=current_url,
media_type=media_type,
title=title[:500] if title else None,
content=b"".join(chunks),
)
except (OSError, http.client.HTTPException, TimeoutError) as error:
raise UrlCaptureError(
"FETCH_UNAVAILABLE", "Source transfer was interrupted"
) from error
finally:
connection.close()
raise UrlCaptureError("FETCH_REDIRECT_LIMIT", "Source redirects exceed the supported limit")
+1
View File
@@ -0,0 +1 @@
"""GEWU use cases."""
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
"""Human accounts and account-owned agent credential contracts."""
from dataclasses import dataclass
from typing import Protocol
from gewu.application.admin import CredentialSummary
from gewu.application.identity import BrowserSession, Principal
@dataclass(frozen=True)
class AccountSummary:
username: str
principal_id: str
role: str
project_ids: list[str]
active: bool
class AccountIdentity(Protocol):
def create_account(
self,
*,
username: str,
password: str,
role: str,
project_ids: tuple[str, ...],
actor: str,
) -> AccountSummary: ...
def list_accounts(self, *, limit: int, offset: int) -> list[AccountSummary]: ...
def login_account(self, username: str, password: str) -> BrowserSession | None: ...
def change_account_password(
self, principal: Principal, current_password: str, new_password: str
) -> bool: ...
def update_account(
self, username: str, *, role: str, project_ids: tuple[str, ...], actor: Principal
) -> AccountSummary | None: ...
def disable_account(self, username: str, *, actor: Principal) -> bool: ...
def list_agent_credentials(
self, principal: Principal, *, limit: int, offset: int
) -> list[CredentialSummary]: ...
def create_agent_credential(
self, principal: Principal, *, name: str, project_ids: tuple[str, ...]
) -> tuple[str, str]: ...
def revoke_agent_credential(self, principal: Principal, credential_id: str) -> bool: ...
+45
View File
@@ -0,0 +1,45 @@
"""Secret-free administration records and credential management port."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class CredentialSummary:
credential_id: str
principal_id: str
roles: list[str]
project_ids: list[str]
revoked: bool
owner_principal_id: str | None = None
@dataclass(frozen=True)
class AdminEvent:
event_id: int
occurred_at: str
actor: str
action: str
credential_id: str
class CredentialAdministration(Protocol):
def list_credentials(self, *, limit: int, offset: int) -> list[CredentialSummary]: ...
def list_admin_events(self, *, limit: int) -> list[AdminEvent]: ...
def provision(
self,
*,
credential_id: str,
principal_id: str,
roles: tuple[str, ...],
project_ids: tuple[str, ...] = (),
actor: str | None = None,
) -> str: ...
def update_credential(
self, credential_id: str, *, role: str, project_ids: tuple[str, ...], actor: str
) -> CredentialSummary | None: ...
def revoke(self, credential_id: str, *, actor: str | None = None) -> bool: ...
+23
View File
@@ -0,0 +1,23 @@
"""Immutable validation snapshot passed into proposal acceptance."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from gewu.domain.contracts import (
AutoAcceptPolicy,
ExtractionRecord,
KnowledgeNode,
ProjectRecord,
SourceRevisionRecord,
)
@dataclass(frozen=True, slots=True)
class ValidationContext:
projects: Mapping[str, ProjectRecord]
policies: Mapping[str, AutoAcceptPolicy]
sources: Mapping[str, SourceRevisionRecord]
extractions: Mapping[str, ExtractionRecord]
existing_nodes: Mapping[str, KnowledgeNode]
+88
View File
@@ -0,0 +1,88 @@
"""Authenticated, secret-free service compatibility diagnostics."""
from __future__ import annotations
from typing import Literal, cast
from gewu.application.identity import Principal
from gewu.application.uploads import ALLOWED_MEDIA_TYPES, CHUNK_BYTES, MAX_ARTIFACT_BYTES
from gewu.domain.contracts import Scope, ServiceDiagnostics
from gewu.domain.operations import Transport, catalog_digest, operations_for
from gewu.version import SERVICE_VERSION
def build_service_diagnostics(
*,
principal: Principal,
transport: Literal["http", "mcp"],
authorized_projects: tuple[str, ...],
available_operations: frozenset[str],
index_status: tuple[str, str | None, str, int | None],
projects: tuple[str, ...] | None = None,
include_shared: bool = False,
) -> ServiceDiagnostics:
"""Build caller-specific diagnostics without exposing credential identifiers."""
visible_projects = tuple(sorted(set(authorized_projects)))
if projects is None:
if include_shared:
raise ValueError("Select at least one project before including shared knowledge")
selected_scope = None
else:
if not projects or len(set(projects)) != len(projects):
raise ValueError("Selected projects must be non-empty and unique")
if any(project not in visible_projects for project in projects):
raise PermissionError("Selected scope includes an unreadable project")
if include_shared and not principal.can_read_project("shared"):
raise PermissionError("Credential cannot read shared knowledge")
selected_scope = Scope(projects=projects, include_shared=include_shared)
capabilities: set[str] = {"service:diagnostics"}
if "admin" in principal.roles and "agent" not in principal.roles:
capabilities.update({"project:admin", "credential:admin"})
if principal.roles.intersection({"admin", "editor", "viewer", "agent"}):
capabilities.add("knowledge:read")
if principal.auth_method == "bearer" and "agent" in principal.roles:
capabilities.update(
{
"proposal:submit",
"proposal:evaluate",
"proposal:read",
"proposal:withdraw",
"run:read",
"run:write",
}
)
if principal.roles.intersection({"admin", "editor", "viewer"}):
capabilities.add("identity:session")
catalog_transport = Transport(transport)
authorized_operations = tuple(
sorted(
operation.name
for operation in operations_for(catalog_transport)
if operation.name in available_operations and operation.capability in capabilities
)
)
canonical, index_revision, index_state, index_schema_version = index_status
return ServiceDiagnostics(
service_version=SERVICE_VERSION,
catalog_sha256=catalog_digest(),
transport=transport,
authentication=cast(Literal["bearer", "browser_session"], principal.auth_method),
roles=tuple(sorted(principal.roles)),
authorized_projects=visible_projects,
selected_scope=selected_scope,
capabilities=tuple(sorted(capabilities)),
authorized_operations=authorized_operations,
max_upload_bytes=MAX_ARTIFACT_BYTES,
upload_chunk_bytes=CHUNK_BYTES,
supported_media_types=tuple(sorted(ALLOWED_MEDIA_TYPES)),
canonical_revision=canonical,
index_state=cast(
Literal["current", "lagging", "not_built", "not_configured", "unavailable"],
index_state,
),
index_revision=index_revision,
index_schema_version=index_schema_version,
)
+75
View File
@@ -0,0 +1,75 @@
"""Authenticated principals and capability checks shared by transports."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
@dataclass(frozen=True, slots=True)
class Principal:
principal_id: str
credential_id: str
roles: frozenset[str]
project_ids: frozenset[str]
auth_method: str = "bearer"
def can_read_project(self, project_id: str) -> bool:
return "admin" in self.roles or project_id in self.project_ids
def can_submit_project(self, project_id: str) -> bool:
return (
self.auth_method == "bearer"
and "agent" in self.roles
and project_id in self.project_ids
)
@dataclass(frozen=True, slots=True)
class BrowserSession:
session_token: str
csrf_token: str
expires_at: datetime
@dataclass(frozen=True, slots=True)
class OwnerSession:
session_token: str
expires_at: datetime
challenge_id: str
class IdentityProvider(Protocol):
def authenticate(self, bearer_token: str) -> Principal | None: ...
def create_browser_session(self, bearer_token: str) -> BrowserSession: ...
def authenticate_browser_session(self, session_token: str) -> Principal | None: ...
def restore_browser_session(self, session_token: str) -> BrowserSession | None: ...
def browser_csrf_valid(self, session_token: str, csrf_token: str) -> bool: ...
def revoke_browser_session(self, session_token: str) -> bool: ...
class OwnerIdentityProvider(Protocol):
"""Owner-only identity surface, separate from agent/browser authentication."""
@property
def owner_authentication_enabled(self) -> bool: ...
def authenticate_owner(
self,
*,
principal_id: str,
password: str,
totp_code: str,
challenge_id: str,
audience: str,
) -> OwnerSession | None: ...
def authenticate_owner_session(
self, session_token: str, *, challenge_id: str, audience: str
) -> Principal | None: ...
+13
View File
@@ -0,0 +1,13 @@
"""Filesystem checks shared by private local persistence adapters."""
from __future__ import annotations
from pathlib import Path
def reject_symlink_components(path: Path) -> None:
"""Reject a path whose existing component redirects persistence elsewhere."""
for component in (path, *path.parents):
if component.is_symlink():
raise ValueError("Private data paths must not traverse symbolic links")
+177
View File
@@ -0,0 +1,177 @@
"""Application-owned ports implemented by replaceable persistence adapters."""
from __future__ import annotations
import re
import sqlite3
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Protocol
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord
class CanonicalRepositoryError(RuntimeError):
"""A committed-record operation failed without exposing backend details."""
class CanonicalConflictError(CanonicalRepositoryError):
"""Expected-head publication lost its compare-and-swap race."""
_CANONICAL_PATH = re.compile(r"^[A-Za-z0-9._/-]+$")
def validate_canonical_path(path: str) -> None:
"""Enforce portable, relative POSIX paths for all repository implementations."""
pure = PurePosixPath(path)
if (
not path
or path.startswith("/")
or "\\" in path
or not _CANONICAL_PATH.fullmatch(path)
or pure.as_posix() != path
or any(part in {"", ".", ".."} for part in pure.parts)
):
raise CanonicalRepositoryError("Canonical record path is invalid")
class CanonicalRepository(Protocol):
"""Committed reads and expected-head atomic publication.
``history`` returns newest-first revisions that changed the requested path;
``introducing_commit`` returns the oldest revision in that path history.
Paths use ``validate_canonical_path``. Implementations raise the port-owned
errors above, never backend exceptions.
"""
def head(self) -> str: ...
def read_file(self, revision: str, path: str) -> bytes | None: ...
def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: ...
def publish_files(
self,
*,
expected_head: str,
files: Mapping[str, bytes],
message: str,
index_directory: Path,
) -> str: ...
def introducing_commit(self, revision: str, path: str) -> str | None: ...
def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]: ...
class ProjectRegistry(Protocol):
"""Versioned project and policy registry with serialized lifecycle writes."""
def records(self) -> tuple[ProjectRecord, ...]: ...
def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord: ...
def update_content(self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]) -> ProjectRecord: ...
def archive(self, project_id: str) -> ProjectRecord: ...
class EmbeddingProvider(Protocol):
"""Replaceable, normalized text-vector provider for derived retrieval indexes."""
@property
def dimensions(self) -> int: ...
@property
def model_id(self) -> str: ...
@property
def artifact_sha256(self) -> str: ...
def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ...
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ...
@dataclass(frozen=True, slots=True)
class VectorHit:
node_id: str
project_id: str
distance: float
@dataclass(frozen=True, slots=True)
class VectorGeneration:
generation_id: str
revision: str
model_id: str
artifact_sha256: str
content_profile: str
dimensions: int
node_count: int
class VectorIndex(Protocol):
"""Scoped vector candidate lookup over an activated immutable generation."""
def search(
self,
*,
query: tuple[float, ...],
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
expected_generation_id: str | None = None,
) -> tuple[VectorHit, ...]: ...
def active_generation(self) -> VectorGeneration | None: ...
def generation(self, generation_id: str) -> VectorGeneration | None: ...
def build_generation(
self,
*,
generation_id: str,
revision: str,
model_id: str,
artifact_sha256: str,
content_profile: str,
dimensions: int,
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
) -> None: ...
def activate(self, generation_id: str, *, expected_active: str | None) -> str | None: ...
def active_generation_in_transaction(
self, connection: sqlite3.Connection
) -> VectorGeneration | None: ...
def generation_in_transaction(
self, connection: sqlite3.Connection, generation_id: str
) -> VectorGeneration | None: ...
def build_generation_in_transaction(
self,
connection: sqlite3.Connection,
*,
generation_id: str,
revision: str,
model_id: str,
artifact_sha256: str,
content_profile: str,
dimensions: int,
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
) -> None: ...
def activate_in_transaction(
self,
connection: sqlite3.Connection,
generation_id: str,
*,
expected_active: str | None,
) -> str | None: ...
def deactivate_in_transaction(self, connection: sqlite3.Connection) -> None: ...
+218
View File
@@ -0,0 +1,218 @@
"""Bounded canonical reads for a single project node and its citations."""
from __future__ import annotations
import hashlib
import json
import secrets
from dataclasses import dataclass
import yaml
from pydantic import TypeAdapter, ValidationError
from gewu.application.identity import Principal
from gewu.application.ports import CanonicalRepository
from gewu.domain.contracts import (
BudgetProfile,
EvidenceReference,
GitCommitId,
KnowledgeNode,
ReadEnvelope,
Scope,
SourceRevisionRecord,
)
class ReadError(RuntimeError):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
def _key(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
_GIT_COMMIT_ID = TypeAdapter(GitCommitId)
@dataclass(frozen=True, slots=True)
class CanonicalNodeReader:
repository: CanonicalRepository
def get_node(
self,
project_id: str,
node_id: str,
*,
principal: Principal,
revision: str | None = None,
) -> ReadEnvelope:
if not principal.can_read_project(project_id):
raise ReadError(403, "Credential cannot read this project")
head = self.repository.head()
path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml"
if revision is None:
selected_revision = head
else:
try:
selected_revision = _GIT_COMMIT_ID.validate_python(revision, strict=True)
except ValidationError as error:
raise ReadError(422, "Canonical node revision is invalid") from error
if selected_revision not in self.repository.history(head, path, 20):
raise ReadError(404, "Knowledge node revision was not found")
raw = self.repository.read_file(selected_revision, path)
if raw is None:
raise ReadError(404, "Knowledge node was not found")
try:
value = yaml.safe_load(raw)
node = KnowledgeNode.model_validate_json(
json.dumps(value, ensure_ascii=False), strict=True
)
except (ValueError, yaml.YAMLError, ValidationError) as error:
raise ReadError(503, "Canonical knowledge record failed validation") from error
if node.node_id != node_id or node.project_id != project_id:
raise ReadError(503, "Canonical record identity does not match its path")
citations = tuple(evidence for claim in node.evidence_claims for evidence in claim.evidence)
envelope = ReadEnvelope(
request_id="read-" + secrets.token_hex(12),
resolved_scope=Scope(
projects=(project_id,), include_shared=principal.can_read_project("shared")
),
data=(node.model_dump(mode="json"),),
citations=citations,
canonical_revision=selected_revision,
index_revision=None,
retrieval_mode="canonical",
budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"),
tokens_used=0,
)
if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000:
raise ReadError(413, "Canonical node exceeds the read response budget")
return envelope
def get_history(
self, project_id: str, node_id: str, *, principal: Principal, limit: int = 20
) -> ReadEnvelope:
if not 1 <= limit <= 20:
raise ReadError(422, "History limit must be between one and twenty")
if not principal.can_read_project(project_id):
raise ReadError(403, "Credential cannot read this project")
path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml"
head = self.repository.head()
rows: list[dict[str, object]] = []
citations: dict[str, EvidenceReference] = {}
for revision in self.repository.history(head, path, limit):
raw = self.repository.read_file(revision, path)
if raw is None:
continue
node = _validated_record(
raw, KnowledgeNode, "Canonical knowledge record failed validation"
)
if node.node_id != node_id or node.project_id != project_id:
raise ReadError(503, "Canonical record identity does not match its path")
rows.append({"revision": revision, "node": node.model_dump(mode="json")})
for claim in node.evidence_claims:
for evidence in claim.evidence:
citations[evidence.model_dump_json()] = evidence
return _envelope(
request_id="history-" + secrets.token_hex(12),
project_id=project_id,
include_shared=principal.can_read_project("shared"),
data=tuple(rows),
citations=tuple(citations[key] for key in sorted(citations)),
revision=head,
)
@dataclass(frozen=True, slots=True)
class CanonicalSourceReader:
repository: CanonicalRepository
def get_source(
self,
project_id: str,
revision_id: str,
*,
principal: Principal,
) -> ReadEnvelope:
source, head = self.get_source_record(project_id, revision_id, principal=principal)
return _envelope(
request_id="source-" + secrets.token_hex(12),
project_id=project_id,
include_shared=principal.can_read_project("shared"),
data=(source.model_dump(mode="json"),),
citations=(),
revision=head,
)
def get_source_record(
self,
project_id: str,
revision_id: str,
*,
principal: Principal,
) -> tuple[SourceRevisionRecord, str]:
if not principal.can_read_project(project_id):
raise ReadError(403, "Credential cannot read this project")
head = self.repository.head()
source_key = _key(revision_id)
source_paths = self.repository.list_files(head, "sources")
if len(source_paths) > 100_000:
raise ReadError(503, "Canonical source registry exceeds the read limit")
matching_path = next(
(path for path in source_paths if path.endswith(f"/{source_key}.yaml")), None
)
if matching_path is None:
raise ReadError(404, "Source revision was not found within the requested project")
raw = self.repository.read_file(head, matching_path)
if raw is None:
raise ReadError(404, "Source revision was not found within the requested project")
source = _validated_record(
raw, SourceRevisionRecord, "Canonical source record failed validation"
)
if (
source.revision_id != revision_id
or matching_path != f"sources/{_key(source.source_id)}/{_key(source.revision_id)}.yaml"
):
raise ReadError(503, "Canonical source identity does not match its path")
if project_id not in source.ingested_for:
raise ReadError(404, "Source revision was not found within the requested project")
return source, head
def _validated_record[RecordT: (KnowledgeNode, SourceRevisionRecord)](
raw: bytes, record_type: type[RecordT], message: str
) -> RecordT:
if len(raw) > 1_048_576:
raise ReadError(503, message)
try:
value = yaml.safe_load(raw)
return record_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True)
except (ValueError, yaml.YAMLError, ValidationError) as error:
raise ReadError(503, message) from error
def _envelope(
*,
request_id: str,
project_id: str,
include_shared: bool,
data: tuple[dict[str, object], ...],
citations: tuple[EvidenceReference, ...],
revision: str,
) -> ReadEnvelope:
envelope = ReadEnvelope(
request_id=request_id,
resolved_scope=Scope(projects=(project_id,), include_shared=include_shared),
data=data,
citations=citations,
canonical_revision=revision,
index_revision=None,
retrieval_mode="canonical",
budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"),
tokens_used=0,
)
if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000:
raise ReadError(413, "Canonical read exceeds the response budget")
return envelope
+426
View File
@@ -0,0 +1,426 @@
"""Local project/policy configuration and committed-record context provider."""
from __future__ import annotations
import json
from contextlib import AbstractContextManager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import portalocker
import yaml
from pydantic import BaseModel, ValidationError
from gewu.application.context import ValidationContext
from gewu.application.paths import reject_symlink_components
from gewu.application.ports import CanonicalConflictError, CanonicalRepository
from gewu.application.serialization import safe_yaml_record
from gewu.domain.contracts import (
AutoAcceptPolicy,
ExtractionRecord,
KnowledgeNode,
ProjectRecord,
ProjectStatus,
SourceRevisionRecord,
)
from gewu.domain.validation import extraction_digest
MAX_CONTEXT_RECORDS = 20_000
MAX_CONTEXT_RECORD_BYTES = 1_048_576
MAX_PROJECT_REGISTRY_RESPONSE_BYTES = 1_048_576
class RegistryError(RuntimeError):
"""Configured or committed context is malformed; acceptance must stop."""
class RegistryConflict(RegistryError):
"""A registry write conflicts with current project or policy identity."""
class RegistryNotFound(RegistryError):
"""A requested project is not registered."""
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def _records[RecordT: BaseModel](
raw: bytes, field: str, record_type: type[RecordT]
) -> tuple[RecordT, ...]:
if len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Registry configuration exceeds the supported size")
try:
value = json.loads(raw, object_pairs_hook=_unique_object)
if (
not isinstance(value, dict)
or set(value) != {"schema_version", "projects", "policies"}
or value.get("schema_version") != 1
):
raise ValueError("registry schema version or fields are invalid")
records = value[field]
if not isinstance(records, list):
raise ValueError("registry field must be a list")
return tuple(
record_type.model_validate_json(json.dumps(item, ensure_ascii=False), strict=True)
for item in records
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError, ValidationError) as error:
raise RegistryError("Registry configuration failed validation") from error
def _index[RecordT: BaseModel](records: tuple[RecordT, ...], field: str) -> dict[str, RecordT]:
result: dict[str, RecordT] = {}
for record in records:
key = getattr(record, field)
if key in result:
raise RegistryError("Registry contains duplicate stable IDs")
result[key] = record
return result
def _canonical_records[RecordT: BaseModel](
repository: CanonicalRepository,
revision: str,
prefix: str,
record_type: type[RecordT],
id_field: str,
) -> dict[str, RecordT]:
records: dict[str, RecordT] = {}
for record_path in repository.list_files(revision, prefix):
if not record_path.endswith(".yaml"):
continue
if prefix == "projects" and "/nodes/" not in record_path:
continue
raw = repository.read_file(revision, record_path)
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical context record is unavailable or too large")
record = safe_yaml_record(raw, record_type)
record_id = getattr(record, id_field)
if record_id in records:
raise RegistryError("Canonical repository contains duplicate stable IDs")
records[record_id] = record
if len(records) > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical context exceeds the configured record limit")
return records
@dataclass(frozen=True, slots=True)
class LocalContextProvider:
repository: CanonicalRepository
registry_file: Path
def __call__(self) -> ValidationContext:
path = self.registry_file.expanduser().absolute()
try:
reject_symlink_components(path)
except ValueError as error:
raise RegistryError("Local registry path traverses a symbolic link") from error
if path.exists() and not path.is_file():
raise RegistryError("Local registry file is unavailable")
if not path.exists():
projects: dict[str, ProjectRecord] = {}
policies: dict[str, AutoAcceptPolicy] = {}
else:
try:
registry_bytes = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
projects = _index(_records(registry_bytes, "projects", ProjectRecord), "project_id")
policies = _index(_records(registry_bytes, "policies", AutoAcceptPolicy), "policy_id")
revision = self.repository.head()
projects.update(_canonical_projects(self.repository, revision))
policies.update(
_canonical_records(
self.repository,
revision,
"policies",
AutoAcceptPolicy,
"policy_id",
)
)
sources = _canonical_records(
self.repository, revision, "sources", SourceRevisionRecord, "revision_id"
)
nodes = _canonical_records(self.repository, revision, "projects", KnowledgeNode, "node_id")
extractions: dict[str, ExtractionRecord] = {}
record_count = 0
try:
record_count += len(sources) + len(nodes)
for record_path in self.repository.list_files(revision, "extractions"):
if not record_path.endswith(".json"):
continue
record_count += 1
extraction_bytes = self.repository.read_file(revision, record_path)
if extraction_bytes is None or len(extraction_bytes) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical extraction is unavailable or too large")
extraction = ExtractionRecord.model_validate_json(extraction_bytes, strict=True)
if extraction_digest(extraction) != extraction.extraction_id:
raise RegistryError("Canonical extraction identity is invalid")
if extraction.extraction_id in extractions:
raise RegistryError("Canonical repository contains duplicate extraction IDs")
extractions[extraction.extraction_id] = extraction
except (OSError, ValueError, ValidationError) as error:
raise RegistryError("Canonical context failed validation") from error
if record_count > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical context exceeds the configured record limit")
return ValidationContext(
projects=projects,
policies=policies,
sources=sources,
extractions=extractions,
existing_nodes=nodes,
)
@dataclass(frozen=True, slots=True)
class LocalProjectRegistry:
"""Read legacy control entries and canonical project lifecycle records."""
registry_file: Path
repository: CanonicalRepository | None = None
index_directory: Path | None = None
authority_lock_path: Path | None = None
def _legacy_projects(self) -> tuple[ProjectRecord, ...]:
path = self.registry_file.expanduser().absolute()
try:
reject_symlink_components(path)
except ValueError as error:
raise RegistryError("Local registry path traverses a symbolic link") from error
if not path.exists():
return ()
if path.is_symlink() or not path.is_file():
raise RegistryError("Local registry file is unavailable")
try:
raw = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
return _records(raw, "projects", ProjectRecord)
def _projects(self) -> tuple[ProjectRecord, ...]:
projects = _index(self._legacy_projects(), "project_id")
if self.repository is not None:
projects.update(_canonical_projects(self.repository, self.repository.head()))
return tuple(projects.values())
def _policies(self) -> tuple[AutoAcceptPolicy, ...]:
path = self.registry_file.expanduser().absolute()
if not path.exists():
policies: dict[str, AutoAcceptPolicy] = {}
elif path.is_symlink() or not path.is_file():
raise RegistryError("Local registry file is unavailable")
else:
try:
raw = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
policies = _index(_records(raw, "policies", AutoAcceptPolicy), "policy_id")
if self.repository is not None:
policies.update(
_canonical_records(
self.repository,
self.repository.head(),
"policies",
AutoAcceptPolicy,
"policy_id",
)
)
return tuple(policies.values())
def __call__(self) -> frozenset[str]:
return frozenset(_index(self._projects(), "project_id"))
def records(self) -> tuple[ProjectRecord, ...]:
records = tuple(sorted(self._projects(), key=lambda project: project.project_id))
response_bytes = sum(
len(
json.dumps(
project.model_dump(mode="json"),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
)
for project in records
)
if response_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
raise RegistryError("Project registry listing exceeds the supported size")
return records
def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord:
"""Atomically add a project and its explicitly supplied restrictive policy."""
if project.project_id == "shared" or project.status != ProjectStatus.ACTIVE:
raise ValueError("New projects must use a non-reserved ID and active status")
if not project.owners:
raise ValueError("At least one named project owner is required")
if (
project.autonomy_policy_id != policy.policy_id
or policy.project_id != project.project_id
):
raise ValueError("Project and initial policy identities must match")
if not policy.require_supported_assessment:
raise ValueError("Project policies must require supported assessments")
if (
self.repository is None
or self.index_directory is None
or self.authority_lock_path is None
):
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project_index = _index(self._projects(), "project_id")
policy_index = _index(self._policies(), "policy_id")
if project.project_id in project_index:
raise RegistryConflict("Project ID is already registered")
if policy.policy_id in policy_index:
raise RegistryConflict("Policy ID is already registered")
available_projects = set(project_index) | {project.project_id, "shared"}
if any(item not in available_projects for item in project.default_includes):
raise ValueError("Project default includes must reference registered projects")
if project.project_id in project.default_includes:
raise ValueError("A project cannot include itself by default")
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={
_project_record_path(project.project_id): _project_markdown(project),
_policy_record_path(policy.policy_id): _yaml_record(policy),
},
message=f"Register project {project.project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict(
"Canonical project registry changed during creation"
) from error
return project
def update_content(
self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]
) -> ProjectRecord:
"""Update a project's human-authored description and research questions."""
if self.repository is None or self.index_directory is None or self.authority_lock_path is None:
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project = _index(self._projects(), "project_id").get(project_id)
if project is None:
raise RegistryNotFound("Project is not registered")
updated = project.model_copy(update={
"description": description, "north_star": north_star, "key_questions": key_questions,
})
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={_project_record_path(project_id): _project_markdown(updated)},
message=f"Update project description and questions for {project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict("Canonical project registry changed during update") from error
return updated
def archive(self, project_id: str) -> ProjectRecord:
"""Mark a project archived while preserving its registry and read history."""
if project_id == "shared":
raise ValueError("The shared scope cannot be archived")
if (
self.repository is None
or self.index_directory is None
or self.authority_lock_path is None
):
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project_index = _index(self._projects(), "project_id")
project = project_index.get(project_id)
if project is None:
raise RegistryNotFound("Project is not registered")
if project.status == ProjectStatus.ARCHIVED:
return project
archived = project.model_copy(update={"status": ProjectStatus.ARCHIVED})
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={_project_record_path(project_id): _project_markdown(archived)},
message=f"Archive project {project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict(
"Canonical project registry changed during archive"
) from error
return archived
def _write_lock(self) -> AbstractContextManager[Any]:
lock_path = self.authority_lock_path
if lock_path is None:
lock_path = self.registry_file.with_suffix(self.registry_file.suffix + ".lock")
lock_path = lock_path.expanduser().absolute()
reject_symlink_components(lock_path)
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
lock_path.parent.chmod(0o700)
return portalocker.Lock(lock_path, mode="a", timeout=0)
def default_includes(self, project_id: str) -> tuple[str, ...]:
projects = _index(self._projects(), "project_id")
project = projects.get(project_id)
if project is None:
raise RegistryError("Project is unavailable for default scope resolution")
return tuple(include for include in project.default_includes if include != "shared")
def _project_record_path(project_id: str) -> str:
return f"projects/{project_id}/project.md"
def _policy_record_path(policy_id: str) -> str:
import hashlib
digest = hashlib.sha256(policy_id.encode("utf-8")).hexdigest()
return f"policies/{digest}.yaml"
def _yaml_record(record: BaseModel) -> bytes:
return yaml.safe_dump(
record.model_dump(mode="json"),
allow_unicode=True,
sort_keys=True,
).encode("utf-8")
def _project_markdown(project: ProjectRecord) -> bytes:
return b"---\n" + _yaml_record(project) + b"---\n"
def _canonical_projects(repository: CanonicalRepository, revision: str) -> dict[str, ProjectRecord]:
projects: dict[str, ProjectRecord] = {}
total_bytes = 0
for record_path in repository.list_files(revision, "projects"):
if not record_path.endswith("/project.md"):
continue
raw = repository.read_file(revision, record_path)
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical project record is unavailable or too large")
total_bytes += len(raw)
if total_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
raise RegistryError("Canonical project registry exceeds the supported size")
if not raw.startswith(b"---\n") or b"\n---\n" not in raw[4:]:
raise RegistryError("Canonical project frontmatter is invalid")
frontmatter = raw[4:].split(b"\n---\n", maxsplit=1)[0]
project = safe_yaml_record(frontmatter, ProjectRecord)
if record_path != _project_record_path(project.project_id):
raise RegistryError("Canonical project path does not match its stable ID")
if project.project_id in projects:
raise RegistryError("Canonical repository contains duplicate project IDs")
projects[project.project_id] = project
if len(projects) > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical project registry exceeds the supported record count")
return projects
+223
View File
@@ -0,0 +1,223 @@
"""Private durable run checkpoints scoped to an authenticated agent identity."""
from __future__ import annotations
import hashlib
import os
import secrets
import stat
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
import portalocker
from pydantic import BaseModel, ConfigDict, ValidationError
from gewu.application.acceptance import ActorContext
from gewu.domain.contracts import (
RunCheckpointRequest,
RunCreateRequest,
RunRecord,
RunState,
Sha256,
)
from gewu.domain.validation import canonical_json
class RunError(RuntimeError):
"""Safe run API failure with a stable transport code."""
def __init__(self, code: str, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
class _StoredRun(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
owner_id: str
create_digest: Sha256
record: RunRecord
def _digest(value: object) -> str:
return hashlib.sha256(canonical_json(value)).hexdigest()
class LocalRunService:
"""Atomic checkpoint store with stable owner/run IDs and revision CAS updates."""
MAX_RECORD_BYTES = 1_000_000
def __init__(self, *, workflow_root: Path, actor_is_current: Callable[[ActorContext], bool]):
self.root = workflow_root.expanduser().absolute() / "runs"
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
if self.root.is_symlink() or not self.root.is_dir():
raise RuntimeError("GEWU run directory must be a real directory")
self.root.chmod(stat.S_IRWXU)
self.lock_path = self.root.parent / "locks" / "canonical-writer.lock"
self.lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if self.lock_path.parent.is_symlink() or not self.lock_path.parent.is_dir():
raise RuntimeError("GEWU run lock directory must be a real directory")
self.lock_path.parent.chmod(stat.S_IRWXU)
self.actor_is_current = actor_is_current
self._thread_lock = threading.RLock()
def create(self, request: RunCreateRequest, *, actor: ActorContext) -> RunRecord:
if not self.actor_is_current(actor):
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
if request.scope.all_projects:
raise RunError("SCOPE_REQUIRED", "Runs require an explicit project scope", 422)
create_digest = _digest(request.model_dump(mode="json"))
path = self._record_path(actor.principal_id, request.run_id)
initial = RunRecord(
run_id=request.run_id,
revision=0,
state=RunState.ACTIVE,
objective=request.objective,
scope=request.scope,
lens=request.lens,
pack_id=request.pack_id,
pack_version=request.pack_version,
pack_snapshot_digest=request.pack_snapshot_digest,
module_pins=request.module_pins,
stage="context",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
with self._writer_lock():
if not self.actor_is_current(actor):
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
if path.is_symlink():
raise RunError("RUN_RECOVERY_REQUIRED", "Run path is unavailable", 503)
if path.exists():
current = self._read(path, actor)
stored = self._read_stored(path)
if stored.create_digest == create_digest:
return current
raise RunError("IDEMPOTENCY_CONFLICT", "Run ID already binds different input", 409)
self._write(
path,
_StoredRun(
owner_id=actor.principal_id,
create_digest=create_digest,
record=initial,
),
)
return initial
def status(self, run_id: str, *, actor: ActorContext) -> RunRecord:
if not self.actor_is_current(actor):
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
return self._read(self._record_path(actor.principal_id, run_id), actor)
def checkpoint(self, request: RunCheckpointRequest, *, actor: ActorContext) -> RunRecord:
if not self.actor_is_current(actor):
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
checkpoint_digest = _digest(request.model_dump(mode="json"))
path = self._record_path(actor.principal_id, request.run_id)
with self._writer_lock():
if not self.actor_is_current(actor):
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
current = self._read(path, actor)
if current.revision == request.expected_revision + 1:
if current.checkpoint_digest == checkpoint_digest:
return current
raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint retry changed content", 409)
if current.revision != request.expected_revision:
raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint revision is stale", 409)
if current.state is not RunState.ACTIVE:
raise RunError("RUN_CLOSED", "Completed or blocked runs cannot be updated", 409)
updated = current.model_copy(
update={
"revision": current.revision + 1,
"state": request.state,
"stage": request.stage,
"sources": request.sources,
"coverage": request.coverage,
"budgets": request.budgets,
"pending_operations": request.pending_operations,
"proposals": request.proposals,
"output_hashes": request.output_hashes,
"updated_at": datetime.now(UTC),
"checkpoint_digest": checkpoint_digest,
}
)
self._write(
path,
_StoredRun(
owner_id=actor.principal_id,
create_digest=self._read_stored(path).create_digest,
record=updated,
),
)
return updated
def _read(self, path: Path, actor: ActorContext) -> RunRecord:
stored = self._read_stored(path)
if stored.owner_id != actor.principal_id:
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
return stored.record
@staticmethod
def _read_stored(path: Path) -> _StoredRun:
if path.is_symlink() or not path.is_file() or path.stat().st_size > 1_000_000:
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
try:
return _StoredRun.model_validate_json(path.read_bytes(), strict=True)
except (OSError, ValueError, ValidationError) as error:
raise RunError("RUN_RECOVERY_REQUIRED", "Run checkpoint is unavailable", 503) from error
def _record_path(self, principal_id: str, run_id: str) -> Path:
if not run_id or len(run_id) > 128:
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
owner_key = hashlib.sha256(principal_id.encode("utf-8")).hexdigest()
run_key = hashlib.sha256(run_id.encode("utf-8")).hexdigest()
directory = self.root / owner_key
if directory.is_symlink():
raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503)
if directory.exists() and not directory.is_dir():
raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503)
return directory / f"{run_key}.json"
def _write(self, path: Path, stored: _StoredRun) -> None:
content = stored.model_dump_json().encode("utf-8")
if len(content) > self.MAX_RECORD_BYTES:
raise RunError("RUN_TOO_LARGE", "Run checkpoint exceeds the one-megabyte limit", 413)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if path.parent.is_symlink() or not path.parent.is_dir():
raise RunError("RUN_STORE_UNAVAILABLE", "Run directory is unavailable", 503)
path.parent.chmod(stat.S_IRWXU)
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
try:
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
if os.name != "nt":
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
directory_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError as error:
raise RunError(
"RUN_STORE_UNAVAILABLE", "Run checkpoint could not be saved", 503
) from error
finally:
temporary.unlink(missing_ok=True)
@contextmanager
def _writer_lock(self) -> Iterator[None]:
with self._thread_lock:
try:
with portalocker.Lock(self.lock_path, mode="a", timeout=0):
yield
except portalocker.exceptions.LockException as error:
raise RunError("BUSY", "Run store is handling another operation", 409) from error
+998
View File
@@ -0,0 +1,998 @@
"""Scope and freshness policy for derived canonical search."""
from __future__ import annotations
import base64
import hashlib
import json
import secrets
import sqlite3
from collections import defaultdict
from collections.abc import Callable, Collection, Iterator
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Literal, Protocol
from gewu.application.identity import Principal
from gewu.application.ports import EmbeddingProvider, VectorGeneration, VectorIndex
from gewu.domain.contracts import (
BudgetProfile,
Confidence,
EdgeEndpoint,
EvidenceReference,
Finding,
KnowledgeNode,
NeighborRequest,
ReadEnvelope,
Scope,
SearchRequest,
SourceListRequest,
SourceRevisionRecord,
TypedEdge,
)
from gewu.domain.validation import canonical_json, evidence_digest
class SearchError(RuntimeError):
"""Safe search failure with an HTTP-compatible status."""
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True, slots=True)
class IndexedNode:
node: KnowledgeNode
score: float
expanded_from: str | None = None
expansion_edge: TypedEdge | None = None
@dataclass(frozen=True, slots=True)
class SearchIndexResult:
index_revision: str
nodes: tuple[IndexedNode, ...]
has_more: bool = False
retrieval_mode: Literal["keyword", "hybrid"] = "keyword"
warnings: tuple[Finding, ...] = ()
@dataclass(frozen=True, slots=True)
class SourceIndexResult:
index_revision: str
sources: tuple[SourceRevisionRecord, ...]
has_more: bool = False
@dataclass(frozen=True, slots=True)
class SearchEdgeResult:
index_revision: str
origin: KnowledgeNode | None
edges: tuple[SearchEdgeHit, ...]
has_more: bool = False
@dataclass(frozen=True, slots=True)
class SearchEdgeHit:
edge: TypedEdge
neighbor_endpoint: EdgeEndpoint
neighbor_node: KnowledgeNode | None
neighbor_source: SourceRevisionRecord | None
class SearchIndex(Protocol):
def diagnostic_status(self) -> tuple[str | None, int, bool]: ...
def search(
self,
*,
query: str,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
node_types: tuple[str, ...] = (),
tags: tuple[str, ...] = (),
audience: tuple[str, ...] = (),
use_for: tuple[str, ...] = (),
domain: tuple[str, ...] = (),
confidences: tuple[Confidence, ...] = (),
as_of_from: date | None = None,
as_of_to: date | None = None,
stale_only: bool = False,
candidate_node_ids: tuple[str, ...] | None = None,
offset: int = 0,
) -> SearchIndexResult: ...
def neighbors(
self,
*,
node_id: str,
edge_types: tuple[str, ...],
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SearchEdgeResult: ...
def list_sources(
self,
*,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SourceIndexResult: ...
_VECTOR_CANDIDATE_LIMIT = 100
_VECTOR_BUILD_BATCH_SIZE = 32
_GRAPH_SEED_LIMIT = 10
_GRAPH_EDGE_LIMIT = 50
_GRAPH_EXPANSION_SCORE = 1 / 160.5
_NODE_CONTENT_PROFILE = "knowledge-node-title-summary-claims-tags-v1"
class HybridSearchIndex:
"""Fuse scoped lexical and vector candidates over a pinned vector generation."""
def __init__(
self,
*,
lexical: SearchIndex,
vectors: VectorIndex,
embeddings: EmbeddingProvider,
) -> None:
self.lexical = lexical
self.vectors = vectors
self.embeddings = embeddings
self._failed_profile: tuple[str, str] | None = None
def diagnostic_status(self) -> tuple[str | None, int, bool]:
return self.lexical.diagnostic_status()
def cursor_profile(self) -> str:
return hashlib.sha256(
"\0".join(
(
"hybrid-rrf-v1",
"typed-graph-one-hop-v1",
str(_VECTOR_CANDIDATE_LIMIT),
str(_GRAPH_SEED_LIMIT),
str(_GRAPH_EDGE_LIMIT),
self.embeddings.model_id,
self.embeddings.artifact_sha256,
str(self.embeddings.dimensions),
_NODE_CONTENT_PROFILE,
)
).encode()
).hexdigest()
def search(
self,
*,
query: str,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
node_types: tuple[str, ...] = (),
tags: tuple[str, ...] = (),
audience: tuple[str, ...] = (),
use_for: tuple[str, ...] = (),
domain: tuple[str, ...] = (),
confidences: tuple[Confidence, ...] = (),
as_of_from: date | None = None,
as_of_to: date | None = None,
stale_only: bool = False,
candidate_node_ids: tuple[str, ...] | None = None,
offset: int = 0,
) -> SearchIndexResult:
if not query.strip() or candidate_node_ids is not None:
return self.lexical.search(
query=query,
project_ids=project_ids,
include_shared=include_shared,
limit=limit,
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
candidate_node_ids=candidate_node_ids,
offset=offset,
)
try:
lexical_head = self.lexical.search(
query=query,
project_ids=project_ids,
include_shared=include_shared,
limit=_VECTOR_CANDIDATE_LIMIT,
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
)
generation = self.vectors.active_generation()
if not self._matches_generation(generation, lexical_head.index_revision):
raise RuntimeError("Vector generation is unavailable")
assert generation is not None
query_vector = self.embeddings.embed_queries((query,))[0]
vector_hits = self.vectors.search(
query=query_vector,
project_ids=project_ids,
include_shared=include_shared,
limit=_VECTOR_CANDIDATE_LIMIT,
expected_generation_id=generation.generation_id,
)
vector_nodes = self.lexical.search(
query="",
project_ids=project_ids,
include_shared=include_shared,
limit=_VECTOR_CANDIDATE_LIMIT,
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
candidate_node_ids=tuple(hit.node_id for hit in vector_hits),
)
vector_node_by_id = {hit.node.node_id: hit.node for hit in vector_nodes.nodes}
fused_scores: defaultdict[str, float] = defaultdict(float)
nodes_by_id: dict[str, KnowledgeNode] = {}
for rank, hit in enumerate(lexical_head.nodes, start=1):
fused_scores[hit.node.node_id] += 1 / (60 + rank)
nodes_by_id[hit.node.node_id] = hit.node
for rank, vector_hit in enumerate(vector_hits, start=1):
node = vector_node_by_id.get(vector_hit.node_id)
if node is not None:
fused_scores[vector_hit.node_id] += 1 / (60 + rank)
nodes_by_id[vector_hit.node_id] = node
ranked = sorted(
fused_scores,
key=lambda node_id: (-fused_scores[node_id], node_id),
)
graph_candidates: list[tuple[str, TypedEdge, KnowledgeNode]] = []
graph_node_ids: set[str] = set()
fused_node_ids = set(ranked)
examined_edge_count = 0
for seed_id in ranked[:_GRAPH_SEED_LIMIT]:
remaining = _GRAPH_EDGE_LIMIT - examined_edge_count
if remaining <= 0:
break
neighbors = self.lexical.neighbors(
node_id=seed_id,
edge_types=(),
project_ids=project_ids,
include_shared=include_shared,
limit=remaining,
)
examined_edge_count += len(neighbors.edges)
for neighbor in neighbors.edges:
node = neighbor.neighbor_node
if (
node is None
or node.node_id in fused_node_ids
or node.node_id in graph_node_ids
):
continue
graph_node_ids.add(node.node_id)
graph_candidates.append((seed_id, neighbor.edge, node))
if graph_candidates:
eligible_graph_nodes = self.lexical.search(
query="",
project_ids=project_ids,
include_shared=include_shared,
limit=len(graph_candidates),
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
candidate_node_ids=tuple(node.node_id for _, _, node in graph_candidates),
)
eligible_ids = {hit.node.node_id for hit in eligible_graph_nodes.nodes}
expanded_nodes = tuple(
IndexedNode(
node=node,
score=_GRAPH_EXPANSION_SCORE,
expanded_from=seed_id,
expansion_edge=edge,
)
for seed_id, edge, node in graph_candidates
if node.node_id in eligible_ids
)
else:
expanded_nodes = ()
fixed_head = tuple(
[IndexedNode(nodes_by_id[node_id], fused_scores[node_id]) for node_id in ranked]
+ list(expanded_nodes)
)
head_start = min(offset, len(fixed_head))
head_page = [*fixed_head[head_start : offset + limit]]
tail_offset = max(0, offset - len(fixed_head))
tail_limit = limit - len(head_page)
lexical_tail = SearchIndexResult(lexical_head.index_revision, ())
if lexical_head.has_more and tail_limit > 0:
lexical_tail = self.lexical.search(
query=query,
project_ids=project_ids,
include_shared=include_shared,
limit=tail_limit,
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
offset=_VECTOR_CANDIDATE_LIMIT + tail_offset,
)
lexical_tail = SearchIndexResult(
index_revision=lexical_tail.index_revision,
nodes=tuple(
IndexedNode(
hit.node,
1 / (60 + _VECTOR_CANDIDATE_LIMIT + tail_offset + rank),
)
for rank, hit in enumerate(lexical_tail.nodes, start=1)
),
has_more=lexical_tail.has_more,
)
page = (*head_page, *lexical_tail.nodes)
has_more = (
offset + len(head_page) < len(fixed_head)
or (tail_limit == 0 and lexical_head.has_more)
or (tail_limit > 0 and lexical_tail.has_more)
)
return SearchIndexResult(
index_revision=lexical_head.index_revision,
nodes=page,
has_more=has_more,
retrieval_mode="hybrid",
)
except (OSError, RuntimeError, ValueError, IndexError):
warning = Finding(
code="HYBRID_RETRIEVAL_DEGRADED",
message=(
"Vector retrieval is unavailable for this index revision; "
"keyword results are shown."
),
)
fallback = self.lexical.search(
query=query,
project_ids=project_ids,
include_shared=include_shared,
limit=limit,
node_types=node_types,
tags=tags,
audience=audience,
use_for=use_for,
domain=domain,
confidences=confidences,
as_of_from=as_of_from,
as_of_to=as_of_to,
stale_only=stale_only,
offset=offset,
)
return SearchIndexResult(
index_revision=fallback.index_revision,
nodes=fallback.nodes,
has_more=fallback.has_more,
warnings=(warning,),
)
def vector_generation_is_current(self, revision: str) -> bool:
active = self.vectors.active_generation()
return self._matches_generation(active, revision) or self._failed_profile == (
revision,
self.cursor_profile(),
)
def build_vector_generation(
self,
connection: sqlite3.Connection,
revision: str,
nodes: tuple[KnowledgeNode, ...],
) -> None:
"""Stage vectors and their active pointer inside the canonical index transaction."""
ordered_nodes = tuple(sorted(nodes, key=lambda node: (node.project_id, node.node_id)))
generation_id = self._generation_id(revision)
active = self.vectors.active_generation_in_transaction(connection)
generation = self.vectors.generation_in_transaction(connection, generation_id)
if not self._matches_generation(generation, revision):
connection.execute("SAVEPOINT vector_generation_stage")
try:
if generation is None:
self.vectors.build_generation_in_transaction(
connection,
generation_id=generation_id,
revision=revision,
model_id=self.embeddings.model_id,
artifact_sha256=self.embeddings.artifact_sha256,
content_profile=_NODE_CONTENT_PROFILE,
dimensions=self.embeddings.dimensions,
vectors=self._vector_records(ordered_nodes),
)
generation = self.vectors.generation_in_transaction(connection, generation_id)
if not self._matches_generation(generation, revision):
raise RuntimeError("Vector generation metadata does not match the model")
self.vectors.activate_in_transaction(
connection,
generation_id,
expected_active=active.generation_id if active is not None else None,
)
connection.execute("RELEASE SAVEPOINT vector_generation_stage")
self._failed_profile = None
except (RuntimeError, ValueError, sqlite3.Error):
connection.execute("ROLLBACK TO SAVEPOINT vector_generation_stage")
connection.execute("RELEASE SAVEPOINT vector_generation_stage")
self.vectors.deactivate_in_transaction(connection)
self._failed_profile = (revision, self.cursor_profile())
return
elif active is None or active.generation_id != generation_id:
self.vectors.activate_in_transaction(
connection,
generation_id,
expected_active=active.generation_id if active is not None else None,
)
self._failed_profile = None
def _generation_id(self, revision: str) -> str:
return hashlib.sha256(
"\0".join(
(
revision,
self.embeddings.model_id,
self.embeddings.artifact_sha256,
str(self.embeddings.dimensions),
_NODE_CONTENT_PROFILE,
)
).encode()
).hexdigest()
def _vector_records(
self, nodes: tuple[KnowledgeNode, ...]
) -> Iterator[tuple[str, str, tuple[float, ...]]]:
for start in range(0, len(nodes), _VECTOR_BUILD_BATCH_SIZE):
batch = nodes[start : start + _VECTOR_BUILD_BATCH_SIZE]
texts = tuple(self._node_text(node) for node in batch)
embeddings = self.embeddings.embed_passages(texts)
yield from (
(node.project_id, node.node_id, vector)
for node, vector in zip(batch, embeddings, strict=True)
)
def _matches_generation(self, generation: VectorGeneration | None, revision: str) -> bool:
return bool(
generation is not None
and generation.revision == revision
and generation.model_id == self.embeddings.model_id
and generation.artifact_sha256 == self.embeddings.artifact_sha256
and generation.content_profile == _NODE_CONTENT_PROFILE
and generation.dimensions == self.embeddings.dimensions
)
@staticmethod
def _node_text(node: KnowledgeNode) -> str:
return "\n".join(
(
node.title,
node.summary,
*(claim.text for claim in node.evidence_claims),
*node.tags,
*node.aliases,
)
)
def neighbors(
self,
*,
node_id: str,
edge_types: tuple[str, ...],
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SearchEdgeResult:
return self.lexical.neighbors(
node_id=node_id,
edge_types=edge_types,
project_ids=project_ids,
include_shared=include_shared,
limit=limit,
offset=offset,
)
def list_sources(
self,
*,
project_ids: tuple[str, ...],
include_shared: bool,
limit: int,
offset: int = 0,
) -> SourceIndexResult:
return self.lexical.list_sources(
project_ids=project_ids,
include_shared=include_shared,
limit=limit,
offset=offset,
)
def _budgeted_envelope(
*,
request_id: str,
scope: Scope,
rows: list[dict[str, object]],
citation_groups: list[dict[str, EvidenceReference]],
warnings: list[Finding],
canonical_revision: str,
index_revision: str,
budget_tokens: int,
retrieval_mode: Literal["keyword", "hybrid", "canonical"],
next_cursor_for: Callable[[int], str | None] | None = None,
) -> ReadEnvelope:
for included_count in range(len(rows), -1, -1):
evidence: dict[str, EvidenceReference] = {}
for group in citation_groups[:included_count]:
evidence.update(group)
omitted = len(rows) - included_count
omissions = (
(
Finding(
code="BUDGET_TRUNCATED",
message=f"Omitted {omitted} lower-ranked result(s) to fit the budget.",
),
)
if omitted
else ()
)
envelope = ReadEnvelope(
request_id=request_id,
resolved_scope=scope,
data=tuple(rows[:included_count]),
citations=tuple(evidence[key] for key in sorted(evidence)),
warnings=tuple(warnings),
omissions=omissions,
next_cursor=next_cursor_for(included_count) if next_cursor_for is not None else None,
canonical_revision=canonical_revision,
index_revision=index_revision,
retrieval_mode=retrieval_mode,
budget_profile=BudgetProfile(
tokenizer="unicode-codepoints-v1", encoding="canonical-json-v1"
),
tokens_used=0,
)
envelope = CanonicalSearchService._count_complete_payload(envelope)
if envelope.tokens_used <= budget_tokens:
if len(canonical_json(envelope.model_dump(mode="json"))) > 1_000_000:
raise SearchError(413, "Search response exceeds the one-megabyte response budget")
return envelope
raise SearchError(413, "Search budget is too small for the required response envelope")
class CanonicalSearchService:
"""Authorize explicit project scope, then search a rebuildable index."""
def __init__(
self,
*,
index: SearchIndex,
project_ids_provider: Callable[[], Collection[str]],
canonical_head: Callable[[], str],
default_includes_provider: Callable[[str], tuple[str, ...]] | None = None,
) -> None:
self.index = index
self.project_ids_provider = project_ids_provider
self.canonical_head = canonical_head
self.default_includes_provider = default_includes_provider
def diagnostic_index_status(self) -> tuple[str, str | None, str, int | None]:
canonical_revision = self.canonical_head()
try:
index_revision, schema_version, available = self.index.diagnostic_status()
except (OSError, RuntimeError):
return canonical_revision, None, "unavailable", None
if not available:
return canonical_revision, None, "unavailable", schema_version
if index_revision is None:
return canonical_revision, None, "not_built", schema_version
state = "current" if index_revision == canonical_revision else "lagging"
return canonical_revision, index_revision, state, schema_version
def search(self, request: SearchRequest, *, principal: Principal) -> ReadEnvelope:
project_ids = self._resolve_scope(request.scope, principal)
fingerprint = hashlib.sha256(
canonical_json(
{
"query": request.query,
"projects": project_ids,
"include_shared": request.scope.include_shared,
"node_types": tuple(sorted(item.value for item in request.node_types)),
"tags": tuple(sorted(request.tags)),
"audience": tuple(sorted(request.audience)),
"use_for": tuple(sorted(request.use_for)),
"domain": tuple(sorted(request.domain)),
"confidences": tuple(sorted(item.value for item in request.confidences)),
"as_of_from": request.as_of_from.isoformat() if request.as_of_from else None,
"as_of_to": request.as_of_to.isoformat() if request.as_of_to else None,
"stale_only": request.stale_only,
"limit": request.limit,
"retrieval_profile": (
self.index.cursor_profile()
if isinstance(self.index, HybridSearchIndex)
else "keyword-v1"
),
}
)
).hexdigest()
offset, expected_index_revision = self._cursor_offset(request.cursor, fingerprint)
result = self.index.search(
query=request.query,
project_ids=project_ids,
include_shared=request.scope.include_shared,
node_types=tuple(item.value for item in request.node_types),
tags=request.tags,
audience=request.audience,
use_for=request.use_for,
domain=request.domain,
confidences=request.confidences,
as_of_from=request.as_of_from,
as_of_to=request.as_of_to,
stale_only=request.stale_only,
limit=request.limit,
offset=offset,
)
if expected_index_revision is not None and expected_index_revision != result.index_revision:
raise SearchError(409, "Search cursor expired because the index revision changed")
canonical_revision = self.canonical_head()
warnings: list[Finding] = list(result.warnings)
now = datetime.now(UTC)
evidence_by_node: list[dict[str, EvidenceReference]] = []
rows: list[dict[str, object]] = []
for hit in result.nodes:
node = hit.node
row: dict[str, object] = {
"node": node.model_dump(mode="json"),
"retrieval_score": hit.score,
}
if hit.expanded_from is not None and hit.expansion_edge is not None:
row["retrieval_path"] = "graph_expansion"
row["expanded_from"] = hit.expanded_from
row["expansion_edge"] = hit.expansion_edge.model_dump(mode="json")
rows.append(row)
if node.stale_after is not None and node.stale_after <= now:
warnings.append(
Finding(
code="STALE_NODE",
message=f"Knowledge node {node.node_id} is past its freshness date.",
path=node.node_id,
)
)
node_evidence: dict[str, EvidenceReference] = {}
for claim in node.evidence_claims:
for item in claim.evidence:
key = evidence_digest(item)
node_evidence[key] = item
if hit.expansion_edge is not None:
for item in hit.expansion_edge.evidence:
key = evidence_digest(item)
node_evidence[key] = item
evidence_by_node.append(node_evidence)
if result.index_revision != canonical_revision:
warnings.append(
Finding(
code="INDEX_LAG",
message=(
"Search results reflect an older accepted revision; "
"canonical reads remain available."
),
)
)
return _budgeted_envelope(
request_id="search-" + secrets.token_hex(12),
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
rows=rows,
citation_groups=evidence_by_node,
warnings=warnings,
canonical_revision=canonical_revision,
index_revision=result.index_revision,
budget_tokens=request.budget_tokens,
retrieval_mode=result.retrieval_mode,
next_cursor_for=(
lambda included: (
(
self._encode_cursor(offset + included, fingerprint, result.index_revision)
if result.has_more or included < len(rows)
else None
)
if not result.warnings
else None
)
),
)
@staticmethod
def _encode_cursor(offset: int, fingerprint: str, index_revision: str) -> str:
payload = canonical_json(
{"offset": offset, "fingerprint": fingerprint, "index_revision": index_revision}
)
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
@staticmethod
def _cursor_offset(
cursor: str | None, fingerprint: str, *, max_offset: int = 100_000
) -> tuple[int, str | None]:
if cursor is None:
return 0, None
try:
padded = cursor + "=" * (-len(cursor) % 4)
payload = json.loads(base64.b64decode(padded, altchars=b"-_", validate=True))
offset = payload["offset"]
if (
not isinstance(payload, dict)
or set(payload) != {"offset", "fingerprint", "index_revision"}
or type(offset) is not int
or not 0 <= offset <= max_offset
or payload["fingerprint"] != fingerprint
or not isinstance(payload["index_revision"], str)
or len(payload["index_revision"]) not in {40, 64}
or any(
character not in "0123456789abcdef" for character in payload["index_revision"]
)
):
raise ValueError("cursor does not match this search")
return offset, payload["index_revision"]
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as error:
raise SearchError(422, "Search cursor is invalid for this query and scope") from error
def list_projects(self, *, principal: Principal) -> tuple[str, ...]:
"""List only projects visible to the authenticated principal."""
return tuple(
sorted(
project_id
for project_id in set(self.project_ids_provider())
if principal.can_read_project(project_id)
)
)
def _resolve_scope(self, scope: Scope, principal: Principal) -> tuple[str, ...]:
registered_projects = set(self.project_ids_provider())
if scope.all_projects:
if "admin" not in principal.roles:
raise SearchError(403, "All-project search requires an administrator")
project_ids = tuple(sorted(registered_projects))
else:
if scope.include_projects is not None:
includes = scope.include_projects
elif len(scope.projects) == 1 and self.default_includes_provider is not None:
includes = self.default_includes_provider(scope.projects[0])
else:
includes = ()
includes = tuple(project_id for project_id in includes if project_id != "shared")
project_ids = tuple(dict.fromkeys((*scope.projects, *includes)))
unknown = set(project_ids) - registered_projects - {"shared"}
if unknown:
raise SearchError(404, "Search scope contains an unknown project")
if not project_ids:
raise SearchError(422, "Search scope resolved to no projects")
if any(not principal.can_read_project(project_id) for project_id in project_ids):
raise SearchError(403, "Credential cannot read every requested project")
if scope.include_shared and not principal.can_read_project("shared"):
raise SearchError(403, "Credential cannot read shared knowledge")
return project_ids
def neighbors(self, request: NeighborRequest, *, principal: Principal) -> ReadEnvelope:
project_ids = self._resolve_scope(request.scope, principal)
fingerprint = hashlib.sha256(
canonical_json(
{
"node_id": request.node_id,
"projects": project_ids,
"include_shared": request.scope.include_shared,
"edge_types": request.edge_types,
"limit": request.limit,
}
)
).hexdigest()
offset, expected_index_revision = self._cursor_offset(
request.cursor, fingerprint, max_offset=500_000
)
result = self.index.neighbors(
node_id=request.node_id,
edge_types=tuple(request.edge_types),
project_ids=project_ids,
include_shared=request.scope.include_shared,
limit=request.limit,
offset=offset,
)
if expected_index_revision is not None and expected_index_revision != result.index_revision:
raise SearchError(409, "Neighbor cursor expired because the index revision changed")
origin = result.origin
allowed = set(project_ids)
if request.scope.include_shared:
allowed.add("shared")
if origin is None or origin.project_id not in allowed:
raise SearchError(404, "Knowledge node was not found within the requested scope")
canonical_revision = self.canonical_head()
warnings: list[Finding] = []
stale_ids: set[str] = set()
if origin.stale_after is not None and origin.stale_after <= datetime.now(UTC):
stale_ids.add(origin.node_id)
rows: list[dict[str, object]] = []
citation_groups: list[dict[str, EvidenceReference]] = []
for hit in result.edges:
endpoint = hit.neighbor_endpoint
if endpoint.kind == "node":
neighbor = hit.neighbor_node
if neighbor is None or neighbor.project_id not in allowed:
continue
neighbor_value: dict[str, object] = {
"kind": "node",
"id": endpoint.id,
"record": neighbor.model_dump(mode="json"),
}
if neighbor.stale_after is not None and neighbor.stale_after <= datetime.now(UTC):
stale_ids.add(neighbor.node_id)
elif endpoint.kind == "project":
if endpoint.id not in allowed:
continue
neighbor_value = {"kind": "project", "id": endpoint.id}
neighbor = None
else:
source = hit.neighbor_source
if source is None or not (set(source.ingested_for) & allowed):
continue
neighbor_value = {
"kind": "source",
"id": endpoint.id,
"record": source.model_dump(mode="json"),
}
neighbor = None
rows.append(
{
"from_node": {
"node_id": origin.node_id,
"project_id": origin.project_id,
"title": origin.title,
},
"edge": hit.edge.model_dump(mode="json"),
"neighbor": neighbor_value,
}
)
group: dict[str, EvidenceReference] = {}
for item in hit.edge.evidence:
group[evidence_digest(item)] = item
if neighbor is not None:
for claim in neighbor.claims:
for item in claim.evidence:
group[evidence_digest(item)] = item
citation_groups.append(group)
warnings.extend(
Finding(
code="STALE_NODE",
message=f"Knowledge node {node_id} is past its freshness date.",
path=node_id,
)
for node_id in sorted(stale_ids)
)
if result.index_revision != canonical_revision:
warnings.append(
Finding(
code="INDEX_LAG",
message=(
"Neighbor results reflect an older accepted revision; "
"canonical reads remain available."
),
)
)
return _budgeted_envelope(
request_id="neighbors-" + secrets.token_hex(12),
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
rows=rows,
citation_groups=citation_groups,
warnings=warnings,
canonical_revision=canonical_revision,
index_revision=result.index_revision,
budget_tokens=request.budget_tokens,
retrieval_mode="keyword",
next_cursor_for=(
lambda included: (
self._encode_cursor(offset + included, fingerprint, result.index_revision)
if result.has_more or included < len(rows)
else None
)
),
)
def list_sources(self, request: SourceListRequest, *, principal: Principal) -> ReadEnvelope:
project_ids = self._resolve_scope(request.scope, principal)
fingerprint = hashlib.sha256(
canonical_json(
{
"projects": project_ids,
"include_shared": request.scope.include_shared,
"limit": request.limit,
}
)
).hexdigest()
offset, expected_index_revision = self._cursor_offset(
request.cursor, fingerprint, max_offset=500_000
)
result = self.index.list_sources(
project_ids=project_ids,
include_shared=request.scope.include_shared,
limit=request.limit,
offset=offset,
)
if expected_index_revision is not None and expected_index_revision != result.index_revision:
raise SearchError(409, "Source cursor expired because the index revision changed")
canonical_revision = self.canonical_head()
rows: list[dict[str, object]] = [
{"source": source.model_dump(mode="json")} for source in result.sources
]
warnings: list[Finding] = []
if result.index_revision != canonical_revision:
warnings.append(
Finding(
code="INDEX_LAG",
message=(
"Source results reflect an older accepted revision; "
"canonical source reads remain available."
),
)
)
return _budgeted_envelope(
request_id="sources-" + secrets.token_hex(12),
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
rows=rows,
citation_groups=[{} for _ in rows],
warnings=warnings,
canonical_revision=canonical_revision,
index_revision=result.index_revision,
budget_tokens=request.budget_tokens,
retrieval_mode="keyword",
next_cursor_for=(
lambda included: (
self._encode_cursor(offset + included, fingerprint, result.index_revision)
if result.has_more or included < len(rows)
else None
)
),
)
@staticmethod
def _count_complete_payload(envelope: ReadEnvelope) -> ReadEnvelope:
counted = envelope
for _ in range(4):
serialized = canonical_json(counted.model_dump(mode="json"))
count = len(serialized.decode("utf-8"))
if count == counted.tokens_used:
return counted
counted = counted.model_copy(update={"tokens_used": count})
return counted
+35
View File
@@ -0,0 +1,35 @@
"""Strict serializers for locally persisted typed application records."""
from __future__ import annotations
import json
import yaml
from pydantic import BaseModel
def safe_yaml_record[RecordT: BaseModel](raw: bytes, expected_type: type[RecordT]) -> RecordT:
"""Parse YAML without custom tags or duplicate keys, then validate strictly."""
class UniqueKeyLoader(yaml.SafeLoader):
pass
def construct_mapping(
loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False
) -> dict[object, object]:
keys: set[object] = set()
mapping: dict[object, object] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in keys:
raise ValueError("duplicate YAML key")
keys.add(key)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
construct_mapping,
)
value = yaml.load(raw, Loader=UniqueKeyLoader)
return expected_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True)
+652
View File
@@ -0,0 +1,652 @@
"""Bounded, resumable local upload and content-addressed artifact storage."""
from __future__ import annotations
import codecs
import hashlib
import json
import os
import re
import secrets
import stat
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO
import portalocker
from gewu.application.paths import reject_symlink_components
from gewu.domain.contracts import ExtractionRecord, Sha256
from gewu.domain.validation import extraction_digest
MAX_ARTIFACT_BYTES = 50 * 1024 * 1024
MAX_CAPTURE_RECEIPTS = 1024
CHUNK_BYTES = 512 * 1024
IDLE_EXPIRY_SECONDS = 10 * 60
ALLOWED_MEDIA_TYPES = {
"application/pdf",
"application/x-subrip",
"text/html",
"text/markdown",
"text/plain",
"text/vtt",
}
ALLOWED_SUFFIXES = {".htm", ".html", ".txt", ".md", ".markdown", ".pdf", ".srt", ".vtt"}
_SAFE_UPLOAD_ID = re.compile(r"^[A-Za-z0-9_-]{24,64}$")
class UploadError(ValueError):
"""Safe client-facing upload failure with a stable error code."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True, slots=True)
class UploadSession:
upload_id: str
verified_offsets: tuple[int, ...]
chunk_bytes: int
max_artifact_bytes: int
expires_at: int
@dataclass(frozen=True, slots=True)
class ArtifactReceipt:
sha256: Sha256
byte_length: int
media_type: str
status: str = "staged"
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def _write_json_atomically(path: Path, payload: dict[str, object]) -> None:
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
try:
with temporary.open("xb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
class LocalArtifactStore:
"""Filesystem adapter. Roots are service-owned and never model-controlled."""
def __init__(self, data_root: Path) -> None:
self.root = data_root.expanduser().absolute()
reject_symlink_components(self.root)
self.upload_root = self.root / "staging" / "uploads"
self.artifact_root = self.root / "artifacts" / "sha256"
self.extraction_root = self.root / "extractions" / "sha256"
self.capture_receipt_root = self.root / "staging" / "capture-receipts"
self._lock = threading.RLock()
for directory in (
self.upload_root,
self.artifact_root,
self.extraction_root,
self.capture_receipt_root,
):
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
if directory.is_symlink() or not directory.is_dir():
raise RuntimeError("GEWU data directory must be a real directory")
directory.chmod(stat.S_IRWXU)
@contextmanager
def capture_receipt_guard(self) -> Iterator[None]:
"""Serialize the bounded text-capture idempotency journal across workers."""
lock_path = self.capture_receipt_root / ".capture.lock"
reject_symlink_components(lock_path)
with portalocker.Lock(lock_path, mode="a", timeout=30):
yield
def get_capture_receipt(
self,
capture_id: str,
*,
owner_id: str,
project_id: str,
request_sha256: str,
) -> dict[str, object] | None:
path = self._capture_receipt_path(capture_id, owner_id)
if path.is_symlink() or not path.exists():
return None
try:
value = json.loads(path.read_bytes())
except (OSError, ValueError) as error:
raise UploadError(
"CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid"
) from error
if (
not isinstance(value, dict)
or value.get("capture_id") != capture_id
or value.get("owner_id") != owner_id
):
raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid")
if value.get("project_id") != project_id or value.get("request_sha256") != request_sha256:
raise UploadError("CAPTURE_CONFLICT", "Capture ID was already used for different input")
result = value.get("result")
if not isinstance(result, dict):
raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid")
path.touch(exist_ok=True)
os.utime(path, None)
return result
def save_capture_receipt(
self,
capture_id: str,
*,
owner_id: str,
project_id: str,
request_sha256: str,
result: dict[str, object],
) -> None:
path = self._capture_receipt_path(capture_id, owner_id)
existing = self.get_capture_receipt(
capture_id,
owner_id=owner_id,
project_id=project_id,
request_sha256=request_sha256,
)
if existing is not None:
if existing != result:
raise UploadError("CAPTURE_CONFLICT", "Capture ID already has a different result")
return
_write_json_atomically(
path,
{
"capture_id": capture_id,
"owner_id": owner_id,
"project_id": project_id,
"request_sha256": request_sha256,
"result": result,
},
)
receipts = sorted(
(item for item in self.capture_receipt_root.glob("*.json") if item != path),
key=lambda item: item.stat().st_mtime_ns,
)
for expired in receipts[: max(0, len(receipts) + 1 - MAX_CAPTURE_RECEIPTS)]:
expired.unlink(missing_ok=True)
def _capture_receipt_path(self, capture_id: str, owner_id: str) -> Path:
key = hashlib.sha256(f"{owner_id}\0{capture_id}".encode()).hexdigest()
return self.capture_receipt_root / f"{key}.json"
def begin(
self,
*,
filename: str,
media_type: str,
byte_length: int,
expected_sha256: str,
owner_id: str | None = None,
project_id: str | None = None,
now: int | None = None,
) -> UploadSession:
suffix = Path(filename).suffix.lower()
expected_media = {
".pdf": "application/pdf",
".srt": "application/x-subrip",
".vtt": "text/vtt",
".html": "text/html",
".htm": "text/html",
".md": "text/markdown",
".markdown": "text/markdown",
".txt": "text/plain",
}
if suffix not in ALLOWED_SUFFIXES or expected_media.get(suffix) != media_type:
raise UploadError(
"UNSUPPORTED_MEDIA_TYPE", "Only PDF and UTF-8 HTML, text and Markdown are supported"
)
if byte_length < 1 or byte_length > MAX_ARTIFACT_BYTES:
raise UploadError("UPLOAD_TOO_LARGE", "Artifact size is outside the supported limit")
if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256):
raise UploadError(
"INVALID_DIGEST", "Expected SHA-256 must be 64 lowercase hex characters"
)
created = int(time.time()) if now is None else now
upload_id = secrets.token_urlsafe(24)
directory = self.upload_root / upload_id
with self._lock:
directory.mkdir(mode=0o700)
metadata: dict[str, object] = {
"upload_id": upload_id,
"media_type": media_type,
"byte_length": byte_length,
"expected_sha256": expected_sha256,
"owner_id": owner_id,
"project_id": project_id,
"created_at": created,
"updated_at": created,
"suffix": suffix,
"chunk_sha256": {},
}
_write_json_atomically(directory / "session.json", metadata)
return UploadSession(
upload_id, (), CHUNK_BYTES, MAX_ARTIFACT_BYTES, created + IDLE_EXPIRY_SECONDS
)
def _session_directory(self, upload_id: str) -> Path:
if not _SAFE_UPLOAD_ID.fullmatch(upload_id):
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
directory = self.upload_root / upload_id
if directory.is_symlink() or not directory.is_dir():
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
return directory
def _load_session(self, directory: Path) -> dict[str, object]:
metadata_path = directory / "session.json"
if metadata_path.is_symlink() or not metadata_path.is_file():
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
try:
data = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") from error
if not isinstance(data, dict):
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
return data
def _check_active(self, metadata: dict[str, object], now: int) -> None:
updated_at = metadata.get("updated_at")
if not isinstance(updated_at, int) or now - updated_at > IDLE_EXPIRY_SECONDS:
raise UploadError("UPLOAD_EXPIRED", "Upload session expired; start a new upload")
@staticmethod
def _check_owner(metadata: dict[str, object], owner_id: str | None) -> None:
if metadata.get("owner_id") != owner_id:
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
def _byte_length(self, metadata: dict[str, object]) -> int:
value = metadata.get("byte_length")
if not isinstance(value, int) or value < 1 or value > MAX_ARTIFACT_BYTES:
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
return value
def verified_offsets(
self, upload_id: str, *, owner_id: str | None = None, now: int | None = None
) -> tuple[int, ...]:
current = int(time.time()) if now is None else now
with self._lock:
directory = self._session_directory(upload_id)
metadata = self._load_session(directory)
self._check_owner(metadata, owner_id)
self._check_active(metadata, current)
byte_length = self._byte_length(metadata)
offsets: list[int] = []
for offset in range(0, byte_length, CHUNK_BYTES):
chunk_path = directory / f"{offset:020d}.chunk"
if chunk_path.is_symlink() or not chunk_path.is_file():
break
expected_length = min(CHUNK_BYTES, byte_length - offset)
if chunk_path.stat().st_size != expected_length:
break
chunk_digests = metadata.get("chunk_sha256")
expected_digest = (
chunk_digests.get(str(offset)) if isinstance(chunk_digests, dict) else None
)
if not isinstance(expected_digest, str) or not secrets.compare_digest(
_sha256_file(chunk_path), expected_digest
):
break
offsets.append(offset)
return tuple(offsets)
def put_chunk(
self,
upload_id: str,
*,
offset: int,
content: bytes,
chunk_sha256: str,
owner_id: str | None = None,
now: int | None = None,
) -> tuple[int, ...]:
current = int(time.time()) if now is None else now
if not content or len(content) > CHUNK_BYTES:
raise UploadError("CHUNK_SIZE_INVALID", "Chunk must contain 1 to 512 KiB")
actual_digest = hashlib.sha256(content).hexdigest()
if not secrets.compare_digest(actual_digest, chunk_sha256):
raise UploadError("CHUNK_DIGEST_MISMATCH", "Chunk SHA-256 did not match its bytes")
with self._lock:
directory = self._session_directory(upload_id)
metadata = self._load_session(directory)
self._check_owner(metadata, owner_id)
self._check_active(metadata, current)
byte_length = self._byte_length(metadata)
if offset < 0 or offset % CHUNK_BYTES or offset >= byte_length:
raise UploadError(
"CHUNK_OFFSET_INVALID", "Chunk offset is outside the upload sequence"
)
required_length = min(CHUNK_BYTES, byte_length - offset)
if len(content) != required_length:
raise UploadError(
"CHUNK_SIZE_INVALID", "Chunk length does not match its upload position"
)
offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current)
chunk_path = directory / f"{offset:020d}.chunk"
if offset in offsets:
if not secrets.compare_digest(_sha256_file(chunk_path), actual_digest):
raise UploadError(
"IDEMPOTENCY_CONFLICT", "Chunk offset already contains different bytes"
)
return offsets
if offset != len(offsets) * CHUNK_BYTES:
raise UploadError("CHUNK_OFFSET_INVALID", "Chunks must be uploaded in order")
with chunk_path.open("xb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
chunk_digests = metadata.get("chunk_sha256")
if not isinstance(chunk_digests, dict):
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
chunk_digests[str(offset)] = actual_digest
metadata["chunk_sha256"] = chunk_digests
metadata["updated_at"] = current
_write_json_atomically(directory / "session.json", metadata)
return offsets + (offset,)
def finalize(
self, upload_id: str, *, owner_id: str | None = None, now: int | None = None
) -> ArtifactReceipt:
current = int(time.time()) if now is None else now
with self._lock:
directory = self._session_directory(upload_id)
metadata = self._load_session(directory)
self._check_owner(metadata, owner_id)
self._check_active(metadata, current)
byte_length = self._byte_length(metadata)
expected_sha256 = metadata.get("expected_sha256")
if not isinstance(expected_sha256, str) or not re.fullmatch(
r"[a-f0-9]{64}", expected_sha256
):
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current)
expected_offsets = tuple(range(0, byte_length, CHUNK_BYTES))
if offsets != expected_offsets:
raise UploadError("UPLOAD_INCOMPLETE", "Upload is missing one or more chunks")
temporary = directory / f".{secrets.token_hex(12)}.artifact"
digest = hashlib.sha256()
media_type = metadata.get("media_type")
is_pdf = media_type == "application/pdf"
decoder = None if is_pdf else codecs.getincrementaldecoder("utf-8")("strict")
header = bytearray()
try:
with temporary.open("xb") as output:
for offset in expected_offsets:
chunk_path = directory / f"{offset:020d}.chunk"
if chunk_path.is_symlink():
raise UploadError("UPLOAD_CORRUPT", "Upload chunk is unavailable")
with chunk_path.open("rb") as source:
while block := source.read(64 * 1024):
digest.update(block)
if len(header) < 5:
header.extend(block[: 5 - len(header)])
if decoder is not None:
try:
decoder.decode(block)
except UnicodeDecodeError as error:
raise UploadError(
"TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8"
) from error
output.write(block)
if decoder is not None:
try:
decoder.decode(b"", final=True)
except UnicodeDecodeError as error:
raise UploadError(
"TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8"
) from error
if is_pdf and bytes(header) != b"%PDF-":
raise UploadError("PDF_INVALID", "PDF artifact signature is invalid")
output.flush()
os.fsync(output.fileno())
if digest.hexdigest() != expected_sha256:
raise UploadError(
"ARTIFACT_DIGEST_MISMATCH", "Artifact SHA-256 did not match upload"
)
artifact_digest = digest.hexdigest()
destination_directory = self.artifact_root / artifact_digest[:2]
destination_directory.mkdir(exist_ok=True, mode=0o700)
if destination_directory.is_symlink():
raise UploadError(
"ARTIFACT_STORE_UNAVAILABLE", "Artifact storage is unavailable"
)
destination = destination_directory / artifact_digest
if destination.exists():
if destination.is_symlink() or destination.stat().st_size != byte_length:
raise UploadError(
"ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks"
)
if not secrets.compare_digest(_sha256_file(destination), artifact_digest):
raise UploadError(
"ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks"
)
temporary.unlink()
else:
os.replace(temporary, destination)
artifact_owner_id = metadata.get("owner_id")
project_id = metadata.get("project_id")
if isinstance(artifact_owner_id, str) and isinstance(project_id, str):
provenance = destination.with_suffix(".access.json")
grants: dict[str, object] = {}
if provenance.exists():
try:
loaded = json.loads(provenance.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
grants = {
key: value
for key, value in loaded.items()
if isinstance(key, str)
and isinstance(value, list)
and all(isinstance(item, str) for item in value)
}
except (OSError, UnicodeError, json.JSONDecodeError):
raise UploadError(
"ARTIFACT_STORE_CONFLICT", "Stored artifact access data is invalid"
) from None
existing_projects = grants.get(artifact_owner_id, [])
projects = (
set(existing_projects)
if isinstance(existing_projects, list)
and all(isinstance(item, str) for item in existing_projects)
else set()
)
projects.add(project_id)
grants[artifact_owner_id] = sorted(projects)
grants["media_type"] = metadata.get("media_type")
_write_json_atomically(provenance, grants)
for path in directory.iterdir():
if path.is_file() and not path.is_symlink():
path.unlink()
directory.rmdir()
return ArtifactReceipt(
sha256=artifact_digest,
byte_length=byte_length,
media_type=str(metadata.get("media_type", "application/octet-stream")),
)
finally:
temporary.unlink(missing_ok=True)
def open_artifact(
self,
sha256: str,
*,
owner_id: str | None = None,
project_id: str | None = None,
) -> BinaryIO:
if not re.fullmatch(r"[a-f0-9]{64}", sha256):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
path = self.artifact_root / sha256[:2] / sha256
if (
path.is_symlink()
or not path.is_file()
or not secrets.compare_digest(_sha256_file(path), sha256)
):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
if owner_id is not None or project_id is not None:
access_path = path.with_suffix(".access.json")
try:
if access_path.is_symlink() or not access_path.is_file():
raise OSError("artifact access metadata is unavailable")
access = json.loads(access_path.read_text(encoding="utf-8"))
projects = access.get(owner_id) if isinstance(access, dict) and owner_id else None
if owner_id is None and project_id is not None and isinstance(access, dict):
projects = [
granted_project
for owner, owner_projects in access.items()
if owner != "media_type" and isinstance(owner_projects, list)
for granted_project in owner_projects
]
except (OSError, UnicodeError, json.JSONDecodeError):
projects = None
if not isinstance(projects, list) or project_id not in projects:
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
return path.open("rb")
def artifact_media_type_for_project(self, sha256: str, *, project_id: str) -> str:
if not re.fullmatch(r"[a-f0-9]{64}", sha256):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
path = self.artifact_root / sha256[:2] / f"{sha256}.access.json"
try:
if path.is_symlink() or not path.is_file():
raise OSError("artifact access metadata is unavailable")
access = json.loads(path.read_text(encoding="utf-8"))
grants = (
[
project
for owner, projects in access.items()
if owner != "media_type" and isinstance(projects, list)
for project in projects
]
if isinstance(access, dict)
else []
)
media_type = access.get("media_type") if isinstance(access, dict) else None
except (OSError, UnicodeError, json.JSONDecodeError):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
if project_id not in grants or not isinstance(media_type, str):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
return media_type
def artifact_media_type(self, sha256: str, *, owner_id: str, project_id: str) -> str:
path = self.artifact_root / sha256[:2] / f"{sha256}.access.json"
try:
access = json.loads(path.read_text(encoding="utf-8"))
projects = access.get(owner_id) if isinstance(access, dict) else None
media_type = access.get("media_type") if isinstance(access, dict) else None
except (OSError, UnicodeError, json.JSONDecodeError):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
if (
not isinstance(projects, list)
or project_id not in projects
or not isinstance(media_type, str)
):
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
return media_type
def save_extraction(
self,
extraction: ExtractionRecord,
*,
owner_id: str | None = None,
project_id: str | None = None,
) -> None:
if extraction_digest(extraction) != extraction.extraction_id:
raise UploadError("EXTRACTION_HASH_MISMATCH", "Extraction identity is invalid")
digest = extraction.extraction_id
directory = self.extraction_root / digest[:2]
directory.mkdir(exist_ok=True, mode=0o700)
if directory.is_symlink():
raise UploadError("EXTRACTION_STORE_UNAVAILABLE", "Extraction storage is unavailable")
path = directory / f"{digest}.json"
with self._lock:
stored: dict[str, object] = {
"owner_id": owner_id,
"project_ids": [project_id] if project_id is not None else [],
"record": extraction.model_dump(mode="json"),
}
if path.exists():
if path.is_symlink():
raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid")
try:
existing = json.loads(path.read_bytes())
except (OSError, ValueError) as error:
raise UploadError(
"EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid"
) from error
if (
not isinstance(existing, dict)
or existing.get("owner_id") != owner_id
or existing.get("record") != stored["record"]
):
raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid")
project_ids = existing.get("project_ids", [])
if not isinstance(project_ids, list) or not all(
isinstance(item, str) for item in project_ids
):
project_ids = []
if project_id is not None and project_id not in project_ids:
project_ids.append(project_id)
stored["project_ids"] = sorted(set(project_ids))
content = json.dumps(
stored,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
_write_json_atomically(path, json.loads(content))
def get_extraction(
self,
extraction_id: str,
*,
owner_id: str | None = None,
project_id: str | None = None,
) -> ExtractionRecord | None:
if not re.fullmatch(r"[a-f0-9]{64}", extraction_id):
return None
path = self.extraction_root / extraction_id[:2] / f"{extraction_id}.json"
if path.is_symlink() or not path.is_file():
return None
try:
stored = json.loads(path.read_bytes())
if (
not isinstance(stored, dict)
or (owner_id is not None and stored.get("owner_id") != owner_id)
or (owner_id is None and project_id is None)
or (
project_id is not None
and project_id not in stored.get("project_ids", [])
and stored.get("project_id") != project_id
)
):
return None
extraction = ExtractionRecord.model_validate_json(
json.dumps(stored.get("record"), ensure_ascii=False), strict=True
)
except (OSError, ValueError):
return None
if extraction_digest(extraction) != extraction_id:
return None
return extraction
+1
View File
@@ -0,0 +1 @@
"""Composition root."""
+955
View File
@@ -0,0 +1,955 @@
"""FastAPI composition root, health check and authenticated canonical reads."""
import os
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Annotated, cast
import portalocker
from fastapi import Cookie, FastAPI, Header, HTTPException, Query, Request, Response
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import StreamingResponse
from starlette.routing import Mount
from gewu.adapters.embeddings import LocalSentenceTransformer
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.source_extractor import LocalSourceExtractor, SourceExtractor
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
from gewu.adapters.url_fetch import PublicUrlFetcher
from gewu.application.acceptance import AcceptanceService
from gewu.application.accounts import AccountIdentity
from gewu.application.admin import CredentialAdministration
from gewu.application.context import ValidationContext
from gewu.application.diagnostics import build_service_diagnostics
from gewu.application.identity import IdentityProvider, OwnerIdentityProvider, Principal
from gewu.application.paths import reject_symlink_components
from gewu.application.ports import ProjectRegistry
from gewu.application.reading import CanonicalNodeReader, CanonicalSourceReader, ReadError
from gewu.application.registry import (
LocalContextProvider,
LocalProjectRegistry,
RegistryConflict,
RegistryError,
RegistryNotFound,
)
from gewu.application.search import (
CanonicalSearchService,
HybridSearchIndex,
SearchError,
SearchIndex,
)
from gewu.application.uploads import LocalArtifactStore, UploadError
from gewu.domain.contracts import (
GitCommitId,
ProjectRecord,
ReadEnvelope,
RecordId,
ServiceDiagnostics,
)
from gewu.transports.accounts_http import register_account_routes
from gewu.transports.admin_http import register_admin_routes
from gewu.transports.http_middleware import ProjectRegistryBodyLimitMiddleware
from gewu.transports.http_models import (
NeighborRequestBody,
OwnerSessionRequestBody,
ProjectContentUpdateBody,
ProjectCreateBody,
SearchRequestBody,
SourceListRequestBody,
)
from gewu.transports.mcp_server import create_mcp_server
from gewu.transports.owner_mcp_server import OwnerReviewProvider, create_owner_mcp_server
from gewu.version import SERVICE_VERSION
def create_app(
*,
identity_provider: IdentityProvider | None = None,
owner_identity_provider: OwnerIdentityProvider | None = None,
owner_service_audience: str | None = None,
node_reader: CanonicalNodeReader | None = None,
artifact_store: LocalArtifactStore | None = None,
extractor: SourceExtractor | None = None,
acceptance_service: AcceptanceService | None = None,
context_provider: Callable[[], ValidationContext] | None = None,
search_service: CanonicalSearchService | None = None,
project_registry: ProjectRegistry | None = None,
credential_administration: CredentialAdministration | None = None,
account_identity: AccountIdentity | None = None,
url_fetcher: PublicUrlFetcher | None = None,
) -> FastAPI:
if (identity_provider is None) != (node_reader is None):
raise ValueError("Identity provider and canonical reader must be configured together")
app = FastAPI(title="GEWU", version=SERVICE_VERSION)
mcp_allowed_hosts = [
host.strip()
for host in os.environ.get("GEWU_MCP_ALLOWED_HOSTS", "").split(",")
if host.strip()
]
if (
project_registry is not None
or owner_identity_provider is not None
or credential_administration is not None
or account_identity is not None
):
app.add_middleware(ProjectRegistryBodyLimitMiddleware)
@app.get("/health", tags=["health"], operation_id="health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "gewu"}
if identity_provider is not None and node_reader is not None:
def authenticate(
authorization: str | None,
session_cookie: str | None = None,
csrf_token: str | None = None,
*,
require_csrf: bool = False,
) -> Principal:
scheme, _, token = (authorization or "").partition(" ")
if scheme.lower() == "bearer" and token:
principal = identity_provider.authenticate(token)
if principal is None:
raise HTTPException(status_code=401, detail="Credential is invalid or revoked")
return principal
if session_cookie:
principal = identity_provider.authenticate_browser_session(session_cookie)
if principal is None:
raise HTTPException(
status_code=401, detail="Browser session is invalid or expired"
)
if require_csrf and not identity_provider.browser_csrf_valid(
session_cookie, csrf_token or ""
):
raise HTTPException(status_code=403, detail="Browser session CSRF check failed")
return principal
raise HTTPException(
status_code=401, detail="Bearer credential or browser session required"
)
def require_same_origin(request: Request) -> None:
origin = request.headers.get("origin")
expected = f"{request.url.scheme}://{request.headers.get('host', '')}"
if origin is not None and origin.rstrip("/") != expected.rstrip("/"):
raise HTTPException(
status_code=403, detail="Browser session requires a same-origin request"
)
def require_admin(principal: Principal) -> None:
if "admin" not in principal.roles or "agent" in principal.roles:
raise HTTPException(status_code=403, detail="Administrator role is required")
if credential_administration is not None:
register_admin_routes(app, credential_administration, authenticate, require_same_origin)
if account_identity is not None:
register_account_routes(app, account_identity, authenticate, require_same_origin)
if owner_identity_provider is not None:
@app.post(
"/auth/owner-session",
tags=["identity"],
operation_id="owner_session_create",
)
def create_owner_session(
body: OwnerSessionRequestBody,
request: Request,
response: Response,
) -> dict[str, str]:
require_same_origin(request)
response.headers["Cache-Control"] = "no-store"
host = request.url.hostname or ""
if request.url.scheme != "https" and host not in {
"localhost",
"127.0.0.1",
"::1",
}:
raise HTTPException(
status_code=403, detail="Owner authentication requires HTTPS"
)
audience = owner_service_audience
if not audience or not owner_identity_provider.owner_authentication_enabled:
raise HTTPException(status_code=503, detail="Owner authentication is disabled")
owner_session = owner_identity_provider.authenticate_owner(
principal_id=body.principal_id,
password=body.password,
totp_code=body.totp_code,
challenge_id=body.challenge_id,
audience=audience,
)
if owner_session is None:
raise HTTPException(status_code=401, detail="Owner authentication failed")
return {
"session_token": owner_session.session_token,
"challenge_id": owner_session.challenge_id,
"expires_at": owner_session.expires_at.isoformat(),
}
@app.post("/v1/session", tags=["identity"], operation_id="browser_session_create")
def create_browser_session(
request: Request,
response: Response,
authorization: str | None = Header(default=None),
) -> dict[str, str]:
require_same_origin(request)
response.headers["Cache-Control"] = "no-store"
scheme, _, bearer_token = (authorization or "").partition(" ")
if scheme.lower() != "bearer" or not bearer_token:
raise HTTPException(status_code=401, detail="Bearer credential required")
principal = identity_provider.authenticate(bearer_token)
if principal is None:
raise HTTPException(status_code=401, detail="Credential is invalid or revoked")
if "agent" in principal.roles or not principal.roles.intersection(
{"admin", "editor", "viewer"}
):
raise HTTPException(
status_code=403, detail="Browser sessions require a human identity"
)
try:
session = identity_provider.create_browser_session(bearer_token)
except PermissionError as error:
raise HTTPException(status_code=403, detail=str(error)) from error
response.set_cookie(
key="gewu_session",
value=session.session_token,
httponly=True,
secure=request.url.scheme == "https",
samesite="strict",
path="/v1",
)
return {"csrf_token": session.csrf_token, "expires_at": session.expires_at.isoformat()}
@app.get("/v1/session", tags=["identity"], operation_id="browser_session_restore")
def restore_browser_session(
request: Request,
response: Response,
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
restore_header: str | None = Header(default=None, alias="X-GEWU-Session"),
) -> dict[str, str | bool]:
require_same_origin(request)
if restore_header != "restore" or request.headers.get("sec-fetch-site") == "cross-site":
raise HTTPException(403, "Session restoration requires a same-origin client")
principal = authenticate(None, session_cookie)
session = identity_provider.restore_browser_session(session_cookie or "")
if session is None:
raise HTTPException(
401,
"Browser session is invalid or expired",
headers={"Cache-Control": "no-store"},
)
response.headers["Cache-Control"] = "private, no-store"
return {
"csrf_token": session.csrf_token,
"expires_at": session.expires_at.isoformat(),
"password_account": principal.credential_id.startswith("account:"),
}
@app.delete("/v1/session", tags=["identity"], operation_id="browser_session_revoke")
def revoke_browser_session(
request: Request,
response: Response,
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> dict[str, str]:
require_same_origin(request)
response.headers["Cache-Control"] = "no-store"
if session_cookie:
authenticate(None, session_cookie, csrf_token, require_csrf=True)
identity_provider.revoke_browser_session(session_cookie)
response.delete_cookie(
key="gewu_session",
path="/v1",
httponly=True,
secure=request.url.scheme == "https",
samesite="strict",
)
return {"status": "signed_out"}
@app.get(
"/v1/projects/{project_id}/nodes/{node_id}",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="get_node",
)
def get_node(
project_id: RecordId,
node_id: RecordId,
revision: GitCommitId | None = None,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> ReadEnvelope:
principal = authenticate(authorization, session_cookie)
try:
envelope = node_reader.get_node(
project_id, node_id, principal=principal, revision=revision
)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
return envelope
source_reader = CanonicalSourceReader(node_reader.repository)
http_operations = {
"diagnostics",
"browser_session_create",
"browser_session_revoke",
"browser_session_restore",
"get_node",
"node_history",
"get_source",
}
if credential_administration is not None:
http_operations.update(
{
"admin_list_credentials",
"admin_create_credential",
"admin_revoke_credential",
"admin_update_credential",
"admin_list_events",
}
)
if account_identity is not None:
http_operations.update(
{
"account_login",
"account_current",
"account_change_password",
"account_list_agent_credentials",
"account_create_agent_credential",
"account_revoke_agent_credential",
"admin_list_accounts",
"admin_create_account",
"admin_update_account",
"admin_disable_account",
}
)
if project_registry is not None:
http_operations.update(
{
"admin_list_projects",
"project_create",
"project_update_content",
"project_archive",
}
)
if project_registry is not None:
@app.get(
"/v1/admin/projects",
response_model=list[ProjectRecord],
tags=["control"],
operation_id="admin_list_projects",
)
def admin_list_projects(
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> list[ProjectRecord]:
response.headers["Cache-Control"] = "private, no-store"
principal = authenticate(authorization, session_cookie)
require_admin(principal)
if project_registry is None:
raise HTTPException(status_code=503, detail="Project registry is unavailable")
try:
records = list(project_registry.records())
return records
except RegistryError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
@app.post(
"/v1/admin/projects",
response_model=ProjectRecord,
status_code=201,
tags=["control"],
operation_id="project_create",
)
def project_create(
request: Request,
body: ProjectCreateBody,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ProjectRecord:
require_same_origin(request)
if session_cookie is not None and request.headers.get("origin") is None:
raise HTTPException(
status_code=403, detail="Browser session requires an Origin"
)
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
require_admin(principal)
if project_registry is None:
raise HTTPException(status_code=503, detail="Project registry is unavailable")
try:
result = project_registry.create(body.project, body.policy)
except RegistryConflict as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except portalocker.exceptions.LockException as error:
raise HTTPException(
status_code=409, detail="Project registry is busy"
) from error
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
except RegistryError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
response.headers["Cache-Control"] = "no-store"
return result
@app.patch(
"/v1/admin/projects/{project_id}",
response_model=ProjectRecord,
tags=["control"],
operation_id="project_update_content",
)
def project_update_content(
project_id: RecordId,
body: ProjectContentUpdateBody,
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ProjectRecord:
require_same_origin(request)
if session_cookie is not None and request.headers.get("origin") is None:
raise HTTPException(
status_code=403, detail="Browser session requires an Origin"
)
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
require_admin(principal)
if project_registry is None:
raise HTTPException(status_code=503, detail="Project registry is unavailable")
try:
existing = next(
(
item
for item in project_registry.records()
if item.project_id == project_id
),
None,
)
if existing is None:
raise RegistryNotFound("Project is not registered")
result = project_registry.update_content(
project_id,
description=body.description,
north_star=body.north_star,
key_questions=tuple(body.key_questions),
)
except RegistryNotFound as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except portalocker.exceptions.LockException as error:
raise HTTPException(
status_code=409, detail="Project registry is busy"
) from error
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
except RegistryError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
response.headers["Cache-Control"] = "no-store"
return result
@app.post(
"/v1/admin/projects/{project_id}/archive",
response_model=ProjectRecord,
tags=["control"],
operation_id="project_archive",
)
def project_archive(
project_id: RecordId,
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ProjectRecord:
require_same_origin(request)
if session_cookie is not None and request.headers.get("origin") is None:
raise HTTPException(
status_code=403, detail="Browser session requires an Origin"
)
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
require_admin(principal)
if project_registry is None:
raise HTTPException(status_code=503, detail="Project registry is unavailable")
try:
result = project_registry.archive(project_id)
except RegistryNotFound as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except portalocker.exceptions.LockException as error:
raise HTTPException(
status_code=409, detail="Project registry is busy"
) from error
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
except RegistryError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
response.headers["Cache-Control"] = "no-store"
return result
@app.get(
"/v1/diagnostics",
response_model=ServiceDiagnostics,
tags=["diagnostics"],
operation_id="diagnostics",
)
def diagnostics(
response: Response,
projects: Annotated[list[str] | None, Query()] = None,
include_shared: bool = False,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> ServiceDiagnostics:
response.headers["Cache-Control"] = "private, no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
principal = authenticate(authorization, session_cookie)
authorized_projects = (
search_service.list_projects(principal=principal)
if search_service is not None
else tuple(
sorted(
project
for project in principal.project_ids
if principal.can_read_project(project)
)
)
)
index_status = (
search_service.diagnostic_index_status()
if search_service is not None
else (node_reader.repository.head(), None, "not_configured", None)
)
try:
return build_service_diagnostics(
principal=principal,
transport="http",
authorized_projects=tuple(authorized_projects),
available_operations=frozenset(http_operations),
index_status=index_status,
projects=tuple(projects) if projects is not None else None,
include_shared=include_shared,
)
except PermissionError as error:
raise HTTPException(status_code=403, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
@app.get(
"/v1/projects/{project_id}/nodes/{node_id}/history",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="node_history",
)
def get_node_history(
project_id: RecordId,
node_id: RecordId,
limit: int = 20,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> ReadEnvelope:
principal = authenticate(authorization, session_cookie)
try:
return node_reader.get_history(
project_id, node_id, principal=principal, limit=limit
)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
@app.get(
"/v1/projects/{project_id}/sources/{revision_id}",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="get_source",
)
def get_source(
project_id: RecordId,
revision_id: RecordId,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> ReadEnvelope:
principal = authenticate(authorization, session_cookie)
try:
return source_reader.get_source(project_id, revision_id, principal=principal)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
if artifact_store is not None:
http_operations.update({"get_source_artifact", "get_source_location"})
@app.get(
"/v1/projects/{project_id}/sources/{revision_id}/artifact",
tags=["knowledge"],
operation_id="get_source_artifact",
)
def get_source_artifact(
project_id: RecordId,
revision_id: RecordId,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> StreamingResponse:
principal = authenticate(authorization, session_cookie)
try:
source, _ = source_reader.get_source_record(
project_id, revision_id, principal=principal
)
if source.archive_status != "archived" or source.artifact_sha256 is None:
raise HTTPException(
status_code=404, detail="This source revision has no archived artifact"
)
media_type = artifact_store.artifact_media_type_for_project(
source.artifact_sha256, project_id=project_id
)
if media_type != source.media_type:
raise HTTPException(
status_code=503, detail="Archived source metadata is inconsistent"
)
stream = artifact_store.open_artifact(
source.artifact_sha256, project_id=project_id
)
if os.fstat(stream.fileno()).st_size != source.byte_length:
stream.close()
raise HTTPException(
status_code=503, detail="Archived source length does not match metadata"
)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
except UploadError as error:
raise HTTPException(
status_code=404, detail="Archived source is unavailable"
) from error
def chunks() -> Iterator[bytes]:
try:
while block := stream.read(1024 * 1024):
yield block
finally:
stream.close()
return StreamingResponse(
chunks(),
media_type=media_type,
headers={
"Cache-Control": "private, no-store",
"Content-Length": str(source.byte_length),
"ETag": f'"{source.artifact_sha256}"',
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Content-Disposition": (
'attachment; filename="source.pdf"'
if media_type == "application/pdf"
else 'attachment; filename="source.txt"'
),
},
)
@app.get(
"/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}",
tags=["knowledge"],
operation_id="get_source_location",
)
def get_source_location(
project_id: RecordId,
revision_id: RecordId,
extraction_id: str,
block_id: RecordId,
response: Response,
offset: int = Query(ge=0),
length: int = Query(ge=1, le=12_000),
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> dict[str, object]:
response.headers["Cache-Control"] = "private, no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
principal = authenticate(authorization, session_cookie)
try:
source, _ = source_reader.get_source_record(
project_id, revision_id, principal=principal
)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
extraction = artifact_store.get_extraction(extraction_id, project_id=project_id)
if extraction is None or extraction.source_revision_id != source.revision_id:
raise HTTPException(status_code=404, detail="Source location is unavailable")
block = next(
(item for item in extraction.blocks if item.block_id == block_id), None
)
if block is None or offset >= len(block.normalized_text):
raise HTTPException(status_code=404, detail="Source location is unavailable")
end = min(offset + length, len(block.normalized_text))
spans = block.normalization_map[offset:end]
original_start = min(start for start, _ in spans)
original_end = max(stop for _, stop in spans)
return {
"source_revision_id": revision_id,
"extraction_id": extraction.extraction_id,
"parser_name": extraction.parser_name,
"parser_version": extraction.parser_version,
"block_id": block_id,
"offset": offset,
"normalized_text": block.normalized_text[offset:end],
"original_text": block.original_text[original_start:original_end],
"normalization_map": spans,
}
if search_service is not None:
http_operations.update({"list_projects", "search", "neighbors", "list_sources"})
@app.get("/v1/projects", tags=["knowledge"], operation_id="list_projects")
def list_projects(
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
) -> list[str]:
principal = authenticate(authorization, session_cookie)
return list(search_service.list_projects(principal=principal))
@app.post(
"/v1/search",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="search",
)
def search(
request: SearchRequestBody,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ReadEnvelope:
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
try:
return search_service.search(request.to_contract(), principal=principal)
except SearchError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
@app.post(
"/v1/neighbors",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="neighbors",
)
def neighbors(
request: NeighborRequestBody,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ReadEnvelope:
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
try:
return search_service.neighbors(request.to_contract(), principal=principal)
except SearchError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
@app.post(
"/v1/sources",
response_model=ReadEnvelope,
tags=["knowledge"],
operation_id="list_sources",
)
def list_sources(
request: SourceListRequestBody,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> ReadEnvelope:
principal = authenticate(
authorization, session_cookie, csrf_token, require_csrf=True
)
try:
return search_service.list_sources(request.to_contract(), principal=principal)
except SearchError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
mcp_server = create_mcp_server(
identity_provider=identity_provider,
node_reader=node_reader,
source_reader=source_reader,
search_service=search_service,
artifact_store=artifact_store,
extractor=extractor,
acceptance_service=acceptance_service,
context_provider=context_provider,
url_fetcher=url_fetcher or PublicUrlFetcher(frozenset()),
)
mcp_asgi = mcp_server.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
max_request_body_size=1_048_576,
transport_security=TransportSecuritySettings(
allowed_hosts=[
"localhost",
"localhost:*",
"127.0.0.1",
"127.0.0.1:*",
"[::1]",
"[::1]:*",
*mcp_allowed_hosts,
]
),
)
owner_mcp_server = None
owner_mcp_asgi = None
if (
owner_identity_provider is not None
and owner_identity_provider.owner_authentication_enabled
and owner_service_audience
and callable(getattr(identity_provider, "get_owner_challenge", None))
and callable(getattr(identity_provider, "decide_owner_challenge", None))
):
owner_mcp_server = create_owner_mcp_server(
identities=owner_identity_provider,
reviews=cast(OwnerReviewProvider, identity_provider),
audience=owner_service_audience,
)
owner_mcp_asgi = owner_mcp_server.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
max_request_body_size=1_048_576,
transport_security=TransportSecuritySettings(
allowed_hosts=[
"localhost",
"localhost:*",
"127.0.0.1",
"127.0.0.1:*",
"[::1]",
"[::1]:*",
*mcp_allowed_hosts,
]
),
)
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_server.session_manager.run())
if owner_mcp_server is not None:
await stack.enter_async_context(owner_mcp_server.session_manager.run())
async with original_lifespan(application):
yield
app.router.lifespan_context = lifespan
app.router.routes.append(Mount("/mcp", app=mcp_asgi))
if owner_mcp_asgi is not None:
app.router.routes.append(Mount("/owner-mcp", app=owner_mcp_asgi))
return app
def create_local_app() -> FastAPI:
"""Compose the local persistent service from one private data root."""
data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute()
reject_symlink_components(data_root)
data_root.mkdir(parents=True, exist_ok=True, mode=0o700)
if data_root.is_symlink() or not data_root.is_dir():
raise RuntimeError("GEWU data root must be a real directory")
data_root.chmod(0o700)
repository = GitCanonicalRepository(data_root / "knowledge.git")
workflow_root = data_root / "workflow"
authority_lock = workflow_root / "locks" / "canonical-writer.lock"
identities = SQLiteIdentityStore(
data_root / "identity.sqlite3",
authority_lock_path=authority_lock,
owner_encryption_key=os.environ.get("GEWU_OWNER_ENCRYPTION_KEY", "").encode("ascii")
or None,
)
artifacts = LocalArtifactStore(data_root)
allowed_fetch_hosts = {
host.strip()
for host in os.environ.get("GEWU_FETCH_ALLOWED_HOSTS", "").split(",")
if host.strip()
}
url_fetcher = PublicUrlFetcher(allowed_fetch_hosts) if allowed_fetch_hosts else None
registry_file = data_root / "control" / "registry.json"
context_provider = LocalContextProvider(repository, registry_file)
project_registry = LocalProjectRegistry(
registry_file,
repository=repository,
index_directory=data_root / "publishing-index",
authority_lock_path=authority_lock,
)
search_path = data_root / "search.sqlite3"
search_index: SearchIndex
model_path = os.environ.get("GEWU_EMBEDDING_MODEL_PATH")
model_digest = os.environ.get("GEWU_EMBEDDING_MODEL_SHA256")
if model_path or model_digest:
if not model_path or not model_digest:
raise RuntimeError(
"GEWU_EMBEDDING_MODEL_PATH and GEWU_EMBEDDING_MODEL_SHA256 "
"must be configured together"
)
embeddings = LocalSentenceTransformer(
Path(model_path),
model_id=os.environ.get(
"GEWU_EMBEDDING_MODEL_ID",
"intfloat/multilingual-e5-small@614241f622f53c4eeff9890bdc4f31cfecc418b3",
),
expected_sha256=model_digest,
dimensions=int(os.environ.get("GEWU_EMBEDDING_DIMENSIONS", "384")),
)
vectors = SQLiteVectorIndex(search_path)
lexical_index = SQLiteSearchIndex(search_path, repository)
hybrid_index = HybridSearchIndex(
lexical=lexical_index,
vectors=vectors,
embeddings=embeddings,
)
lexical_index.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=hybrid_index.build_vector_generation,
is_current=hybrid_index.vector_generation_is_current,
)
search_index = hybrid_index
else:
search_index = SQLiteSearchIndex(search_path, repository)
search_service = CanonicalSearchService(
index=search_index,
project_ids_provider=project_registry,
canonical_head=repository.head,
default_includes_provider=project_registry.default_includes,
)
acceptance = AcceptanceService(
repository=repository,
artifact_store=artifacts,
workflow_root=workflow_root,
context_provider=context_provider,
actor_is_current=lambda actor: identities.is_current(
actor.principal_id,
actor.credential_id,
roles=actor.roles,
project_ids=actor.project_ids,
),
owner_challenge_issuer=identities.issue_owner_challenge,
owner_decision_lookup=identities.authorized_owner_challenges,
)
return create_app(
identity_provider=identities,
credential_administration=identities,
account_identity=identities,
owner_identity_provider=identities,
owner_service_audience=os.environ.get("GEWU_OWNER_SERVICE_AUDIENCE"),
node_reader=CanonicalNodeReader(repository),
artifact_store=artifacts,
extractor=LocalSourceExtractor(),
acceptance_service=acceptance,
context_provider=context_provider,
search_service=search_service,
project_registry=project_registry,
url_fetcher=url_fetcher,
)
+61
View File
@@ -0,0 +1,61 @@
"""Restricted interactive owner-account provisioning command."""
from __future__ import annotations
import argparse
import getpass
import os
import sys
from pathlib import Path
import pyotp
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("principal_id")
parser.add_argument(
"--project", action="append", dest="project_ids", required=True, metavar="PROJECT_ID"
)
arguments = parser.parse_args()
if not sys.stdin.isatty() or not sys.stdout.isatty():
parser.error("owner provisioning requires an interactive terminal")
data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute()
encryption_key = os.environ.get("GEWU_OWNER_ENCRYPTION_KEY")
if encryption_key is None:
parser.error("GEWU_OWNER_ENCRYPTION_KEY must be configured outside the database")
password = getpass.getpass("Owner password: ")
confirmation = getpass.getpass("Confirm owner password: ")
if password != confirmation:
parser.error("owner passwords did not match")
secret = pyotp.random_base32()
store = SQLiteIdentityStore(
data_root / "identity.sqlite3",
authority_lock_path=data_root / "workflow/locks/canonical-writer.lock",
owner_encryption_key=encryption_key.encode("ascii"),
)
try:
store.provision_owner(
principal_id=arguments.principal_id,
password=password,
totp_secret=secret,
project_ids=tuple(arguments.project_ids),
)
except (ValueError, RuntimeError) as error:
parser.error(str(error))
finally:
password = ""
confirmation = ""
enrollment_uri = pyotp.TOTP(secret).provisioning_uri(
name=arguments.principal_id, issuer_name="GEWU"
)
print("Add this one-time enrollment URI to the owner authenticator:")
print(enrollment_uri)
print("The URI contains the TOTP seed. Store it securely; it will not be shown again.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Transport-independent GEWU contracts."""
+894
View File
@@ -0,0 +1,894 @@
"""Versioned public records shared by GEWU transports and adapters."""
from __future__ import annotations
import re
import unicodedata
from datetime import date, datetime
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
FiniteFloat,
StringConstraints,
field_validator,
model_validator,
)
SCHEMA_VERSION = 1
RecordId = Annotated[
str,
StringConstraints(
min_length=1,
max_length=128,
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
),
]
SearchFacetValue = Annotated[
str,
StringConstraints(min_length=1, max_length=100),
]
Sha256 = Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")]
GitCommitId = Annotated[str, StringConstraints(pattern=r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")]
class StrictRecord(BaseModel):
"""Base for public records: reject implicit coercion and unknown fields."""
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
schema_version: Literal[1] = 1
class ProjectStatus(StrEnum):
ACTIVE = "active"
PAUSED = "paused"
ARCHIVED = "archived"
class ProjectRecord(StrictRecord):
project_id: RecordId
title: str = Field(min_length=1, max_length=200)
status: ProjectStatus
description: str = Field(default="", max_length=4000)
owners: tuple[RecordId, ...] = ()
north_star: str = Field(min_length=1, max_length=4000)
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
default_includes: tuple[RecordId, ...] = ("shared",)
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
autonomy_policy_id: RecordId
@field_validator("project_id")
@classmethod
def project_id_is_slug(cls, value: str) -> str:
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value):
raise ValueError("Project IDs must be lowercase hyphen-separated slugs")
return value
class ProjectLens(StrictRecord):
project_id: RecordId
title: str = Field(min_length=1, max_length=200)
status: ProjectStatus
north_star: str = Field(min_length=1, max_length=4000)
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
class ProjectContextResponse(StrictRecord):
lens: ProjectLens
class ReliabilityGrade(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class AutoAcceptPolicy(StrictRecord):
policy_id: RecordId
policy_version: int = Field(ge=1)
project_id: RecordId
automatic_node_types: tuple[
Literal[
"finding",
"problem",
"technique",
"case",
"entity",
"event",
"concept",
"datapoint",
"synthesis",
],
...,
] = ()
automatic_edge_types: tuple[
Literal[
"solves",
"addresses",
"causes",
"supports",
"contradicts",
"instance_of",
"part_of",
"precedes",
"supersedes",
"compared_with",
"derived_from",
"applies_to",
],
...,
] = ()
automatic_source_registration: bool = False
require_supported_assessment: bool = True
require_owner_decision_for: tuple[
Literal["delete", "merge", "promote", "intent", "policy"], ...
] = (
"delete",
"merge",
"promote",
"intent",
"policy",
)
class SourceRevisionRecord(StrictRecord):
source_id: RecordId
revision_id: RecordId
artifact_sha256: Sha256 | None = None
media_type: str = Field(min_length=1, max_length=128)
byte_length: int = Field(ge=0)
captured_at: datetime
capture_method: Literal[
"unspecified", "local-file", "url-fetch", "pasted-text", "notebooklm-export", "transcript"
] = "unspecified"
original_url: str | None = Field(default=None, max_length=4000)
final_url: str | None = Field(default=None, max_length=4000)
title: str = Field(min_length=1, max_length=500)
authors: tuple[str, ...] = Field(default=(), max_length=50)
publisher: str | None = Field(default=None, max_length=300)
publication_date: datetime | None = None
license: str = Field(min_length=1, max_length=500)
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
]
reliability_grade: ReliabilityGrade
ingested_for: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
archive_status: Literal["archived", "reference-only", "capture-failed"]
@field_validator("captured_at", "publication_date")
@classmethod
def timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
if value is not None and (offset is None or offset.total_seconds() != 0):
raise ValueError("timestamps must be timezone-aware UTC")
return value
@model_validator(mode="after")
def archive_metadata_matches_artifact(self) -> SourceRevisionRecord:
if self.archive_status == "archived":
if self.artifact_sha256 is None or self.byte_length < 1:
raise ValueError("archived sources require a non-empty archived artifact")
elif self.artifact_sha256 is not None or self.byte_length != 0:
raise ValueError("unarchived sources cannot claim artifact bytes or a digest")
if self.archive_status != "archived" and not self.original_url:
raise ValueError("unarchived source records require an original URL")
return self
class ExtractionBlock(StrictRecord):
block_id: RecordId
ordinal: int = Field(ge=0)
original_text: str = Field(max_length=100_000)
normalized_text: str = Field(max_length=100_000)
normalization_map: tuple[tuple[int, int], ...] = Field(max_length=100_000)
page: int | None = Field(default=None, ge=1)
time_start_ms: int | None = Field(default=None, ge=0)
time_end_ms: int | None = Field(default=None, ge=0)
@model_validator(mode="after")
def validate_normalization_map(self) -> ExtractionBlock:
if len(self.normalization_map) != len(self.normalized_text):
raise ValueError("normalization map must cover every normalized code point")
if (self.time_start_ms is None) != (self.time_end_ms is None):
raise ValueError("transcript cue start and end times must be provided together")
if self.time_start_ms is not None and self.time_end_ms is not None:
if self.time_end_ms <= self.time_start_ms:
raise ValueError("transcript cue end must follow its start")
previous_start = -1
for start, end in self.normalization_map:
if start < previous_start or end <= start or end > len(self.original_text):
raise ValueError("normalization map contains an invalid original span")
previous_start = start
return self
class ExtractionRecord(StrictRecord):
extraction_id: Sha256
source_revision_id: RecordId
parser_name: str = Field(min_length=1, max_length=100)
parser_version: str = Field(min_length=1, max_length=100)
parser_config_sha256: Sha256
normalization_profile: Literal["unicode-nfc-lf-v1"]
status: Literal["complete", "partial", "unavailable"]
blocks: tuple[ExtractionBlock, ...] = Field(max_length=100_000)
warnings: tuple[str, ...] = ()
class Claim(StrictRecord):
claim_id: RecordId
text: str = Field(min_length=1, max_length=4000)
evidence_type: Literal["measured", "reported", "opinion", "vendor_marketing"]
evidence: tuple[EvidenceReference, ...] = Field(min_length=1, max_length=20)
assessment: SupportAssessment
@model_validator(mode="after")
def assessment_matches_evidence(self) -> Claim:
expected_digests = {item.assertion_digest for item in self.evidence}
if expected_digests != {self.assessment.assertion_digest}:
raise ValueError("support assessment must bind the claim's exact assertion digest")
return self
class NodeType(StrEnum):
FINDING = "finding"
PROBLEM = "problem"
TECHNIQUE = "technique"
CASE = "case"
ENTITY = "entity"
EVENT = "event"
CONCEPT = "concept"
DATAPOINT = "datapoint"
SYNTHESIS = "synthesis"
class ReviewState(StrEnum):
DRAFT = "draft"
REVIEWED = "reviewed"
class Lifecycle(StrEnum):
ACTIVE = "active"
SUPERSEDED = "superseded"
class Confidence(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class LensFit(StrictRecord):
score: FiniteFloat = Field(ge=0, le=1)
horizon: Literal["H1", "H2", "H3"]
key_question: str | None = Field(default=None, max_length=1000)
reason: str = Field(min_length=1, max_length=1000)
class KnowledgeNode(StrictRecord):
node_id: RecordId
project_id: RecordId
node_type: NodeType
title: str = Field(min_length=1, max_length=300)
summary: str = Field(min_length=1, max_length=8000)
created: datetime
updated: datetime
review_state: ReviewState
lifecycle: Lifecycle
confidence: Confidence
claim_support: Literal["pending", "supported", "unsupported"]
assumptions: tuple[Claim, ...] = Field(default=(), max_length=100)
limitations: tuple[Claim, ...] = Field(default=(), max_length=100)
tags: tuple[str, ...] = Field(default=(), max_length=100)
audience: tuple[str, ...] = Field(default=(), max_length=20)
domain: tuple[str, ...] = Field(default=(), max_length=20)
use_for: tuple[str, ...] = Field(default=(), max_length=20)
lens_fit: dict[RecordId, LensFit] = Field(default_factory=dict, max_length=50)
run_id: RecordId
agent_id: RecordId
model_id: str = Field(min_length=1, max_length=200)
claims: tuple[Claim, ...] = Field(min_length=1, max_length=100)
as_of: datetime | None = None
stale_after: datetime | None = None
supersedes: tuple[RecordId, ...] = ()
superseded_by: tuple[RecordId, ...] = ()
aliases: tuple[str, ...] = Field(default=(), max_length=100)
merged_from: tuple[RecordId, ...] = Field(default=(), max_length=100)
value: FiniteFloat | None = None
unit: str | None = Field(default=None, max_length=100)
sample_size: int | None = Field(default=None, ge=0)
reporting_year: int | None = Field(default=None, ge=1, le=9999)
demographic: str | None = Field(default=None, max_length=1000)
@property
def evidence_claims(self) -> tuple[Claim, ...]:
"""All factual statements that carry citation and assessment requirements."""
return (*self.claims, *self.assumptions, *self.limitations)
@field_validator("aliases")
@classmethod
def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]:
return tuple(" ".join(unicodedata.normalize("NFC", value).split()) for value in values)
@model_validator(mode="after")
def datapoint_has_freshness(self) -> KnowledgeNode:
claim_ids = [claim.claim_id for claim in self.evidence_claims]
if len(claim_ids) != len(set(claim_ids)):
raise ValueError("claim IDs must be unique across claims, assumptions and limitations")
normalized_aliases = [" ".join(alias.split()).casefold() for alias in self.aliases]
if any(not alias for alias in normalized_aliases):
raise ValueError("aliases cannot be empty")
if len(normalized_aliases) != len(set(normalized_aliases)):
raise ValueError("aliases must be unique ignoring case and whitespace")
normalized_title = " ".join(self.title.split()).casefold()
if normalized_title in normalized_aliases:
raise ValueError("aliases cannot repeat the node title")
if self.node_type == NodeType.DATAPOINT and (
self.as_of is None
or self.stale_after is None
or self.value is None
or self.unit is None
):
raise ValueError("datapoints require as_of, stale_after, value and unit")
if self.as_of and self.stale_after and self.stale_after <= self.as_of:
raise ValueError("stale_after must be later than as_of")
if self.updated < self.created:
raise ValueError("updated must not precede created")
if self.claim_support != "supported":
raise ValueError("accepted factual nodes must carry supported claim status")
if self.lifecycle == Lifecycle.SUPERSEDED and not self.superseded_by:
raise ValueError("superseded nodes require a superseded_by target")
return self
@field_validator("created", "updated", "as_of", "stale_after")
@classmethod
def node_timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
if value is not None and (offset is None or offset.total_seconds() != 0):
raise ValueError("timestamps must be timezone-aware UTC")
return value
class EdgeEndpoint(StrictRecord):
kind: Literal["node", "source", "project"]
id: RecordId
class TypedEdge(StrictRecord):
edge_id: RecordId
edge_type: Literal[
"solves",
"addresses",
"causes",
"supports",
"contradicts",
"instance_of",
"part_of",
"precedes",
"supersedes",
"compared_with",
"derived_from",
"applies_to",
]
source: EdgeEndpoint
target: EdgeEndpoint
conditions: str | None = Field(default=None, max_length=2000)
evidence: tuple[EvidenceReference, ...] = ()
interpretation_rationale: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def substantive_edge_has_basis(self) -> TypedEdge:
if self.edge_type == "derived_from" and (
self.source.kind != "node" or self.target.kind != "source"
):
raise ValueError("derived_from connects a knowledge node to a source revision")
if self.edge_type == "applies_to" and (
self.source.kind != "node" or self.target.kind != "project"
):
raise ValueError("applies_to connects a knowledge node to a project")
if self.edge_type not in {"derived_from", "applies_to"} and (
self.source.kind != "node" or self.target.kind != "node"
):
raise ValueError("this edge type connects two knowledge nodes")
if self.edge_type in {"solves", "addresses", "causes", "supports", "contradicts"}:
if not self.evidence and not self.interpretation_rationale:
raise ValueError(
"substantive edges require evidence or labeled interpretive rationale"
)
return self
class ProposalOperation(StrictRecord):
operation: Literal["register_source", "upsert_node", "add_edge", "flag_stale"]
source: SourceRevisionRecord | None = None
node: KnowledgeNode | None = None
edge: TypedEdge | None = None
target_id: RecordId | None = None
@model_validator(mode="after")
def payload_matches_operation(self) -> ProposalOperation:
payloads = {
"register_source": self.source,
"upsert_node": self.node,
"add_edge": self.edge,
}
expected = payloads.get(self.operation)
supplied = sum(value is not None for value in (self.source, self.node, self.edge))
if self.operation == "flag_stale":
if supplied or self.target_id is None:
raise ValueError(
"flag_stale uses an explicit target elsewhere, not a record payload"
)
elif supplied != 1 or expected is None:
raise ValueError("operation must carry exactly its matching typed payload")
elif self.target_id is not None:
raise ValueError("target_id is only valid for flag_stale")
return self
class ProposalRevision(StrictRecord):
proposal_id: RecordId
revision: int = Field(ge=1)
initiating_project_id: RecordId
scope: Scope
base_commit: GitCommitId
payload_digest: Sha256
proposer_id: RecordId
run_id: RecordId
operations: tuple[ProposalOperation, ...] = Field(min_length=1, max_length=100)
class Scope(StrictRecord):
"""Project context plus optional project includes; all-project access is deliberate."""
projects: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
include_projects: tuple[RecordId, ...] | None = Field(default=None, max_length=50)
include_shared: bool = True
all_projects: bool = False
@model_validator(mode="after")
def validate_all_projects(self) -> Scope:
if self.all_projects and self.projects != ("all",):
raise ValueError("all_projects requires projects=['all']")
if not self.all_projects and "all" in self.projects:
raise ValueError("projects=['all'] requires all_projects=true")
if len(set(self.projects)) != len(self.projects):
raise ValueError("scope projects must be unique")
if self.include_projects is not None:
if len(set(self.include_projects)) != len(self.include_projects):
raise ValueError("scope includes must be unique")
if set(self.projects) & set(self.include_projects):
raise ValueError("scope includes cannot repeat project context")
if "shared" in self.include_projects:
raise ValueError("shared scope must use include_shared")
if self.all_projects and self.include_projects is not None:
raise ValueError("all_projects cannot be combined with project includes")
return self
class SearchRequest(StrictRecord):
query: str = Field(default="", max_length=1000)
scope: Scope
node_types: tuple[NodeType, ...] = Field(default=(), max_length=9)
tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
confidences: tuple[Confidence, ...] = Field(default=(), max_length=3)
as_of_from: date | None = None
as_of_to: date | None = None
stale_only: bool = False
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
@field_validator("query")
@classmethod
def normalize_query(cls, value: str) -> str:
return value.strip()
@model_validator(mode="after")
def require_query_or_type_filter(self) -> SearchRequest:
if not self.query and not self.node_types:
raise ValueError("search requires text or at least one knowledge type")
if self.as_of_from is not None and self.as_of_to is not None:
if self.as_of_from > self.as_of_to:
raise ValueError("as_of_from must not be later than as_of_to")
return self
@field_validator("node_types")
@classmethod
def search_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]:
if len(value) != len(set(value)):
raise ValueError("search node types must be unique")
return value
@field_validator("confidences")
@classmethod
def search_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]:
if len(value) != len(set(value)):
raise ValueError("search confidences must be unique")
return value
@field_validator("tags", "audience", "use_for", "domain")
@classmethod
def search_facet_values_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if any(item != item.strip() for item in value):
raise ValueError("search facet values cannot have leading or trailing whitespace")
if len(value) != len(set(value)):
raise ValueError("search facet values must be unique")
return value
class NeighborRequest(StrictRecord):
node_id: RecordId
scope: Scope
edge_types: tuple[
Literal[
"solves",
"addresses",
"causes",
"supports",
"contradicts",
"instance_of",
"part_of",
"precedes",
"supersedes",
"compared_with",
"derived_from",
"applies_to",
],
...,
] = Field(default=(), max_length=12)
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
class SourceListRequest(StrictRecord):
scope: Scope
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
class Locator(StrictRecord):
kind: Literal["text_span", "pdf_page_span", "transcript_timestamp_span"]
block_id: RecordId
start: int = Field(ge=0)
end: int = Field(gt=0)
page: int | None = Field(default=None, ge=1)
time_start_ms: int | None = Field(default=None, ge=0)
time_end_ms: int | None = Field(default=None, ge=0)
@model_validator(mode="after")
def validate_span(self) -> Locator:
if self.end <= self.start:
raise ValueError("locator end must be greater than start")
if self.kind == "pdf_page_span" and self.page is None:
raise ValueError("PDF locators require a one-based page")
if self.kind != "pdf_page_span" and self.page is not None:
raise ValueError("only PDF locators can specify a page")
if self.kind == "transcript_timestamp_span":
if self.time_start_ms is None or self.time_end_ms is None:
raise ValueError("transcript locators require cue start and end times")
if self.time_end_ms <= self.time_start_ms:
raise ValueError("transcript locator end must follow its start")
elif self.time_start_ms is not None or self.time_end_ms is not None:
raise ValueError("only transcript locators can specify cue times")
return self
class EvidenceReference(StrictRecord):
source_revision_id: RecordId
extraction_id: Sha256
assertion_path: str = Field(min_length=1, max_length=256)
assertion_digest: Sha256
quote: str = Field(min_length=1, max_length=8000)
locator: Locator
normalization_profile: Literal["unicode-nfc-lf-v1"]
class SupportStatus(StrEnum):
SUPPORTED = "supported"
UNSUPPORTED = "unsupported"
UNAVAILABLE = "unavailable"
class SupportAssessment(StrictRecord):
assessment_id: RecordId
assertion_digest: Sha256
evidence_digests: tuple[Sha256, ...] = Field(min_length=1, max_length=20)
assessor_id: RecordId
run_id: RecordId
method: str = Field(min_length=1, max_length=100)
status: SupportStatus
rationale: str = Field(min_length=1, max_length=4000)
class ProposalState(StrEnum):
STAGED = "staged"
VALIDATING = "validating"
NEEDS_REVIEW = "needs-review"
ACCEPTED = "accepted"
REJECTED = "rejected"
WITHDRAWN = "withdrawn"
class Finding(StrictRecord):
code: RecordId
message: str = Field(min_length=1, max_length=1000)
path: str | None = Field(default=None, max_length=256)
class ProposalReceipt(StrictRecord):
proposal_id: RecordId
revision: int = Field(ge=1)
payload_digest: Sha256
state: ProposalState
outcome: Literal["accepted", "held", "rejected", "validation-failed"]
commit_id: GitCommitId | None = None
accepted_node_ids: tuple[RecordId, ...] = ()
findings: tuple[Finding, ...] = ()
decided_at: datetime | None = None
class ProposalStatus(StrictRecord):
proposal_id: RecordId
revision: int = Field(ge=1)
lifecycle: Literal[
"staged",
"evaluated",
"publishing",
"published-unreconciled",
"accepted",
"withdrawn",
]
validation_status: Literal["eligible", "needs-review", "invalid", "conflict"] | None = None
eligible_for_auto_accept: bool | None = None
findings: tuple[Finding, ...] = Field(default=(), max_length=200)
receipt: ProposalReceipt | None = None
canonical_revision: GitCommitId | None = None
class RunModulePin(StrictRecord):
module_id: RecordId
implementation_version: str = Field(min_length=1, max_length=64)
sha256: Sha256
class RunCoverageItem(StrictRecord):
item: str = Field(min_length=1, max_length=500)
reason: str = Field(min_length=1, max_length=1000)
class RunCoverage(StrictRecord):
selected: tuple[str, ...] = Field(default=(), max_length=10_000)
included: tuple[str, ...] = Field(default=(), max_length=10_000)
excluded: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
skipped: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
class RunSourceReference(StrictRecord):
source_revision_id: RecordId
artifact_sha256: Sha256 | None = None
extraction_id: Sha256 | None = None
status: Literal["selected", "captured", "extracted", "unavailable", "failed"]
class RunOperationReference(StrictRecord):
operation_id: RecordId
tool_name: RecordId
input_sha256: Sha256
status: Literal["pending", "uncertain", "complete", "failed"]
class RunProposalReference(StrictRecord):
proposal_id: RecordId
revision: int = Field(ge=1)
status: Literal[
"staged",
"evaluated",
"eligible",
"needs-review",
"invalid",
"conflict",
"publishing",
"accepted",
"rejected",
"withdrawn",
"uncertain",
]
receipt: ProposalReceipt | None = None
@model_validator(mode="after")
def receipt_matches_proposal(self) -> RunProposalReference:
if self.status == "accepted":
if (
self.receipt is None
or self.receipt.proposal_id != self.proposal_id
or self.receipt.revision != self.revision
or self.receipt.state != ProposalState.ACCEPTED
):
raise ValueError("accepted run proposal requires its matching service receipt")
return self
class RunState(StrEnum):
ACTIVE = "active"
COMPLETED = "completed"
BLOCKED = "blocked"
class RunCreateRequest(StrictRecord):
run_id: RecordId
objective: str = Field(min_length=1, max_length=4000)
scope: Scope
lens: str = Field(min_length=1, max_length=2000)
pack_id: RecordId
pack_version: str = Field(min_length=1, max_length=64)
pack_snapshot_digest: Sha256
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def explicit_scope_and_unique_modules(self) -> RunCreateRequest:
if self.scope.all_projects:
raise ValueError("runs require explicit project scope")
module_ids = tuple(module.module_id for module in self.module_pins)
if len(set(module_ids)) != len(module_ids):
raise ValueError("run module pins must have unique IDs")
if not self.objective.strip() or not self.lens.strip():
raise ValueError("run objective and lens must not be blank")
return self
class RunCheckpointRequest(StrictRecord):
run_id: RecordId
expected_revision: int = Field(ge=0, le=2**31 - 2)
state: RunState = RunState.ACTIVE
stage: RecordId
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
coverage: RunCoverage = Field(default_factory=RunCoverage)
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
default_factory=dict, max_length=100
)
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
class RunRecord(StrictRecord):
run_id: RecordId
revision: int = Field(ge=0)
state: RunState
objective: str = Field(min_length=1, max_length=4000)
scope: Scope
lens: str = Field(min_length=1, max_length=2000)
pack_id: RecordId
pack_version: str = Field(min_length=1, max_length=64)
pack_snapshot_digest: Sha256
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
stage: RecordId
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
coverage: RunCoverage = Field(default_factory=RunCoverage)
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
default_factory=dict, max_length=100
)
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
created_at: datetime
updated_at: datetime
checkpoint_digest: Sha256 | None = None
class ChangedFile(StrictRecord):
path: str = Field(min_length=1, max_length=500)
content_sha256: Sha256
class PolicyBinding(StrictRecord):
project_id: RecordId
policy_id: RecordId
policy_version: int = Field(ge=1)
class OwnerDecisionReference(StrictRecord):
challenge_id: RecordId
owner_principal_id: RecordId
owner_session_id: Sha256
decision_digest: Sha256
domains: tuple[RecordId, ...] = Field(min_length=1, max_length=100)
grant_version: int = Field(ge=1)
decision: Literal["authorize"] = "authorize"
rationale: str = Field(min_length=1, max_length=2000)
decided_at: datetime
class AcceptanceManifest(StrictRecord):
proposal_id: RecordId
proposal_revision: int = Field(ge=1)
payload_digest: Sha256
before_revision: GitCommitId
changed_files: tuple[ChangedFile, ...] = Field(min_length=1, max_length=200)
accepted_node_ids: tuple[RecordId, ...] = ()
actor_id: RecordId
credential_id: RecordId
policy_bindings: tuple[PolicyBinding, ...]
owner_decisions: tuple[OwnerDecisionReference, ...] = Field(default=(), max_length=100)
decided_at: datetime
class ErrorCode(StrEnum):
SCOPE_REQUIRED = "SCOPE_REQUIRED"
OUT_OF_SCOPE = "OUT_OF_SCOPE"
UNAUTHORIZED = "UNAUTHORIZED"
OWNER_DECISION_REQUIRED = "OWNER_DECISION_REQUIRED"
POLICY_MISSING = "POLICY_MISSING"
EVIDENCE_MISMATCH = "EVIDENCE_MISMATCH"
SUPPORT_UNAVAILABLE = "SUPPORT_UNAVAILABLE"
BASE_CONFLICT = "BASE_CONFLICT"
IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
BUDGET_TOO_SMALL = "BUDGET_TOO_SMALL"
UPLOAD_EXPIRED = "UPLOAD_EXPIRED"
BUSY = "BUSY"
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
class DomainError(StrictRecord):
code: ErrorCode
message: str = Field(min_length=1, max_length=1000)
findings: tuple[Finding, ...] = ()
retryable: bool
request_id: RecordId
class BudgetProfile(StrictRecord):
tokenizer: str = Field(min_length=1, max_length=100)
encoding: str = Field(min_length=1, max_length=100)
class ReadEnvelope(StrictRecord):
api_version: Literal["v1"] = "v1"
request_id: RecordId
resolved_scope: Scope
data: tuple[dict[str, object], ...]
citations: tuple[EvidenceReference, ...] = ()
warnings: tuple[Finding, ...] = ()
omissions: tuple[Finding, ...] = ()
canonical_revision: GitCommitId | None
index_revision: GitCommitId | None
retrieval_mode: Literal["keyword", "hybrid", "canonical"]
budget_profile: BudgetProfile
tokens_used: int = Field(ge=0)
next_cursor: str | None = None
class ServiceDiagnostics(StrictRecord):
service_version: str = Field(min_length=1, max_length=64)
api_version: Literal["v1"] = "v1"
catalog_sha256: Sha256
transport: Literal["http", "mcp"]
authentication: Literal["bearer", "browser_session"]
roles: tuple[RecordId, ...] = ()
authorized_projects: tuple[RecordId, ...] = ()
selected_scope: Scope | None = None
capabilities: tuple[RecordId, ...] = ()
authorized_operations: tuple[RecordId, ...] = ()
connection_status: Literal["authenticated"] = "authenticated"
workflow_status: Literal["not_verified"] = "not_verified"
max_upload_bytes: int = Field(ge=1)
upload_chunk_bytes: int = Field(ge=1)
supported_media_types: tuple[str, ...] = Field(default=(), max_length=20)
canonical_revision: GitCommitId
index_state: Literal["current", "lagging", "not_built", "not_configured", "unavailable"]
index_revision: GitCommitId | None = None
index_schema_version: int | None = Field(default=None, ge=1)
+112
View File
@@ -0,0 +1,112 @@
"""Allowlisted operation catalog shared by transport adapters."""
import hashlib
import json
from dataclasses import dataclass
from enum import StrEnum
from typing import Final
class Transport(StrEnum):
HTTP = "http"
MCP = "mcp"
OWNER_MCP = "owner_mcp"
STDIO = "stdio"
@dataclass(frozen=True, slots=True)
class Operation:
name: str
capability: str
transports: frozenset[Transport]
mutates_knowledge: bool = False
OPERATIONS: Final[tuple[Operation, ...]] = (
Operation("health", "health:read", frozenset({Transport.HTTP})),
Operation("diagnostics", "service:diagnostics", frozenset({Transport.HTTP, Transport.MCP})),
Operation("browser_session_create", "identity:session", frozenset({Transport.HTTP})),
Operation("browser_session_restore", "identity:session", frozenset({Transport.HTTP})),
Operation("browser_session_revoke", "identity:session", frozenset({Transport.HTTP})),
Operation("account_login", "identity:session", frozenset({Transport.HTTP})),
Operation("account_current", "identity:session", frozenset({Transport.HTTP})),
Operation("account_change_password", "identity:session", frozenset({Transport.HTTP})),
Operation("account_list_agent_credentials", "identity:session", frozenset({Transport.HTTP})),
Operation("account_create_agent_credential", "identity:session", frozenset({Transport.HTTP})),
Operation("account_revoke_agent_credential", "identity:session", frozenset({Transport.HTTP})),
Operation("admin_list_accounts", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_create_account", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_update_account", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_disable_account", "credential:admin", frozenset({Transport.HTTP})),
Operation("owner_session_create", "owner:authenticate", frozenset({Transport.HTTP})),
Operation("admin_list_credentials", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_create_credential", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_update_credential", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_revoke_credential", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_list_events", "credential:admin", frozenset({Transport.HTTP})),
Operation("admin_list_projects", "project:admin", frozenset({Transport.HTTP})),
Operation("project_create", "project:admin", frozenset({Transport.HTTP})),
Operation("project_update_content", "project:admin", frozenset({Transport.HTTP})),
Operation("project_archive", "project:admin", frozenset({Transport.HTTP})),
Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_project_context", "knowledge:read", frozenset({Transport.MCP})),
Operation("search", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("list_sources", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_node", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("node_history", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_source", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_source_artifact", "knowledge:read", frozenset({Transport.HTTP})),
Operation("get_source_location", "knowledge:read", frozenset({Transport.HTTP})),
Operation("neighbors", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("source_capture_url", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_capture_text", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_upload_begin", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_upload_status", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_upload_chunk", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_upload_finalize", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_extract", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_extraction_block", "proposal:submit", frozenset({Transport.MCP})),
Operation("proposal_submit", "proposal:submit", frozenset({Transport.MCP}), True),
Operation("proposal_evaluate", "proposal:evaluate", frozenset({Transport.MCP})),
Operation("proposal_status", "proposal:read", frozenset({Transport.MCP})),
Operation("proposal_withdraw", "proposal:withdraw", frozenset({Transport.MCP})),
Operation("run_create", "run:write", frozenset({Transport.MCP})),
Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})),
Operation("run_status", "run:read", frozenset({Transport.MCP})),
Operation("owner_challenge_get", "owner:review", frozenset({Transport.OWNER_MCP})),
Operation("owner_diff_get", "owner:review", frozenset({Transport.OWNER_MCP})),
Operation("owner_decide", "owner:decide", frozenset({Transport.OWNER_MCP})),
Operation("list_selected_files", "local:file:read", frozenset({Transport.STDIO})),
Operation("upload_selected_file", "proposal:submit", frozenset({Transport.STDIO})),
)
def operations_for(transport: Transport) -> tuple[Operation, ...]:
"""Return the explicit allowlist for a transport."""
return tuple(operation for operation in OPERATIONS if transport in operation.transports)
def catalog_bytes() -> bytes:
"""Return the canonical generated catalog bytes used by contract snapshots."""
payload = {
"api_version": "v1",
"schema_version": 1,
"operations": [
{
"name": operation.name,
"capability": operation.capability,
"transports": sorted(transport.value for transport in operation.transports),
"mutates_knowledge": operation.mutates_knowledge,
}
for operation in OPERATIONS
],
}
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
def catalog_digest() -> str:
"""Hash the same bytes written by the schema exporter."""
return hashlib.sha256(catalog_bytes()).hexdigest()
+695
View File
@@ -0,0 +1,695 @@
"""Deterministic proposal and evidence validation rules."""
from __future__ import annotations
import hashlib
import json
import unicodedata
from collections.abc import Iterable
from dataclasses import dataclass
from gewu.domain.contracts import (
AutoAcceptPolicy,
ErrorCode,
EvidenceReference,
ExtractionBlock,
ExtractionRecord,
Finding,
KnowledgeNode,
Locator,
ProjectRecord,
ProjectStatus,
ProposalRevision,
SourceRevisionRecord,
SupportStatus,
TypedEdge,
)
def canonical_json(value: object) -> bytes:
"""Serialize contract data deterministically for digests and replay checks."""
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def assertion_digest(text: str) -> str:
normalized = unicodedata.normalize("NFC", text.replace("\r\n", "\n").replace("\r", "\n"))
return hashlib.sha256(canonical_json({"assertion": normalized})).hexdigest()
def evidence_digest(evidence: EvidenceReference) -> str:
"""Hash a typed evidence reference using its public JSON representation."""
return hashlib.sha256(canonical_json(evidence.model_dump(mode="json"))).hexdigest()
def extraction_digest(extraction: ExtractionRecord) -> str:
value = extraction.model_dump(mode="json", exclude={"extraction_id"})
return hashlib.sha256(canonical_json(value)).hexdigest()
def proposal_payload_digest(proposal: ProposalRevision) -> str:
operations = [item.model_dump(mode="json") for item in proposal.operations]
return hashlib.sha256(canonical_json(operations)).hexdigest()
def edge_assertion_digest(edge: TypedEdge) -> str:
"""Bind edge evidence to the relationship, conditions and rationale."""
value = edge.model_dump(mode="json", exclude={"edge_id", "evidence"})
return hashlib.sha256(canonical_json(value)).hexdigest()
def _locator_matches_block(locator: Locator, block: ExtractionBlock) -> bool:
if locator.kind == "pdf_page_span":
return (
locator.page == block.page and block.time_start_ms is None and block.time_end_ms is None
)
if locator.kind == "transcript_timestamp_span":
return (
locator.page is None
and locator.time_start_ms == block.time_start_ms
and locator.time_end_ms == block.time_end_ms
)
return (
locator.page is None
and locator.time_start_ms is None
and locator.time_end_ms is None
and block.page is None
and block.time_start_ms is None
and block.time_end_ms is None
)
@dataclass(frozen=True, slots=True)
class ValidationReport:
status: str
eligible_for_auto_accept: bool
findings: tuple[Finding, ...]
affected_policy_ids: tuple[str, ...]
def _finding(code: str, message: str, path: str | None = None) -> Finding:
return Finding(code=code, message=message, path=path)
def _entity_names(node: KnowledgeNode) -> set[str]:
return {
unicodedata.normalize("NFC", " ".join(value.split())).casefold()
for value in (node.title, *node.aliases)
if value.strip()
}
def _entity_visible(
candidate: KnowledgeNode,
existing: KnowledgeNode,
readable_project_ids: frozenset[str],
) -> bool:
return (
existing.project_id == candidate.project_id or existing.project_id in readable_project_ids
)
def validate_proposal(
proposal: ProposalRevision,
*,
projects: dict[str, ProjectRecord],
policies: dict[str, AutoAcceptPolicy],
sources: dict[str, SourceRevisionRecord],
extractions: dict[str, ExtractionRecord],
existing_nodes: dict[str, KnowledgeNode],
current_base_commit: str,
readable_project_ids: frozenset[str] = frozenset(),
) -> ValidationReport:
"""Validate evidence, scope, relationships, policy and replay bindings."""
findings: list[Finding] = []
affected_policy_ids: set[str] = set()
eligibility_checks: list[tuple[tuple[str, ...], str, str]] = []
scoped_projects = set(projects) if proposal.scope.all_projects else set(proposal.scope.projects)
if proposal.scope.include_shared:
scoped_projects.add("shared")
comparison_project_ids = readable_project_ids & frozenset(scoped_projects)
if (
not proposal.scope.all_projects
and proposal.initiating_project_id != "shared"
and proposal.initiating_project_id not in proposal.scope.projects
):
findings.append(
_finding(
"OUT_OF_SCOPE", "Initiating project is outside explicit scope", "scope.projects"
)
)
known_sources = dict(sources)
proposed_nodes = {
operation.node.node_id: operation.node
for operation in proposal.operations
if operation.node is not None
}
known_nodes = {**existing_nodes, **proposed_nodes}
entity_nodes_seen: list[KnowledgeNode] = []
if proposal.base_commit != current_base_commit:
findings.append(_finding("BASE_CONFLICT", "Canonical base revision has changed"))
if proposal_payload_digest(proposal) != proposal.payload_digest:
findings.append(_finding("PAYLOAD_DIGEST_MISMATCH", "Proposal payload digest is incorrect"))
for index, operation in enumerate(proposal.operations):
path = f"operations[{index}]"
if operation.source is not None:
source = operation.source
previous = known_sources.get(source.revision_id)
if previous and previous != source:
findings.append(
_finding(
"SOURCE_REVISION_CONFLICT", "Source revision ID is already bound", path
)
)
known_sources[source.revision_id] = source
source_domains = {"shared", *source.ingested_for}
for project_id in source.ingested_for:
if project_id != "shared" and project_id not in projects:
findings.append(
_finding(
"PROJECT_NOT_REGISTERED",
"Source relevance names an unregistered project",
f"{path}.source.ingested_for",
)
)
if (
project_id != "shared"
and project_id not in proposal.scope.projects
and not proposal.scope.all_projects
):
findings.append(
_finding(
"OUT_OF_SCOPE",
"Source relevance names a project outside explicit scope",
f"{path}.source.ingested_for",
)
)
affected_policy_ids.update(source_domains)
eligibility_checks.append((tuple(sorted(source_domains)), "source", "register_source"))
if operation.node is not None:
node = operation.node
eligibility_checks.append(((node.project_id,), "node", node.node_type.value))
if node.node_id in existing_nodes:
findings.append(
_finding(
"NODE_ID_EXISTS",
"Node IDs are stable and cannot be reused",
f"{path}.node.node_id",
)
)
if node.node_type.value == "entity":
possible_duplicates = [
other
for other in (*existing_nodes.values(), *entity_nodes_seen)
if other.node_id != node.node_id
and other.node_type.value == "entity"
and _entity_visible(node, other, comparison_project_ids)
and _entity_names(node) & _entity_names(other)
]
if possible_duplicates:
findings.append(
_finding(
"POSSIBLE_DUPLICATE_ENTITY",
"Entity name or alias matches an in-scope entity; review provenance "
"before linking or merging",
f"{path}.node",
)
)
entity_nodes_seen.append(node)
if node.project_id != "shared" and node.project_id not in projects:
findings.append(
_finding(
"PROJECT_NOT_REGISTERED",
"Node project is not registered",
f"{path}.node.project_id",
)
)
affected_policy_ids.add(node.project_id)
if node.agent_id != proposal.proposer_id:
findings.append(
_finding(
"AGENT_ATTRIBUTION_MISMATCH",
"Node attribution must match the authenticated proposal author",
f"{path}.node.agent_id",
)
)
claim_items = (
(group_name, claim_index, claim)
for group_name, claims in (
("claims", node.claims),
("assumptions", node.assumptions),
("limitations", node.limitations),
)
for claim_index, claim in enumerate(claims)
)
for group_name, claim_index, claim in claim_items:
claim_path = f"{path}.node.{group_name}[{claim_index}]"
if claim.assessment.assessor_id != proposal.proposer_id:
findings.append(
_finding(
"ASSESSOR_ATTRIBUTION_MISMATCH",
"Support assessment must be attributed to the proposal author",
f"{claim_path}.assessment.assessor_id",
)
)
expected_assertion_digest = assertion_digest(claim.text)
assessments = {claim.assessment.assertion_digest}
if assessments != {expected_assertion_digest}:
findings.append(
_finding(
"ASSESSMENT_STALE",
"Support assessment does not bind the current claim text",
claim_path,
)
)
expected_evidence_digests = {
evidence_digest(evidence) for evidence in claim.evidence
}
if set(claim.assessment.evidence_digests) != expected_evidence_digests:
findings.append(
_finding(
"ASSESSMENT_EVIDENCE_MISMATCH",
"Support assessment does not bind the current evidence",
claim_path,
)
)
if claim.assessment.status != SupportStatus.SUPPORTED:
findings.append(
_finding(
"SUPPORT_UNAVAILABLE",
"Claim lacks a supported semantic assessment",
claim_path,
)
)
for evidence_index, evidence in enumerate(claim.evidence):
evidence_path = f"{claim_path}.evidence[{evidence_index}]"
expected_assertion_path = f"{group_name}[{claim_index}].text"
if evidence.assertion_path != expected_assertion_path:
findings.append(
_finding(
"EVIDENCE_ASSERTION_PATH_MISMATCH",
"Evidence assertion path does not match its typed claim group",
evidence_path,
)
)
if evidence.assertion_digest != expected_assertion_digest:
findings.append(
_finding(
"EVIDENCE_ASSERTION_MISMATCH",
"Evidence is not bound to the current claim text",
evidence_path,
)
)
source_record = known_sources.get(evidence.source_revision_id)
if source_record is None:
findings.append(
_finding(
"SOURCE_REVISION_MISSING",
"Evidence source revision is unavailable",
evidence_path,
)
)
extraction = extractions.get(evidence.extraction_id)
if (
extraction is None
or extraction.source_revision_id != evidence.source_revision_id
):
findings.append(
_finding(
"EXTRACTION_MISMATCH",
"Evidence extraction does not belong to its source revision",
evidence_path,
)
)
continue
if extraction_digest(extraction) != extraction.extraction_id:
findings.append(
_finding(
"EXTRACTION_HASH_MISMATCH",
"Stored extraction does not match its immutable identity",
evidence_path,
)
)
continue
if source_record and source_record.archive_status != "archived":
findings.append(
_finding(
"SOURCE_NOT_ARCHIVED",
"Reference-only sources cannot support accepted factual claims",
evidence_path,
)
)
block = next(
(
item
for item in extraction.blocks
if item.block_id == evidence.locator.block_id
),
None,
)
if block is None:
findings.append(
_finding(
"EVIDENCE_LOCATION_MISMATCH",
"Evidence block does not exist",
evidence_path,
)
)
continue
if not _locator_matches_block(evidence.locator, block):
findings.append(
_finding(
"EVIDENCE_LOCATION_MISMATCH",
"Evidence locator metadata does not match its source block",
evidence_path,
)
)
continue
if evidence.locator.end > len(block.normalized_text):
findings.append(
_finding(
"EVIDENCE_LOCATION_MISMATCH",
"Evidence span extends beyond its source block",
evidence_path,
)
)
continue
located_text = block.normalized_text[
evidence.locator.start : evidence.locator.end
]
normalized_quote = unicodedata.normalize(
"NFC", evidence.quote.replace("\r\n", "\n").replace("\r", "\n")
)
location_map = block.normalization_map[
evidence.locator.start : evidence.locator.end
]
original_start = min((span[0] for span in location_map), default=-1)
original_end = max((span[1] for span in location_map), default=-1)
original_quote = (
block.original_text[original_start:original_end]
if original_start >= 0
else ""
)
normalized_original_quote = unicodedata.normalize(
"NFC", original_quote.replace("\r\n", "\n").replace("\r", "\n")
)
if (
located_text != normalized_quote
or normalized_original_quote != normalized_quote
):
findings.append(
_finding(
ErrorCode.EVIDENCE_MISMATCH.value,
"Quote does not match text at the stated location",
evidence_path,
)
)
if operation.edge is not None:
edge = operation.edge
expected_edge_digest = edge_assertion_digest(edge)
for evidence_index, evidence in enumerate(edge.evidence):
evidence_path = f"{path}.edge.evidence[{evidence_index}]"
if evidence.assertion_digest != expected_edge_digest:
findings.append(
_finding(
"EVIDENCE_ASSERTION_MISMATCH",
"Evidence is not bound to the current relationship",
evidence_path,
)
)
source_record = known_sources.get(evidence.source_revision_id)
extraction = extractions.get(evidence.extraction_id)
if source_record is None:
findings.append(
_finding(
"SOURCE_REVISION_MISSING",
"Evidence source revision is unavailable",
evidence_path,
)
)
if (
extraction is None
or extraction.source_revision_id != evidence.source_revision_id
):
findings.append(
_finding(
"EXTRACTION_MISMATCH",
"Evidence extraction does not belong to its source revision",
evidence_path,
)
)
continue
if extraction_digest(extraction) != extraction.extraction_id:
findings.append(
_finding(
"EXTRACTION_HASH_MISMATCH",
"Stored extraction does not match its immutable identity",
evidence_path,
)
)
continue
if source_record and source_record.archive_status != "archived":
findings.append(
_finding(
"SOURCE_NOT_ARCHIVED",
"Reference-only sources cannot support accepted relationships",
evidence_path,
)
)
block = next(
(
item
for item in extraction.blocks
if item.block_id == evidence.locator.block_id
),
None,
)
if block is None or not _locator_matches_block(evidence.locator, block):
findings.append(
_finding(
"EVIDENCE_LOCATION_MISMATCH",
"Evidence block or locator metadata does not match",
evidence_path,
)
)
continue
if evidence.locator.end > len(block.normalized_text):
findings.append(
_finding(
"EVIDENCE_LOCATION_MISMATCH",
"Evidence span extends beyond its source block",
evidence_path,
)
)
continue
actual_quote = block.normalized_text[evidence.locator.start : evidence.locator.end]
if actual_quote != unicodedata.normalize(
"NFC", evidence.quote.replace("\\r\\n", "\\n").replace("\\r", "\\n")
):
findings.append(
_finding(
ErrorCode.EVIDENCE_MISMATCH.value,
"Quote does not match text at the stated location",
evidence_path,
)
)
source_node = known_nodes.get(edge.source.id) if edge.source.kind == "node" else None
target_node = known_nodes.get(edge.target.id) if edge.target.kind == "node" else None
edge_domains: set[str] = set()
for endpoint, node_endpoint, label in (
(edge.source, source_node, "source"),
(edge.target, target_node, "target"),
):
if endpoint.kind == "node":
if node_endpoint is None:
findings.append(
_finding(
"EDGE_TARGET_MISSING",
f"Edge {label} node {endpoint.id} is unavailable",
f"{path}.edge.{label}.id",
)
)
else:
affected_policy_ids.add(node_endpoint.project_id)
edge_domains.add(node_endpoint.project_id)
elif endpoint.kind == "source":
if endpoint.id not in known_sources and endpoint.id not in {
source.revision_id
for source in (
item.source for item in proposal.operations if item.source is not None
)
}:
findings.append(
_finding(
"EDGE_SOURCE_MISSING",
f"Edge source revision {endpoint.id} is unavailable",
f"{path}.edge.{label}.id",
)
)
affected_policy_ids.add("shared")
edge_domains.add("shared")
elif endpoint.id not in projects:
findings.append(
_finding(
"EDGE_PROJECT_MISSING",
f"Edge project {endpoint.id} is not registered",
f"{path}.edge.{label}.id",
)
)
else:
affected_policy_ids.add(endpoint.id)
edge_domains.add(endpoint.id)
if edge_domains:
eligibility_checks.append((tuple(sorted(edge_domains)), "edge", edge.edge_type))
if edge.edge_type in {"solves", "addresses"} and source_node and target_node:
if (
source_node.node_type.value != "technique"
or target_node.node_type.value != "problem"
):
findings.append(
_finding(
"EDGE_TYPE_MISMATCH",
f"{edge.edge_type} must connect a technique to a problem",
f"{path}.edge",
)
)
if operation.target_id is not None:
target = existing_nodes.get(operation.target_id)
if target is None:
findings.append(
_finding(
"NODE_NOT_FOUND", "Staleness target does not exist", f"{path}.target_id"
)
)
else:
affected_policy_ids.add(target.project_id)
eligibility_checks.append(((target.project_id,), "stale", "flag_stale"))
policy_by_domain: dict[str, AutoAcceptPolicy] = {}
for project_id in sorted(affected_policy_ids):
project = projects.get(project_id)
if project and project.status != ProjectStatus.ACTIVE:
findings.append(
_finding(
"PROJECT_NOT_ACTIVE",
f"Project {project_id} is {project.status.value} and cannot accept changes",
)
)
policy_id = project.autonomy_policy_id if project else project_id
policy = policies.get(policy_id)
if policy is None or policy.project_id != project_id:
findings.append(
_finding("POLICY_MISSING", f"No applicable policy is registered for {project_id}")
)
continue
if not policy.require_supported_assessment:
findings.append(
_finding(
"POLICY_WEAKENING",
"Policy cannot waive required claim support assessments",
f"policies.{policy_id}.require_supported_assessment",
)
)
policy_by_domain[project_id] = policy
if project_id == "shared" and not proposal.scope.include_shared:
findings.append(
_finding(
"OUT_OF_SCOPE", "Proposal changes shared outside its explicit scope", "scope"
)
)
elif project_id != "shared" and project_id not in proposal.scope.projects:
findings.append(
_finding(
"OUT_OF_SCOPE",
"Proposal changes a project outside its explicit scope",
"scope.projects",
)
)
automatic = bool(policy_by_domain) and len(policy_by_domain) == len(affected_policy_ids)
for domains, change_kind, change_type in eligibility_checks:
applicable = [policy_by_domain.get(project_id) for project_id in domains]
if any(policy is None for policy in applicable):
automatic = False
elif change_kind == "source":
automatic = automatic and all(
bool(policy and policy.automatic_source_registration) for policy in applicable
)
elif change_kind == "node":
automatic = automatic and all(
bool(policy and change_type in policy.automatic_node_types) for policy in applicable
)
elif change_kind == "edge":
automatic = automatic and all(
bool(policy and change_type in policy.automatic_edge_types) for policy in applicable
)
else:
automatic = False
if not automatic and not findings:
findings.append(
_finding(
"AUTO_ACCEPT_UNSPECIFIED", "Applicable policy requires an exceptional decision"
)
)
blocking_codes = {"BASE_CONFLICT", "PAYLOAD_DIGEST_MISMATCH"}
invalid_codes = {
"SOURCE_REVISION_CONFLICT",
"PROJECT_NOT_REGISTERED",
"NODE_ID_EXISTS",
"ASSESSMENT_STALE",
"ASSESSMENT_EVIDENCE_MISMATCH",
"SUPPORT_UNAVAILABLE",
"EVIDENCE_ASSERTION_MISMATCH",
"SOURCE_REVISION_MISSING",
"EXTRACTION_MISMATCH",
"EXTRACTION_HASH_MISMATCH",
"SOURCE_NOT_ARCHIVED",
"EVIDENCE_LOCATION_MISMATCH",
ErrorCode.EVIDENCE_MISMATCH.value,
"EDGE_TARGET_MISSING",
"EDGE_SOURCE_MISSING",
"EDGE_PROJECT_MISSING",
"EDGE_TYPE_MISMATCH",
"NODE_NOT_FOUND",
"POLICY_WEAKENING",
"OUT_OF_SCOPE",
}
codes = {item.code for item in findings}
if codes & blocking_codes:
status = "conflict"
elif codes & invalid_codes:
status = "invalid"
elif findings:
status = "needs-review"
else:
status = "eligible"
return ValidationReport(
status=status,
eligible_for_auto_accept=status == "eligible" and automatic,
findings=tuple(findings),
affected_policy_ids=tuple(sorted(policy.policy_id for policy in policy_by_domain.values())),
)
def unique_findings(findings: Iterable[Finding]) -> tuple[Finding, ...]:
"""Stable deduplication for findings shared across bundle members."""
result: dict[tuple[str, str, str | None], Finding] = {}
for item in findings:
result[(item.code, item.message, item.path)] = item
return tuple(result.values())
+1
View File
@@ -0,0 +1 @@
"""Wire adapters for HTTP and MCP; business rules stay in application services."""
+273
View File
@@ -0,0 +1,273 @@
"""Password sign-in and human-owned agent credential management."""
import sqlite3
from collections.abc import Callable
from typing import Annotated, Literal
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, ConfigDict, Field, model_validator
from starlette.responses import JSONResponse
from gewu.application.accounts import AccountIdentity, AccountSummary
from gewu.application.admin import CredentialSummary
from gewu.application.identity import Principal
from gewu.domain.contracts import RecordId
from gewu.transports.admin_http import IssuedCredential
Username = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,63}$")]
Password = Annotated[str, Field(min_length=12, max_length=1024)]
class LoginBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
username: Username
password: Annotated[str, Field(min_length=1, max_length=1024)]
class AccountCreateBody(LoginBody):
password: Password
role: Literal["viewer", "editor", "admin"] = "editor"
project_ids: list[RecordId] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def unique_grants(self) -> "AccountCreateBody":
if len(self.project_ids) != len(set(self.project_ids)):
raise ValueError("Project grants must be unique")
return self
class AccountUpdateBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
role: Literal["viewer", "editor", "admin"]
project_ids: list[RecordId] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def unique_grants(self) -> "AccountUpdateBody":
if len(self.project_ids) != len(set(self.project_ids)):
raise ValueError("Project grants must be unique")
return self
class PasswordChangeBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
current_password: Annotated[str, Field(min_length=1, max_length=1024)]
new_password: Password
class AgentCreateBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
project_ids: list[RecordId] = Field(min_length=1, max_length=50)
class CurrentUser(BaseModel):
principal_id: str
roles: list[str]
project_ids: list[str]
password_account: bool
def register_account_routes(
app: FastAPI,
store: AccountIdentity,
authenticate: Callable[..., Principal],
require_same_origin: Callable[[Request], None],
) -> None:
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, error: RequestValidationError) -> JSONResponse:
# Passwords must not be echoed in FastAPI's default validation error input fields.
sensitive = request.url.path in {
"/v1/users/session",
"/v1/admin/users",
"/v1/me/password",
}
if sensitive:
return JSONResponse(
status_code=422,
content={"detail": "Check the username, password and account fields"},
headers={"Cache-Control": "no-store"},
)
from fastapi.exception_handlers import request_validation_exception_handler
return await request_validation_exception_handler(request, error)
def human(
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> Principal:
response.headers["Cache-Control"] = "private, no-store"
write = request.method not in {"GET", "HEAD"}
if write:
require_same_origin(request)
principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write)
if "agent" in principal.roles:
raise HTTPException(403, "A human account is required")
return principal
def admin(principal: Annotated[Principal, Depends(human)]) -> Principal:
if "admin" not in principal.roles:
raise HTTPException(403, "Administrator role is required")
return principal
def account_error(error: Exception) -> HTTPException:
if isinstance(error, PermissionError):
return HTTPException(403, str(error))
if isinstance(error, sqlite3.IntegrityError):
return HTTPException(409, "Username already exists")
return HTTPException(422, "Account or agent credential fields are invalid")
@app.post("/v1/users/session", tags=["identity"], operation_id="account_login")
def login(body: LoginBody, request: Request, response: Response) -> dict[str, str]:
require_same_origin(request)
if request.url.scheme != "https" and request.url.hostname not in {
"localhost",
"127.0.0.1",
"::1",
}:
raise HTTPException(403, "Password sign-in requires HTTPS")
session = store.login_account(body.username, body.password)
if session is None:
raise HTTPException(
401,
"Sign-in failed. Check your username and password, or try again later.",
headers={"Cache-Control": "no-store"},
)
response.headers["Cache-Control"] = "no-store"
response.set_cookie(
"gewu_session",
session.session_token,
httponly=True,
secure=request.url.scheme == "https",
samesite="strict",
path="/v1",
)
return {"csrf_token": session.csrf_token, "expires_at": session.expires_at.isoformat()}
@app.get("/v1/me", tags=["identity"], operation_id="account_current")
def me(principal: Annotated[Principal, Depends(human)]) -> CurrentUser:
return CurrentUser(
principal_id=principal.principal_id,
roles=sorted(principal.roles),
project_ids=sorted(principal.project_ids),
password_account=principal.credential_id.startswith("account:"),
)
@app.post("/v1/me/password", tags=["identity"], operation_id="account_change_password")
def change_password(
body: PasswordChangeBody,
response: Response,
principal: Annotated[Principal, Depends(human)],
) -> dict[str, str]:
try:
changed = store.change_account_password(
principal, body.current_password, body.new_password
)
except (PermissionError, ValueError) as error:
raise account_error(error) from error
if not changed:
raise HTTPException(403, "Current password is incorrect")
response.delete_cookie("gewu_session", path="/v1")
return {"status": "password_changed", "next": "sign_in"}
@app.get("/v1/admin/users", tags=["control"], operation_id="admin_list_accounts")
def users(
principal: Annotated[Principal, Depends(admin)],
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> list[AccountSummary]:
return store.list_accounts(limit=limit, offset=offset)
@app.post(
"/v1/admin/users", status_code=201, tags=["control"], operation_id="admin_create_account"
)
def create_user(
body: AccountCreateBody, principal: Annotated[Principal, Depends(admin)]
) -> AccountSummary:
try:
return store.create_account(
username=body.username,
password=body.password,
role=body.role,
project_ids=tuple(body.project_ids),
actor=principal.principal_id,
)
except (sqlite3.IntegrityError, ValueError) as error:
raise account_error(error) from error
@app.patch("/v1/admin/users/{username}", tags=["control"], operation_id="admin_update_account")
def update_user(
username: Username, body: AccountUpdateBody, principal: Annotated[Principal, Depends(admin)]
) -> AccountSummary:
try:
result = store.update_account(
username, role=body.role, project_ids=tuple(body.project_ids), actor=principal
)
except (PermissionError, ValueError) as error:
raise account_error(error) from error
if result is None:
raise HTTPException(404, "Active account was not found")
return result
@app.delete(
"/v1/admin/users/{username}", tags=["control"], operation_id="admin_disable_account"
)
def disable_user(
username: Username, principal: Annotated[Principal, Depends(admin)]
) -> dict[str, str]:
try:
disabled = store.disable_account(username, actor=principal)
except PermissionError as error:
raise account_error(error) from error
if not disabled:
raise HTTPException(404, "Active account was not found")
return {"status": "disabled"}
@app.get(
"/v1/me/agent-credentials", tags=["identity"], operation_id="account_list_agent_credentials"
)
def agents(
principal: Annotated[Principal, Depends(human)],
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> list[CredentialSummary]:
try:
return store.list_agent_credentials(principal, limit=limit, offset=offset)
except PermissionError as error:
raise account_error(error) from error
@app.post(
"/v1/me/agent-credentials",
status_code=201,
tags=["identity"],
operation_id="account_create_agent_credential",
)
def create_agent(
body: AgentCreateBody, principal: Annotated[Principal, Depends(human)]
) -> IssuedCredential:
try:
key_id, token = store.create_agent_credential(
principal, name=body.name, project_ids=tuple(body.project_ids)
)
except (PermissionError, ValueError) as error:
raise account_error(error) from error
return IssuedCredential(credential_id=key_id, token=token)
@app.delete(
"/v1/me/agent-credentials/{credential_id}",
tags=["identity"],
operation_id="account_revoke_agent_credential",
)
def revoke_agent(
credential_id: RecordId, principal: Annotated[Principal, Depends(human)]
) -> dict[str, str]:
try:
revoked = store.revoke_agent_credential(principal, credential_id)
except PermissionError as error:
raise account_error(error) from error
if not revoked:
raise HTTPException(404, "Active agent credential was not found")
return {"status": "revoked"}
+152
View File
@@ -0,0 +1,152 @@
"""Human administrator credential controls; browser writes require CSRF."""
import sqlite3
from collections.abc import Callable
from typing import Annotated, Literal
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response
from pydantic import BaseModel, ConfigDict, Field, model_validator
from gewu.application.admin import AdminEvent, CredentialAdministration, CredentialSummary
from gewu.application.identity import Principal
from gewu.domain.contracts import RecordId
class CredentialCreateBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
credential_id: RecordId
principal_id: RecordId
role: Literal["viewer", "editor", "agent", "admin"]
project_ids: list[RecordId] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def unique_projects(self) -> "CredentialCreateBody":
if len(self.project_ids) != len(set(self.project_ids)):
raise ValueError("Project grants must be unique")
if self.role != "admin" and not self.project_ids:
raise ValueError("Select at least one project for this credential")
return self
class CredentialUpdateBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
role: Literal["viewer", "editor", "agent", "admin"]
project_ids: list[RecordId] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def unique_projects(self) -> "CredentialUpdateBody":
if len(self.project_ids) != len(set(self.project_ids)):
raise ValueError("Project grants must be unique")
if self.role != "admin" and not self.project_ids:
raise ValueError("Select at least one project for this credential")
return self
class IssuedCredential(BaseModel):
credential_id: str
token: str
def register_admin_routes(
app: FastAPI,
store: CredentialAdministration,
authenticate: Callable[..., Principal],
require_same_origin: Callable[[Request], None],
) -> None:
def admin(
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> Principal:
response.headers["Cache-Control"] = "private, no-store"
write = request.method not in {"GET", "HEAD"}
if write:
require_same_origin(request)
principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write)
if "admin" not in principal.roles or "agent" in principal.roles:
raise HTTPException(403, "Administrator role is required")
return principal
@app.get("/v1/admin/credentials", tags=["control"], operation_id="admin_list_credentials")
def credentials(
principal: Annotated[Principal, Depends(admin)],
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> list[CredentialSummary]:
return store.list_credentials(limit=limit, offset=offset)
@app.post(
"/v1/admin/credentials",
status_code=201,
tags=["control"],
operation_id="admin_create_credential",
)
def create(
body: CredentialCreateBody, principal: Annotated[Principal, Depends(admin)]
) -> IssuedCredential:
try:
token = store.provision(
credential_id=body.credential_id,
principal_id=body.principal_id,
roles=(body.role,),
project_ids=tuple(body.project_ids),
actor=principal.principal_id,
)
except sqlite3.IntegrityError as error:
raise HTTPException(409, "Credential ID already exists") from error
except ValueError as error:
raise HTTPException(422, "Credential identity or grants are invalid") from error
return IssuedCredential(credential_id=body.credential_id, token=token)
@app.patch(
"/v1/admin/credentials/{credential_id}",
tags=["control"],
operation_id="admin_update_credential",
)
def update_credential(
credential_id: RecordId,
body: CredentialUpdateBody,
principal: Annotated[Principal, Depends(admin)],
) -> CredentialSummary:
if credential_id == principal.credential_id:
raise HTTPException(
409, "Use another administrator credential to change this credential"
)
try:
result = store.update_credential(
credential_id,
role=body.role,
project_ids=tuple(body.project_ids),
actor=principal.principal_id,
)
except ValueError as error:
raise HTTPException(422, "Credential role or grants are invalid") from error
if result is None:
raise HTTPException(404, "Active credential was not found")
return result
@app.delete(
"/v1/admin/credentials/{credential_id}",
tags=["control"],
operation_id="admin_revoke_credential",
)
def revoke(
credential_id: RecordId, principal: Annotated[Principal, Depends(admin)]
) -> dict[str, str]:
if credential_id == principal.credential_id:
raise HTTPException(
409, "Use another administrator credential to revoke this session's key"
)
if not store.revoke(credential_id, actor=principal.principal_id):
raise HTTPException(404, "Active credential was not found")
return {"status": "revoked"}
@app.get("/v1/admin/events", tags=["control"], operation_id="admin_list_events")
def events(
principal: Annotated[Principal, Depends(admin)],
limit: int = Query(default=50, ge=1, le=100),
) -> list[AdminEvent]:
return store.list_admin_events(limit=limit)
+91
View File
@@ -0,0 +1,91 @@
"""Transport-level request limits for small control-plane payloads."""
from __future__ import annotations
from starlette.types import ASGIApp, Message, Receive, Scope, Send
MAX_PROJECT_REGISTRY_BODY_BYTES = 64 * 1024
MAX_OWNER_AUTH_BODY_BYTES = 4 * 1024
class ProjectRegistryBodyLimitMiddleware:
"""Bound small control-plane JSON bodies before FastAPI parses credentials or records."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
limits = {
"/v1/admin/projects": MAX_PROJECT_REGISTRY_BODY_BYTES,
"/auth/owner-session": MAX_OWNER_AUTH_BODY_BYTES,
"/v1/admin/credentials": MAX_OWNER_AUTH_BODY_BYTES,
"/v1/users/session": MAX_OWNER_AUTH_BODY_BYTES,
"/v1/admin/users": 8 * 1024,
"/v1/me/password": 8 * 1024,
"/v1/me/agent-credentials": MAX_OWNER_AUTH_BODY_BYTES,
}
path = scope.get("path")
if scope["type"] != "http" or scope.get("method") != "POST" or path not in limits:
await self.app(scope, receive, send)
return
limit = limits[path]
content_length = next(
(
value
for name, value in scope.get("headers", [])
if name.lower() == b"content-length"
),
None,
)
if content_length is not None:
try:
if int(content_length) > limit:
await self._too_large(send)
return
except ValueError:
await self._too_large(send)
return
messages: list[Message] = []
size = 0
while True:
message = await receive()
messages.append(message)
if message["type"] == "http.disconnect":
return
if message["type"] == "http.request":
size += len(message.get("body", b""))
if size > limit:
await self._too_large(send)
return
if not message.get("more_body", False):
break
replay_index = 0
async def replay_receive() -> Message:
nonlocal replay_index
if replay_index < len(messages):
message = messages[replay_index]
replay_index += 1
return message
return await receive()
await self.app(scope, replay_receive, send)
@staticmethod
async def _too_large(send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": b'{"detail":"Control-plane request exceeds its size limit"}',
}
)
+185
View File
@@ -0,0 +1,185 @@
"""JSON-native request schemas for strict HTTP adapters."""
from __future__ import annotations
import json
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from gewu.domain.contracts import (
AutoAcceptPolicy,
Confidence,
NeighborRequest,
NodeType,
ProjectRecord,
RecordId,
SearchFacetValue,
SearchRequest,
SourceListRequest,
)
class SearchScopeBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
projects: list[RecordId] = Field(min_length=1, max_length=50)
include_projects: list[RecordId] | None = Field(default=None, max_length=50)
include_shared: bool = True
all_projects: bool = False
class SearchRequestBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
query: str = Field(default="", max_length=1000)
scope: SearchScopeBody
node_types: list[str] = Field(default_factory=list, max_length=9)
tags: list[SearchFacetValue] = Field(default_factory=list, max_length=20)
audience: list[SearchFacetValue] = Field(default_factory=list, max_length=20)
use_for: list[SearchFacetValue] = Field(default_factory=list, max_length=20)
domain: list[SearchFacetValue] = Field(default_factory=list, max_length=20)
confidences: list[Confidence] = Field(default_factory=list, max_length=3)
as_of_from: date | None = None
as_of_to: date | None = None
stale_only: bool = False
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
@field_validator("query")
@classmethod
def normalize_query(cls, value: str) -> str:
return value.strip()
@field_validator("confidences", mode="before")
@classmethod
def parse_confidences(cls, value: object) -> object:
if not isinstance(value, list):
return value
try:
return [Confidence(item) if isinstance(item, str) else item for item in value]
except ValueError as error:
raise ValueError("confidences contains an unsupported value") from error
@field_validator("as_of_from", "as_of_to", mode="before")
@classmethod
def parse_as_of_dates(cls, value: object) -> object:
if value is None or isinstance(value, date):
return value
if not isinstance(value, str):
return value
try:
return date.fromisoformat(value)
except ValueError as error:
raise ValueError("as-of filters must use YYYY-MM-DD dates") from error
@model_validator(mode="after")
def require_query_or_type_filter(self) -> SearchRequestBody:
if not self.query and not self.node_types:
raise ValueError("search requires text or at least one knowledge type")
return self
@field_validator("node_types")
@classmethod
def validate_node_types(cls, values: list[str]) -> list[str]:
try:
normalized = [NodeType(value).value for value in values]
except ValueError as error:
raise ValueError("node_types contains an unsupported knowledge type") from error
if len(normalized) != len(set(normalized)):
raise ValueError("node_types must be unique")
return normalized
def to_contract(self) -> SearchRequest:
return SearchRequest.model_validate_json(self.model_dump_json(), strict=True)
class NeighborRequestBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
node_id: RecordId
scope: SearchScopeBody
edge_types: list[str] = Field(default_factory=list, max_length=12)
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
def to_contract(self) -> NeighborRequest:
return NeighborRequest.model_validate_json(self.model_dump_json(), strict=True)
class SourceListRequestBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
scope: SearchScopeBody
limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000)
def to_contract(self) -> SourceListRequest:
return SourceListRequest.model_validate_json(self.model_dump_json(), strict=True)
class OwnerSessionRequestBody(BaseModel):
"""Short-lived owner authentication inputs; never accepted as a normal session."""
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
principal_id: RecordId
password: str = Field(min_length=12, max_length=1024)
totp_code: str = Field(pattern=r"^\d{6}$")
challenge_id: RecordId
class ProjectContentUpdateBody(BaseModel):
"""Editable human-authored project intent fields."""
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
description: str = Field(max_length=4000)
north_star: str = Field(min_length=1, max_length=4000)
key_questions: list[str] = Field(min_length=1, max_length=30)
@field_validator("key_questions")
@classmethod
def questions_are_nonempty(cls, value: list[str]) -> list[str]:
if any(not question.strip() or len(question) > 1000 for question in value):
raise ValueError("Key questions must contain 1 to 1000 characters")
return [question.strip() for question in value]
class ProjectCreateBody(BaseModel):
"""An administrator supplies the human-owned project lens and its initial policy."""
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
project: ProjectRecord
policy: AutoAcceptPolicy
@model_validator(mode="before")
@classmethod
def validate_nested_contracts(cls, value: object) -> object:
if not isinstance(value, dict):
return value
parsed = dict(value)
for field, record_type in (
("project", ProjectRecord),
("policy", AutoAcceptPolicy),
):
raw = parsed.get(field)
if isinstance(raw, dict):
parsed[field] = record_type.model_validate_json(
json.dumps(raw, ensure_ascii=False), strict=True
)
return parsed
@model_validator(mode="after")
def project_and_policy_match(self) -> ProjectCreateBody:
if (
self.project.autonomy_policy_id != self.policy.policy_id
or self.project.project_id != self.policy.project_id
):
raise ValueError("Project and initial policy identities must match")
if not self.project.owners:
raise ValueError("At least one named project owner is required")
return self
File diff suppressed because it is too large. Load diff
+116
View File
@@ -0,0 +1,116 @@
"""Owner-only MCP surface for exact, challenge-bound protected decisions."""
from __future__ import annotations
import json
from typing import Protocol
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from gewu.application.identity import OwnerIdentityProvider, Principal
class OwnerReviewProvider(Protocol):
def get_owner_challenge(
self, challenge_id: str, *, principal_id: str
) -> dict[str, object] | None: ...
def decide_owner_challenge(
self,
*,
challenge_id: str,
principal_id: str,
proposal_revision: int,
payload_digest: str,
diff_digest: str,
decision: str,
rationale: str,
owner_session_id: str,
audience: str,
) -> dict[str, object]: ...
def _owner_principal(
ctx: Context,
identities: OwnerIdentityProvider,
*,
challenge_id: str,
audience: str,
) -> Principal:
headers = ctx.headers or {}
authorization = next(
(value for key, value in headers.items() if key.lower() == "authorization"), ""
)
scheme, _, token = authorization.partition(" ")
principal = (
identities.authenticate_owner_session(token, challenge_id=challenge_id, audience=audience)
if scheme.lower() == "bearer" and token
else None
)
if principal is None:
raise PermissionError("A current owner session bound to this challenge is required")
return principal
def create_owner_mcp_server(
*,
identities: OwnerIdentityProvider,
reviews: OwnerReviewProvider,
audience: str,
) -> MCPServer:
server = MCPServer(
"GEWU Owner Review",
instructions="Review the exact proposal diff bound to your short-lived owner session.",
)
@server.tool(name="owner_challenge_get", structured_output=False)
def owner_challenge_get(challenge_id: str, ctx: Context) -> str:
"""Read challenge metadata bound to this owner session."""
principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience)
record = reviews.get_owner_challenge(challenge_id, principal_id=principal.principal_id)
if record is None:
raise ValueError("Owner challenge is unavailable")
record.pop("diff_json", None)
record.pop("owner_session_id", None)
return json.dumps(record, ensure_ascii=False)
@server.tool(name="owner_diff_get", structured_output=False)
def owner_diff_get(challenge_id: str, ctx: Context) -> str:
"""Read the complete, digest-bound diff; oversized diffs fail rather than truncate."""
principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience)
record = reviews.get_owner_challenge(challenge_id, principal_id=principal.principal_id)
if record is None:
raise ValueError("Owner challenge is unavailable")
raw_diff = record.pop("diff_json")
if not isinstance(raw_diff, str) or len(raw_diff.encode("utf-8")) > 512_000:
raise ValueError("Owner challenge diff is unavailable")
record["diff"] = json.loads(raw_diff)
return json.dumps(record, ensure_ascii=False)
@server.tool(name="owner_decide", structured_output=False)
def owner_decide(
challenge_id: str,
expected_proposal_revision: int,
expected_payload_digest: str,
expected_diff_digest: str,
decision: str,
rationale: str,
ctx: Context,
) -> str:
"""Authorize or reject one exact proposal revision and diff."""
principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience)
result = reviews.decide_owner_challenge(
challenge_id=challenge_id,
principal_id=principal.principal_id,
proposal_revision=expected_proposal_revision,
payload_digest=expected_payload_digest,
diff_digest=expected_diff_digest,
decision=decision,
rationale=rationale,
owner_session_id=principal.credential_id.rpartition(":")[2],
audience=audience,
)
return json.dumps(result, ensure_ascii=False)
return server
+625
View File
@@ -0,0 +1,625 @@
"""Local stdio bridge for explicitly selected files and the remote GEWU MCP service."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import re
import secrets
import stat
import threading
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
import httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.types import TextContent
from gewu.application.paths import reject_symlink_components
from gewu.application.uploads import CHUNK_BYTES, MAX_ARTIFACT_BYTES
from gewu.domain.contracts import ReliabilityGrade, SourceRevisionRecord
MAX_CONNECTOR_FILES = 128
MAX_CONNECTOR_STATE_ENTRIES = 1024
_FILE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
@dataclass(frozen=True, slots=True)
class SelectedFile:
file_id: str
path: Path
RemoteClientFactory = Callable[[], AbstractAsyncContextManager[Client]]
class UploadStateStore:
"""Private, restart-safe connector checkpoints keyed by source and project."""
def __init__(self, path: Path) -> None:
self.path = path.expanduser().absolute()
reject_symlink_components(self.path)
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.path.parent.chmod(0o700)
self._lock = threading.RLock()
def _read(self) -> dict[str, dict[str, str]]:
if not self.path.exists():
return {}
if self.path.is_symlink() or not self.path.is_file():
raise ValueError("Connector state path must be a private regular file")
if stat.S_IMODE(self.path.stat().st_mode) & 0o077:
raise ValueError("Connector state file permissions must be private")
try:
value = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise ValueError("Connector state file is invalid") from error
if not isinstance(value, dict) or any(
not isinstance(key, str)
or not isinstance(item, dict)
or any(not isinstance(k, str) or not isinstance(v, str) for k, v in item.items())
for key, item in value.items()
):
raise ValueError("Connector state file is invalid")
return value
def get(self, key: str) -> dict[str, str] | None:
with self._lock:
return self._read().get(key)
def set(self, key: str, **values: str) -> None:
with self._lock:
state = self._read()
state[key] = {
**state.get(key, {}),
**values,
"updated_at": datetime.now(UTC).isoformat(timespec="microseconds"),
}
self._prune(state, keep=key)
self._write(state)
def set_completed(self, key: str, **values: str) -> None:
with self._lock:
state = self._read()
state[key] = {
**values,
"updated_at": datetime.now(UTC).isoformat(timespec="microseconds"),
}
self._prune(state, keep=key)
self._write(state)
def set_ready(self, key: str, **values: str) -> None:
"""Retain source identity after upload while allowing extraction retries."""
with self._lock:
state = self._read()
state[key] = {
**values,
"updated_at": datetime.now(UTC).isoformat(timespec="microseconds"),
}
self._prune(state, keep=key)
self._write(state)
def remove(self, key: str) -> None:
with self._lock:
state = self._read()
state.pop(key, None)
self._write(state)
def _write(self, state: dict[str, dict[str, str]]) -> None:
temporary = self.path.with_name(f".{self.path.name}.{secrets.token_hex(8)}.tmp")
try:
with temporary.open("x", encoding="utf-8") as stream:
json.dump(state, stream, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, self.path)
finally:
temporary.unlink(missing_ok=True)
@staticmethod
def _prune(state: dict[str, dict[str, str]], *, keep: str) -> None:
while len(state) > MAX_CONNECTOR_STATE_ENTRIES:
candidates = (key for key in state if key != keep)
oldest = min(candidates, key=lambda key: state[key].get("updated_at", ""))
del state[oldest]
async def _active_upload(
remote: Client,
*,
selected: SelectedFile,
project_id: str,
source_revision_id: str,
media_type: str,
byte_length: int,
digest: str,
prior: dict[str, str] | None,
state_store: UploadStateStore | None,
state_key: str,
) -> tuple[str | None, set[int], dict[str, object] | None]:
if prior is not None and isinstance(prior.get("upload_id"), str):
try:
status = _json_tool_result(
await remote.call_tool("source_upload_status", {"upload_id": prior["upload_id"]})
)
offsets = status.get("verified_offsets")
if (
isinstance(offsets, list)
and all(
isinstance(offset, int) and not isinstance(offset, bool) for offset in offsets
)
and offsets == list(range(0, len(offsets) * CHUNK_BYTES, CHUNK_BYTES))
and all(
0 <= offset < byte_length and offset % CHUNK_BYTES == 0 for offset in offsets
)
):
return prior["upload_id"], set(offsets), None
raise ValueError("GEWU upload status could not be verified")
except ValueError:
try:
recovered = _json_tool_result(
await remote.call_tool(
"source_extract",
{
"project_id": project_id,
"artifact_sha256": digest,
"source_revision_id": source_revision_id,
},
)
)
except ValueError:
pass
else:
return None, set(), recovered
begun = _json_tool_result(
await remote.call_tool(
"source_upload_begin",
{
"project_id": project_id,
"filename": selected.path.name,
"media_type": media_type,
"byte_length": byte_length,
"expected_sha256": digest,
},
)
)
upload_id = begun.get("upload_id")
if not isinstance(upload_id, str):
raise ValueError("GEWU did not return an upload session")
if state_store is not None:
state_store.set(state_key, upload_id=upload_id)
return upload_id, set(), None
def _read_selected_file(selected: SelectedFile) -> tuple[bytes, str]:
path = selected.path.expanduser().absolute()
try:
reject_symlink_components(path)
except ValueError as error:
raise ValueError("Selected file path cannot traverse symbolic links") from error
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
except OSError as error:
raise ValueError("Selected file is unavailable") from error
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size < 1:
raise ValueError("Selected file must be a non-empty regular file")
if metadata.st_size > MAX_ARTIFACT_BYTES:
raise ValueError("Selected file exceeds the 50 MiB limit")
content = bytearray()
while len(content) <= MAX_ARTIFACT_BYTES:
block = os.read(descriptor, min(1024 * 1024, MAX_ARTIFACT_BYTES + 1 - len(content)))
if not block:
break
content.extend(block)
if len(content) > MAX_ARTIFACT_BYTES or len(content) != metadata.st_size:
raise ValueError("Selected file changed or exceeded the supported limit")
finally:
os.close(descriptor)
return bytes(content), hashlib.sha256(content).hexdigest()
async def upload_selected_file(
remote: Client,
selected: SelectedFile,
*,
project_id: str,
source_revision_id: str | None = None,
state_store: UploadStateStore | None = None,
title: str | None = None,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
] = "other",
reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW,
license: str = "unspecified",
authors: list[str] | None = None,
publisher: str | None = None,
capture_method: Literal["local-file", "notebooklm-export", "transcript"] | None = None,
) -> dict[str, object]:
"""Transfer selected bytes inside the connector and return metadata only."""
media_type = _selected_media_type(selected.path)
effective_capture_method = capture_method or (
"transcript" if selected.path.suffix.lower() in {".vtt", ".srt"} else "local-file"
)
if effective_capture_method == "transcript" and selected.path.suffix.lower() not in {
".vtt",
".srt",
}:
raise ValueError("Transcript capture requires a WebVTT or SubRip file")
if effective_capture_method == "notebooklm-export" and selected.path.suffix.lower() not in {
".txt",
".md",
".markdown",
".pdf",
}:
raise ValueError(
"NotebookLM export provenance is supported for text, Markdown, and PDF files"
)
content, digest = _read_selected_file(selected)
source_id = _local_source_id(selected.file_id)
state_key = hashlib.sha256(
json.dumps(
[project_id, source_id, digest, source_revision_id or "auto", effective_capture_method],
separators=(",", ":"),
).encode()
).hexdigest()
state = state_store.get(state_key) if state_store is not None else None
if state is not None and "completed_receipt" in state:
try:
completed = json.loads(state["completed_receipt"])
except json.JSONDecodeError as error:
raise ValueError("Completed connector receipt is invalid") from error
candidate = completed.get("source_candidate") if isinstance(completed, dict) else None
if (
not isinstance(completed, dict)
or completed.get("sha256") != digest
or not isinstance(candidate, dict)
or candidate.get("source_id") != source_id
or candidate.get("capture_method") != effective_capture_method
or (
source_revision_id is not None
and candidate.get("revision_id") != source_revision_id
)
):
raise ValueError("Completed connector receipt does not match this source revision")
return completed
captured_at_text = state.get("captured_at") if state is not None else None
if captured_at_text is None:
captured_at_text = datetime.now(UTC).isoformat(timespec="microseconds")
if state_store is not None:
state_store.set(state_key, captured_at=captured_at_text)
state = {**(state or {}), "captured_at": captured_at_text}
try:
captured_at = datetime.fromisoformat(captured_at_text)
except ValueError as error:
raise ValueError("Connector capture timestamp is invalid") from error
utc_offset = captured_at.utcoffset()
if utc_offset is None or utc_offset.total_seconds() != 0:
raise ValueError("Connector capture timestamp must be UTC")
effective_revision_id = source_revision_id or _local_source_revision_id(
source_id, digest, captured_at
)
extraction: dict[str, object] | None = None
if state is None or state.get("artifact_sha256") != digest:
upload_id, offsets, extraction = await _active_upload(
remote,
selected=selected,
project_id=project_id,
source_revision_id=effective_revision_id,
media_type=media_type,
byte_length=len(content),
digest=digest,
prior=state,
state_store=state_store,
state_key=state_key,
)
if extraction is None:
assert upload_id is not None
for offset in range(0, len(content), CHUNK_BYTES):
if offset in offsets:
continue
chunk = content[offset : offset + CHUNK_BYTES]
result = await remote.call_tool(
"source_upload_chunk",
{
"upload_id": upload_id,
"offset": offset,
"content_base64": base64.b64encode(chunk).decode("ascii"),
"chunk_sha256": hashlib.sha256(chunk).hexdigest(),
},
)
_json_tool_result(result)
finalized = _json_tool_result(
await remote.call_tool("source_upload_finalize", {"upload_id": upload_id})
)
if finalized.get("sha256") != digest:
raise ValueError("GEWU artifact digest differs from the selected file")
if state_store is not None:
state_store.set(state_key, artifact_sha256=digest)
if extraction is None:
extraction = _json_tool_result(
await remote.call_tool(
"source_extract",
{
"project_id": project_id,
"artifact_sha256": digest,
"source_revision_id": effective_revision_id,
},
)
)
receipt = _connector_receipt(
selected,
content,
digest,
media_type,
extraction,
source_revision_id=effective_revision_id,
project_id=project_id,
title=title,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
captured_at=captured_at,
capture_method=effective_capture_method,
)
if state_store is not None:
if receipt["extraction_status"] in {"complete", "partial"}:
state_store.set_completed(
state_key,
artifact_sha256=digest,
captured_at=captured_at_text,
completed_receipt=json.dumps(receipt, ensure_ascii=False, sort_keys=True),
)
else:
state_store.set_ready(
state_key,
artifact_sha256=digest,
captured_at=captured_at_text,
)
return receipt
def _connector_receipt(
selected: SelectedFile,
content: bytes,
digest: str,
media_type: str,
extraction: dict[str, object],
*,
source_revision_id: str,
project_id: str,
title: str | None,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
],
reliability_grade: ReliabilityGrade,
license: str,
authors: list[str] | None,
publisher: str | None,
captured_at: datetime,
capture_method: Literal["local-file", "notebooklm-export", "transcript"],
) -> dict[str, object]:
source_id = _local_source_id(selected.file_id)
source = SourceRevisionRecord(
source_id=source_id,
revision_id=source_revision_id,
artifact_sha256=digest,
media_type=media_type,
byte_length=len(content),
captured_at=captured_at,
capture_method=capture_method,
title=(title or selected.path.name)[:500],
authors=tuple(authors or ()),
publisher=publisher,
license=license,
source_type=source_type,
reliability_grade=reliability_grade,
ingested_for=(project_id,),
archive_status="archived",
)
return {
"file_id": selected.file_id,
"byte_length": len(content),
"sha256": digest,
"media_type": media_type,
"source_candidate": source.model_dump(mode="json"),
"extraction_status": extraction.get("status", "complete"),
"extraction_error_code": extraction.get("error_code"),
"extraction_id": extraction.get("extraction_id"),
"blocks": extraction.get("blocks"),
}
def _selected_media_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".pdf":
return "application/pdf"
if suffix == ".vtt":
return "text/vtt"
if suffix == ".srt":
return "application/x-subrip"
if suffix in {".md", ".markdown"}:
return "text/markdown"
if suffix in {".htm", ".html"}:
return "text/html"
if suffix == ".txt":
return "text/plain"
raise ValueError("Selected file type is not supported for source intake")
def _local_source_id(file_id: str) -> str:
return f"local:{hashlib.sha256(file_id.encode('utf-8')).hexdigest()[:32]}"
def _local_source_revision_id(source_id: str, digest: str, captured_at: datetime) -> str:
stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ")
return f"{source_id}:r:{digest}:{stamp}"
def _json_tool_result(result: object) -> dict[str, object]:
if getattr(result, "is_error", True):
raise ValueError("GEWU source operation failed")
content = getattr(result, "content", ())
if not content or not isinstance(content[0], TextContent):
raise ValueError("GEWU returned an invalid source-operation response")
try:
value = json.loads(content[0].text)
except (TypeError, json.JSONDecodeError) as error:
raise ValueError("GEWU returned an invalid source-operation response") from error
if not isinstance(value, dict):
raise ValueError("GEWU returned an invalid source-operation response")
return value
def create_connector_server(
selected_files: tuple[SelectedFile, ...],
remote_client_factory: RemoteClientFactory,
state_store: UploadStateStore | None = None,
) -> MCPServer:
if not selected_files or len(selected_files) > MAX_CONNECTOR_FILES:
raise ValueError("Configure between 1 and 128 explicitly selected files")
if any(not _FILE_ID.fullmatch(item.file_id) for item in selected_files):
raise ValueError("Selected file IDs have invalid syntax")
if len({item.file_id for item in selected_files}) != len(selected_files):
raise ValueError("Selected file IDs must be unique")
server = MCPServer("GEWU selected-file connector", version="0.1.0")
files = {item.file_id: item for item in selected_files}
@server.tool(name="list_selected_files", structured_output=False)
def list_selected_files() -> str:
"""List only files explicitly allowlisted when this connector was started."""
listed = []
for selected in files.values():
content, digest = _read_selected_file(selected)
listed.append(
{
"file_id": selected.file_id,
"filename": selected.path.name,
"byte_length": len(content),
"sha256": digest,
}
)
return json.dumps(listed)
@server.tool(name="upload_selected_file", structured_output=False)
async def upload_selected_file_tool(
file_id: str,
project_id: str,
ctx: Context,
source_revision_id: str | None = None,
title: str | None = None,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
] = "other",
reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW,
license: str = "unspecified",
authors: list[str] | None = None,
publisher: str | None = None,
capture_method: Literal["local-file", "notebooklm-export", "transcript"] | None = None,
) -> str:
"""Upload an allowlisted local file; file bytes stay inside this connector."""
selected = files.get(file_id)
if selected is None:
raise ValueError("File ID is not in the connector's explicit allowlist")
try:
async with remote_client_factory() as remote:
receipt = await upload_selected_file(
remote,
selected,
project_id=project_id,
source_revision_id=source_revision_id,
state_store=state_store,
title=title,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
capture_method=capture_method,
)
except Exception:
raise ValueError(
"GEWU source transfer failed; retry the selected-file operation"
) from None
return json.dumps(receipt, ensure_ascii=False)
return server
def _parse_selected_file(value: str) -> SelectedFile:
file_id, separator, raw_path = value.partition("=")
if not separator or not _FILE_ID.fullmatch(file_id) or not raw_path:
raise argparse.ArgumentTypeError("--file must use ID=PATH with a stable file ID")
return SelectedFile(file_id, Path(raw_path))
def _remote_factory(url: str, token: str) -> RemoteClientFactory:
parsed = urlparse(url)
local_hosts = {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not (
parsed.scheme == "http" and parsed.hostname in local_hosts
):
raise ValueError("GEWU URL must use HTTPS except for loopback development")
if not parsed.netloc or parsed.username or parsed.password:
raise ValueError("GEWU URL is invalid")
@asynccontextmanager
async def connect() -> AsyncIterator[Client]:
# Keep service identity verification explicit: remote connectors must
# authenticate the endpoint certificate before sending the bearer key.
async with httpx2.AsyncClient(
headers={"Authorization": f"Bearer {token}"},
verify=True,
) as http:
async with Client(streamable_http_client(url, http_client=http)) as remote:
yield remote
return connect
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True)
parser.add_argument("--file", action="append", type=_parse_selected_file, required=True)
parser.add_argument(
"--state-file",
type=Path,
default=Path(os.environ.get("XDG_STATE_HOME", "~/.local/state")) / "gewu" / "uploads.json",
)
arguments = parser.parse_args()
token = os.environ.get("GEWU_TOKEN", "")
if not token or len(token) > 128 or any(char.isspace() for char in token):
parser.error("GEWU_TOKEN must contain an operator-provisioned bearer credential")
try:
server = create_connector_server(
tuple(arguments.file),
_remote_factory(arguments.url, token),
UploadStateStore(arguments.state_file),
)
except ValueError as error:
parser.error(str(error))
server.run("stdio")
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
"""GEWU service version shared by transports and diagnostics."""
SERVICE_VERSION = "0.1.0"