This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""GEWU knowledge service."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Replaceable infrastructure adapters."""
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)})
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""GEWU use cases."""
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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: ...
|
||||
@@ -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")
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Composition root."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Transport-independent GEWU contracts."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Wire adapters for HTTP and MCP; business rules stay in application services."""
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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"}',
|
||||
}
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GEWU service version shared by transports and diagnostics."""
|
||||
|
||||
SERVICE_VERSION = "0.1.0"
|
||||
Reference in new issue
Block a user