diff --git a/.changes/core-app-separation.added.md b/.changes/core-app-separation.added.md new file mode 100644 index 00000000..f9b74a59 --- /dev/null +++ b/.changes/core-app-separation.added.md @@ -0,0 +1 @@ +新增可独立构建的 inkcre_core SDK,显式绑定数据库与 Peer 执行上下文;服务保留环境、HTTP、调度和安装装配,旧扩展导入继续兼容。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a177a5cb..f518cc99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: cache: true - name: Install locked dependencies - run: pdm install -G dev --frozen-lockfile + run: pdm install -G app -G dev --frozen-lockfile - name: Enforce release intent env: diff --git a/.github/workflows/extension-documentation-publish.yml b/.github/workflows/extension-documentation-publish.yml index 011fb5f6..9cee2505 100644 --- a/.github/workflows/extension-documentation-publish.yml +++ b/.github/workflows/extension-documentation-publish.yml @@ -75,7 +75,7 @@ jobs: cache: true - name: Install frozen publisher - run: pdm install --no-default --group extension-publisher --frozen-lockfile + run: pdm install --no-self --no-default --group extension-publisher --frozen-lockfile - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -126,7 +126,7 @@ jobs: cache: true - name: Install frozen publisher - run: pdm install --no-default --group extension-publisher --frozen-lockfile + run: pdm install --no-self --no-default --group extension-publisher --frozen-lockfile - name: Restore exact documentation candidates uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/extension-publish.yml b/.github/workflows/extension-publish.yml index d3fc5edb..1935c164 100644 --- a/.github/workflows/extension-publish.yml +++ b/.github/workflows/extension-publish.yml @@ -112,7 +112,7 @@ jobs: - name: Install frozen publisher if: steps.selection.outputs.selected == 'true' run: >- - pdm install --no-default --group extension-publisher --frozen-lockfile + pdm install --no-self --no-default --group extension-publisher --frozen-lockfile - name: Build and verify native wheel if: steps.selection.outputs.selected == 'true' @@ -203,7 +203,7 @@ jobs: - name: Install frozen publisher if: steps.candidate.outputs.selected == 'true' run: >- - pdm install --no-default --group extension-publisher --frozen-lockfile + pdm install --no-self --no-default --group extension-publisher --frozen-lockfile - name: Prepare exact native Release association if: steps.candidate.outputs.selected == 'true' diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 06685060..3782835f 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -82,7 +82,7 @@ jobs: - name: Install frozen Extension preview tooling working-directory: source run: >- - pdm install --no-default --group extension-preview --frozen-lockfile + pdm install --no-self --no-default --group extension-preview --frozen-lockfile - name: Build exact-head Extension wheels and sibling Registry facade env: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d2b61d3c..2dc3aceb 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -31,7 +31,7 @@ jobs: cache: true - name: Install locked release tooling - run: pdm install -G dev --frozen-lockfile + run: pdm install -G app -G dev --frozen-lockfile - name: Prepare every pending project run: pdm run release:prepare diff --git a/.github/workflows/self-host-render-neon.yml b/.github/workflows/self-host-render-neon.yml index 8dbb5bef..8d0d71be 100644 --- a/.github/workflows/self-host-render-neon.yml +++ b/.github/workflows/self-host-render-neon.yml @@ -41,7 +41,7 @@ jobs: cache: true - name: Install locked runtime dependencies - run: pdm install --prod --no-editable --frozen-lockfile + run: pdm install -G app --prod --no-editable --frozen-lockfile - name: Resolve the Neon default branch owner coordinates id: neon diff --git a/AGENTS.md b/AGENTS.md index 8350f947..c8b16ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,11 @@ Before choosing a design, identify shared state, business contracts, and each pa ## Repository Map - `run.py`: FastAPI bootstrap -- `app/`: application routes, schemas, and business units +- `inkcre_core/`: independently consumable SDK, models, domain operations, persistence and execution +- `app/`: service routes, configuration, extension/sink composition and legacy import shims - `cli/`: `inkcre-cli` implementation - `extensions/`: built-in first-party extension packages -- `libs/`: shared libraries +- `libs/`, `utils/`: legacy imports; implementations live under `inkcre_core/` - `migrations/`: Alembic revisions and integrity baseline (append-only after deployment) - `tests/`: migration integrity and mature integration/acceptance checks - `docs/30-unit-tdd/`: `impl-py` internal design diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84fcdd8e..edd2e096 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ bugs that do not cross a security boundary. ```bash git submodule update --init --recursive -pdm install -G dev --frozen-lockfile +pdm install -G app -G dev --frozen-lockfile pdm run pre-commit install pdm run doctor pdm run check diff --git a/Dockerfile b/Dockerfile index 8f209f10..742f479d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ WORKDIR /app # PDM 2.28 requires the httpx client replaced in unearth 0.19. RUN pip install --no-cache-dir "pdm==${PDM_VERSION}" "unearth==0.18.3" -COPY pyproject.toml pdm.lock README.md ./ -RUN pdm install --prod --no-editable --frozen-lockfile +COPY pyproject.toml pdm.lock README.md LICENSE ./ +COPY inkcre_core/ inkcre_core/ +RUN pdm install -G app --prod --no-editable --frozen-lockfile FROM python:${PYTHON_VERSION}-slim AS runtime diff --git a/README.md b/README.md index d5e32cfd..c4e8315d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ You do not need to clone this repository. Fork it on GitHub, configure the docum ```bash git submodule update --init --recursive -pdm install -G dev --frozen-lockfile +pdm install -G app -G dev --frozen-lockfile svc dev ensure database --repo . --json pdm run dev ``` @@ -40,3 +40,28 @@ Report vulnerabilities privately through [SECURITY.md](SECURITY.md). Security de - OpenAPI schema: `docs/openapi.json` (regenerate with `pdm run python scripts/generate-openapi.py`) - This repository does not publish hosted API documentation automatically + +## 独立 Python SDK + +构建 `pdm build` 得到 `inkcre-core` wheel,安装后使用 `inkcre_core`。基础依赖不包含 FastAPI、Uvicorn、APScheduler 或扩展安装器;服务开发使用 `pdm install -G app -G dev`。Core 当前支持 Python 3.12、PostgreSQL/psycopg,数据库 schema 和迁移仍由本仓 App 运维入口管理。 + +```python +from inkcre_core import Core +from inkcre_core.business.info_base import BlockService + +async def read_block(database_url: str, block_id: int): + core = Core(database_url) + try: + with core.bind(): + return await BlockService.get(block_id) + finally: + await core.aclose() +``` + +`Core` 构造不连接数据库或启动执行者。调用方传入数据库 URL 或借用的 `AsyncEngine`,用 `CoreOptions` 提供 Peer 身份、认证和调试参数。绑定按异步任务隔离,子任务继承;每次领域操作仍建立自己的短事务。A、B 实例可以并行操作不同信息库,应用切换配置不改变既有实例。关闭会收束该实例的 Job,释放自建 engine;借用 engine 的关闭权仍属于调用方。调用方应先结束自己的操作再关闭实例。 + +公开消费面是 `Core` / `CoreOptions`、`inkcre_core.schemas` 的模型,以及各 `inkcre_core.business` 包导出的领域入口(例如 `BlockService`、`submit_graph`)。`persistence` 和私有实现模块属于内部细节。普通读写不需要启动 Job、恢复扩展或加载 `app.settings`;使用 Resolver 时可显式注册所需实现。Job 的启动和轮询由调用方决定。 + +HTTP 路由、环境读取、调度频率、扩展安装/进程重启和 Sink 的服务挂载由 `app/` 保留。已有独立 Extension runtime 的生命周期合同保持原 owner。原 `app.business.*`、`app.schemas.*`、`libs.*`、`utils.*` 导入作为服务的兼容入口保留,历史迁移与已发布扩展仍指向同一实现;独立 wheel 只交付 `inkcre_core`,不交付这些旧服务路径。 + +本地边界检查:`pdm run check:core-package` 构建 wheel 并在仓库之外检查独立消费,`pdm run python -m pytest -q tests/test_core_context.py` 验证并行绑定与资源所有权;两者均不连接数据库,不属于端到端验收。 diff --git a/app/AGENTS.md b/app/AGENTS.md index d3ebabc2..797ef93c 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -1,9 +1,9 @@ -# Application Core +# Service Application > Applies to `app/`; deeper guides add narrower constraints. - Authority and dependency direction are documented in `docs/30-unit-tdd/business-pipeline-and-authority.md`; do not reconstruct them from directory names. -- Settings enter through `app/settings.py`; database sessions enter through `app/engine.py`. Do not create a second process-global settings, engine, or session authority. +- Settings enter through `app/settings.py`; `app/core.py` composes and binds the service-owned SDK instance. `app/engine.py` only preserves old service imports. Domain operations use the bound Core and own short UoWs. - Runtime registration and database effects belong in lifespan/bootstrap paths, not module import. Preserve import-only OpenAPI generation. - Routes parse transport input and call owning business managers; do not duplicate business policy in routes. - Required check: run the narrow tests for the changed unit, then `pdm run check:foundation` for bootstrap, dependency, or import-boundary changes. diff --git a/app/business/AGENTS.md b/app/business/AGENTS.md index a6d9d538..c6bc92b7 100644 --- a/app/business/AGENTS.md +++ b/app/business/AGENTS.md @@ -1,12 +1,3 @@ -# Business Units +# Service-owned execution -> Applies to `app/business/`; deeper guides own local hazards. - -- Read `docs/30-unit-tdd/business-pipeline-and-authority.md` before a change crosses business subtrees. -- `info_base` owns graph persistence; sources and extensions propose commands but do not copy generic persistence. -- AI execution remains graph-blind. Agent and organization layers may compose it but must not move graph or resolver policy into AI. -- Semantic and lexical retrieval own separate derived records and ranking policy; neither owns graph facts or mutates the graph while reading. -- Global Job/Cron machinery owns durable execution occurrence and claim lifecycle; domain units retain typed payload and effect semantics. -- Peer owns identity, capability, lease, and delegation transport facts; typed business codecs remain with their business owner. -- Application use cases choose UoW scopes; raw sessions stay in persistence. Composed operations require a UoW and never end its transaction. Each concurrent task owns a separate UoW; external I/O runs outside DB scopes. -- Required check: run tests for every affected business owner and the repository import-boundary checks. +Extension installation/restart and FastAPI Sink composition belong here. Reusable domain operations and transaction ownership live in `inkcre_core`; remaining old domain modules only preserve published imports. Do not add application settings, scheduler or HTTP dependencies to the SDK. diff --git a/app/business/agent/__init__.py b/app/business/agent/__init__.py index 33fcb1d3..a891e654 100644 --- a/app/business/agent/__init__.py +++ b/app/business/agent/__init__.py @@ -1,40 +1,3 @@ -"""Reusable Agent definitions, typed Tools and in-memory Thread execution.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .contracts import ( - AgentError, - AgentNotFoundError, - AgentToolBindingError, - AgentTurnActiveError, - BoundAgentTool, - DuplicateAgentToolRegistrationError, - MissingAgentToolError, - ToolExecutionError, -) -from .main import AgentManager -from .bootstrap import register_core_agent_tools -from .persistence import ( - InMemoryThreadPersistenceBackend, - ThreadID, - ThreadPersistenceBackend, - ThreadState, -) -from .thread import Thread, TurnTermination - -__all__ = [ - "AgentError", - "AgentManager", - "register_core_agent_tools", - "AgentNotFoundError", - "AgentToolBindingError", - "AgentTurnActiveError", - "BoundAgentTool", - "DuplicateAgentToolRegistrationError", - "InMemoryThreadPersistenceBackend", - "MissingAgentToolError", - "Thread", - "ThreadID", - "ThreadPersistenceBackend", - "ThreadState", - "ToolExecutionError", - "TurnTermination", -] +from inkcre_core.business.agent import * diff --git a/app/business/agent/bootstrap.py b/app/business/agent/bootstrap.py index d55bff50..c97d869a 100644 --- a/app/business/agent/bootstrap.py +++ b/app/business/agent/bootstrap.py @@ -1,17 +1,10 @@ -"""Explicit registration entrypoint for core-owned Agent Tool controllers.""" +"""Legacy import; use inkcre_core for new consumers.""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -def register_core_agent_tools() -> None: - """Import every core Tool owner, including Sink delivery controllers. +sys.modules[__name__] = import_module("inkcre_core.business.agent.bootstrap") - Decorators register Tools when their modules load; Python's import cache makes - repeated bootstrap calls harmless. Entry points must call this explicitly, - rather than rely on routes or package re-exports importing the owners first. - """ - from app.business.graph_navigation_retrieval import tools as _graph_tools - from app.business.info_base import tools as _info_base_tools - from app.business.info_base.resolver import tools as _resolver_tools - from app.business.organization import tools as _organization_tools - from app.business.sink import agent_query as _agent_query - - del _graph_tools, _info_base_tools, _resolver_tools, _organization_tools, _agent_query +if TYPE_CHECKING: + from inkcre_core.business.agent.bootstrap import * diff --git a/app/business/agent/contracts.py b/app/business/agent/contracts.py index 6b47fdc9..26349453 100644 --- a/app/business/agent/contracts.py +++ b/app/business/agent/contracts.py @@ -1,59 +1,10 @@ -"""Agent Tool binding and runtime failure contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -import typing +sys.modules[__name__] = import_module("inkcre_core.business.agent.contracts") -import pydantic - -from app.schemas.ai import FunctionTool, JSONValue - - -AgentToolHandler: typing.TypeAlias = Callable[ - [pydantic.BaseModel], - JSONValue | Awaitable[JSONValue], -] -AgentToolInputModelFactory: typing.TypeAlias = Callable[[], type[pydantic.BaseModel]] - - -class AgentError(RuntimeError): - """Base failure exposed by the Agent domain.""" - - -class AgentNotFoundError(AgentError): - pass - - -class AgentToolBindingError(AgentError): - pass - - -class DuplicateAgentToolRegistrationError(AgentToolBindingError): - pass - - -class MissingAgentToolError(AgentToolBindingError): - pass - - -class AgentTurnActiveError(AgentError): - pass - - -class ToolExecutionError(Exception): - """A Tool-owned actionable failure intended for the model.""" - - def __init__(self, content: JSONValue): - super().__init__("Agent Tool execution failed") - self.content = content - - -@dataclass(frozen=True) -class BoundAgentTool: - """One run-local Tool schema, validation model and code-owned handler.""" - - definition: FunctionTool - input_model: type[pydantic.BaseModel] - handler: AgentToolHandler +if TYPE_CHECKING: + from inkcre_core.business.agent.contracts import * diff --git a/app/business/agent/debug.py b/app/business/agent/debug.py index deeb4239..a9ecc8ce 100644 --- a/app/business/agent/debug.py +++ b/app/business/agent/debug.py @@ -1,51 +1,10 @@ -"""Opt-in development traces through the existing logging backend.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -import datetime -import json +from typing import TYPE_CHECKING +from importlib import import_module import sys -import typing -import uuid -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.agent.debug") -from app.settings import settings -from libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID -from libs.obsrv.main import get_logger - - -def _json_value(value: typing.Any) -> typing.Any: - if isinstance(value, pydantic.BaseModel): - return value.model_dump(mode="python") - if isinstance(value, bytes): - return {"omitted_binary_bytes": len(value)} - if isinstance(value, datetime.datetime | datetime.date): - return value.isoformat() - if isinstance(value, uuid.UUID): - return str(value) - raise TypeError(f"Unsupported Agent trace value: {type(value).__name__}") - - -def _emit(event: str, thread_id: str, payload: dict[str, typing.Any]) -> None: - token = ENABLE_LOG_BACKEND.set(True) - try: - envelope = { - "event": event, - "thread_id": thread_id, - "trace_id": TRACE_ID.get(), - **payload, - } - get_logger().getChild("agent.debug").info( - json.dumps(envelope, ensure_ascii=False, default=_json_value), - extra={"event": event, "agent_thread_id": thread_id}, - ) - except Exception as error: - # A debug destination must never replace the Agent's real outcome. - sys.stderr.write(f"Agent debug trace unavailable: {type(error).__name__}\n") - finally: - ENABLE_LOG_BACKEND.reset(token) - - -async def trace(event: str, thread_id: uuid.UUID, **payload: typing.Any) -> None: - if settings.obsrv.agent_debug: - await asyncio.to_thread(_emit, event, str(thread_id), payload) +if TYPE_CHECKING: + from inkcre_core.business.agent.debug import * diff --git a/app/business/agent/main.py b/app/business/agent/main.py index 94f2e20f..cb79293a 100644 --- a/app/business/agent/main.py +++ b/app/business/agent/main.py @@ -1,244 +1,10 @@ -"""Persisted Agent definition loading and exact Tool registry binding.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from dataclasses import dataclass -import inspect -import typing +sys.modules[__name__] = import_module("inkcre_core.business.agent.main") -import pydantic - -from app.business.ai import AIExecutionRequirement, AIManager -from app.schemas import AgentDefinitionModel -from app.schemas.agent import AgentID, AgentForm, AgentUpdateForm -from app.schemas.ai import FunctionTool, SystemMessage, UserMessage - -from app.persistence.agent.uow import agent_uow - -from .contracts import ( - AgentNotFoundError, - AgentToolBindingError, - AgentToolHandler, - AgentToolInputModelFactory, - BoundAgentTool, - DuplicateAgentToolRegistrationError, - MissingAgentToolError, -) -from .persistence import ( - InMemoryThreadPersistenceBackend, - ThreadPersistenceBackend, - ThreadState, -) -from .thread import Thread -from .debug import trace - - -HandlerT = typing.TypeVar("HandlerT", bound=typing.Callable[..., typing.Any]) - - -@dataclass(frozen=True) -class _ToolRegistration: - handler: AgentToolHandler - original_handler: typing.Callable[..., typing.Any] - description: str - input_model: type[pydantic.BaseModel] - input_model_factory: AgentToolInputModelFactory | None - - def bind(self, tool_id: str) -> BoundAgentTool: - input_model = ( - self.input_model_factory() - if self.input_model_factory is not None - else self.input_model - ) - if not isinstance(input_model, type) or not issubclass(input_model, pydantic.BaseModel): - raise AgentToolBindingError( - f"Agent Tool {tool_id!r} did not produce a Pydantic input model" - ) - return BoundAgentTool( - definition=FunctionTool( - id=tool_id, - description=self.description, - input_schema=input_model.model_json_schema(), - ), - input_model=input_model, - handler=self.handler, - ) - - -class AgentManager: - """Agent-definition execution and peer-local exact Tool registry.""" - - _TOOLS: dict[str, _ToolRegistration] = {} - _persistence: ThreadPersistenceBackend = InMemoryThreadPersistenceBackend() - - @classmethod - async def get_definition(cls, agent_id: AgentID) -> AgentDefinitionModel | None: - async with agent_uow() as agents: - return await agents.get(agent_id) - - @classmethod - async def list_definitions( - cls, *, limit: int | None = None, cursor: int | None = None - ) -> tuple[list[AgentDefinitionModel], int | None]: - async with agent_uow() as agents: - return await agents.list(limit=limit, cursor=cursor) - - @classmethod - async def create_definition(cls, form: AgentForm) -> AgentDefinitionModel: - async with agent_uow() as agents: - record = await agents.create(form) - return record - - @classmethod - async def update_definition( - cls, agent_id: AgentID, form: AgentUpdateForm - ) -> AgentDefinitionModel: - async with agent_uow() as agents: - record = await agents.get(agent_id, for_update=True) - if record is None: - raise AgentNotFoundError(f"Agent {agent_id} does not exist") - changes = form.model_dump(exclude_unset=True) - candidate = AgentForm.model_validate( - {**{field: getattr(record, field) for field in AgentForm.model_fields}, **changes} - ) - for field in changes: - setattr(record, field, getattr(candidate, field)) - await agents.save(record) - return record - - @classmethod - async def delete_definition(cls, agent_id: AgentID) -> bool: - async with agent_uow() as agents: - record = await agents.get(agent_id) - if record is None: - return False - await agents.delete(record) - return True - - @classmethod - def list_tools( - cls, *, limit: int | None = None, cursor: str | None = None - ) -> tuple[list[dict[str, str]], str | None]: - ids = sorted(key for key in cls._TOOLS if cursor is None or key > cursor) - more = limit is not None and len(ids) > limit - ids = ids[:limit] - return [{"id": key, "description": cls._TOOLS[key].description} for key in ids], ids[ - -1 - ] if more else None - - @classmethod - def get_tool(cls, tool_id: str) -> FunctionTool: - registration = cls._TOOLS.get(tool_id) - if registration is None: - raise MissingAgentToolError(f"Agent Tool {tool_id!r} is not registered") - return registration.bind(tool_id).definition - - @classmethod - async def can_execute(cls, agent_id: AgentID, input_modality: str) -> bool: - """Return static local eligibility for one Agent and canonical input modality.""" - definition = await cls.get_definition(agent_id) - if definition is None: - return False - try: - cls._bind_tools(definition.tools) - except MissingAgentToolError: - return False - requires_tools = bool(definition.tools) or definition.tool_choice is not None - return await AIManager.can_execute( - definition.model, - AIExecutionRequirement( - capability="chat", - input_modalities=frozenset({"text", input_modality}), - output_modalities=frozenset({"text"}), - features=frozenset({"tool_calling"}) if requires_tools else frozenset(), - tool_choice=definition.tool_choice, - ), - ) - - @classmethod - def tool( - cls, - tool_id: str, - *, - description: str, - input_model_factory: AgentToolInputModelFactory | None = None, - ) -> typing.Callable[[HandlerT], HandlerT]: - """Register one function as a typed Agent Tool and return it unchanged.""" - if not tool_id: - raise AgentToolBindingError("Agent Tool ID must not be empty") - - def decorator(handler: HandlerT) -> HandlerT: - signature = inspect.signature(handler) - parameters = tuple(signature.parameters.values()) - if len(parameters) != 1: - raise AgentToolBindingError( - f"Agent Tool {tool_id!r} handler must accept exactly one input model" - ) - hints = typing.get_type_hints(handler) - annotation = hints.get(parameters[0].name) - if not isinstance(annotation, type) or not issubclass(annotation, pydantic.BaseModel): - raise AgentToolBindingError( - f"Agent Tool {tool_id!r} input must be annotated with a Pydantic model" - ) - - existing = cls._TOOLS.get(tool_id) - if existing is not None: - if ( - existing.original_handler is handler - and existing.description == description - and existing.input_model_factory is input_model_factory - ): - return handler - raise DuplicateAgentToolRegistrationError( - f"Agent Tool {tool_id!r} is already registered" - ) - - cls._TOOLS[tool_id] = _ToolRegistration( - handler=typing.cast(AgentToolHandler, handler), - original_handler=handler, - description=description, - input_model=annotation, - input_model_factory=input_model_factory, - ) - return handler - - return decorator - - @classmethod - async def run(cls, agent_id: AgentID, initial_message: UserMessage) -> Thread: - """Create one active Thread from a persisted Agent definition snapshot.""" - definition = await cls.get_definition(agent_id) - if definition is None: - raise AgentNotFoundError(f"Agent {agent_id} does not exist") - - bound_tools = cls._bind_tools(definition.tools) - state = ThreadState( - model=definition.model, - tools=tuple(tool.definition for tool in bound_tools), - tool_choice=definition.tool_choice, - max_model_calls_per_turn=definition.max_model_calls_per_turn, - messages=(SystemMessage(content=definition.system_prompt),), - ) - thread_id, persisted = await cls._persistence.create(state) - await trace( - "agent.thread.created", - thread_id, - agent_id=agent_id, - agent_name=definition.name, - state=persisted, - ) - thread = Thread(thread_id, persisted, cls._persistence, bound_tools) - thread.start_turn(initial_message) - return thread - - @classmethod - def _bind_tools(cls, tool_ids: tuple[str, ...]) -> tuple[BoundAgentTool, ...]: - bound: list[BoundAgentTool] = [] - for tool_id in tool_ids: - registration = cls._TOOLS.get(tool_id) - if registration is None: - raise MissingAgentToolError( - f"Agent definition references unavailable Tool {tool_id!r}" - ) - bound.append(registration.bind(tool_id)) - return tuple(bound) +if TYPE_CHECKING: + from inkcre_core.business.agent.main import * diff --git a/app/business/agent/persistence.py b/app/business/agent/persistence.py index 2b8252bc..a1d43b34 100644 --- a/app/business/agent/persistence.py +++ b/app/business/agent/persistence.py @@ -1,147 +1,10 @@ -"""Replaceable whole-Thread persistence with an in-memory MVP backend.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import asyncio -from abc import ABC, abstractmethod -import typing -import uuid +sys.modules[__name__] = import_module("inkcre_core.business.agent.persistence") -import pydantic - -from app.schemas.ai import ( - AIModelID, - AssistantMessage, - FunctionTool, - Message, - ToolChoice, - validate_message_history, -) - - -ThreadID: typing.TypeAlias = uuid.UUID - - -def validate_thread_message_history(value: typing.Any) -> tuple[Message, ...]: - """Allow strict history plus one recoverable trailing ToolCall message.""" - messages = tuple(value) - if messages and isinstance(messages[-1], AssistantMessage) and messages[-1].tool_calls: - validate_message_history(messages[:-1]) - return messages - return validate_message_history(messages) - - -class ThreadState(pydantic.BaseModel): - """Serializable Thread snapshot owned by a persistence backend.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - model: AIModelID - tools: tuple[FunctionTool, ...] - tool_choice: ToolChoice | None - max_model_calls_per_turn: int = pydantic.Field(gt=0) - messages: tuple[Message, ...] - - @pydantic.field_validator("tools") - @classmethod - def unique_tools(cls, value: tuple[FunctionTool, ...]) -> tuple[FunctionTool, ...]: - tool_ids = tuple(tool.id for tool in value) - if len(tool_ids) != len(set(tool_ids)): - raise ValueError("Thread Tool IDs must be unique") - return value - - @pydantic.field_validator("messages") - @classmethod - def closed_or_recoverable_history(cls, value: tuple[Message, ...]) -> tuple[Message, ...]: - return validate_thread_message_history(value) - - -class ThreadPersistenceBackend(ABC): - """Atomic persistence boundary for complete Thread snapshots.""" - - @abstractmethod - async def create(self, state: ThreadState) -> tuple[ThreadID, ThreadState]: - raise NotImplementedError - - @abstractmethod - async def read(self, thread_id: ThreadID) -> ThreadState: - raise NotImplementedError - - @abstractmethod - async def append( - self, - thread_id: ThreadID, - messages: tuple[Message, ...], - ) -> ThreadState: - raise NotImplementedError - - @abstractmethod - async def discard_trailing_incomplete_tool_calls( - self, - thread_id: ThreadID, - ) -> ThreadState: - raise NotImplementedError - - -class InMemoryThreadPersistenceBackend(ThreadPersistenceBackend): - """Process-local backend preserving atomic Thread message updates.""" - - def __init__(self) -> None: - self._states: dict[ThreadID, ThreadState] = {} - self._locks: dict[ThreadID, asyncio.Lock] = {} - - async def create(self, state: ThreadState) -> tuple[ThreadID, ThreadState]: - thread_id = uuid.uuid4() - self._states[thread_id] = state - self._locks[thread_id] = asyncio.Lock() - return thread_id, state - - async def read(self, thread_id: ThreadID) -> ThreadState: - try: - return self._states[thread_id] - except KeyError as error: - raise KeyError(f"Thread {thread_id} does not exist") from error - - async def append( - self, - thread_id: ThreadID, - messages: tuple[Message, ...], - ) -> ThreadState: - async with self._lock(thread_id): - current = await self.read(thread_id) - updated = ThreadState( - model=current.model, - tools=current.tools, - tool_choice=current.tool_choice, - max_model_calls_per_turn=current.max_model_calls_per_turn, - messages=current.messages + messages, - ) - self._states[thread_id] = updated - return updated - - async def discard_trailing_incomplete_tool_calls( - self, - thread_id: ThreadID, - ) -> ThreadState: - async with self._lock(thread_id): - current = await self.read(thread_id) - if not current.messages: - return current - trailing = current.messages[-1] - if not isinstance(trailing, AssistantMessage) or not trailing.tool_calls: - return current - updated = ThreadState( - model=current.model, - tools=current.tools, - tool_choice=current.tool_choice, - max_model_calls_per_turn=current.max_model_calls_per_turn, - messages=current.messages[:-1], - ) - self._states[thread_id] = updated - return updated - - def _lock(self, thread_id: ThreadID) -> asyncio.Lock: - try: - return self._locks[thread_id] - except KeyError as error: - raise KeyError(f"Thread {thread_id} does not exist") from error +if TYPE_CHECKING: + from inkcre_core.business.agent.persistence import * diff --git a/app/business/agent/projection.py b/app/business/agent/projection.py index 658b2a5d..7c57cb83 100644 --- a/app/business/agent/projection.py +++ b/app/business/agent/projection.py @@ -1,45 +1,10 @@ -"""JSON projection shared by domain-owned Agent Tool controllers.""" +"""Legacy import; use inkcre_core for new consumers.""" -import dataclasses -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.agent.projection") -from app.schemas.ai import JSONValue - - -_JSON_ADAPTER = pydantic.TypeAdapter(JSONValue) - - -def project_json(value: typing.Any) -> JSONValue: - """Project ordinary domain values without admitting binary Tool results.""" - if _contains_bytes(value): - raise TypeError("Binary values are unavailable through JSON Agent Tools") - if isinstance(value, pydantic.BaseModel): - projected = value.model_dump(mode="json") - elif dataclasses.is_dataclass(value) and not isinstance(value, type): - projected = dataclasses.asdict(value) - else: - projected = pydantic.TypeAdapter(typing.Any).dump_python( - typing.cast(typing.Any, value), - mode="json", - ) - return _JSON_ADAPTER.validate_python(projected) - - -def _contains_bytes(value: typing.Any) -> bool: - if isinstance(value, bytes): - return True - if isinstance(value, pydantic.BaseModel): - return any( - _contains_bytes(getattr(value, field)) for field in value.__class__.model_fields - ) - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return any( - _contains_bytes(getattr(value, field.name)) for field in dataclasses.fields(value) - ) - if isinstance(value, dict): - return any(_contains_bytes(item) for item in value.values()) - if isinstance(value, list | tuple | set | frozenset): - return any(_contains_bytes(item) for item in value) - return False +if TYPE_CHECKING: + from inkcre_core.business.agent.projection import * diff --git a/app/business/agent/thread.py b/app/business/agent/thread.py index d8d06c91..83f9ca75 100644 --- a/app/business/agent/thread.py +++ b/app/business/agent/thread.py @@ -1,316 +1,10 @@ -"""Cancellable Agent Thread and per-turn model/Tool orchestration.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import asyncio -from enum import StrEnum -import inspect -import hashlib -import json -import time -import traceback -import typing +sys.modules[__name__] = import_module("inkcre_core.business.agent.thread") -import pydantic - -from app.business.ai import AIManager -from app.schemas.ai import ( - AssistantMessage, - JSONValue, - ToolCall, - ToolResult, - ToolResultMessage, - UserMessage, -) - -from .contracts import AgentTurnActiveError, BoundAgentTool, ToolExecutionError -from .persistence import ThreadID, ThreadPersistenceBackend, ThreadState -from .debug import trace -from libs.obsrv.main import get_logger -from libs.obsrv.telemetry import operation - - -logger = get_logger().getChild("agent.thread") - - -class TurnTermination(StrEnum): - COMPLETED = "completed" - MAX_MODEL_CALLS = "max_model_calls" - - -class Thread: - """One persisted Agent-definition snapshot with at most one active Turn.""" - - def __init__( - self, - thread_id: ThreadID, - state: ThreadState, - persistence: ThreadPersistenceBackend, - tools: tuple[BoundAgentTool, ...], - ) -> None: - self.id = thread_id - self._state = state - self._persistence = persistence - self._tools = {tool.definition.id: tool for tool in tools} - self.current_turn: asyncio.Task[TurnTermination] | None = None - self._turn_index = 0 - self._model_calls = 0 - - @property - def messages(self): - return self._state.messages - - @property - def model(self): - return self._state.model - - @property - def tools(self): - return self._state.tools - - @property - def tool_choice(self): - return self._state.tool_choice - - @property - def max_model_calls_per_turn(self): - return self._state.max_model_calls_per_turn - - async def refresh(self) -> ThreadState: - """Refresh the local view from the persistence authority.""" - self._state = await self._persistence.read(self.id) - return self._state - - def start_turn(self, input: UserMessage) -> asyncio.Task[TurnTermination]: - """Schedule one complete model/Tool turn and return its cancellation handle.""" - if self.current_turn is not None and not self.current_turn.done(): - raise AgentTurnActiveError(f"Thread {self.id} already has an active Turn") - self.current_turn = asyncio.create_task( - self._run_turn(input), - name=f"agent-thread-{self.id}", - ) - return self.current_turn - - async def _run_turn(self, input: UserMessage) -> TurnTermination: - self._turn_index += 1 - self._model_calls = 0 - with operation( - "agent.turn", - attributes={ - "gen_ai.conversation.id": str(self.id), - "inkcre.agent.turn": self._turn_index, - }, - ) as observation: - span = observation.span - started = time.monotonic() - await trace( - "agent.turn.started", - self.id, - turn=self._turn_index, - input=input, - model=self.model, - max_model_calls=self.max_model_calls_per_turn, - ) - try: - outcome = await self._execute_turn(input) - except asyncio.CancelledError: - span.set_attribute("inkcre.agent.outcome", "cancelled") - await trace( - "agent.turn.finished", - self.id, - turn=self._turn_index, - model_calls=self._model_calls, - outcome="cancelled", - elapsed_seconds=time.monotonic() - started, - ) - raise - except Exception as error: - span.set_attribute("inkcre.agent.outcome", "failed") - await trace( - "agent.turn.finished", - self.id, - turn=self._turn_index, - model_calls=self._model_calls, - outcome="failed", - error_type=type(error).__name__, - error=str(error), - traceback=traceback.format_exc(), - elapsed_seconds=time.monotonic() - started, - ) - raise - await trace( - "agent.turn.finished", - self.id, - turn=self._turn_index, - model_calls=self._model_calls, - outcome=outcome, - elapsed_seconds=time.monotonic() - started, - ) - span.set_attribute("inkcre.agent.outcome", outcome.value) - span.set_attribute("inkcre.agent.model_calls", self._model_calls) - return outcome - - async def _execute_turn(self, input: UserMessage) -> TurnTermination: - self._state = await self._persistence.discard_trailing_incomplete_tool_calls(self.id) - self._state = await self._persistence.append(self.id, (input,)) - - while True: - self._model_calls += 1 - with operation( - "agent.step", - attributes={ - "gen_ai.conversation.id": str(self.id), - "inkcre.agent.turn": self._turn_index, - "inkcre.agent.model_call": self._model_calls, - }, - ): - await trace( - "agent.model.started", - self.id, - turn=self._turn_index, - call=self._model_calls, - ) - started = time.monotonic() - assistant = await AIManager.chat( - self._state.model, - self._state.messages, - self._state.tools, - self._state.tool_choice, - ) - await trace( - "agent.model.completed", - self.id, - turn=self._turn_index, - call=self._model_calls, - response=assistant, - elapsed_seconds=time.monotonic() - started, - ) - if not assistant.tool_calls: - self._state = await self._persistence.append(self.id, (assistant,)) - return TurnTermination.COMPLETED - - results = await self._execute_tool_batch(assistant) - self._state = await self._persistence.append( - self.id, - (assistant, ToolResultMessage(results=results)), - ) - if self._model_calls >= self._state.max_model_calls_per_turn: - return TurnTermination.MAX_MODEL_CALLS - - async def _execute_tool_batch( - self, - assistant: AssistantMessage, - ) -> tuple[ToolResult, ...]: - tasks = tuple( - asyncio.create_task( - self._execute_tool_call(call), - name=f"agent-tool-{call.tool}-{call.id}", - ) - for call in assistant.tool_calls - ) - return tuple(await asyncio.gather(*tasks)) - - async def _execute_tool_call(self, call: ToolCall) -> ToolResult: - with operation( - "agent.tool", - attributes={ - "gen_ai.operation.name": "execute_tool", - "gen_ai.conversation.id": str(self.id), - "inkcre.agent.turn": self._turn_index, - "inkcre.agent.model_call": self._model_calls, - # Provider-generated IDs may contain content; retain correlation as a digest. - "inkcre.agent.tool_call.id_hash": hashlib.sha256( - call.id.encode(errors="replace") - ).hexdigest(), - }, - ) as observation: - span = observation.span - tool = self._tools.get(call.tool) - if tool is not None: - span.set_attribute("gen_ai.tool.name", tool.definition.id) - await trace( - "agent.tool.started", - self.id, - turn=self._turn_index, - call=self._model_calls, - tool_call=call, - ) - started = time.monotonic() - try: - result = await self._invoke_tool_call(call) - except asyncio.CancelledError: - span.set_attribute("inkcre.agent.outcome", "cancelled") - await trace( - "agent.tool.cancelled", - self.id, - turn=self._turn_index, - call=self._model_calls, - tool_call_id=call.id, - tool=call.tool, - elapsed_seconds=time.monotonic() - started, - ) - raise - await trace( - "agent.tool.completed", - self.id, - turn=self._turn_index, - call=self._model_calls, - tool=call.tool, - result=result, - elapsed_seconds=time.monotonic() - started, - ) - span.set_attribute( - "inkcre.agent.outcome", "error" if result.is_error else "completed" - ) - if result.is_error: - span.set_attribute("error.type", "ToolResultError") - observation.outcome = "error" - return result - - async def _invoke_tool_call(self, call: ToolCall) -> ToolResult: - tool = self._tools.get(call.tool) - if tool is None: - return ToolResult( - tool_call_id=call.id, - content={"error": "unknown_tool", "tool": call.tool}, - is_error=True, - ) - - try: - tool_input = tool.input_model.model_validate(call.arguments) - except pydantic.ValidationError as error: - return ToolResult( - tool_call_id=call.id, - content=typing.cast(JSONValue, json.loads(error.json())), - is_error=True, - ) - - try: - content = tool.handler(tool_input) - if inspect.isawaitable(content): - content = await content - return ToolResult(tool_call_id=call.id, content=content) - except ToolExecutionError as error: - return ToolResult( - tool_call_id=call.id, - content=error.content, - is_error=True, - ) - except Exception as error: - await trace( - "agent.tool.exception", - self.id, - turn=self._turn_index, - call=self._model_calls, - tool_call_id=call.id, - tool=call.tool, - error_type=type(error).__name__, - error=str(error), - traceback=traceback.format_exc(), - ) - logger.exception("Unexpected Agent Tool failure", extra={"tool": call.tool}) - return ToolResult( - tool_call_id=call.id, - content={"error": "tool_execution_failed"}, - is_error=True, - ) +if TYPE_CHECKING: + from inkcre_core.business.agent.thread import * diff --git a/app/business/ai/__init__.py b/app/business/ai/__init__.py index df830957..a0700833 100644 --- a/app/business/ai/__init__.py +++ b/app/business/ai/__init__.py @@ -1,45 +1,3 @@ -"""AI registry facts and peer-local capability execution.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .contracts import ( - AICapabilityUnavailableError, - AIDialectAdapter, - AIExecutionRequirement, - AIFeatureUnavailableError, - AIInputUnavailableError, - AIModelDisabledError, - AIModelNotFoundError, - AIOutputContractError, - AIProviderDisabledError, - AIProviderNotFoundError, - DuplicateAIDialectRegistrationError, - InvalidAIProviderConfigError, - UnknownAIDialectError, -) -from .main import AIManager - -# Import installed core dialects only after AIManager exists so decorators register. -from .dialects import ( - AlibabaModelStudioDialect, - OpenAICompatibleConfig, - OpenAICompatibleDialect, -) - -__all__ = [ - "AICapabilityUnavailableError", - "AIDialectAdapter", - "AIExecutionRequirement", - "AIFeatureUnavailableError", - "AIInputUnavailableError", - "AIManager", - "AIModelDisabledError", - "AIModelNotFoundError", - "AIOutputContractError", - "AIProviderDisabledError", - "AIProviderNotFoundError", - "AlibabaModelStudioDialect", - "DuplicateAIDialectRegistrationError", - "InvalidAIProviderConfigError", - "OpenAICompatibleConfig", - "OpenAICompatibleDialect", - "UnknownAIDialectError", -] +from inkcre_core.business.ai import * diff --git a/app/business/ai/contracts.py b/app/business/ai/contracts.py index f9f3b15a..9a5a3f35 100644 --- a/app/business/ai/contracts.py +++ b/app/business/ai/contracts.py @@ -1,111 +1,10 @@ -"""AI dialect adapter boundary and stable execution failures.""" +"""Legacy import; use inkcre_core for new consumers.""" -import abc -from collections.abc import Sequence -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.ai.contracts") -from app.schemas.ai import ( - AICapabilityType, - AssistantMessage, - FunctionTool, - Message, - ToolChoice, -) -from app.schemas.info_base.main import Vector - - -@dataclass(frozen=True) -class AIExecutionRequirement: - """Static capability shape used for peer-local execution eligibility.""" - - capability: AICapabilityType - input_modalities: frozenset[str] - output_modalities: frozenset[str] - features: frozenset[str] = frozenset() - tool_choice: ToolChoice | None = None - - -class DuplicateAIDialectRegistrationError(ValueError): - """Two adapter implementations claimed the same exact dialect ID.""" - - -class UnknownAIDialectError(LookupError): - """The current peer has no adapter for an exact persisted dialect ID.""" - - -class AIProviderNotFoundError(LookupError): - """A requested AI model references no provider visible to this peer.""" - - -class AIModelNotFoundError(LookupError): - """A requested AI model does not exist.""" - - -class AIProviderDisabledError(RuntimeError): - """A disabled provider rejected a new operation.""" - - -class AIModelDisabledError(RuntimeError): - """A disabled model rejected a new operation.""" - - -class InvalidAIProviderConfigError(RuntimeError): - """Persisted provider config does not satisfy its dialect contract.""" - - -class AICapabilityUnavailableError(RuntimeError): - """The selected provider-bound model does not declare an operation.""" - - -class AIFeatureUnavailableError(RuntimeError): - """Effective model/adapter support lacks a requested feature.""" - - -class AIInputUnavailableError(RuntimeError): - """A canonical input cannot be conveyed through the selected dialect.""" - - -class AIOutputContractError(RuntimeError): - """A provider response violated the canonical capability result contract.""" - - -class AIDialectAdapter(abc.ABC): - """Graph-blind provider config/client construction and wire translation.""" - - supported_features: dict[AICapabilityType, frozenset[str]] = {} - supported_input_modalities: dict[AICapabilityType, frozenset[str]] = {} - - @abc.abstractmethod - async def embed( - self, - config: pydantic.BaseModel, - native_model_id: str, - inputs: Sequence[str], - dimensions: int, - ) -> tuple[Vector, ...]: ... - - @abc.abstractmethod - async def chat( - self, - config: pydantic.BaseModel, - native_model_id: str, - messages: Sequence[Message], - tools: Sequence[FunctionTool], - tool_choice: ToolChoice | None, - ) -> AssistantMessage: ... - - def supports_feature(self, capability: AICapabilityType, feature: str) -> bool: - return feature in self.supported_features.get(capability, frozenset()) - - def supports_input_modality( - self, - capability: AICapabilityType, - modality: str, - ) -> bool: - return modality in self.supported_input_modalities.get(capability, frozenset()) - - def supports_tool_choice(self, tool_choice: ToolChoice) -> bool: - del tool_choice - return False +if TYPE_CHECKING: + from inkcre_core.business.ai.contracts import * diff --git a/app/business/ai/dialects/__init__.py b/app/business/ai/dialects/__init__.py index 93f92271..78255c7a 100644 --- a/app/business/ai/dialects/__init__.py +++ b/app/business/ai/dialects/__init__.py @@ -1,10 +1,3 @@ -"""Installed core AI dialect adapters.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .alibaba_model_studio import AlibabaModelStudioDialect -from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleDialect - -__all__ = [ - "AlibabaModelStudioDialect", - "OpenAICompatibleConfig", - "OpenAICompatibleDialect", -] +from inkcre_core.business.ai.dialects import * diff --git a/app/business/ai/dialects/alibaba_model_studio.py b/app/business/ai/dialects/alibaba_model_studio.py index ee31b56b..f18191f8 100644 --- a/app/business/ai/dialects/alibaba_model_studio.py +++ b/app/business/ai/dialects/alibaba_model_studio.py @@ -1,181 +1,12 @@ -"""Alibaba Model Studio chat dialect, including native multimodal parts.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Sequence -import json -import typing -import time +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from openai import AsyncStream -from openai.types.chat import ChatCompletionChunk -from opentelemetry import trace -from opentelemetry.trace import INVALID_SPAN -import pydantic - -from app.schemas.ai import ( - AudioContentPart, - AssistantMessage, - FunctionTool, - ImageContentPart, - Message, - TextContentPart, - ToolCall, - ToolChoice, - UserMessage, - VideoContentPart, -) - -from libs.obsrv.telemetry import is_enabled - -from ..contracts import AIOutputContractError -from ..main import AIManager -from ..telemetry import record_response_usage -from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleDialect - - -@AIManager.register_dialect( - "core.alibaba-model-studio.v1", - description="Alibaba Model Studio OpenAI-compatible multimodal chat protocol.", - config_model=OpenAICompatibleConfig, +sys.modules[__name__] = import_module( + "inkcre_core.business.ai.dialects.alibaba_model_studio" ) -class AlibabaModelStudioDialect(OpenAICompatibleDialect): - """Translate canonical chat through Model Studio's multimodal extensions.""" - - supported_features = {"chat": frozenset({"tool_calling"})} - supported_input_modalities = { - "embedding": frozenset({"text"}), - "chat": frozenset({"text", "image", "audio", "video"}), - } - - @classmethod - def _user_content_params(cls, message: UserMessage) -> list[dict[str, typing.Any]]: - result: list[dict[str, typing.Any]] = [] - for part in message.content: - if isinstance(part, TextContentPart): - result.append({"type": "text", "text": part.text}) - elif isinstance(part, ImageContentPart): - result.append( - { - "type": "image_url", - "image_url": { - "url": cls._url_or_inline_data( - mime_type=part.mime_type, - data=part.data, - transfer_url=part.transfer_url, - ) - }, - } - ) - elif isinstance(part, AudioContentPart): - result.append( - { - "type": "input_audio", - "input_audio": { - "data": cls._url_or_inline_data( - mime_type=part.mime_type, - data=part.data, - transfer_url=part.transfer_url, - ), - "format": part.mime_type.removeprefix("audio/"), - }, - } - ) - elif isinstance(part, VideoContentPart): - result.append( - { - "type": "video_url", - "video_url": { - "url": cls._url_or_inline_data( - mime_type=part.mime_type, - data=part.data, - transfer_url=part.transfer_url, - ) - }, - } - ) - return result - - async def chat( - self, - config: pydantic.BaseModel, - native_model_id: str, - messages: Sequence[Message], - tools: Sequence[FunctionTool], - tool_choice: ToolChoice | None, - ) -> AssistantMessage: - client = self._client_factory(self._config(config)) - arguments: dict[str, typing.Any] = { - "model": native_model_id, - "messages": self._message_params(messages), - "stream": True, - "stream_options": {"include_usage": True}, - "modalities": ["text"], - } - if tools: - arguments["tools"] = self._tool_params(tools) - if tool_choice is not None: - arguments["tool_choice"] = self._tool_choice_param(tool_choice) - - span = trace.get_current_span() if is_enabled() else INVALID_SPAN - span.set_attribute("gen_ai.request.stream", True) - started = time.monotonic() - usage = None - finish_reasons: list[str] = [] - first_chunk = True - try: - stream = typing.cast( - AsyncStream[ChatCompletionChunk], - await client.chat.completions.create(**arguments), - ) - text_parts: list[str] = [] - calls: dict[int, dict[str, str]] = {} - async for chunk in stream: - if first_chunk: - span.set_attribute( - "gen_ai.response.time_to_first_chunk", time.monotonic() - started - ) - first_chunk = False - # Usage-only terminal chunks have no choices. Totals replace earlier totals. - if chunk.usage is not None: - usage = chunk.usage - if not chunk.choices: - continue - if chunk.choices[0].finish_reason is not None: - finish_reasons = [chunk.choices[0].finish_reason] - delta = chunk.choices[0].delta - if isinstance(delta.content, str): - text_parts.append(delta.content) - for call in delta.tool_calls or (): - current = calls.setdefault(call.index, {"id": "", "name": "", "arguments": ""}) - if call.id: - current["id"] = call.id - if call.function is not None: - if call.function.name: - current["name"] += call.function.name - if call.function.arguments: - current["arguments"] += call.function.arguments - finally: - record_response_usage("chat", usage, finish_reasons) - await client.close() - parsed_calls: list[ToolCall] = [] - for index in sorted(calls): - call = calls[index] - if not call["id"] or not call["name"]: - raise AIOutputContractError("Alibaba chat stream returned an incomplete ToolCall") - try: - arguments_object = json.loads(call["arguments"]) - except json.JSONDecodeError as error: - raise AIOutputContractError( - f"ToolCall {call['id']!r} arguments are not valid JSON" - ) from error - if not isinstance(arguments_object, dict): - raise AIOutputContractError( - f"ToolCall {call['id']!r} arguments must be a JSON object" - ) - parsed_calls.append( - ToolCall(id=call["id"], tool=call["name"], arguments=arguments_object) - ) - content = "".join(text_parts) or None - if content is None and not parsed_calls: - raise AIOutputContractError("Alibaba chat stream returned no content or ToolCalls") - return AssistantMessage(content=content, tool_calls=tuple(parsed_calls)) +if TYPE_CHECKING: + from inkcre_core.business.ai.dialects.alibaba_model_studio import * diff --git a/app/business/ai/dialects/openai_compatible.py b/app/business/ai/dialects/openai_compatible.py index 8bb24ba8..0f62d1cb 100644 --- a/app/business/ai/dialects/openai_compatible.py +++ b/app/business/ai/dialects/openai_compatible.py @@ -1,323 +1,10 @@ -"""OpenAI-compatible AI dialect across embedding and chat capabilities.""" +"""Legacy import; use inkcre_core for new consumers.""" -import base64 -from collections.abc import Callable, Sequence -import json -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion, ChatCompletionMessageParam -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.ai.dialects.openai_compatible") -from app.schemas.ai import ( - AudioContentPart, - AssistantMessage, - FunctionTool, - ImageContentPart, - Message, - NamedToolChoice, - SystemMessage, - TextContentPart, - ToolCall, - ToolChoice, - ToolResultMessage, - UserMessage, - VideoContentPart, -) -from app.schemas.info_base.main import Vector - -from ..contracts import AIDialectAdapter, AIInputUnavailableError, AIOutputContractError -from ..main import AIManager -from ..telemetry import record_response_usage - -INLINE_MEDIA_MAX_BYTES = 7 * 1024 * 1024 - - -class OpenAICompatibleConfig(pydantic.BaseModel): - """Connection values for one OpenAI-compatible provider instance.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - api_key: pydantic.SecretStr - base_url: str | None = pydantic.Field(default=None, min_length=1) - - -OpenAIClientFactory: typing.TypeAlias = Callable[[OpenAICompatibleConfig], AsyncOpenAI] - - -@AIManager.register_dialect( - "core.openai-compatible.v1", - description="OpenAI-compatible embedding and chat protocol.", - config_model=OpenAICompatibleConfig, -) -class OpenAICompatibleDialect(AIDialectAdapter): - """Translate canonical AI contracts through the OpenAI Python SDK.""" - - supported_features = {"chat": frozenset({"tool_calling"})} - supported_input_modalities = { - "embedding": frozenset({"text"}), - "chat": frozenset({"text", "image", "audio"}), - } - - def __init__(self, client_factory: OpenAIClientFactory | None = None) -> None: - self._client_factory = client_factory or self._create_client - - @staticmethod - def _create_client(config: OpenAICompatibleConfig) -> AsyncOpenAI: - return AsyncOpenAI( - api_key=config.api_key.get_secret_value(), - base_url=config.base_url, - ) - - @staticmethod - def _config(config: pydantic.BaseModel) -> OpenAICompatibleConfig: - return typing.cast(OpenAICompatibleConfig, config) - - async def embed( - self, - config: pydantic.BaseModel, - native_model_id: str, - inputs: Sequence[str], - dimensions: int, - ) -> tuple[Vector, ...]: - client = self._client_factory(self._config(config)) - usage = None - try: - response = await client.embeddings.create( - model=native_model_id, - input=tuple(inputs), - dimensions=dimensions, - encoding_format="float", - ) - usage = getattr(response, "usage", None) - finally: - record_response_usage("embeddings", usage) - await client.close() - - by_index = {item.index: tuple(item.embedding) for item in response.data} - expected = set(range(len(inputs))) - if set(by_index) != expected: - raise AIOutputContractError( - "OpenAI-compatible embedding response indices do not cover the input batch" - ) - return tuple(by_index[index] for index in range(len(inputs))) - - @staticmethod - def _data_url(mime_type: str, data: bytes) -> str: - encoded = base64.b64encode(data).decode("ascii") - return f"data:{mime_type};base64,{encoded}" - - @classmethod - def _url_or_inline_data( - cls, - *, - mime_type: str, - data: bytes, - transfer_url: str | None, - ) -> str: - if len(data) <= INLINE_MEDIA_MAX_BYTES: - return cls._data_url(mime_type, data) - if transfer_url is not None: - return transfer_url - raise AIInputUnavailableError( - "Media exceeds the inline transfer bound and has no transfer URL" - ) - - @staticmethod - def _audio_format(mime_type: str) -> str: - formats = { - "audio/mpeg": "mp3", - "audio/mp3": "mp3", - "audio/wav": "wav", - "audio/x-wav": "wav", - } - try: - return formats[mime_type] - except KeyError as error: - raise AIInputUnavailableError( - f"OpenAI Chat input_audio does not support {mime_type!r}" - ) from error - - @classmethod - def _user_content_params(cls, message: UserMessage) -> list[dict[str, typing.Any]]: - result: list[dict[str, typing.Any]] = [] - for part in message.content: - if isinstance(part, TextContentPart): - result.append({"type": "text", "text": part.text}) - elif isinstance(part, ImageContentPart): - result.append( - { - "type": "image_url", - "image_url": { - "url": cls._url_or_inline_data( - mime_type=part.mime_type, - data=part.data, - transfer_url=part.transfer_url, - ) - }, - } - ) - elif isinstance(part, AudioContentPart): - if len(part.data) > INLINE_MEDIA_MAX_BYTES: - raise AIInputUnavailableError( - "OpenAI Chat input_audio cannot use a transfer URL and exceeds the " - "inline transfer bound" - ) - result.append( - { - "type": "input_audio", - "input_audio": { - "data": base64.b64encode(part.data).decode("ascii"), - "format": cls._audio_format(part.mime_type), - }, - } - ) - elif isinstance(part, VideoContentPart): - raise AIInputUnavailableError( - "OpenAI Chat does not define a canonical video input part" - ) - return result - - @classmethod - def _message_params(cls, messages: Sequence[Message]) -> list[ChatCompletionMessageParam]: - result: list[ChatCompletionMessageParam] = [] - for message in messages: - if isinstance(message, SystemMessage): - result.append({"role": "system", "content": message.content}) - elif isinstance(message, UserMessage): - result.append( - typing.cast( - ChatCompletionMessageParam, - {"role": "user", "content": cls._user_content_params(message)}, - ) - ) - elif isinstance(message, AssistantMessage): - result.append( - { - "role": "assistant", - "content": message.content, - "tool_calls": [ - { - "id": call.id, - "type": "function", - "function": { - "name": call.tool, - "arguments": json.dumps( - call.arguments, - separators=(",", ":"), - sort_keys=True, - ), - }, - } - for call in message.tool_calls - ], - } - ) - elif isinstance(message, ToolResultMessage): - result.extend( - { - "role": "tool", - "tool_call_id": tool_result.tool_call_id, - "content": json.dumps( - { - "content": tool_result.content, - "is_error": tool_result.is_error, - }, - separators=(",", ":"), - sort_keys=True, - ), - } - for tool_result in message.results - ) - return result - - @staticmethod - def _tool_params(tools: Sequence[FunctionTool]) -> list[dict[str, typing.Any]]: - return [ - { - "type": "function", - "function": { - "name": tool.id, - "description": tool.description, - "parameters": tool.input_schema, - }, - } - for tool in tools - ] - - @staticmethod - def _tool_choice_param(tool_choice: ToolChoice) -> typing.Any: - if isinstance(tool_choice, NamedToolChoice): - return { - "type": "function", - "function": {"name": tool_choice.tool}, - } - return tool_choice - - def supports_tool_choice(self, tool_choice: ToolChoice) -> bool: - return isinstance(tool_choice, NamedToolChoice) or tool_choice in { - "none", - "auto", - "required", - } - - @staticmethod - def _assistant_message(message: typing.Any) -> AssistantMessage: - calls: list[ToolCall] = [] - for call in message.tool_calls or (): - if call.type != "function": - raise AIOutputContractError( - f"Unsupported OpenAI-compatible ToolCall type: {call.type!r}" - ) - try: - parsed_arguments = json.loads(call.function.arguments) - except (TypeError, json.JSONDecodeError) as error: - raise AIOutputContractError( - f"ToolCall {call.id!r} arguments are not valid JSON" - ) from error - if not isinstance(parsed_arguments, dict): - raise AIOutputContractError(f"ToolCall {call.id!r} arguments must be a JSON object") - calls.append( - ToolCall( - id=call.id, - tool=call.function.name, - arguments=parsed_arguments, - ) - ) - return AssistantMessage(content=message.content, tool_calls=tuple(calls)) - - async def chat( - self, - config: pydantic.BaseModel, - native_model_id: str, - messages: Sequence[Message], - tools: Sequence[FunctionTool], - tool_choice: ToolChoice | None, - ) -> AssistantMessage: - client = self._client_factory(self._config(config)) - arguments: dict[str, typing.Any] = { - "model": native_model_id, - "messages": self._message_params(messages), - "stream": False, - } - if tools: - arguments["tools"] = self._tool_params(tools) - if tool_choice is not None: - arguments["tool_choice"] = self._tool_choice_param(tool_choice) - - usage = None - finish_reasons: list[str] = [] - try: - response = typing.cast( - ChatCompletion, - await client.chat.completions.create(**arguments), - ) - usage = response.usage - finish_reasons = [choice.finish_reason for choice in response.choices] - finally: - record_response_usage("chat", usage, finish_reasons) - await client.close() - - if not response.choices: - raise AIOutputContractError("OpenAI-compatible chat response has no choices") - return self._assistant_message(response.choices[0].message) +if TYPE_CHECKING: + from inkcre_core.business.ai.dialects.openai_compatible import * diff --git a/app/business/ai/main.py b/app/business/ai/main.py index afe888ca..97a5d190 100644 --- a/app/business/ai/main.py +++ b/app/business/ai/main.py @@ -1,396 +1,10 @@ -"""Shared AI fact management and peer-local capability routing.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Sequence -from dataclasses import dataclass -import math -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -from opentelemetry.trace import SpanKind +sys.modules[__name__] = import_module("inkcre_core.business.ai.main") -from app.database_contract.profile import BUILTIN_AI_DIALECTS_BY_ID -from app.schemas.ai import ( - AICapabilityType, - AIDialectID, - AIModelCapability, - AIModelID, - AIModelModel, - AIProviderModel, - AssistantMessage, - FunctionTool, - ImageContentPart, - Message, - AudioContentPart, - TextContentPart, - ToolChoice, - UserMessage, - VideoContentPart, - validate_message_history, -) -from app.schemas.info_base.main import Vector - -from app.persistence.ai.uow import ai_uow -from libs.obsrv.telemetry import operation - -from .telemetry import model_attributes - -from .contracts import ( - AICapabilityUnavailableError, - AIDialectAdapter, - AIExecutionRequirement, - AIFeatureUnavailableError, - AIInputUnavailableError, - AIModelDisabledError, - AIModelNotFoundError, - AIOutputContractError, - AIProviderDisabledError, - AIProviderNotFoundError, - DuplicateAIDialectRegistrationError, - InvalidAIProviderConfigError, - UnknownAIDialectError, -) - - -ConfigModelT = typing.TypeVar("ConfigModelT", bound=pydantic.BaseModel) -AdapterT = typing.TypeVar("AdapterT", bound=type[AIDialectAdapter]) - - -@dataclass(frozen=True) -class _DialectRegistration: - adapter: AIDialectAdapter - adapter_type: type[AIDialectAdapter] - config_model: type[pydantic.BaseModel] - description: str - - -@dataclass(frozen=True) -class _ExecutionTarget: - model: AIModelModel - provider: AIProviderModel - adapter: AIDialectAdapter - config: pydantic.BaseModel - - -class AIManager: - """Sole domain manager for AI dialects, shared facts and execution.""" - - _DIALECTS: dict[AIDialectID, _DialectRegistration] = {} - - @classmethod - async def get_model(cls, model_id: AIModelID) -> AIModelModel | None: - """Read a model without requiring its provider/dialect to execute here.""" - async with ai_uow() as models: - return await models.get_model(model_id) - - @classmethod - async def list_models( - cls, *, limit: int | None = None, cursor: int | None = None - ) -> tuple[list[AIModelModel], int | None]: - async with ai_uow() as models: - return await models.list_models(limit=limit, cursor=cursor) - - @classmethod - def register_dialect( - cls, - dialect_id: AIDialectID, - *, - description: str, - config_model: type[ConfigModelT], - ) -> typing.Callable[[AdapterT], AdapterT]: - """Bind one adapter class to an exact ID and return it unchanged.""" - - def decorator(adapter_type: AdapterT) -> AdapterT: - existing = cls._DIALECTS.get(dialect_id) - if existing is not None: - if ( - existing.adapter_type is adapter_type - and existing.config_model is config_model - and existing.description == description - ): - return adapter_type - raise DuplicateAIDialectRegistrationError( - f"AI dialect {dialect_id!r} is already registered by " - f"{existing.adapter_type.__qualname__}" - ) - cls._DIALECTS[dialect_id] = _DialectRegistration( - adapter=adapter_type(), - adapter_type=adapter_type, - config_model=config_model, - description=description, - ) - return adapter_type - - return decorator - - @classmethod - async def sync_dialects_async(cls) -> None: - records = [] - for dialect_id, registration in cls._DIALECTS.items(): - builtin = BUILTIN_AI_DIALECTS_BY_ID.get(dialect_id) - records.append( - { - "id": dialect_id, - "description": builtin.description - if builtin is not None - else registration.description, - "config_schema": builtin.config_schema - if builtin is not None - else registration.config_model.model_json_schema(), - } - ) - async with ai_uow() as models: - await models.sync_dialects(records) - - @classmethod - def _registration(cls, dialect_id: AIDialectID) -> _DialectRegistration: - try: - return cls._DIALECTS[dialect_id] - except KeyError as error: - raise UnknownAIDialectError( - f"No local adapter implements AI dialect {dialect_id!r}" - ) from error - - @classmethod - async def _load_target_async(cls, model_id: AIModelID) -> _ExecutionTarget: - async with ai_uow() as models: - model = await models.get_model(model_id) - provider = None if model is None else await models.get_provider(model.provider) - return cls._execution_target(model_id, model, provider) - - @classmethod - def _execution_target( - cls, model_id: AIModelID, model: AIModelModel | None, provider: AIProviderModel | None - ) -> _ExecutionTarget: - if model is None: - raise AIModelNotFoundError(f"AI model {model_id} does not exist") - if not model.enabled: - raise AIModelDisabledError(f"AI model {model_id} is disabled") - if provider is None: - raise AIProviderNotFoundError( - f"AI model {model_id} references missing provider {model.provider}" - ) - if not provider.enabled: - raise AIProviderDisabledError(f"AI provider {provider.id} is disabled") - registration = cls._registration(provider.dialect) - try: - config = registration.config_model.model_validate(provider.config) - except pydantic.ValidationError as error: - raise InvalidAIProviderConfigError( - f"AI provider {provider.id} does not satisfy dialect {provider.dialect!r}" - ) from error - return _ExecutionTarget(model, provider, registration.adapter, config) - - @staticmethod - def _capability( - model: AIModelModel, - capability_type: AICapabilityType, - ) -> AIModelCapability: - capability = next( - (item for item in model.capabilities if item.type == capability_type), - None, - ) - if capability is None: - raise AICapabilityUnavailableError( - f"AI model {model.id} does not declare {capability_type!r}" - ) - return capability - - @staticmethod - def _require_modalities( - model: AIModelModel, - capability: AIModelCapability, - *, - input_: str, - output: str, - ) -> None: - if ( - input_ not in capability.input_modalities - or output not in capability.output_modalities - ): - raise AICapabilityUnavailableError( - f"AI model {model.id} {capability.type!r} does not support {input_!r} -> {output!r}" - ) - - @classmethod - async def can_execute( - cls, - model: AIModelID, - requirement: AIExecutionRequirement, - ) -> bool: - """Return static peer-local eligibility without probing a remote provider.""" - try: - target = await cls._load_target_async(model) - capability = cls._capability(target.model, requirement.capability) - except ( - AIModelNotFoundError, - AIModelDisabledError, - AIProviderNotFoundError, - AIProviderDisabledError, - UnknownAIDialectError, - InvalidAIProviderConfigError, - AICapabilityUnavailableError, - ): - return False - requested_inputs = requirement.input_modalities - requested_outputs = requirement.output_modalities - requested_features = requirement.features - if not requested_inputs <= set(capability.input_modalities): - return False - if not requested_outputs <= set(capability.output_modalities): - return False - if not requested_features <= set(capability.features): - return False - if any( - not target.adapter.supports_input_modality(requirement.capability, modality) - for modality in requested_inputs - ): - return False - if any( - not target.adapter.supports_feature(requirement.capability, feature) - for feature in requested_features - ): - return False - return requirement.tool_choice is None or target.adapter.supports_tool_choice( - requirement.tool_choice - ) - - @classmethod - async def embed( - cls, - model: AIModelID, - inputs: Sequence[str], - dimensions: int, - ) -> tuple[Vector, ...]: - """Embed one ordered non-empty text batch through the selected model.""" - with operation( - "ai.embed", - kind=SpanKind.CLIENT, - attributes={ - "gen_ai.operation.name": "embeddings", - "inkcre.ai.model.id": model, - "inkcre.ai.usage.input.source": "unavailable", - "inkcre.ai.usage.output.source": "unavailable", - }, - ) as observation: - span = observation.span - if not inputs: - raise ValueError("embedding inputs must not be empty") - if dimensions <= 0: - raise ValueError("embedding dimensions must be positive") - target = await cls._load_target_async(model) - span.set_attributes( - model_attributes( - target.model.native_model_id, target.provider.dialect, target.model.provider - ) - ) - capability = cls._capability(target.model, "embedding") - cls._require_modalities( - target.model, - capability, - input_="text", - output="vector", - ) - vectors = await target.adapter.embed( - target.config, - target.model.native_model_id, - tuple(inputs), - dimensions, - ) - if len(vectors) != len(inputs): - raise AIOutputContractError( - f"Embedding result count {len(vectors)} does not match input count {len(inputs)}" - ) - for vector in vectors: - if len(vector) != dimensions: - raise AIOutputContractError( - f"Embedding dimension {len(vector)} does not match requested {dimensions}" - ) - if not vector or not all(math.isfinite(value) for value in vector): - raise AIOutputContractError("Embedding vectors must be finite and non-empty") - if not any(value != 0 for value in vector): - raise AIOutputContractError("Embedding vectors must be non-zero") - return vectors - - @classmethod - async def chat( - cls, - model: AIModelID, - messages: Sequence[Message], - tools: Sequence[FunctionTool] = (), - tool_choice: ToolChoice | None = None, - ) -> AssistantMessage: - """Execute one provider-neutral chat model call without owning history.""" - with operation( - "ai.chat", - kind=SpanKind.CLIENT, - attributes={ - "gen_ai.operation.name": "chat", - "inkcre.ai.model.id": model, - "inkcre.ai.usage.input.source": "unavailable", - "inkcre.ai.usage.output.source": "unavailable", - }, - ) as observation: - span = observation.span - history = validate_message_history(messages) - target = await cls._load_target_async(model) - span.set_attributes( - model_attributes( - target.model.native_model_id, target.provider.dialect, target.model.provider - ) - ) - capability = cls._capability(target.model, "chat") - # System instructions, Tool schemas/results and Assistant history all travel - # through the textual chat channel even when the latest User turn is media-only. - input_modalities: set[str] = {"text"} - for message in history: - if not isinstance(message, UserMessage): - continue - for part in message.content: - if isinstance(part, TextContentPart): - input_modalities.add("text") - elif isinstance(part, ImageContentPart): - input_modalities.add("image") - elif isinstance(part, AudioContentPart): - input_modalities.add("audio") - elif isinstance(part, VideoContentPart): - input_modalities.add("video") - for modality in sorted(input_modalities): - cls._require_modalities( - target.model, - capability, - input_=modality, - output="text", - ) - if not target.adapter.supports_input_modality("chat", modality): - raise AIInputUnavailableError( - f"AI dialect {target.provider.dialect!r} cannot convey " - f"chat modality {modality!r}" - ) - - tool_ids = tuple(tool.id for tool in tools) - if len(tool_ids) != len(set(tool_ids)): - raise ValueError("Tool IDs must be unique") - requires_tool_calling = bool(tools) or tool_choice is not None - if requires_tool_calling: - if "tool_calling" not in capability.features or not target.adapter.supports_feature( - "chat", "tool_calling" - ): - raise AIFeatureUnavailableError( - f"AI model {model} and dialect {target.provider.dialect!r} do not jointly " - "support tool_calling" - ) - if tool_choice is not None: - if not tools: - raise ValueError("tool_choice requires at least one Tool") - if not target.adapter.supports_tool_choice(tool_choice): - raise AIFeatureUnavailableError( - f"AI dialect {target.provider.dialect!r} cannot represent tool_choice" - ) - - return await target.adapter.chat( - target.config, - target.model.native_model_id, - history, - tuple(tools), - tool_choice, - ) +if TYPE_CHECKING: + from inkcre_core.business.ai.main import * diff --git a/app/business/ai/telemetry.py b/app/business/ai/telemetry.py index 00431aae..000c69ca 100644 --- a/app/business/ai/telemetry.py +++ b/app/business/ai/telemetry.py @@ -1,86 +1,10 @@ -"""Content-free mapping for the pinned GenAI semantic conventions. +"""Legacy import; use inkcre_core for new consumers.""" -Keys follow semantic-conventions-genai e07f4ebacb08f56db8c4c882d117720333fbca04. -Provider responses are untrusted: copy numeric usage and known finish reasons only. -""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from collections.abc import Sequence -import re +sys.modules[__name__] = import_module("inkcre_core.business.ai.telemetry") -from openai.types.completion_usage import CompletionUsage -from openai.types.create_embedding_response import Usage as EmbeddingUsage -from opentelemetry import trace -from opentelemetry.util.types import AttributeValue - -from libs.obsrv.telemetry import is_enabled, record_ai_usage - - -_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}\Z") -_FINISH_REASONS = frozenset( - {"stop", "length", "tool_calls", "content_filter", "function_call"} -) - - -def model_attributes( - native_model_id: str, dialect: str, provider_id: int -) -> dict[str, AttributeValue]: - attributes: dict[str, AttributeValue] = { - "inkcre.ai.provider.id": provider_id, - "inkcre.ai.dialect": dialect if _IDENTIFIER.fullmatch(dialect) else "other", - "gen_ai.provider.name": { - "core.openai-compatible.v1": "openai", - "core.alibaba-model-studio.v1": "alibaba", - }.get(dialect, "other"), - } - # This is the configured model identifier, never the provider's arbitrary echo. - if _IDENTIFIER.fullmatch(native_model_id): - attributes["gen_ai.request.model"] = native_model_id - return attributes - - -def record_response_usage( - operation: str, - usage: CompletionUsage | EmbeddingUsage | None, - finish_reasons: Sequence[str] = (), -) -> None: - """Record one response's totals once; absent or invalid totals stay unknown.""" - if not is_enabled(): - return - span = trace.get_current_span() - input_tokens = getattr(usage, "prompt_tokens", None) - output_tokens = getattr(usage, "completion_tokens", None) - input_tokens = ( - input_tokens if type(input_tokens) is int and 0 <= input_tokens < 2**63 else None - ) - output_tokens = ( - output_tokens if type(output_tokens) is int and 0 <= output_tokens < 2**63 else None - ) - for direction, value in (("input", input_tokens), ("output", output_tokens)): - span.set_attribute( - f"inkcre.ai.usage.{direction}.source", - "provider" if value is not None else "unavailable", - ) - if value is not None: - span.set_attribute(f"gen_ai.usage.{direction}_tokens", value) - if isinstance(usage, CompletionUsage): - for key, value in ( - ( - "gen_ai.usage.cache_read.input_tokens", - getattr(usage.prompt_tokens_details, "cached_tokens", None), - ), - ( - "gen_ai.usage.reasoning.output_tokens", - getattr(usage.completion_tokens_details, "reasoning_tokens", None), - ), - ): - if type(value) is int and 0 <= value < 2**63: - span.set_attribute(key, value) - if finish_reasons: - span.set_attribute( - "gen_ai.response.finish_reasons", - tuple( - reason if isinstance(reason, str) and reason in _FINISH_REASONS else "other" - for reason in finish_reasons[:16] - ), - ) - record_ai_usage(operation, input_tokens=input_tokens, output_tokens=output_tokens) +if TYPE_CHECKING: + from inkcre_core.business.ai.telemetry import * diff --git a/app/business/cron.py b/app/business/cron.py index 3bb7765f..64f13f2d 100644 --- a/app/business/cron.py +++ b/app/business/cron.py @@ -1,231 +1,10 @@ -"""Application-owned materialization of deployment-wide Cron occurrences.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import datetime -import typing -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +sys.modules[__name__] = import_module("inkcre_core.business.cron") -import croniter # pyrefly: ignore[untyped-import] -import pydantic - -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.business.job import JobManager -from app.persistence.cron.uow import cron_uow -from app.schemas.cron import CronForm, CronID, CronModel, CronUpdateForm -from app.schemas.job import JobModel, JobStatus -from libs.obsrv.main import get_logger -from libs.obsrv.telemetry import operation, emit_event -from app.validation import input_path - - -CRON_CONFIG_KEY = "core.cron" -CRON_CONFIG_SCHEMA_ID = "core.cron.config.v1" -LOGGER = get_logger().getChild(__name__) - - -class CronNotFoundError(LookupError): - """A Cron template does not exist.""" - - -class InvalidCronScheduleError(ValueError): - """A submitted schedule is not a five-field UNIX Cron expression.""" - - -class CronDeploymentConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - timezone: str = "UTC" - - @pydantic.field_validator("timezone") - @classmethod - def validate_timezone(cls, value: str) -> str: - try: - ZoneInfo(value) - except ZoneInfoNotFoundError as error: - raise ValueError("timezone must be an installed IANA timezone") from error - return value - - -DeploymentConfigManager.register_schema( - CRON_CONFIG_SCHEMA_ID, CronDeploymentConfig, keys=(CRON_CONFIG_KEY,) -) - - -class InvalidCronTemplateError(ValueError): - """A failed template must leave its complete transaction before discovery continues.""" - - -class CronManager: - """Evaluate only the current minute and atomically create typed Jobs.""" - - @classmethod - async def _timezone(cls) -> ZoneInfo: - config = await DeploymentConfigService.get(CRON_CONFIG_KEY) - if config is None: - return ZoneInfo("UTC") - return ZoneInfo(typing.cast(CronDeploymentConfig, config).timezone) - - @classmethod - def _matches(cls, schedule: str, now: datetime.datetime) -> bool: - return croniter.croniter.match(schedule, now) - - @classmethod - async def check(cls) -> int: - """Materialize each matching occurrence in one independent transaction.""" - async with cron_uow() as uow: - cron_ids = await uow.crons.enabled_ids() - timezone = await cls._timezone() - created = 0 - for cron_id in cron_ids: - if cron_id is None: - continue - try: - created += await cls._materialize(cron_id, timezone) - except InvalidCronTemplateError: - LOGGER.exception("Cron Job template is invalid", extra={"cron_id": cron_id}) - return created - - @classmethod - async def _materialize(cls, cron_id: CronID, timezone: ZoneInfo) -> int: - with operation("cron.materialize", attributes={"inkcre.cron.id": cron_id}): - return await cls._materialize_occurrence(cron_id, timezone) - - @classmethod - async def _materialize_occurrence(cls, cron_id: CronID, timezone: ZoneInfo) -> int: - async with cron_uow() as uow: - cron = await uow.crons.get(cron_id, lock=True, skip_locked=True) - if cron is None or not cron.enabled: - return 0 - database_now = await uow.crons.database_now() - occurrence = database_now.astimezone(datetime.UTC).replace(second=0, microsecond=0) - local_minute = database_now.astimezone(timezone).replace(second=0, microsecond=0) - try: - matches = cls._matches(cron.schedule, local_minute) - except (ValueError, KeyError): - LOGGER.exception("Invalid persisted Cron schedule", extra={"cron_id": cron.id}) - return 0 - if not matches or cron.last_scheduled_for == occurrence: - return 0 - if cron.last_job is not None: - last_job = await uow.jobs.get(cron.last_job) - if last_job is not None and last_job.status in { - JobStatus.PENDING, - JobStatus.RUNNING, - }: - return 0 - try: - job = await JobManager.create_in_uow( - cron.job_type, cron.job_parameters, cron.job_timeout_seconds, uow=uow - ) - except Exception as error: - raise InvalidCronTemplateError(f"Invalid Cron {cron.id} template") from error - cron.last_job = job.id - cron.last_scheduled_for = occurrence - await uow.crons.save(cron) - emit_event("job.submitted", {"inkcre.job.id": job.id or 0, "inkcre.cron.id": cron_id}) - return 1 - - @classmethod - async def run_now(cls, cron_id: CronID) -> JobModel: - """Create a Job from one Cron template without changing Cron progress.""" - async with cron_uow() as uow: - cron = await uow.crons.get(cron_id) - if cron is None: - raise CronNotFoundError(f"Cron {cron_id} does not exist") - job = await JobManager.create_in_uow( - cron.job_type, - cron.job_parameters, - cron.job_timeout_seconds, - uow=uow, - ) - emit_event("job.submitted", {"inkcre.job.id": job.id or 0, "inkcre.cron.id": cron_id}) - return job - - @classmethod - async def create(cls, form: CronForm) -> CronModel: - """Validate and create one Cron template.""" - if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): - raise InvalidCronScheduleError( - "Cron schedule must be a valid five-field UNIX expression" - ) - async with cron_uow() as uow: - cron = CronModel(**form.model_dump()) - with input_path("job_parameters"): - cron.job_parameters = await JobManager.normalize_parameters( - form.job_type, form.job_parameters, uow - ) - await uow.crons.save(cron) - return cron - - @classmethod - async def update(cls, cron_id: CronID, form: CronForm) -> CronModel: - """Validate and replace the editable fields of one Cron template.""" - if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): - raise InvalidCronScheduleError( - "Cron schedule must be a valid five-field UNIX expression" - ) - async with cron_uow() as uow: - cron = await uow.crons.get(cron_id) - if cron is None: - raise CronNotFoundError(f"Cron {cron_id} does not exist") - cron.schedule = form.schedule - cron.enabled = form.enabled - cron.job_type = form.job_type - with input_path("job_parameters"): - cron.job_parameters = await JobManager.normalize_parameters( - form.job_type, form.job_parameters, uow - ) - cron.job_timeout_seconds = form.job_timeout_seconds - await uow.crons.save(cron) - return cron - - @classmethod - async def get(cls, cron_id: CronID) -> CronModel | None: - async with cron_uow() as uow: - return await uow.crons.get(cron_id) - - @classmethod - async def list_crons( - cls, *, limit: int | None = None, cursor: CronID | None = None - ) -> tuple[list[CronModel], CronID | None]: - async with cron_uow() as uow: - return await uow.crons.list(limit=limit, cursor=cursor) - - @classmethod - async def patch(cls, cron_id: CronID, form: CronUpdateForm) -> CronModel: - async with cron_uow() as uow: - cron = await uow.crons.get(cron_id, lock=True) - if cron is None: - raise CronNotFoundError(f"Cron {cron_id} does not exist") - changes = form.model_dump(exclude_unset=True) - candidate = CronForm.model_validate( - { - **{field: getattr(cron, field) for field in CronForm.model_fields}, - **changes, - } - ) - if len(candidate.schedule.split()) != 5 or not croniter.croniter.is_valid( - candidate.schedule - ): - raise InvalidCronScheduleError( - "Cron schedule must be a valid five-field UNIX expression" - ) - with input_path("job_parameters"): - parameters = await JobManager.normalize_parameters( - candidate.job_type, candidate.job_parameters, uow - ) - for field in changes: - setattr(cron, field, getattr(candidate, field)) - cron.job_parameters = parameters - await uow.crons.save(cron) - return cron - - @classmethod - async def delete(cls, cron_id: CronID) -> bool: - async with cron_uow() as uow: - cron = await uow.crons.get(cron_id) - if cron is None: - return False - await uow.crons.delete(cron) - return True +if TYPE_CHECKING: + from inkcre_core.business.cron import * diff --git a/app/business/deployment_config.py b/app/business/deployment_config.py index cda9963c..db921256 100644 --- a/app/business/deployment_config.py +++ b/app/business/deployment_config.py @@ -1,186 +1,10 @@ -"""Deployment-scoped configuration registry and persistence.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing -import dataclasses +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.deployment_config") -from app.persistence.deployment_config.uow import configuration_transaction -from app.configuration import ConfigContract -from app.schemas.deployment_config import ( - DeploymentConfigKey, - DeploymentConfigModel, - DeploymentConfigSchemaID, - DeploymentConfigView, -) - - -class UnknownDeploymentConfigSchemaError(LookupError): - """The current peer has no validator for an exact persisted schema ID.""" - - -class DeploymentConfigSchemaCollisionError(ValueError): - """Two different local models claimed the same exact schema ID.""" - - -class DeploymentConfigNotFoundError(LookupError): - """A patch addressed a deployment config that does not exist.""" - - -@dataclasses.dataclass(frozen=True) -class _SchemaRegistration: - contract: ConfigContract - keys: tuple[str, ...] - - -class DeploymentConfigManager: - """Own exact schema registration and the shared ``configs`` relation.""" - - _contracts: dict[DeploymentConfigSchemaID, _SchemaRegistration] = {} - - @classmethod - def register_schema( - cls, - schema_id: DeploymentConfigSchemaID, - model: type[pydantic.BaseModel], - *, - keys: tuple[str, ...] = (), - ) -> None: - """Register one exact schema ID idempotently for the same model.""" - existing = cls._contracts.get(schema_id) - if existing is not None: - if existing.contract.model is model: - cls._contracts[schema_id] = _SchemaRegistration( - existing.contract, tuple(sorted(set(existing.keys) | set(keys))) - ) - return - raise DeploymentConfigSchemaCollisionError( - f"Deployment config schema {schema_id!r} is already registered " - f"by {existing.contract.model.__qualname__}" - ) - cls._contracts[schema_id] = _SchemaRegistration(ConfigContract(model), keys) - - @classmethod - def _contract( - cls, - schema_id: DeploymentConfigSchemaID, - ) -> ConfigContract: - try: - return cls._contracts[schema_id].contract - except KeyError as error: - raise UnknownDeploymentConfigSchemaError( - f"Unknown deployment config schema: {schema_id}" - ) from error - - @classmethod - def _restore_record( - cls, - record: DeploymentConfigModel, - ) -> pydantic.BaseModel: - return cls._contract(record.schema_id).validate(record.value) - - @classmethod - def _view(cls, record: DeploymentConfigModel) -> DeploymentConfigView: - return DeploymentConfigView( - key=record.key, - schema=record.schema_id, - value=record.value, - created_at=record.created_at, - updated_at=record.updated_at, - ) - - @classmethod - def get_schema(cls, schema_id: str, *, include_schema: bool = True) -> dict: - contract = cls._contract(schema_id) - entry = cls._contracts[schema_id] - result: dict[str, typing.Any] = { - "id": schema_id, - "keys": entry.keys, - "description": contract.model.__doc__ or "", - } - if include_schema: - result["input_schema"] = contract.json_schema() - return result - - @classmethod - def list_schemas( - cls, *, limit: int | None = None, cursor: str | None = None - ) -> tuple[list[dict], str | None]: - ids = sorted(key for key in cls._contracts if cursor is None or key > cursor) - more = limit is not None and len(ids) > limit - ids = ids[:limit] - return [cls.get_schema(key, include_schema=False) for key in ids], ids[ - -1 - ] if more else None - - -class DeploymentConfigService: - """Deployment configuration use cases over the process-local schema registry.""" - - @staticmethod - async def get(key: DeploymentConfigKey) -> pydantic.BaseModel | None: - async with configuration_transaction() as configs: - record = await configs.get(key) - return None if record is None else DeploymentConfigManager._restore_record(record) - - @staticmethod - async def read(key: DeploymentConfigKey) -> DeploymentConfigView | None: - async with configuration_transaction() as configs: - record = await configs.get(key) - return None if record is None else DeploymentConfigManager._view(record) - - @staticmethod - async def replace( - key: DeploymentConfigKey, - schema_id: DeploymentConfigSchemaID, - complete_value: dict[str, typing.Any], - ) -> DeploymentConfigView: - result, _ = await DeploymentConfigService.replace_with_status( - key, schema_id, complete_value - ) - return result - - @staticmethod - async def replace_with_status( - key: DeploymentConfigKey, - schema_id: DeploymentConfigSchemaID, - complete_value: dict[str, typing.Any], - ) -> tuple[DeploymentConfigView, bool]: - normalized = DeploymentConfigManager._contract(schema_id).normalize(complete_value) - async with configuration_transaction() as configs: - record, created = await configs.replace(key, schema_id, normalized) - result = DeploymentConfigManager._view(record) - return result, created - - @staticmethod - async def list_configs( - *, limit: int | None = None, cursor: str | None = None - ) -> tuple[list[DeploymentConfigView], str | None]: - async with configuration_transaction() as configs: - rows, next_cursor = await configs.list(limit=limit, cursor=cursor) - return [DeploymentConfigManager._view(row) for row in rows], next_cursor - - @staticmethod - async def delete(key: DeploymentConfigKey) -> bool: - async with configuration_transaction() as configs: - record = await configs.get(key) - if record is None: - return False - await configs.delete(record) - return True - - @staticmethod - async def patch( - key: DeploymentConfigKey, partial_value: dict[str, typing.Any] - ) -> DeploymentConfigView: - async with configuration_transaction() as configs: - record = await configs.get(key, for_update=True) - if record is None: - raise DeploymentConfigNotFoundError(f"Deployment config {key!r} does not exist") - contract = DeploymentConfigManager._contract(record.schema_id) - record.value = contract.prepare_patch(record.value, partial_value).model_dump( - mode="json" - ) - await configs.save(record) - result = DeploymentConfigManager._view(record) - return result +if TYPE_CHECKING: + from inkcre_core.business.deployment_config import * diff --git a/app/business/extension/AGENTS.md b/app/business/extension/AGENTS.md index 69fd7c97..3005bf20 100644 --- a/app/business/extension/AGENTS.md +++ b/app/business/extension/AGENTS.md @@ -1,7 +1,7 @@ # extension/ Local Guide -本目录实现唯一的 Core Extension Host。修改前同时核对 `main.py`、`state.py`、 -`release.py`、`distribution.py`、`app/schemas/extension/main.py` 与 `run.py`。 +本目录实现服务的 Extension Host 装配。修改前同时核对 `main.py`、`state.py`、 +`release.py`、`distribution.py`、`inkcre_core/schemas/extension/main.py` 与 `run.py`。 ## 稳定边界 @@ -35,7 +35,7 @@ ## 权限和持久化 `state.py` 拥有异步持久化用例与 shared state/config 规则;SQL 和 session-bound repository 位于 -`app/persistence/extension/`,UoW 工厂使用原生异步事务。`extensions.state` 是 deployment-wide Extension-produced state; +`inkcre_core/persistence/extension/`,UoW 工厂使用原生异步事务。`extensions.state` 是 deployment-wide Extension-produced state; `extension_peer_participations` 保存 Peer-local selection、enabled intent 与精确版本 config schema。SQLModel 不应泄露成 Host 的稳定接口。 Extension authoring 与 wheel metadata 规则见 `extensions/AGENTS.md`。 diff --git a/app/business/extension/config.py b/app/business/extension/config.py index 1e95d7f3..778969cb 100644 --- a/app/business/extension/config.py +++ b/app/business/extension/config.py @@ -5,8 +5,11 @@ import pydantic -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.business.peer import PeerManager +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.business.peer import PeerManager from app.settings import settings diff --git a/app/business/extension/main.py b/app/business/extension/main.py index b23bab72..631c1c09 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -18,8 +18,8 @@ from packaging.utils import canonicalize_name import sqlmodel -from app.business.peer import PeerManager -from app.schemas.extension import ( +from inkcre_core.business.peer import PeerManager +from inkcre_core.schemas.extension import ( DisableExtensionCommand, EnableExtensionCommand, ExtensionPeerStatus, @@ -30,11 +30,11 @@ SelectExtensionCommand, UnselectExtensionCommand, ) -from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef +from inkcre_core.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef from app.settings import settings from app.version import CORE_VERSION from app.runtime import request_process_restart -from libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.main import get_logger from .distribution import ( AcquiredDistribution, diff --git a/app/business/extension/state.py b/app/business/extension/state.py index 8f37aa72..187034ce 100644 --- a/app/business/extension/state.py +++ b/app/business/extension/state.py @@ -8,8 +8,8 @@ import pydantic -from app.persistence.extension.uow import extension_uow -from app.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel +from inkcre_core.persistence.extension.uow import extension_uow +from inkcre_core.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel from .errors import ExtensionNotInstalledError, ExtensionStateConflictError diff --git a/app/business/graph_navigation_retrieval/__init__.py b/app/business/graph_navigation_retrieval/__init__.py index 0b10b714..c4601a7f 100644 --- a/app/business/graph_navigation_retrieval/__init__.py +++ b/app/business/graph_navigation_retrieval/__init__.py @@ -1,3 +1,3 @@ -from .main import GraphNavigationRetrievalManager +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = ["GraphNavigationRetrievalManager"] +from inkcre_core.business.graph_navigation_retrieval import * diff --git a/app/business/graph_navigation_retrieval/main.py b/app/business/graph_navigation_retrieval/main.py index 92b2cccf..249957a2 100644 --- a/app/business/graph_navigation_retrieval/main.py +++ b/app/business/graph_navigation_retrieval/main.py @@ -1,476 +1,12 @@ -"""Bounded, presentation-neutral navigation over persisted graph authority.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections import deque -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys - -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from app.schemas.graph_navigation_retrieval import ( - DEFAULT_NEIGHBORHOOD_LIMIT, - MAX_NEIGHBORHOOD_LIMIT, - DEFAULT_MAX_HOPS, - MAX_MAX_HOPS, - DEFAULT_MAX_EXPLORED_BLOCKS, - MAX_MAX_EXPLORED_BLOCKS, - DEFAULT_MAX_EXPLORED_RELATIONS, - BlockNeighborhood, - ConnectedComponentsResult, - ConnectedSeedComponent, - GraphDirection, - GraphModel, - PathFound, - PathLimitReached, - PathNotFound, - PathResult, - RelationNeighborhood, +sys.modules[__name__] = import_module( + "inkcre_core.business.graph_navigation_retrieval.main" ) -from app.schemas.info_base.block import BlockID, BlockModel -from app.schemas.info_base.relation import RelationID, RelationModel - - -FRONTIER_QUERY_SIZE = 200 - - -class GraphNavigationRetrievalManager: - """Own bounded graph-navigation queries over persisted entities.""" - - @classmethod - async def get_random_block( - cls, - ) -> BlockModel | None: - async with graph_uow() as uow: - return await uow.blocks.get_random() - - @classmethod - async def get_block_neighborhood( - cls, - focal_block: BlockID, - *, - direction: GraphDirection = "both", - contents: typing.Collection[str] = (), - limit: int = DEFAULT_NEIGHBORHOOD_LIMIT, - cursor: RelationID | None = None, - ) -> BlockNeighborhood | None: - if not 1 <= limit <= MAX_NEIGHBORHOOD_LIMIT: - raise ValueError(f"limit must be between 1 and {MAX_NEIGHBORHOOD_LIMIT}") - async with graph_uow() as uow: - focal = await uow.blocks.get(focal_block) - if focal is None: - return None - - requested = limit + 1 - branches: list[tuple[RelationModel, ...]] = [] - if direction in ("out", "both"): - branches.append( - await uow.relations.get_endpoint_page( - (focal_block,), - endpoint="from", - contents=contents, - cursor=cursor, - limit=requested, - ) - ) - if direction in ("in", "both"): - branches.append( - await uow.relations.get_endpoint_page( - (focal_block,), endpoint="to", contents=contents, cursor=cursor, limit=requested - ) - ) - by_id = { - relation.id: relation - for branch in branches - for relation in branch - if relation.id is not None - } - ordered = sorted( - by_id.values(), - key=lambda relation: typing.cast(int, relation.id), - reverse=True, - ) - page = ordered[:requested] - has_more = len(page) > limit - page = page[:limit] - endpoint_ids = {focal_block} - for relation in page: - endpoint_ids.update((relation.from_, relation.to_)) - blocks = await uow.blocks.get_many(endpoint_ids) - existing_ids = {block.id for block in blocks} - closed_relations = tuple( - relation - for relation in page - if relation.from_ in existing_ids and relation.to_ in existing_ids - ) - next_cursor = typing.cast(RelationID, page[-1].id) if has_more and page else None - return BlockNeighborhood( - focal_block=focal_block, - graph=GraphModel(blocks=blocks, relations=closed_relations), - next_cursor=next_cursor, - ) - - @classmethod - async def get_relation_neighborhood( - cls, - focal_relation: RelationID, - ) -> RelationNeighborhood | None: - async with graph_uow() as uow: - relation = await uow.relations.get_by_id(focal_relation) - if relation is None: - return None - blocks = await uow.blocks.get_many((relation.from_, relation.to_)) - if {block.id for block in blocks} != {relation.from_, relation.to_}: - return None - return RelationNeighborhood( - focal_relation=focal_relation, - graph=GraphModel(blocks=blocks, relations=(relation,)), - ) - - @classmethod - async def get_connected_components( - cls, - seed_block_ids: typing.Collection[BlockID], - *, - contents: typing.Collection[str], - max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS, - max_explored_relations: int = DEFAULT_MAX_EXPLORED_RELATIONS, - ) -> ConnectedComponentsResult: - """Partition existing seeds by bounded undirected exact-content reachability.""" - seeds = tuple(dict.fromkeys(seed_block_ids)) - relation_contents = tuple(dict.fromkeys(contents)) - if not relation_contents: - raise ValueError("contents must not be empty") - if max_explored_blocks < 1 or max_explored_relations < 1: - raise ValueError("exploration bounds must be positive") - if len(seeds) > max_explored_blocks: - raise ValueError("seed blocks exceed max_explored_blocks") - async with graph_uow() as uow: - existing_blocks = await uow.blocks.get_many(seeds) - existing_seed_ids = {block.id for block in existing_blocks if block.id is not None} - missing = tuple(seed for seed in seeds if seed not in existing_seed_ids) - assigned_seeds: set[BlockID] = set() - explored_blocks = set(existing_seed_ids) - seen_relations: set[RelationID] = set() - explored_relation_count = 0 - proof_relations: dict[RelationID, RelationModel] = {} - components: list[ConnectedSeedComponent] = [] - truncated = False - - for seed in seeds: - if seed not in existing_seed_ids or seed in assigned_seeds: - continue - if truncated: - components.append( - ConnectedSeedComponent(seed_block_ids=(seed,), member_block_ids=(seed,)) - ) - assigned_seeds.add(seed) - continue - - members = {seed} - frontier = deque((seed,)) - while frontier and not truncated: - current = frontier.popleft() - for endpoint in ("from", "to"): - cursor: RelationID | None = None - while not truncated: - remaining = max_explored_relations - explored_relation_count - if remaining == 0: - truncated = True - break - requested = min(FRONTIER_QUERY_SIZE, remaining + 1) - page = await uow.relations.get_endpoint_page( - (current,), - endpoint=typing.cast(typing.Literal["from", "to"], endpoint), - contents=relation_contents, - cursor=cursor, - limit=requested, - ) - if len(page) > remaining: - page = page[:remaining] - truncated = True - explored_relation_count += len(page) - for relation in page: - if relation.id is None or relation.id in seen_relations: - continue - relation_id = relation.id - seen_relations.add(relation_id) - neighbor = relation.to_ if relation.from_ == current else relation.from_ - if neighbor in members: - continue - if ( - neighbor not in explored_blocks - and len(explored_blocks) >= max_explored_blocks - ): - truncated = True - break - members.add(neighbor) - explored_blocks.add(neighbor) - frontier.append(neighbor) - proof_relations[relation_id] = relation - if truncated or len(page) < requested: - break - cursor = typing.cast(RelationID, page[-1].id) - - component_seeds = tuple(seed_id for seed_id in seeds if seed_id in members) - assigned_seeds.update(component_seeds) - components.append( - ConnectedSeedComponent( - seed_block_ids=component_seeds, - member_block_ids=tuple(sorted(members)), - ) - ) - - proof_blocks = await uow.blocks.get_many(explored_blocks) - return ConnectedComponentsResult( - components=tuple(components), - proof_graph=GraphModel( - blocks=proof_blocks, - relations=tuple(proof_relations.values()), - ), - missing_seed_block_ids=missing, - truncated=truncated, - ) - - @classmethod - async def find_path( # noqa: PLR0913 - cls, - from_block: BlockID, - to_block: BlockID, - *, - direction: GraphDirection = "both", - contents: typing.Collection[str] = (), - max_hops: int = DEFAULT_MAX_HOPS, - max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS, - ) -> PathResult: - if not 0 <= max_hops <= MAX_MAX_HOPS: - raise ValueError(f"max_hops must be between 0 and {MAX_MAX_HOPS}") - if not 1 <= max_explored_blocks <= MAX_MAX_EXPLORED_BLOCKS: - raise ValueError( - f"max_explored_blocks must be between 1 and {MAX_MAX_EXPLORED_BLOCKS}" - ) - async with graph_uow() as uow: - endpoints = await uow.blocks.get_many((from_block, to_block)) - if {block.id for block in endpoints} != {from_block, to_block}: - return PathNotFound() - if from_block == to_block: - return PathFound( - graph=GraphModel(blocks=endpoints, relations=()), - block_path=(from_block,), - relation_path=(), - ) - - forward_parents: dict[BlockID, tuple[BlockID, RelationID] | None] = {from_block: None} - backward_next: dict[BlockID, tuple[BlockID, RelationID] | None] = {to_block: None} - forward_depths = {from_block: 0} - backward_depths = {to_block: 0} - forward_frontier = {from_block} - backward_frontier = {to_block} - traversed_relations: dict[RelationID, RelationModel] = {} - - while forward_frontier and backward_frontier: - forward_level = forward_depths[next(iter(forward_frontier))] - backward_level = backward_depths[next(iter(backward_frontier))] - if forward_level + backward_level >= max_hops: - return PathLimitReached() - - expand_forward = len(forward_frontier) <= len(backward_frontier) - if expand_forward: - next_frontier, meeting = await cls._expand_path_frontier( - forward_frontier, - direction=direction, - contents=contents, - reverse=False, - own_steps=forward_parents, - own_depths=forward_depths, - other_depths=backward_depths, - max_hops=max_hops, - traversed_relations=traversed_relations, - uow=uow, - ) - forward_frontier = next_frontier - else: - next_frontier, meeting = await cls._expand_path_frontier( - backward_frontier, - direction=direction, - contents=contents, - reverse=True, - own_steps=backward_next, - own_depths=backward_depths, - other_depths=forward_depths, - max_hops=max_hops, - traversed_relations=traversed_relations, - uow=uow, - ) - backward_frontier = next_frontier - explored = set(forward_parents) | set(backward_next) - if len(explored) > max_explored_blocks: - return PathLimitReached() - if meeting is not None: - return await cls._assemble_bidirectional_path( - from_block, - to_block, - meeting=meeting, - forward_parents=forward_parents, - backward_next=backward_next, - traversed_relations=traversed_relations, - uow=uow, - ) - return PathNotFound() - - @classmethod - async def _expand_path_frontier( # noqa: PLR0913 - cls, - frontier: set[BlockID], - *, - direction: GraphDirection, - contents: typing.Collection[str], - reverse: bool, - own_steps: dict[BlockID, tuple[BlockID, RelationID] | None], - own_depths: dict[BlockID, int], - other_depths: dict[BlockID, int], - max_hops: int, - traversed_relations: dict[RelationID, RelationModel], - uow: GraphUnitOfWork, - ) -> tuple[set[BlockID], BlockID | None]: - next_frontier: set[BlockID] = set() - meeting: BlockID | None = None - best_hops: int | None = None - frontier_items = tuple(frontier) - for chunk_start in range(0, len(frontier), FRONTIER_QUERY_SIZE): - chunk = frontier_items[chunk_start : chunk_start + FRONTIER_QUERY_SIZE] - chunk_members = set(chunk) - relations = await cls._frontier_relations( - chunk, - direction=direction, - contents=contents, - reverse=reverse, - uow=uow, - ) - for relation in relations: - relation_id = typing.cast(RelationID, relation.id) - traversed_relations[relation_id] = relation - for current, neighbor in cls._relation_steps( - relation, frontier=chunk_members, direction=direction, reverse=reverse - ): - if neighbor in own_steps: - continue - own_steps[neighbor] = (current, relation_id) - own_depths[neighbor] = own_depths[current] + 1 - next_frontier.add(neighbor) - if neighbor not in other_depths: - continue - hops = own_depths[neighbor] + other_depths[neighbor] - if hops <= max_hops and (best_hops is None or hops < best_hops): - best_hops = hops - meeting = neighbor - return next_frontier, meeting - - @staticmethod - def _relation_steps( - relation: RelationModel, - *, - frontier: set[BlockID], - direction: GraphDirection, - reverse: bool, - ) -> tuple[tuple[BlockID, BlockID], ...]: - steps: list[tuple[BlockID, BlockID]] = [] - if not reverse: - if direction in ("out", "both") and relation.from_ in frontier: - steps.append((relation.from_, relation.to_)) - if direction in ("in", "both") and relation.to_ in frontier: - steps.append((relation.to_, relation.from_)) - else: - if direction in ("out", "both") and relation.to_ in frontier: - steps.append((relation.to_, relation.from_)) - if direction in ("in", "both") and relation.from_ in frontier: - steps.append((relation.from_, relation.to_)) - return tuple(steps) - - @classmethod - async def _frontier_relations( - cls, - frontier: typing.Collection[BlockID], - *, - direction: GraphDirection, - contents: typing.Collection[str], - reverse: bool = False, - uow: GraphUnitOfWork, - ) -> tuple[RelationModel, ...]: - relations: dict[RelationID, RelationModel] = {} - needs_from = ( - direction in ("out", "both") - if not reverse - else direction - in ( - "in", - "both", - ) - ) - needs_to = ( - direction in ("in", "both") - if not reverse - else direction - in ( - "out", - "both", - ) - ) - if needs_from: - for relation in await uow.relations.get_endpoint_page( - frontier, endpoint="from", contents=contents, limit=MAX_MAX_EXPLORED_BLOCKS - ): - if relation.id is not None: - relations[relation.id] = relation - if needs_to: - for relation in await uow.relations.get_endpoint_page( - frontier, endpoint="to", contents=contents, limit=MAX_MAX_EXPLORED_BLOCKS - ): - if relation.id is not None: - relations[relation.id] = relation - return tuple(relations.values()) - @classmethod - async def _assemble_bidirectional_path( # noqa: PLR0913 - cls, - from_block: BlockID, - to_block: BlockID, - *, - meeting: BlockID, - forward_parents: dict[BlockID, tuple[BlockID, RelationID] | None], - backward_next: dict[BlockID, tuple[BlockID, RelationID] | None], - traversed_relations: dict[RelationID, RelationModel], - uow: GraphUnitOfWork, - ) -> PathResult: - block_path: list[BlockID] = [meeting] - relation_path: list[RelationID] = [] - current = meeting - while current != from_block: - parent = forward_parents[current] - if parent is None: # pragma: no cover - reconstruction invariant - raise RuntimeError("Path reconstruction lost its parent") - current, relation_id = parent - block_path.append(current) - relation_path.append(relation_id) - block_path.reverse() - relation_path.reverse() - current = meeting - while current != to_block: - next_step = backward_next[current] - if next_step is None: # pragma: no cover - reconstruction invariant - raise RuntimeError("Path reconstruction lost its next step") - current, relation_id = next_step - block_path.append(current) - relation_path.append(relation_id) - blocks = await uow.blocks.get_many(block_path) - relations = tuple( - relation - for relation_id in relation_path - if (relation := traversed_relations.get(relation_id)) is not None - ) - if {block.id for block in blocks} != set(block_path) or len(relations) != len( - relation_path - ): - return PathNotFound() - return PathFound( - graph=GraphModel(blocks=blocks, relations=relations), - block_path=tuple(block_path), - relation_path=tuple(relation_path), - ) +if TYPE_CHECKING: + from inkcre_core.business.graph_navigation_retrieval.main import * diff --git a/app/business/graph_navigation_retrieval/tools.py b/app/business/graph_navigation_retrieval/tools.py index 7c0a2eb0..16056631 100644 --- a/app/business/graph_navigation_retrieval/tools.py +++ b/app/business/graph_navigation_retrieval/tools.py @@ -1,148 +1,12 @@ -"""Agent Tool controllers for graph-navigation retrieval.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic - -from app.business.agent import AgentManager, ToolExecutionError -from app.business.agent.projection import project_json -from app.schemas.ai import JSONValue -from app.schemas.graph_navigation_retrieval import ( - DEFAULT_MAX_EXPLORED_BLOCKS, - DEFAULT_MAX_EXPLORED_RELATIONS, - DEFAULT_MAX_HOPS, - DEFAULT_NEIGHBORHOOD_LIMIT, - MAX_MAX_EXPLORED_BLOCKS, - MAX_MAX_HOPS, - MAX_NEIGHBORHOOD_LIMIT, - GraphDirection, +sys.modules[__name__] = import_module( + "inkcre_core.business.graph_navigation_retrieval.tools" ) -from app.schemas.info_base.block import BlockID -from app.schemas.info_base.relation import RelationID - -from .main import GraphNavigationRetrievalManager - - -GET_ENTITY_NEIGHBORHOOD_TOOL = "get_entity_neighborhood" -FIND_PATH_TOOL = "find_path" -GET_CONNECTED_COMPONENTS_TOOL = "get_connected_components" - - -class BlockNeighborhoodInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entity_type: typing.Literal["block"] - entity_id: BlockID - direction: GraphDirection = "both" - contents: tuple[str, ...] = () - limit: int = pydantic.Field( - default=DEFAULT_NEIGHBORHOOD_LIMIT, ge=1, le=MAX_NEIGHBORHOOD_LIMIT - ) - cursor: RelationID | None = None - - -class RelationNeighborhoodInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entity_type: typing.Literal["relation"] - entity_id: RelationID - - -class EntityNeighborhoodInput( - pydantic.RootModel[ - typing.Annotated[ - BlockNeighborhoodInput | RelationNeighborhoodInput, - pydantic.Field(discriminator="entity_type"), - ] - ] -): - model_config = pydantic.ConfigDict(json_schema_extra={"type": "object"}) - - @classmethod - def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: - schema = super().model_json_schema(*args, **kwargs) - properties = BlockNeighborhoodInput.model_json_schema(*args, **kwargs)["properties"] - properties["entity_type"] = {"type": "string", "enum": ["block", "relation"]} - schema["properties"] = properties - return schema - -class FindPathInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - from_block_id: BlockID - to_block_id: BlockID - direction: GraphDirection = "both" - contents: tuple[str, ...] = () - max_hops: int = pydantic.Field(default=DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) - max_explored_blocks: int = pydantic.Field( - default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS - ) - - -class ConnectedComponentsInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - seed_block_ids: tuple[BlockID, ...] - contents: tuple[str, ...] = pydantic.Field(min_length=1) - max_explored_blocks: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) - max_explored_relations: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) - - -@AgentManager.tool( - GET_ENTITY_NEIGHBORHOOD_TOOL, - description=( - "Read a Block's direct neighborhood or a Relation with its endpoints. " - "Null may indicate an incorrect entity type." - ), -) -async def get_entity_neighborhood(input: EntityNeighborhoodInput) -> JSONValue: - request = input.root - if request.entity_type == "block": - result = await GraphNavigationRetrievalManager.get_block_neighborhood( - request.entity_id, - direction=request.direction, - contents=request.contents, - limit=request.limit, - cursor=request.cursor, - ) - else: - result = await GraphNavigationRetrievalManager.get_relation_neighborhood( - request.entity_id - ) - return project_json(result) - - -@AgentManager.tool( - FIND_PATH_TOOL, - description="Find a bounded graph path; an exploration limit is not proof of absence.", -) -async def find_path(input: FindPathInput) -> JSONValue: - return project_json( - await GraphNavigationRetrievalManager.find_path( - input.from_block_id, - input.to_block_id, - direction=input.direction, - contents=input.contents, - max_hops=input.max_hops, - max_explored_blocks=input.max_explored_blocks, - ) - ) - - -@AgentManager.tool( - GET_CONNECTED_COMPONENTS_TOOL, - description=( - "Partition seeds by bounded undirected reachability through exact Relation contents." - ), -) -async def get_connected_components(input: ConnectedComponentsInput) -> JSONValue: - try: - result = await GraphNavigationRetrievalManager.get_connected_components( - **input.model_dump() - ) - except ValueError as error: - raise ToolExecutionError( - {"error": type(error).__name__, "message": str(error)} - ) from error - return project_json(result) +if TYPE_CHECKING: + from inkcre_core.business.graph_navigation_retrieval.tools import * diff --git a/app/business/info_base/__init__.py b/app/business/info_base/__init__.py index 72c49b13..c1ea652b 100644 --- a/app/business/info_base/__init__.py +++ b/app/business/info_base/__init__.py @@ -1,14 +1,3 @@ -from .main import InfoBaseManager -from .commands import persist_graph, submit_graph, persist_stars, submit_stars -from .services import BlockService, RelationService, get_entity_records +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "InfoBaseManager", - "persist_graph", - "submit_graph", - "persist_stars", - "submit_stars", - "get_entity_records", - "BlockService", - "RelationService", -] +from inkcre_core.business.info_base import * diff --git a/app/business/info_base/commands.py b/app/business/info_base/commands.py index 926847d1..90d158d4 100644 --- a/app/business/info_base/commands.py +++ b/app/business/info_base/commands.py @@ -1,79 +1,10 @@ -"""Graph application operations shared by transports and composing use cases.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.schemas.info_base.main import ( - GraphBlockIDMapping, - GraphForm, - SubmitGraphResult, - StarsGraphForm, -) -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.relation import RelationCreateForm +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow +sys.modules[__name__] = import_module("inkcre_core.business.info_base.commands") - -async def persist_graph(graph: GraphForm, uow: GraphUnitOfWork) -> SubmitGraphResult: - """Add a flat graph to the supplied unit of work without ending its transaction.""" - blocks = await uow.blocks.create_many(graph.blocks) - mappings: list[GraphBlockIDMapping] = [] - for form, block in zip(graph.blocks, blocks, strict=True): - if block.id is None: - raise RuntimeError("Inserted Block is missing its database ID") - mappings.append(GraphBlockIDMapping(local_id=form.id, id=block.id)) - persisted_ids = {mapping.local_id: mapping.id for mapping in mappings} - await uow.relations.create_many( - RelationCreateForm( - from_=persisted_ids.get(form.from_, form.from_), - to_=persisted_ids.get(form.to_, form.to_), - content=form.content, - ) - for form in graph.relations - ) - return SubmitGraphResult(blocks=tuple(mappings)) - - -async def submit_graph(graph: GraphForm) -> SubmitGraphResult: - """Commit one complete flat graph before returning its Block identity mapping.""" - async with graph_uow() as uow: - result = await persist_graph(graph, uow) - return result - - -async def get_related_block( - block_id: int, *, content: str, outgoing: bool = True -) -> BlockModel | None: - async with graph_uow() as uow: - return await uow.blocks.get_related(block_id, content=content, outgoing=outgoing) - - -async def persist_stars(graph: StarsGraphForm, uow: GraphUnitOfWork) -> BlockModel: - """Reconcile exact resolver identities and relations within one transaction.""" - from .resolver import ResolverManager - - candidate = BlockModel.model_validate(graph.block) - existing = await ResolverManager.get(candidate).get_existing_async(uow.blocks) - block = existing if existing is not None else await uow.blocks.create(graph.block) - if block.id is None: - raise RuntimeError("Persisted star Block is missing its database ID") - for arc in graph.out_arcs: - target = await persist_stars(arc.to_graph, uow) - if target.id is None: - raise RuntimeError("Persisted star Block is missing its database ID") - await uow.relations.fetchsert( - RelationModel(from_=block.id, to_=target.id, content=arc.relation.content) - ) - for arc in graph.in_arcs: - source = await persist_stars(arc.from_graph, uow) - if source.id is None: - raise RuntimeError("Persisted star Block is missing its database ID") - await uow.relations.fetchsert( - RelationModel(from_=source.id, to_=block.id, content=arc.relation.content) - ) - return block - - -async def submit_stars(graph: StarsGraphForm) -> BlockModel: - async with graph_uow() as uow: - result = await persist_stars(graph, uow) - return result +if TYPE_CHECKING: + from inkcre_core.business.info_base.commands import * diff --git a/app/business/info_base/main.py b/app/business/info_base/main.py index 8f1fd428..ef1ff3b1 100644 --- a/app/business/info_base/main.py +++ b/app/business/info_base/main.py @@ -1,117 +1,10 @@ -"""Info-base graph command coordination and cross-retrieval use cases.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.business.info_base.services import BlockService, get_entity_records -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.semantic_retrieval import SemanticRetrievalManager -from app.schemas.lexical_retrieval import LexicalRetrievalResult -from app.schemas.semantic_retrieval import SemanticRetrievalResult, VectorRetrievalOptions -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel +sys.modules[__name__] = import_module("inkcre_core.business.info_base.main") -from app.schemas.info_base.main import ( - GraphBlockForm, - GraphForm, - GraphRelationForm, - StarsGraphForm, -) - - -@dataclass(frozen=True) -class RetrievalBranch: - result: LexicalRetrievalResult | SemanticRetrievalResult | None = None - error: BaseException | None = None - - -class InfoBaseManager: - """Own graph commands and use cases that span info-base read capabilities.""" - - @classmethod - async def retrieve( - cls, - query: str, - mode: typing.Literal["lexical", "semantic", "hybrid"] = "hybrid", - limit: int = 20, - ) -> dict[str, RetrievalBranch]: - """Run selected retrieval owners independently without merging their ranking.""" - operations: dict[str, typing.Awaitable] = {} - if mode in {"lexical", "hybrid"}: - operations["lexical"] = LexicalRetrievalManager.retrieve_local(query, limit) - if mode in {"semantic", "hybrid"}: - operations["semantic"] = SemanticRetrievalManager.retrieve_local( - query, options=VectorRetrievalOptions(limit=limit) - ) - outcomes = await asyncio.gather(*operations.values(), return_exceptions=True) - return { - name: RetrievalBranch( - error=outcome if isinstance(outcome, BaseException) else None, - result=None if isinstance(outcome, BaseException) else outcome, - ) - for name, outcome in zip(operations, outcomes, strict=True) - } - - @classmethod - async def get_entities( - cls, - entities: typing.Collection[tuple[typing.Literal["block", "relation"], int]], - *, - random_count: int = 1, - ) -> tuple[BlockModel | RelationModel | None, ...]: - """Read ordered persisted entities, or random Blocks when selection is empty.""" - if not entities: - return typing.cast( - tuple[BlockModel | RelationModel | None, ...], - await BlockService.get_random_many(random_count), - ) - blocks, relations = await get_entity_records( - tuple(identity for kind, identity in entities if kind == "block"), - tuple(identity for kind, identity in entities if kind == "relation"), - ) - return tuple( - blocks.get(identity) if kind == "block" else relations.get(identity) - for kind, identity in entities - ) - - @classmethod - def normalize_graph( - cls, - stars: StarsGraphForm, - id_start: int = -1, - ) -> GraphForm: - """Flatten recursive authoring while allocating deterministic negative IDs.""" - if id_start >= 0: - raise ValueError("id_start must be negative") - - next_id = id_start - blocks: list[GraphBlockForm] = [] - relations: list[GraphRelationForm] = [] - - def visit(star: StarsGraphForm) -> int: - nonlocal next_id - block_id = next_id - next_id -= 1 - blocks.append(GraphBlockForm.model_validate(star.block, update={"id": block_id})) - - for arc in star.out_arcs: - to_id = visit(arc.to_graph) - relations.append( - GraphRelationForm.model_validate( - arc.relation, - update={"from_": block_id, "to_": to_id}, - ) - ) - for arc in star.in_arcs: - from_id = visit(arc.from_graph) - relations.append( - GraphRelationForm.model_validate( - arc.relation, - update={"from_": from_id, "to_": block_id}, - ) - ) - return block_id - - visit(stars) - return GraphForm(blocks=tuple(blocks), relations=tuple(relations)) +if TYPE_CHECKING: + from inkcre_core.business.info_base.main import * diff --git a/app/business/info_base/resolver/__init__.py b/app/business/info_base/resolver/__init__.py index edf45ec6..fcc64c12 100644 --- a/app/business/info_base/resolver/__init__.py +++ b/app/business/info_base/resolver/__init__.py @@ -1,60 +1,3 @@ -from .bootstrap import register_core_resolvers -from .contracts import ( - CORE_RESOLVER_IDS, - CoreResolverID, - DuplicateResolverRegistrationError, - ResolverContentError, - ResolverContractError, - TextProjectionContext, - UnknownDraftResolverError, - UnknownResolverError, - UnsupportedResolverCapability, - ResolverMethodInputError, - UnknownResolverMethodError, -) -from .main import ( - Resolver, - ResolverDraftCapability, - ResolverManager, - ResolverMethodContract, -) +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "ResolverManager", - "Resolver", - "register_core_resolvers", - "CORE_RESOLVER_IDS", - "CoreResolverID", - "ResolverContractError", - "TextProjectionContext", - "UnknownResolverError", - "UnknownDraftResolverError", - "DuplicateResolverRegistrationError", - "ResolverContentError", - "UnsupportedResolverCapability", - "ResolverDraftCapability", - "ResolverMethodContract", - "ResolverMethodInputError", - "UnknownResolverMethodError", - "AudioResolver", - "EPUBResolver", - "FileResolver", - "ImageResolver", - "PDFResolver", - "VideoResolver", - "TextResolver", - "MarkdownResolver", - "HTMLResolver", - "ZIPResolver", -] - -from .audio import AudioResolver -from .epub import EPUBResolver -from .file import FileResolver -from .image import ImageResolver -from .pdf import PDFResolver -from .video import VideoResolver -from .text import TextResolver -from .markdown import MarkdownResolver -from .html import HTMLResolver -from .zip import ZIPResolver +from inkcre_core.business.info_base.resolver import * diff --git a/app/business/info_base/resolver/audio.py b/app/business/info_base/resolver/audio.py index e5ce2fa4..ee83732b 100644 --- a/app/business/info_base/resolver/audio.py +++ b/app/business/info_base/resolver/audio.py @@ -1,170 +1,10 @@ -"""Exact audio resolver using metadata-only PyAV inspection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -from io import BytesIO -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import av -from av.stream import Disposition -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.audio") -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.schemas.ai import AudioContentPart - -from .contracts import ( - ResolverContentError, - TextProjectionContext, - UnsupportedResolverCapability, -) -from .inspection import ( - ByteContentFacts, - detect_media_type, - format_lexical_facts, - require_bytes, -) -from .main import Resolver -from .materialization import try_materialize_model_text - - -AUDIO_RESOLVER_CONFIG_KEY = "core.resolver.audio" -AUDIO_RESOLVER_CONFIG_SCHEMA = "core.resolver.audio.config.v1" - - -class AudioResolverConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - transcript_model: int - - -DeploymentConfigManager.register_schema( - AUDIO_RESOLVER_CONFIG_SCHEMA, AudioResolverConfig, keys=(AUDIO_RESOLVER_CONFIG_KEY,) -) - - -@dataclass(frozen=True, slots=True) -class AudioSolvedContent(ByteContentFacts): - container: str | None - codec: str | None - duration_ms: int | None - channels: int | None - sample_rate_hz: int | None - bitrate_bps: int | None - - -def _container_name(format_name: str | None) -> str | None: - if not format_name: - return None - names = format_name.split(",") - return "mp4" if "mp4" in names else names[0] - - -def _duration_ms(container, stream) -> int | None: - if stream.duration is not None and stream.time_base is not None: - return round(float(stream.duration * stream.time_base) * 1000) - if container.duration is not None: - return round(container.duration / 1000) - return None - - -def _primary_stream(streams): - default = [stream for stream in streams if bool(stream.disposition & Disposition.default)] - ordinary = [ - stream for stream in streams if not bool(stream.disposition & Disposition.attached_pic) - ] - candidates = default or ordinary or list(streams) - return candidates[0] if candidates else None - - -def _inspect_audio(content: bytes) -> AudioSolvedContent: - try: - with av.open(BytesIO(content), mode="r") as container: - stream = _primary_stream(container.streams.audio) - if stream is None: - raise ResolverContentError("core.audio.v1", "no audio stream") - codec = stream.codec_context - facts = AudioSolvedContent( - content=content, - byte_size=len(content), - detected_media_type=detect_media_type(content), - container=_container_name(container.format.name), - codec=codec.name, - duration_ms=_duration_ms(container, stream), - channels=codec.channels, - sample_rate_hz=codec.sample_rate, - bitrate_bps=stream.bit_rate or container.bit_rate, - ) - except ResolverContentError: - raise - except (av.FFmpegError, OSError, ValueError) as error: - raise ResolverContentError("core.audio.v1", "invalid audio") from error - return facts - - -class AudioResolver( - Resolver[AudioSolvedContent, bytes], - rso_type="core.audio.v1", -): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> AudioSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_audio, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - if materialize_missing: - config = typing.cast( - AudioResolverConfig | None, - await DeploymentConfigService.get(AUDIO_RESOLVER_CONFIG_KEY), - ) - if config is not None: - media_type = solved.detected_media_type or ( - f"audio/{solved.container}" if solved.container is not None else None - ) - if media_type is not None: - await try_materialize_model_text( - block_id=self.block_id, - role="transcript", - model=config.transcript_model, - instruction=( - "Transcribe only speech and authored spoken language in the audio. " - "Return plain text with no summary or explanation." - ), - media=AudioContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=await self.get_transfer_url(), - ), - ) - return format_lexical_facts( - "audio", - ( - ("media type", solved.detected_media_type), - ("container", solved.container), - ("codec", solved.codec), - ("duration ms", solved.duration_ms), - ("channels", solved.channels), - ("sample rate Hz", solved.sample_rate_hz), - ("bitrate bps", solved.bitrate_bps), - ), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "audio" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.audio import * diff --git a/app/business/info_base/resolver/bootstrap.py b/app/business/info_base/resolver/bootstrap.py index 862a2379..cbae51ed 100644 --- a/app/business/info_base/resolver/bootstrap.py +++ b/app/business/info_base/resolver/bootstrap.py @@ -1,31 +1,10 @@ -"""Explicit registration of resolver decoders owned by impl-py.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .main import ResolverManager +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.bootstrap") -def register_core_resolvers() -> None: - """Register built-in decoders independently of extension loading.""" - from .audio import AudioResolver - from .epub import EPUBResolver - from .file import FileResolver - from .html import HTMLResolver - from .image import ImageResolver - from .pdf import PDFResolver - from .text import TextResolver - from .markdown import MarkdownResolver - from .video import VideoResolver - from .zip import ZIPResolver - - for resolver_class in ( - TextResolver, - MarkdownResolver, - HTMLResolver, - ImageResolver, - AudioResolver, - VideoResolver, - PDFResolver, - EPUBResolver, - ZIPResolver, - FileResolver, - ): - ResolverManager.register_resolver(resolver_class) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.bootstrap import * diff --git a/app/business/info_base/resolver/contracts.py b/app/business/info_base/resolver/contracts.py index fbcfa89b..63abb2f6 100644 --- a/app/business/info_base/resolver/contracts.py +++ b/app/business/info_base/resolver/contracts.py @@ -1,95 +1,10 @@ -"""Stable resolver IDs, projection controls, and typed contract failures.""" +"""Legacy import; use inkcre_core for new consumers.""" -from typing import Literal, TypeAlias +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.contracts") - -TextProjectionContext: TypeAlias = Literal["default", "lexical"] - - -CoreResolverID: TypeAlias = Literal[ - "core.text.v1", - "core.markdown.v1", - "core.html.v1", - "core.image.v1", - "core.audio.v1", - "core.video.v1", - "core.pdf.v1", - "core.epub.v1", - "core.zip.v1", - "core.file.v1", -] - -CORE_RESOLVER_IDS: tuple[CoreResolverID, ...] = ( - "core.text.v1", - "core.markdown.v1", - "core.html.v1", - "core.image.v1", - "core.audio.v1", - "core.video.v1", - "core.pdf.v1", - "core.epub.v1", - "core.zip.v1", - "core.file.v1", -) - - -class ResolverContractError(RuntimeError): - """Base failure for resolver selection and capability contracts.""" - - -class ResolverMethodInputError(pydantic.ValidationError): - """Native input details, distinguishable from errors inside a content method.""" - - -class UnknownResolverMethodError(ResolverContractError, ValueError): - """An exact Resolver has no projected read method with this name.""" - - -class UnknownResolverError(ResolverContractError): - """No exact resolver decoder is registered for a persisted resolver ID.""" - - def __init__(self, resolver_id: str): - self.resolver_id = resolver_id - super().__init__(f"Unknown resolver ID: {resolver_id}") - - -class UnknownDraftResolverError(ResolverContractError): - """The selected exact Resolver has no graph-drafting capability.""" - - def __init__(self, resolver_id: str): - self.resolver_id = resolver_id - super().__init__(f"Resolver is not available for graph drafting: {resolver_id}") - - -class DuplicateResolverRegistrationError(ResolverContractError): - """Two different decoder classes claimed the same exact resolver ID.""" - - def __init__(self, resolver_id: str, existing: type, attempted: type): - self.resolver_id = resolver_id - self.existing = existing - self.attempted = attempted - super().__init__( - f"Resolver ID {resolver_id} is already registered by " - f"{existing.__module__}.{existing.__qualname__}; " - f"cannot register {attempted.__module__}.{attempted.__qualname__}" - ) - - -class UnsupportedResolverCapability(ResolverContractError): - """The exact resolver contract does not provide one requested projection.""" - - def __init__(self, resolver_id: str, capability: str): - self.resolver_id = resolver_id - self.capability = capability - super().__init__(f"Resolver {resolver_id} does not support {capability}") - - -class ResolverContentError(ResolverContractError, ValueError): - """Hydrated content does not satisfy its exact resolver contract.""" - - def __init__(self, resolver_id: str, reason: str): - self.resolver_id = resolver_id - self.reason = reason - super().__init__(f"Resolver {resolver_id} rejected content: {reason}") +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.contracts import * diff --git a/app/business/info_base/resolver/epub.py b/app/business/info_base/resolver/epub.py index a403579e..793e672a 100644 --- a/app/business/info_base/resolver/epub.py +++ b/app/business/info_base/resolver/epub.py @@ -1,219 +1,10 @@ -"""Exact EPUB resolver using bounded package-document inspection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -import datetime -from dataclasses import dataclass -from io import BytesIO -import zipfile +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from lxml import etree +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.epub") -from .contracts import ( - ResolverContentError, - TextProjectionContext, - UnsupportedResolverCapability, -) -from .inspection import ByteContentFacts, format_lexical_facts, require_bytes -from .label import format_label -from .main import Resolver - - -_MAX_EPUB_MEMBERS = 10_000 -_MAX_PACKAGE_BYTES = 2 * 1024 * 1024 -_XML_PARSER = etree.XMLParser( - resolve_entities=False, - no_network=True, - recover=False, - huge_tree=False, -) - - -@dataclass(frozen=True, slots=True) -class EPUBSolvedContent(ByteContentFacts): - epub_version: str | None - title: str | None - creators: tuple[str, ...] - languages: tuple[str, ...] - modified_at: datetime.datetime | None - manifest_count: int - spine_count: int - has_navigation: bool - - -def _local_name(element: etree._Element) -> str: - return etree.QName(element).localname - - -def _read_bounded( - archive: zipfile.ZipFile, - member_name: str, - *, - limit: int = _MAX_PACKAGE_BYTES, -) -> bytes: - try: - member = archive.getinfo(member_name) - except KeyError as error: - raise ResolverContentError( - "core.epub.v1", - f"missing required EPUB member {member_name}", - ) from error - if member.file_size > limit: - raise ResolverContentError( - "core.epub.v1", - f"EPUB member {member_name} exceeds inspection limit", - ) - with archive.open(member) as stream: - result = stream.read(limit + 1) - if len(result) > limit: - raise ResolverContentError( - "core.epub.v1", - f"EPUB member {member_name} exceeds inspection limit", - ) - return result - - -def _parse_xml(content: bytes, label: str) -> etree._Element: - try: - return etree.fromstring(content, parser=_XML_PARSER) - except etree.XMLSyntaxError as error: - raise ResolverContentError("core.epub.v1", f"invalid {label}") from error - - -def _first_text(elements: list[etree._Element]) -> str | None: - for element in elements: - if element.text and (value := element.text.strip()): - return value - return None - - -def _all_text(elements: list[etree._Element]) -> tuple[str, ...]: - return tuple( - value for element in elements if element.text and (value := element.text.strip()) - ) - - -def _parse_modified(value: str | None) -> datetime.datetime | None: - if value is None: - return None - try: - parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as error: - raise ResolverContentError( - "core.epub.v1", - "invalid dcterms:modified timestamp", - ) from error - return parsed - - -def _inspect_epub(content: bytes) -> EPUBSolvedContent: - try: - with zipfile.ZipFile(BytesIO(content)) as archive: - if len(archive.infolist()) > _MAX_EPUB_MEMBERS: - raise ResolverContentError("core.epub.v1", "too many EPUB members") - if _read_bounded(archive, "mimetype", limit=128) != b"application/epub+zip": - raise ResolverContentError("core.epub.v1", "invalid EPUB mimetype member") - - container = _parse_xml( - _read_bounded(archive, "META-INF/container.xml"), - "EPUB container document", - ) - rootfiles = [ - element for element in container.iter() if _local_name(element) == "rootfile" - ] - package_path = next( - (element.get("full-path") for element in rootfiles if element.get("full-path")), - None, - ) - if package_path is None: - raise ResolverContentError("core.epub.v1", "missing EPUB package path") - package = _parse_xml( - _read_bounded(archive, package_path), - "EPUB package document", - ) - except ResolverContentError: - raise - except (zipfile.BadZipFile, OSError, ValueError) as error: - raise ResolverContentError("core.epub.v1", "invalid EPUB container") from error - - elements = list(package.iter()) - titles = [element for element in elements if _local_name(element) == "title"] - creators = [element for element in elements if _local_name(element) == "creator"] - languages = [element for element in elements if _local_name(element) == "language"] - manifest_items = [element for element in elements if _local_name(element) == "item"] - spine_items = [element for element in elements if _local_name(element) == "itemref"] - modified = next( - ( - element.text.strip() - for element in elements - if _local_name(element) == "meta" - and element.get("property") == "dcterms:modified" - and element.text - and element.text.strip() - ), - None, - ) - - return EPUBSolvedContent( - content=content, - byte_size=len(content), - detected_media_type="application/epub+zip", - epub_version=package.get("version"), - title=_first_text(titles), - creators=_all_text(creators), - languages=_all_text(languages), - modified_at=_parse_modified(modified), - manifest_count=len(manifest_items), - spine_count=len(spine_items), - has_navigation=any( - "nav" in (element.get("properties") or "").split() for element in manifest_items - ), - ) - - -class EPUBResolver( - Resolver[EPUBSolvedContent, bytes], - rso_type="core.epub.v1", -): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> EPUBSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_epub, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - del materialize_missing - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - return format_lexical_facts( - "EPUB", - ( - ("media type", solved.detected_media_type), - ("title", solved.title), - ("creators", ", ".join(solved.creators) or None), - ("languages", ", ".join(solved.languages) or None), - ("EPUB version", solved.epub_version), - ("manifest items", solved.manifest_count), - ("spine items", solved.spine_count), - ), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - solved = await self.get_solved_content( - refresh=refresh, - materialize_missing=False, - ) - return format_label("EPUB", solved.title) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.epub import * diff --git a/app/business/info_base/resolver/file.py b/app/business/info_base/resolver/file.py index b95167f7..84459095 100644 --- a/app/business/info_base/resolver/file.py +++ b/app/business/info_base/resolver/file.py @@ -1,64 +1,10 @@ -"""Exact generic-file resolver for bytes without stronger semantics.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .contracts import TextProjectionContext, UnsupportedResolverCapability -from .inspection import ( - ByteContentFacts, - detect_media_type, - format_lexical_facts, - require_bytes, -) -from .main import Resolver +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.file") - -@dataclass(frozen=True, slots=True) -class FileSolvedContent(ByteContentFacts): - pass - - -def _inspect_file(content: bytes) -> FileSolvedContent: - return FileSolvedContent( - content=content, - byte_size=len(content), - detected_media_type=detect_media_type(content), - ) - - -class FileResolver( - Resolver[FileSolvedContent, bytes], - rso_type="core.file.v1", -): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> FileSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_file, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - del materialize_missing - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - return format_lexical_facts( - "file", - (("media type", solved.detected_media_type), ("bytes", solved.byte_size)), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "file" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.file import * diff --git a/app/business/info_base/resolver/html.py b/app/business/info_base/resolver/html.py index ad506485..3dece393 100644 --- a/app/business/info_base/resolver/html.py +++ b/app/business/info_base/resolver/html.py @@ -1,73 +1,10 @@ -"""Exact HTML-source resolver with a derived text projection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import html2text -from lxml import html as lxml_html +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.html") -from .inspection import decode_html_bytes -from .label import format_label -from .main import Resolver, TextProjectionContext - - -class HTMLResolver(Resolver[str, str | bytes], rso_type="core.html.v1"): - @classmethod - def create_graph(cls, url: str): - from app.schemas.info_base.block import BlockForm - from app.schemas.info_base.main import StarsGraphForm - - return StarsGraphForm( - block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1) - ) - - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del materialize_missing - content = await self.get_raw_content(refresh=refresh) - return ( - content if isinstance(content, str) else decode_html_bytes(content, self.__rsotype__) - ) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context - source = await self.get_solved_content( - refresh=refresh, - materialize_missing=materialize_missing, - ) - if any(f"<{tag}" in source.lower() for tag in ("video", "audio", "track")): - document = lxml_html.document_fromstring(source) - for resource in document.xpath("//video | //audio | //source | //track"): - for attribute in ("src", "poster"): - if address := resource.get(attribute): - link = lxml_html.Element("a", href=address) - link.text = resource.get("label") or address - resource.addnext(link) - source = lxml_html.tostring(document, encoding="unicode") - return await asyncio.to_thread(html2text.HTML2Text().handle, source) - - async def get_label(self, *, refresh: bool = False) -> str: - source = await self.get_solved_content( - refresh=refresh, - materialize_missing=False, - ) - identifier = None - try: - document = lxml_html.fromstring(source) - values = document.xpath("//title/text()") or document.xpath( - "//h1//text() | //h2//text() | //h3//text() | //h4//text() | " - "//h5//text() | //h6//text()" - ) - identifier = next((str(value) for value in values if str(value).strip()), None) - except (TypeError, ValueError): - pass - return format_label("html", identifier) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.html import * diff --git a/app/business/info_base/resolver/image.py b/app/business/info_base/resolver/image.py index 3f4e5765..53727271 100644 --- a/app/business/info_base/resolver/image.py +++ b/app/business/info_base/resolver/image.py @@ -1,155 +1,10 @@ -"""Exact image resolver using bounded Pillow header inspection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -from io import BytesIO -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from PIL import Image, UnidentifiedImageError -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.image") -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.schemas.ai import ImageContentPart -from app.schemas.info_base.block import BlockForm -from app.schemas.info_base.main import OutArcForm, StarsGraphForm -from app.schemas.info_base.relation import RelationForm - -from .contracts import ( - ResolverContentError, - TextProjectionContext, - UnsupportedResolverCapability, -) -from .inspection import ( - ByteContentFacts, - detect_media_type, - format_lexical_facts, - require_bytes, -) -from .main import Resolver -from .materialization import try_materialize_model_text - - -IMAGE_RESOLVER_CONFIG_KEY = "core.resolver.image" -IMAGE_RESOLVER_CONFIG_SCHEMA = "core.resolver.image.config.v1" - - -class ImageResolverConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - text_model: int - - -DeploymentConfigManager.register_schema( - IMAGE_RESOLVER_CONFIG_SCHEMA, ImageResolverConfig, keys=(IMAGE_RESOLVER_CONFIG_KEY,) -) - - -@dataclass(frozen=True, slots=True) -class ImageSolvedContent(ByteContentFacts): - format: str | None - width: int | None - height: int | None - frame_count: int | None - - -def _inspect_image(content: bytes) -> ImageSolvedContent: - try: - with Image.open(BytesIO(content)) as image: - image_format = image.format.lower() if image.format else None - width, height = image.size - frame_count = getattr(image, "n_frames", None) - image.verify() - except (UnidentifiedImageError, OSError, SyntaxError, ValueError) as error: - raise ResolverContentError("core.image.v1", "invalid image") from error - return ImageSolvedContent( - content=content, - byte_size=len(content), - detected_media_type=detect_media_type(content), - format=image_format, - width=width, - height=height, - frame_count=frame_count, - ) - - -class ImageResolver( - Resolver[ImageSolvedContent, bytes], - rso_type="core.image.v1", -): - @classmethod - def create_graph(cls, url: str, alt_text: str | None = None) -> StarsGraphForm: - from .text import TextResolver - - return StarsGraphForm( - block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1), - out_arcs=( - OutArcForm( - relation=RelationForm(content="alt:text"), - to_graph=TextResolver.create_graph(alt_text), - ), - ) - if alt_text - else (), - ) - - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> ImageSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_image, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - if materialize_missing: - config = typing.cast( - ImageResolverConfig | None, - await DeploymentConfigService.get(IMAGE_RESOLVER_CONFIG_KEY), - ) - if config is not None: - media_type = solved.detected_media_type or ( - f"image/{solved.format}" if solved.format is not None else None - ) - if media_type is not None: - await try_materialize_model_text( - block_id=self.block_id, - role="text", - model=config.text_model, - instruction=( - "Transcribe only written text visibly present in the image. " - "Return plain text with no explanation; return no content when none exists." - ), - media=ImageContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=await self.get_transfer_url(), - ), - ) - return format_lexical_facts( - "image", - ( - ("media type", solved.detected_media_type), - ("format", solved.format), - ("width", solved.width), - ("height", solved.height), - ("frames", solved.frame_count), - ), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "image" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.image import * diff --git a/app/business/info_base/resolver/inspection.py b/app/business/info_base/resolver/inspection.py index 4ac77f4f..b690a7d2 100644 --- a/app/business/info_base/resolver/inspection.py +++ b/app/business/info_base/resolver/inspection.py @@ -1,104 +1,10 @@ -"""Bounded implementation helpers shared by exact core content resolvers.""" +"""Legacy import; use inkcre_core for new consumers.""" -from dataclasses import dataclass -from collections.abc import Iterable -import codecs -import re +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import puremagic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.inspection") -from .contracts import ResolverContentError - - -@dataclass(frozen=True, slots=True) -class ByteContentFacts: - """Peer-local actual bytes plus common byte-derived facts.""" - - content: bytes - byte_size: int - detected_media_type: str | None - - -def detect_media_type(content: bytes) -> str | None: - """Return bounded signature detection without inventing an unknown value.""" - try: - detected = puremagic.from_string(content, mime=True) - except puremagic.PureError: - return None - return detected if isinstance(detected, str) else None - - -def decode_unicode_bytes(content: bytes, resolver_id: str) -> str: - """Decode a Unicode BOM when present, otherwise require strict UTF-8.""" - decoders = ( - (codecs.BOM_UTF32_LE, "utf-32"), - (codecs.BOM_UTF32_BE, "utf-32"), - (codecs.BOM_UTF8, "utf-8-sig"), - (codecs.BOM_UTF16_LE, "utf-16"), - (codecs.BOM_UTF16_BE, "utf-16"), - ) - encoding = next( - (candidate for bom, candidate in decoders if content.startswith(bom)), - "utf-8", - ) - try: - return content.decode(encoding, errors="strict") - except UnicodeError as error: - raise ResolverContentError(resolver_id, f"invalid {encoding} text") from error - - -_HTML_CHARSET_SEARCH_BYTES = 4096 -_HTML_CHARSET_PATTERNS = ( - re.compile(rb"]*charset\s*=\s*['\"]?\s*([a-zA-Z0-9._-]+)", re.I), - re.compile(rb"]*content\s*=\s*['\"][^'\"]*charset=([a-zA-Z0-9._-]+)", re.I), -) - - -def decode_html_bytes(content: bytes, resolver_id: str) -> str: - """Decode HTML using a Unicode BOM, bounded meta declaration, or strict UTF-8.""" - for bom in ( - codecs.BOM_UTF32_LE, - codecs.BOM_UTF32_BE, - codecs.BOM_UTF8, - codecs.BOM_UTF16_LE, - codecs.BOM_UTF16_BE, - ): - if content.startswith(bom): - return decode_unicode_bytes(content, resolver_id) - - prefix = content[:_HTML_CHARSET_SEARCH_BYTES] - declared_encoding = next( - ( - match.group(1).decode("ascii") - for pattern in _HTML_CHARSET_PATTERNS - if (match := pattern.search(prefix)) is not None - ), - "utf-8", - ) - try: - encoding = codecs.lookup(declared_encoding).name - except LookupError as error: - raise ResolverContentError( - resolver_id, - f"unknown declared HTML charset {declared_encoding}", - ) from error - try: - return content.decode(encoding, errors="strict") - except UnicodeError as error: - raise ResolverContentError(resolver_id, f"invalid {encoding} HTML") from error - - -def require_bytes(content: object, resolver_id: str) -> bytes: - if not isinstance(content, bytes): - raise ResolverContentError(resolver_id, "byte-oriented content must be storage-backed") - return content - - -def format_lexical_facts( - kind: str, - facts: Iterable[tuple[str, object | None]], -) -> str: - """Format bounded Resolver-owned metadata without serializing arbitrary objects.""" - lines = [kind] - lines.extend(f"{label}: {value}" for label, value in facts if value is not None) - return "\n".join(lines) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.inspection import * diff --git a/app/business/info_base/resolver/label.py b/app/business/info_base/resolver/label.py index a271cb2d..0da9422f 100644 --- a/app/business/info_base/resolver/label.py +++ b/app/business/info_base/resolver/label.py @@ -1,31 +1,10 @@ -"""Shared mechanics for concise Resolver-owned Block labels.""" +"""Legacy import; use inkcre_core for new consumers.""" -LABEL_IDENTIFIER_LIMIT = 96 +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.label") -def normalize_label_identifier( - value: str | None, - *, - first_line: bool = False, -) -> str | None: - """Normalize whitespace and bound one optional human-readable identifier.""" - if value is None: - return None - candidates = value.splitlines() if first_line else (value,) - normalized = next((" ".join(item.split()) for item in candidates if item.strip()), "") - if not normalized: - return None - if len(normalized) > LABEL_IDENTIFIER_LIMIT: - return f"{normalized[:LABEL_IDENTIFIER_LIMIT]}…" - return normalized - - -def format_label( - kind: str, - identifier: str | None = None, - *, - first_line: bool = False, -) -> str: - """Format one stable resolver-qualified label without exposing exact IDs.""" - normalized = normalize_label_identifier(identifier, first_line=first_line) - return kind if normalized is None else f"{kind} <{normalized}>" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.label import * diff --git a/app/business/info_base/resolver/main.py b/app/business/info_base/resolver/main.py index 57820783..0bb432e7 100644 --- a/app/business/info_base/resolver/main.py +++ b/app/business/info_base/resolver/main.py @@ -1,434 +1,10 @@ -import abc -from collections.abc import Collection -from dataclasses import dataclass -import inspect -import typing -from typing import Optional as Opt +"""Legacy import; use inkcre_core for new consumers.""" -import pydantic +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.business.info_base.services import RelationService -from app.schemas.info_base.main import StarsGraphForm -from app.schemas.info_base.block import BlockForm, BlockID, ResolverType, BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageID +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.main") -from app.persistence.info_base.repository import BlockRepository - -from .contracts import ( - DuplicateResolverRegistrationError, - TextProjectionContext, - UnknownDraftResolverError, - UnknownResolverError, - UnknownResolverMethodError, - ResolverMethodInputError, -) - - -@dataclass(frozen=True) -class ResolverDraftCapability: - """One code-owned Resolver graph-drafting contract.""" - - resolver: ResolverType - description: str - input_model: type[pydantic.BaseModel] - resolver_cls: type["Resolver"] - - -@dataclass(frozen=True) -class ResolverMethodContract: - """One typed public read method on a registered Resolver.""" - - name: str - description: str - input_model: type[pydantic.BaseModel] - - @property - def input_schema(self) -> dict[str, typing.Any]: - return self.input_model.model_json_schema() - - -class ResolverManager: - RESOLVER_CLS: dict[ResolverType, type["Resolver"]] = {} - """Global resolver registry. - - Map ResolverType to Resolver class - """ - - @classmethod - def register_resolver(cls, resolver_cls: type["Resolver"]) -> None: - existing = cls.RESOLVER_CLS.get(resolver_cls.__rsotype__) - if existing is resolver_cls: - return - if existing is not None: - raise DuplicateResolverRegistrationError( - resolver_cls.__rsotype__, - existing, - resolver_cls, - ) - cls.RESOLVER_CLS[resolver_cls.__rsotype__] = resolver_cls - - @classmethod - def get(cls, block: BlockModel) -> "Resolver": - """Create resolver instance from block.""" - try: - resolver_cls = cls.RESOLVER_CLS[block.resolver] - except KeyError as error: - raise UnknownResolverError(block.resolver) from error - return resolver_cls(block) - - @classmethod - def get_draft_capabilities(cls) -> tuple[ResolverDraftCapability, ...]: - """Snapshot currently registered Resolvers that can draft rooted graphs.""" - capabilities = ( - ResolverDraftCapability( - resolver=resolver_id, - description=resolver_cls.draft_description, - input_model=resolver_cls.draft_input_model, - resolver_cls=resolver_cls, - ) - for resolver_id, resolver_cls in cls.RESOLVER_CLS.items() - if resolver_cls.draft_description is not None - and resolver_cls.draft_input_model is not None - ) - return tuple(sorted(capabilities, key=lambda capability: capability.resolver)) - - @classmethod - def get_draft_capability( - cls, - resolver: ResolverType, - ) -> ResolverDraftCapability: - """Select one exact graph-drafting contract without fallback.""" - for capability in cls.get_draft_capabilities(): - if capability.resolver == resolver: - return capability - raise UnknownDraftResolverError(resolver) - - @classmethod - def get_method_contracts( - cls, - resolver: ResolverType, - ) -> tuple[ResolverMethodContract, ...]: - """Discover typed public read methods on one registered Resolver.""" - resolver_cls = cls.RESOLVER_CLS.get(resolver) - if resolver_cls is None: - return () - return cls._method_contracts(resolver_cls) - - @classmethod - def get_common_method_contracts(cls) -> tuple[ResolverMethodContract, ...]: - """Common Resolver reads, available without per-type discovery.""" - return cls._method_contracts(Resolver) - - @staticmethod - def _method_contracts( - resolver_cls: type["Resolver"], - ) -> tuple[ResolverMethodContract, ...]: - contracts: list[ResolverMethodContract] = [] - for name, function in inspect.getmembers(resolver_cls, predicate=inspect.isfunction): - if name.startswith("_") or not name.startswith(("get_", "read_")): - continue - try: - signature = inspect.signature(function, eval_str=True) - fields: dict[str, tuple[typing.Any, typing.Any]] = {} - for parameter in signature.parameters.values(): - if parameter.name == "self": - continue - if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): - raise TypeError("Variadic Resolver methods are not projectable") - if parameter.annotation is inspect.Parameter.empty: - raise TypeError("Resolver method parameters must be typed") - default = ( - ... if parameter.default is inspect.Parameter.empty else parameter.default - ) - annotation = parameter.annotation - if typing.get_origin(annotation) is Collection: - item_type = typing.get_args(annotation)[0] - annotation = tuple[item_type, ...] - fields[parameter.name] = (annotation, default) - input_model = typing.cast(typing.Any, pydantic.create_model)( - f"{resolver_cls.__name__}_{name}_Arguments", - __config__=pydantic.ConfigDict(extra="forbid"), - **fields, - ) - input_model.model_json_schema() - except (NameError, TypeError, pydantic.PydanticSchemaGenerationError): - continue - contracts.append( - ResolverMethodContract( - name=name, - description=inspect.getdoc(function) or name.replace("_", " "), - input_model=input_model, - ) - ) - return tuple(contracts) - - @classmethod - def get_method_contract( - cls, - resolver: ResolverType, - name: str, - ) -> ResolverMethodContract | None: - return next( - ( - contract for contract in cls.get_method_contracts(resolver) if contract.name == name - ), - None, - ) - - @classmethod - async def invoke_method( - cls, - block: BlockModel, - name: str, - arguments: dict[str, typing.Any], - ) -> typing.Any: - """Validate and invoke one projected read method on an exact Block Resolver.""" - resolver = cls.get(block) - contract = cls.get_method_contract(block.resolver, name) - if contract is None: - raise UnknownResolverMethodError( - f"Resolver {block.resolver!r} has no method {name!r}" - ) - try: - validated = contract.input_model.model_validate(arguments) - except pydantic.ValidationError as error: - raise ResolverMethodInputError.from_exception_data( - error.title, typing.cast(typing.Any, error.errors(include_url=False)) - ) from error - # Keep nested Python values typed when calling the annotated domain method; - # model_dump would convert nested input models back into dictionaries. - value = getattr(resolver, name)( - **{field: getattr(validated, field) for field in contract.input_model.model_fields} - ) - return await value if inspect.isawaitable(value) else value - - @classmethod - def match_media_type(cls, media_type: str | None) -> ResolverType | None: - """Map one specific media type to an installed exact core resolver ID. - - Evidence precedence and generic/file fallback remain extension-owned. - """ - if media_type is None: - return None - normalized = media_type.partition(";")[0].strip().lower() - if not normalized or normalized in { - "application/octet-stream", - "binary/octet-stream", - "application/binary", - }: - return None - - exact = { - "text/plain": "core.text.v1", - "text/markdown": "core.markdown.v1", - "text/html": "core.html.v1", - "application/xhtml+xml": "core.html.v1", - "application/pdf": "core.pdf.v1", - "application/epub+zip": "core.epub.v1", - "application/zip": "core.zip.v1", - "application/x-zip-compressed": "core.zip.v1", - } - resolver_id = exact.get(normalized) - if resolver_id is None: - family = normalized.partition("/")[0] - resolver_id = { - "image": "core.image.v1", - "audio": "core.audio.v1", - "video": "core.video.v1", - }.get(family) - return resolver_id if resolver_id in cls.RESOLVER_CLS else None - - -SolvedContentTV = typing.TypeVar("SolvedContentTV") -RawContentTV = typing.TypeVar("RawContentTV") -_UNSET = object() - - -class Resolver(abc.ABC, typing.Generic[SolvedContentTV, RawContentTV]): - """Resolver resolves a star graph (a block and its direct relations) - - :tparam SolvedContentTV: The type of the solved content - :tparam RawContentTV: The type of the raw content - """ - - __rsotype__: ResolverType - """Resolver type - - Extension resolvers should be namespaced and versioned, e.g., - `extensions.twitter.tweet.v1`. - """ - - draft_description: typing.ClassVar[str | None] = None - draft_input_model: typing.ClassVar[type[pydantic.BaseModel] | None] = None - - def __init_subclass__(cls, rso_type: str, **kwargs) -> None: - cls.__rsotype__ = rso_type - ResolverManager.register_resolver(cls) - return super().__init_subclass__(**kwargs) - - def __init__(self, block: BlockModel, relations: Opt[tuple[RelationModel, ...]] = None): - """Should never override __init__ in subclasses, use __post_init__ instead. - - :param block: Block to resolve. - :param relations: Relations of the block. - """ - self._block = block - self.__relations: dict[tuple[bool, bool], tuple[RelationModel, ...]] = {} - if relations is not None: - self.__relations[(True, True)] = relations - self.__solved_content: SolvedContentTV | object = _UNSET - """Solved content is the content the resolver really works with, - commonly from raw content. - """ - inline_content = ( - typing.cast(RawContentTV, self._block.content) - if self._block.storage is None - else None - ) - self.__post_init__(inline_content) - - def __post_init__(self, raw_content: Opt[RawContentTV] = None) -> None: - """Subclass post-initialization hook. - - It's suggest to set __solved_content here if possible: - ```python - async def __post_init__(self, raw_content): - if raw_content is not None: - ... # anyhow from raw_content - self.set_solved_content(solved_content) - ``` - """ - ... - - @property - def block_id(self) -> BlockID: - """Get the block ID.""" - return typing.cast(BlockID, self._block.id) - - async def get_raw_content( - self, - *, - refresh: typing.Annotated[ - bool, pydantic.Field(description="Reread current content.") - ] = False, - ) -> RawContentTV: - """Read hydrated content: text or bytes, not a storage pointer.""" - return typing.cast( - RawContentTV, - await self._block.get_hydrated_content(refresh=refresh), - ) - - async def get_transfer_url(self) -> str | None: - """Get a content transfer URL when available.""" - if self._block.storage is None: - return None - from app.business.info_base.storage import StorageManager - - storage = await StorageManager.get_storage_async(self._block.storage) - return storage.get_transfer_url(self._block.content) - - async def get_solved_content( - self, - *, - refresh: typing.Annotated[ - bool, pydantic.Field(description="Reread current content.") - ] = False, - materialize_missing: typing.Annotated[ - bool, pydantic.Field(description="Allow creation of missing derived information.") - ] = True, - ) -> SolvedContentTV: - """Read the Resolver's typed interpretation of content.""" - if refresh or self.__solved_content is _UNSET: - self.__solved_content = await self._get_solved_content( - refresh=refresh, - materialize_missing=materialize_missing, - ) - return typing.cast(SolvedContentTV, self.__solved_content) - - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> SolvedContentTV: - """Get the solved content (non-cache). - - Description: - When you say "will be resolved from relations", - it means you acquire the resolver of the relation's other side block, - and use its solved content. - """ - del materialize_missing - return typing.cast( - SolvedContentTV, - await self.get_raw_content(refresh=refresh), - ) - - def set_solved_content(self, content: SolvedContentTV) -> None: - self.__solved_content = content - - async def get_relations( - self, - *, - include_in: typing.Annotated[ - bool, pydantic.Field(description="Include relations pointing to this Block.") - ] = True, - include_out: typing.Annotated[ - bool, pydantic.Field(description="Include relations pointing from this Block.") - ] = True, - refresh: bool = False, - ) -> tuple[RelationModel, ...]: - """Read direct relations of this Block.""" - key = (include_in, include_out) - if refresh or key not in self.__relations: - all_relations = None if refresh else self.__relations.get((True, True)) - if all_relations is not None: - self.__relations[key] = tuple( - relation - for relation in all_relations - if (include_in and relation.to_ == self.block_id) - or (include_out and relation.from_ == self.block_id) - ) - else: - self.__relations[key] = await RelationService.get( - block_id=self.block_id, - include_in=include_in, - include_out=include_out, - ) - return self.__relations[key] - - @classmethod - # @abc.abstractmethod TODO - def create_block(cls, content, storage: Opt[StorageID] = None) -> BlockForm: ... - - @classmethod - # @abc.abstractmethod TODO - def create_graph(cls, *args, **kwargs) -> StarsGraphForm: ... - - @abc.abstractmethod - async def get_text( - self, - *, - context: typing.Annotated[ - TextProjectionContext, - pydantic.Field(description="Lexical projection is Block-local and non-recursive."), - ] = "default", - refresh: typing.Annotated[ - bool, pydantic.Field(description="Reread current content.") - ] = False, - materialize_missing: typing.Annotated[ - bool, pydantic.Field(description="Allow creation of missing derived information.") - ] = True, - ) -> str | None: - """Read a text projection; unsupported, absent and empty are distinct.""" - ... - - @abc.abstractmethod - async def get_label(self, *, refresh: bool = False) -> str: - """Read a concise label for this Block.""" - ... - - async def get_existing_async(self, blocks: BlockRepository) -> Opt[BlockModel]: - """Reconcile the exact resolver/content identity in the caller's transaction.""" - return await blocks.find_content(self._block.resolver, self._block.content) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.main import * diff --git a/app/business/info_base/resolver/markdown.py b/app/business/info_base/resolver/markdown.py index e381cde1..9a0aadf1 100644 --- a/app/business/info_base/resolver/markdown.py +++ b/app/business/info_base/resolver/markdown.py @@ -1,35 +1,10 @@ -"""Markdown source remains the exact text projection; rendering is a reader concern.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .inspection import decode_unicode_bytes -from .label import format_label -from .main import Resolver, TextProjectionContext +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.markdown") -class MarkdownResolver(Resolver[str, str | bytes], rso_type="core.markdown.v1"): - async def _get_solved_content( - self, *, refresh: bool = False, materialize_missing: bool = True - ) -> str: - content = await self.get_raw_content(refresh=refresh) - return ( - content - if isinstance(content, str) - else decode_unicode_bytes(content, self.__rsotype__) - ) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - return await self.get_solved_content( - refresh=refresh, materialize_missing=materialize_missing - ) - - async def get_label(self, *, refresh: bool = False) -> str: - return format_label( - "markdown", - await self.get_text(refresh=refresh, materialize_missing=False), - first_line=True, - ) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.markdown import * diff --git a/app/business/info_base/resolver/materialization.py b/app/business/info_base/resolver/materialization.py index 1db5c82f..96c96778 100644 --- a/app/business/info_base/resolver/materialization.py +++ b/app/business/info_base/resolver/materialization.py @@ -1,76 +1,12 @@ -"""Shared mechanics for Resolver-owned faithful text derivations.""" +"""Legacy import; use inkcre_core for new consumers.""" -import logging +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.business.ai import AIManager -from app.schemas.ai import SystemMessage, UserContentPart, UserMessage +sys.modules[__name__] = import_module( + "inkcre_core.business.info_base.resolver.materialization" +) - -logger = logging.getLogger(__name__) - - -async def generate_faithful_text( - *, - model: int, - instruction: str, - media: UserContentPart, -) -> str | None: - """Ask one exact configured Model for source-faithful text, without graph access.""" - response = await AIManager.chat( - model, - ( - SystemMessage(content=instruction), - UserMessage(content=(media,)), - ), - ) - text = (response.content or "").strip() - return text or None - - -async def materialize_text_child(block_id: int, role: str, text: str) -> None: - """Best-effort idempotent graph write for one exact information role.""" - from app.schemas.info_base.main import GraphBlockForm, GraphForm, GraphRelationForm - - from app.business.info_base.commands import persist_graph - from app.persistence.info_base.uow import graph_uow - - # Recheck after provider work in the same short scope as the graph insertion. - async with graph_uow() as uow: - if await uow.blocks.get_related(block_id, content=role) is not None: - return - await persist_graph( - GraphForm( - blocks=(GraphBlockForm(id=-1, resolver="core.text.v1", content=text),), - relations=(GraphRelationForm(from_=block_id, to_=-1, content=role),), - ), - uow, - ) - - -async def try_materialize_model_text( - *, - block_id: int, - role: str, - model: int, - instruction: str, - media: UserContentPart, -) -> None: - """Keep one unavailable derivation shallow while preserving diagnostic evidence.""" - from app.business.info_base.commands import get_related_block - - if await get_related_block(block_id, content=role) is not None: - return - try: - text = await generate_faithful_text( - model=model, - instruction=instruction, - media=media, - ) - if text is not None: - await materialize_text_child(block_id, role, text) - except Exception: - logger.warning( - "Resolver text materialization unavailable", - exc_info=True, - extra={"block": block_id, "role": role, "model": model}, - ) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.materialization import * diff --git a/app/business/info_base/resolver/pdf.py b/app/business/info_base/resolver/pdf.py index 9c0ea924..f7cf0c06 100644 --- a/app/business/info_base/resolver/pdf.py +++ b/app/business/info_base/resolver/pdf.py @@ -1,131 +1,10 @@ -"""Exact PDF resolver using bounded structural inspection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -from io import BytesIO +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from pypdf import PdfReader -from pypdf.errors import PdfReadError +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.pdf") -from .contracts import ResolverContentError, TextProjectionContext -from .inspection import ( - ByteContentFacts, - detect_media_type, - format_lexical_facts, - require_bytes, -) -from .label import format_label -from .main import Resolver - - -@dataclass(frozen=True, slots=True) -class PDFSolvedContent(ByteContentFacts): - pdf_version: str | None - page_count: int | None - is_encrypted: bool - title: str | None - author: str | None - - -def _metadata_text(metadata: object, attribute: str) -> str | None: - value = getattr(metadata, attribute, None) - return value if isinstance(value, str) else None - - -def _inspect_pdf(content: bytes) -> PDFSolvedContent: - try: - reader = PdfReader(BytesIO(content), strict=True) - encrypted = reader.is_encrypted - metadata = None if encrypted else reader.metadata - page_count = None if encrypted else len(reader.pages) - except (PdfReadError, OSError, ValueError, TypeError) as error: - raise ResolverContentError("core.pdf.v1", "invalid PDF") from error - - header = reader.pdf_header - version = header.removeprefix("%PDF-") if header.startswith("%PDF-") else None - return PDFSolvedContent( - content=content, - byte_size=len(content), - detected_media_type=detect_media_type(content), - pdf_version=version, - page_count=page_count, - is_encrypted=encrypted, - title=_metadata_text(metadata, "title"), - author=_metadata_text(metadata, "author"), - ) - - -def _extract_pdf_text(content: bytes) -> str | None: - """Extract a bounded text-layer projection without treating metadata as body.""" - try: - reader = PdfReader(BytesIO(content), strict=True) - if reader.is_encrypted: - return None - parts: list[str] = [] - remaining = 1_000_000 - for page in reader.pages[:2_000]: - text = page.extract_text() or "" - if text.strip(): - selected = text[:remaining] - parts.append(selected) - remaining -= len(selected) - if remaining <= 0: - break - except (PdfReadError, OSError, ValueError, TypeError) as error: - raise ResolverContentError("core.pdf.v1", "invalid PDF text layer") from error - joined = "\n\n".join(parts).strip() - return joined or None - - -class PDFResolver( - Resolver[PDFSolvedContent, bytes], - rso_type="core.pdf.v1", -): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> PDFSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_pdf, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str | None: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - body = await asyncio.to_thread(_extract_pdf_text, content) - if context == "default": - return body - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - metadata = format_lexical_facts( - "PDF", - ( - ("media type", solved.detected_media_type), - ("title", solved.title), - ("author", solved.author), - ("pages", solved.page_count), - ("PDF version", solved.pdf_version), - ("encrypted", solved.is_encrypted), - ), - ) - return f"{metadata}\n\n{body}" if body else metadata - - async def get_label(self, *, refresh: bool = False) -> str: - solved = await self.get_solved_content( - refresh=refresh, - materialize_missing=False, - ) - return format_label("PDF", solved.title) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.pdf import * diff --git a/app/business/info_base/resolver/text.py b/app/business/info_base/resolver/text.py index 47264337..100dacf0 100644 --- a/app/business/info_base/resolver/text.py +++ b/app/business/info_base/resolver/text.py @@ -1,62 +1,10 @@ -"""Exact plain-text resolver.""" +"""Legacy import; use inkcre_core for new consumers.""" -import pydantic +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .inspection import decode_unicode_bytes -from .label import format_label -from .main import Resolver, TextProjectionContext +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.text") - -class TextDraftInput(pydantic.BaseModel): - """Resolver-native input for drafting one plain-text Block.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - text: str - - -class TextResolver(Resolver[str, str | bytes], rso_type="core.text.v1"): - draft_description = "Create one ordinary plain-text semantic content Block." - draft_input_model = TextDraftInput - - @classmethod - def create_graph(cls, input: str | TextDraftInput): - from app.schemas.info_base.block import BlockForm - from app.schemas.info_base.main import StarsGraphForm - - text = input.text if isinstance(input, TextDraftInput) else input - return StarsGraphForm(block=BlockForm(resolver=cls.__rsotype__, content=text)) - - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del materialize_missing - content = await self.get_raw_content(refresh=refresh) - return ( - content - if isinstance(content, str) - else decode_unicode_bytes(content, self.__rsotype__) - ) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context - return await self.get_solved_content( - refresh=refresh, - materialize_missing=materialize_missing, - ) - - async def get_label(self, *, refresh: bool = False) -> str: - return format_label( - "text", - await self.get_text(refresh=refresh, materialize_missing=False), - first_line=True, - ) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.text import * diff --git a/app/business/info_base/resolver/tools.py b/app/business/info_base/resolver/tools.py index 22091973..0d66f832 100644 --- a/app/business/info_base/resolver/tools.py +++ b/app/business/info_base/resolver/tools.py @@ -1,277 +1,10 @@ -"""Agent Tool controller for typed Resolver method discovery and invocation.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.tools") -from app.business.agent import AgentManager -from app.business.agent.projection import project_json -from app.business.info_base.services import BlockService -from app.schemas.ai import JSONValue -from app.schemas.info_base.block import BlockID, ResolverType -from libs.obsrv.telemetry import emit_event - -from .main import ResolverManager - - -RESOLVER_TOOL = "resolver" - - -class ResolverMethodCall(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block_id: BlockID - method: str - arguments: dict[str, JSONValue] = pydantic.Field(default_factory=dict) - - -class ResolverDescribeInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["describe"] - resolver_types: tuple[ResolverType, ...] = () - block_ids: tuple[BlockID, ...] = () - calls: tuple[ResolverMethodCall, ...] = pydantic.Field(default=(), max_length=0) - - -class ResolverInvokeInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["invoke"] - resolver_types: tuple[ResolverType, ...] = pydantic.Field(default=(), max_length=0) - block_ids: tuple[BlockID, ...] = pydantic.Field(default=(), max_length=0) - calls: tuple[ResolverMethodCall, ...] = pydantic.Field(min_length=1, max_length=20) - - -class ResolverMetaToolInput( - pydantic.RootModel[ - typing.Annotated[ - ResolverDescribeInput | ResolverInvokeInput, - pydantic.Field(discriminator="action"), - ] - ] -): - pass - - -def _resolver_input_model() -> type[pydantic.BaseModel]: - common = ResolverManager.get_common_method_contracts() - variants: list[type[pydantic.BaseModel]] = [] - contracts = list(common) - common_names = {item.name for item in common} - for resolver_type in ResolverManager.RESOLVER_CLS: - contracts.extend( - contract - for contract in ResolverManager.get_method_contracts(resolver_type) - if contract.name in common_names - ) - seen: set[tuple] = set() - for contract in contracts: - signature = ( - contract.name, - tuple( - (name, repr(field.annotation), repr(field.default), repr(field.metadata)) - for name, field in contract.input_model.model_fields.items() - ), - ) - if signature in seen: - continue - seen.add(signature) - variants.append( - pydantic.create_model( - f"{contract.name}_Call_{len(variants)}", - __config__=pydantic.ConfigDict(extra="forbid"), - block_id=(int, ...), - method=( - typing.cast(typing.Any, typing.Literal)[contract.name], - pydantic.Field(description=contract.description), - ), - arguments=( - contract.input_model, - ... - if any( - field.is_required() for field in contract.input_model.model_fields.values() - ) - else pydantic.Field(default_factory=contract.input_model), - ), - ) - ) - - variants.append( - pydantic.create_model( - "ExtraMethodCall", - __base__=ResolverMethodCall, - method=( - str, - pydantic.Field( - json_schema_extra={"not": {"enum": [contract.name for contract in common]}} - ), - ), - ) - ) - call_type = typing.cast(typing.Any, typing.Union)[tuple(variants)] - invoke = pydantic.create_model( - "BoundResolverInvokeInput", - __base__=ResolverInvokeInput, - calls=(tuple[call_type, ...], pydantic.Field(min_length=1, max_length=20)), - ) - provider_envelope = pydantic.create_model( - "ResolverEnvelope", - __base__=ResolverDescribeInput, - action=(typing.Literal["describe", "invoke"], ...), - calls=(tuple[call_type, ...], pydantic.Field(default=(), max_length=20)), - ) - method_contract = pydantic.RootModel[ - typing.Annotated[ResolverDescribeInput | invoke, pydantic.Field(discriminator="action")] - ] - - # Runtime validates the dispatch envelope, then the selected Resolver validates - # each call's arguments. One invalid method argument must not reject the batch. - # The richer method schema below guides the model without changing that boundary. - class BoundResolverInput(ResolverMetaToolInput): - @classmethod - def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: - method_schema = method_contract.model_json_schema(*args, **kwargs) - # Some providers infer parameter types only from top-level properties. - # Expose the wider envelope there while retaining the union's oneOf, - # discriminator and $defs so describe/invoke keep their distinct contracts. - provider_schema = provider_envelope.model_json_schema(*args, **kwargs) - method_schema.update(type="object", properties=provider_schema["properties"]) - method_schema.setdefault("$defs", {}).update(provider_schema.get("$defs", {})) - return method_schema - - return BoundResolverInput - - -@AgentManager.tool( - RESOLVER_TOOL, - input_model_factory=_resolver_input_model, - description="Describe or invoke public typed read methods on exact Block Resolvers.", -) -async def resolver(input: ResolverMetaToolInput) -> JSONValue: - request = input.root - if request.action == "describe": - found = await BlockService.get_many(request.block_ids) - resolver_ids = set(request.resolver_types) - resolver_ids.update(block.resolver for block in found) - if not request.block_ids and not request.resolver_types: - resolver_ids.update(ResolverManager.RESOLVER_CLS) - return typing.cast( - JSONValue, - { - "results": [ - { - "resolver": resolver_id, - "methods": [ - { - "name": contract.name, - "description": contract.description, - "input_schema": contract.input_schema, - } - for contract in ResolverManager.get_method_contracts(resolver_id) - ], - } - for resolver_id in sorted(resolver_ids) - if resolver_id in ResolverManager.RESOLVER_CLS - ], - "missing_blocks": sorted(set(request.block_ids) - {block.id for block in found}), - "missing_resolvers": sorted( - resolver_id - for resolver_id in resolver_ids - if resolver_id not in ResolverManager.RESOLVER_CLS - ), - }, - ) - - results: list[JSONValue] = [] - for index, call in enumerate(request.calls): - block = await BlockService.get(call.block_id) - if block is None: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "not_found", - } - ) - continue - contract = ResolverManager.get_method_contract(block.resolver, call.method) - if block.resolver not in ResolverManager.RESOLVER_CLS: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "resolver_unavailable", - "message": f"Resolver {block.resolver!r} is not registered.", - } - ) - continue - if contract is None: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "method_unavailable", - "message": "Method does not exist; use describe for available method contracts.", - "available_methods": [ - item.name for item in ResolverManager.get_method_contracts(block.resolver) - ], - } - ) - continue - try: - value = await ResolverManager.invoke_method( - block, - call.method, - call.arguments.model_dump() - if isinstance(call.arguments, pydantic.BaseModel) - else typing.cast(dict[str, typing.Any], call.arguments), - ) - projected = project_json(value) - except pydantic.ValidationError as error: - results.append( - typing.cast( - JSONValue, - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "invalid_arguments", - "fields": error.errors( - include_url=False, include_context=False, include_input=False - ), - "input_schema": contract.input_schema, - }, - ) - ) - except Exception as error: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": type(error).__name__, - "message": str(error), - } - ) - else: - emit_event( - "inkcre.entity.read", - { - "inkcre.entity.read.kind": "resolved", - "inkcre.entity.block_ids": (call.block_id,), - "inkcre.entity.read.count": 1, - }, - ) - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "result": projected, - } - ) - return typing.cast(JSONValue, {"results": results}) +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.tools import * diff --git a/app/business/info_base/resolver/video.py b/app/business/info_base/resolver/video.py index 93e36820..5d4ba510 100644 --- a/app/business/info_base/resolver/video.py +++ b/app/business/info_base/resolver/video.py @@ -1,201 +1,10 @@ -"""Exact video resolver using metadata-only PyAV inspection.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -from fractions import Fraction -from io import BytesIO -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import av -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.video") -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.schemas.ai import VideoContentPart -from app.schemas.info_base.block import BlockForm -from app.schemas.info_base.main import StarsGraphForm - -from .audio import _container_name, _duration_ms, _primary_stream -from .contracts import ( - ResolverContentError, - TextProjectionContext, - UnsupportedResolverCapability, -) -from .inspection import ( - ByteContentFacts, - detect_media_type, - format_lexical_facts, - require_bytes, -) -from .main import Resolver -from .materialization import materialize_text_child, try_materialize_model_text - - -VIDEO_RESOLVER_CONFIG_KEY = "core.resolver.video" -VIDEO_RESOLVER_CONFIG_SCHEMA = "core.resolver.video.config.v1" - - -class VideoResolverConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - text_model: int - transcript_model: int - - -DeploymentConfigManager.register_schema( - VIDEO_RESOLVER_CONFIG_SCHEMA, VideoResolverConfig, keys=(VIDEO_RESOLVER_CONFIG_KEY,) -) - - -@dataclass(frozen=True, slots=True) -class VideoSolvedContent(ByteContentFacts): - container: str | None - video_codec: str | None - duration_ms: int | None - width: int | None - height: int | None - frame_rate: float | None - subtitles: tuple[str, ...] - - -def _frame_rate(rate: Fraction | None) -> float | None: - return float(rate) if rate is not None else None - - -def _subtitles(container: typing.Any) -> tuple[str, ...]: - values: list[str] = [] - streams = tuple(container.streams.subtitles) - if not streams: - return () - for packet in container.demux(*streams): - try: - decoded = packet.decode() - except (av.FFmpegError, OSError, ValueError): - continue - for subtitle in decoded: - raw = getattr(subtitle, "dialogue", None) or getattr(subtitle, "text", None) - if isinstance(raw, bytes): - text = raw.decode("utf-8", errors="replace").strip() - elif isinstance(raw, str): - text = raw.strip() - else: - text = "" - if text and (not values or values[-1] != text): - values.append(text) - return tuple(values) - - -def _inspect_video(content: bytes) -> VideoSolvedContent: - try: - with av.open(BytesIO(content), mode="r") as container: - stream = _primary_stream(container.streams.video) - if stream is None: - raise ResolverContentError("core.video.v1", "no video stream") - codec = stream.codec_context - facts = VideoSolvedContent( - content=content, - byte_size=len(content), - detected_media_type=detect_media_type(content), - container=_container_name(container.format.name), - video_codec=codec.name, - duration_ms=_duration_ms(container, stream), - width=codec.width, - height=codec.height, - frame_rate=_frame_rate(stream.average_rate), - subtitles=_subtitles(container), - ) - except ResolverContentError: - raise - except (av.FFmpegError, OSError, ValueError) as error: - raise ResolverContentError("core.video.v1", "invalid video") from error - return facts - - -class VideoResolver( - Resolver[VideoSolvedContent, bytes], - rso_type="core.video.v1", -): - @classmethod - def create_graph(cls, url: str) -> StarsGraphForm: - return StarsGraphForm( - block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1) - ) - - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> VideoSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_video, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - if materialize_missing: - if solved.subtitles: - await materialize_text_child( - self.block_id, "subtitle", "\n\n".join(solved.subtitles) - ) - config = typing.cast( - VideoResolverConfig | None, - await DeploymentConfigService.get(VIDEO_RESOLVER_CONFIG_KEY), - ) - if config is not None: - media_type = solved.detected_media_type or ( - f"video/{solved.container}" if solved.container is not None else None - ) - if media_type is not None: - media = VideoContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=await self.get_transfer_url(), - ) - await try_materialize_model_text( - block_id=self.block_id, - role="transcript", - model=config.transcript_model, - instruction=( - "Transcribe only speech and authored spoken language in the video. " - "Return plain text with no summary or visual description." - ), - media=media, - ) - await try_materialize_model_text( - block_id=self.block_id, - role="text", - model=config.text_model, - instruction=( - "Transcribe only written text visibly present in the video. " - "Return plain text with no summary or scene description." - ), - media=media, - ) - return format_lexical_facts( - "video", - ( - ("media type", solved.detected_media_type), - ("container", solved.container), - ("codec", solved.video_codec), - ("duration ms", solved.duration_ms), - ("width", solved.width), - ("height", solved.height), - ("frame rate", solved.frame_rate), - ("subtitle cues", len(solved.subtitles) or None), - ), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "video" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.video import * diff --git a/app/business/info_base/resolver/zip.py b/app/business/info_base/resolver/zip.py index 0a0a0f35..472bf2d2 100644 --- a/app/business/info_base/resolver/zip.py +++ b/app/business/info_base/resolver/zip.py @@ -1,86 +1,10 @@ -"""Exact ZIP resolver using central-directory metadata only.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from dataclasses import dataclass -from io import BytesIO -import zipfile +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .contracts import ( - ResolverContentError, - TextProjectionContext, - UnsupportedResolverCapability, -) -from .inspection import ByteContentFacts, format_lexical_facts, require_bytes -from .main import Resolver +sys.modules[__name__] = import_module("inkcre_core.business.info_base.resolver.zip") - -@dataclass(frozen=True, slots=True) -class ZIPSolvedContent(ByteContentFacts): - member_count: int - total_compressed_bytes: int - total_uncompressed_bytes: int - compression_methods: tuple[int, ...] - encrypted_member_count: int - - -def _inspect_zip(content: bytes) -> ZIPSolvedContent: - try: - with zipfile.ZipFile(BytesIO(content)) as archive: - members = archive.infolist() - except (zipfile.BadZipFile, OSError, ValueError) as error: - raise ResolverContentError("core.zip.v1", "invalid ZIP archive") from error - - return ZIPSolvedContent( - content=content, - byte_size=len(content), - detected_media_type="application/zip", - member_count=len(members), - total_compressed_bytes=sum(member.compress_size for member in members), - total_uncompressed_bytes=sum(member.file_size for member in members), - compression_methods=tuple(sorted({member.compress_type for member in members})), - encrypted_member_count=sum(bool(member.flag_bits & 0x1) for member in members), - ) - - -class ZIPResolver( - Resolver[ZIPSolvedContent, bytes], - rso_type="core.zip.v1", -): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> ZIPSolvedContent: - del materialize_missing - content = require_bytes( - await self.get_raw_content(refresh=refresh), - self.__rsotype__, - ) - return await asyncio.to_thread(_inspect_zip, content) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - if context == "default": - raise UnsupportedResolverCapability(self.__rsotype__, "text") - del materialize_missing - solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) - return format_lexical_facts( - "ZIP", - ( - ("media type", solved.detected_media_type), - ("members", solved.member_count), - ("compressed bytes", solved.total_compressed_bytes), - ("uncompressed bytes", solved.total_uncompressed_bytes), - ("encrypted members", solved.encrypted_member_count), - ), - ) - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "ZIP" +if TYPE_CHECKING: + from inkcre_core.business.info_base.resolver.zip import * diff --git a/app/business/info_base/services.py b/app/business/info_base/services.py index 913da8e9..4e258a7d 100644 --- a/app/business/info_base/services.py +++ b/app/business/info_base/services.py @@ -1,183 +1,10 @@ -"""Independent info-base use cases; composed writes use GraphUnitOfWork directly.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing -from typing import Optional as Opt -from app.schemas.info_base.block import BlockForm, BlockModel, BlockID, ResolverType -from app.schemas.info_base.relation import RelationModel, RelationID -from app.schemas.info_base.storage import StorageID -from utils.types_ import Undefined, _undefined -from app.persistence.info_base.uow import graph_uow +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.info_base.services") -class BlockService: - @staticmethod - async def get_many(block_ids: typing.Collection[BlockID]) -> tuple[BlockModel, ...]: - async with graph_uow() as uow: - result = await uow.blocks.get_many(block_ids) - return result - - @staticmethod - async def get_random_many(count: int) -> tuple[BlockModel, ...]: - async with graph_uow() as uow: - result = await uow.blocks.get_random_many(count) - return result - - @staticmethod - async def get_random() -> Opt[BlockModel]: - async with graph_uow() as uow: - result = await uow.blocks.get_random() - return result - - @staticmethod - async def get(block_id: BlockID) -> Opt[BlockModel]: - async with graph_uow() as uow: - result = await uow.blocks.get(block_id) - return result - - @staticmethod - async def edit_block( - block_id: BlockID, - content: Opt[str] = None, - resolver: Opt[ResolverType] = None, - storage: Opt[StorageID] | Undefined = _undefined, - ) -> BlockModel: - async with graph_uow() as uow: - result = await uow.blocks.edit_block(block_id, content, resolver, storage) - return result - - @staticmethod - async def delete(block_id: BlockID) -> bool: - async with graph_uow() as uow: - result = await uow.blocks.delete(block_id) - return result - - @staticmethod - async def get_recent( - num: int = 10, resolver: Opt[ResolverType] = None - ) -> tuple[BlockModel, ...]: - """获取最新的块 - - 按创建时间倒序排序。 - - :param num: 获取的块数量 - :param resolver: 限定解析器类型, None则不限定 - """ - async with graph_uow() as uow: - result = await uow.blocks.get_recent(num, resolver) - return result - - @staticmethod - async def create(form: BlockForm) -> BlockModel: - async with graph_uow() as uow: - result = await uow.blocks.create(form) - return result - - -class RelationService: - @staticmethod - async def get_text( - relation: RelationModel, - *, - refresh: bool = False, - ) -> str | None: - """Project one directed dynamic property through Block-local endpoint labels.""" - if not relation.content.strip(): - return None - async with graph_uow() as uow: - endpoints = { - block.id: block - for block in await uow.blocks.get_many((relation.from_, relation.to_)) - } - from_block = endpoints.get(relation.from_) - to_block = endpoints.get(relation.to_) - if from_block is None or to_block is None: - return None - - # Local imports avoid reversing Resolver -> RelationService ownership. - from app.business.info_base.resolver import ResolverManager - - subject = await ResolverManager.get(from_block).get_label(refresh=refresh) - value = await ResolverManager.get(to_block).get_label(refresh=refresh) - if not subject.strip() or not value.strip(): - return None - return f"subject:\n{subject}\nproperty:\n{relation.content}\nvalue:\n{value}" - - @staticmethod - async def get_many( - relation_ids: typing.Collection[RelationID], - ) -> tuple[RelationModel, ...]: - async with graph_uow() as uow: - result = await uow.relations.get_many(relation_ids) - return result - - @staticmethod - async def get_by_id(relation_id: RelationID) -> RelationModel | None: - async with graph_uow() as uow: - result = await uow.relations.get_by_id(relation_id) - return result - - @staticmethod - async def get_endpoint_page( - block_ids: typing.Collection[BlockID], - *, - endpoint: typing.Literal["from", "to"], - contents: typing.Collection[str] = (), - cursor: RelationID | None = None, - limit: int = 21, - ) -> tuple[RelationModel, ...]: - async with graph_uow() as uow: - result = await uow.relations.get_endpoint_page( - block_ids, endpoint=endpoint, contents=contents, cursor=cursor, limit=limit - ) - return result - - @staticmethod - async def get( - block_id: BlockID, - include_in: bool = True, - include_out: bool = True, - content: Opt[str] = None, - ) -> tuple[RelationModel, ...]: - async with graph_uow() as uow: - result = await uow.relations.get(block_id, include_in, include_out, content) - return result - - @staticmethod - async def update( - relation_id: RelationID, - *, - from_: BlockID | Undefined = _undefined, - to_: BlockID | Undefined = _undefined, - content: str | Undefined = _undefined, - ) -> RelationModel: - async with graph_uow() as uow: - result = await uow.relations.update( - relation_id, from_=from_, to_=to_, content=content - ) - return result - - @staticmethod - async def delete(relation_id: RelationID) -> bool: - async with graph_uow() as uow: - result = await uow.relations.delete(relation_id) - return result - - @staticmethod - async def create(from_: BlockID, to_: BlockID, content: str) -> RelationModel: - async with graph_uow() as uow: - result = await uow.relations.create(from_, to_, content) - return result - - -async def get_entity_records( - block_ids: typing.Collection[BlockID], relation_ids: typing.Collection[RelationID] -) -> tuple[dict[BlockID, BlockModel], dict[RelationID, RelationModel]]: - """Read both entity kinds in one short scope before any content hydration.""" - async with graph_uow() as uow: - blocks = await uow.blocks.get_many(block_ids) - relations = await uow.relations.get_many(relation_ids) - # Persisted rows always have primary keys; the shared model also permits new rows. - return ( - {typing.cast(BlockID, block.id): block for block in blocks}, - {typing.cast(RelationID, row.id): row for row in relations}, - ) +if TYPE_CHECKING: + from inkcre_core.business.info_base.services import * diff --git a/app/business/info_base/storage/__init__.py b/app/business/info_base/storage/__init__.py index 2f318982..f53fbbe9 100644 --- a/app/business/info_base/storage/__init__.py +++ b/app/business/info_base/storage/__init__.py @@ -1,15 +1,3 @@ -from .main import Storage, StorageManager, WritableStorage +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "Storage", - "StorageManager", - "WritableStorage", - "HTTPStorage", - "HTTPStorageConfig", - "StorageContentTooLargeError", - "PostgreSQLBinaryStorage", -] - -# Import storage implementations to ensure they're registered -from .http import HTTPStorage, HTTPStorageConfig, StorageContentTooLargeError -from .postgresql import PostgreSQLBinaryStorage +from inkcre_core.business.info_base.storage import * diff --git a/app/business/info_base/storage/http.py b/app/business/info_base/storage/http.py index b86143c7..65b01158 100644 --- a/app/business/info_base/storage/http.py +++ b/app/business/info_base/storage/http.py @@ -1,86 +1,10 @@ -"""Mechanics-only HTTP storage returning bounded response bytes.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import aiohttp -import pydantic -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.business.info_base.storage.http") -from utils.base import AIOHTTP_CONNECTOR_GETTER - -from .main import Storage - - -__all__ = [ - "HTTPStorageConfig", - "HTTPStorage", - "StorageContentTooLargeError", -] - - -DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 -READ_CHUNK_BYTES = 64 * 1024 - - -class HTTPStorageConfig(sqlmodel.SQLModel): - """Configuration for bounded HTTP byte retrieval.""" - - timeout: int = pydantic.Field(default=30, gt=0) - follow_redirects: bool = True - max_response_bytes: int = pydantic.Field( - default=DEFAULT_MAX_RESPONSE_BYTES, - gt=0, - ) - - -class StorageContentTooLargeError(ValueError): - """The configured byte boundary was exceeded while hydrating content.""" - - -class HTTPStorage( - Storage[HTTPStorageConfig, bytes], - stg_type="http", - config_cls=HTTPStorageConfig, -): - """Fetch opaque bytes over HTTP(S) without assigning content semantics.""" - - @staticmethod - def _url(block_content: str) -> str: - url = block_content.strip() - if not url.lower().startswith(("http://", "https://")): - raise ValueError("HTTP storage pointer must use http:// or https://") - return url - - def get_transfer_url(self, block_content: str) -> str: - return self._url(block_content) - - async def get_raw_content(self, block_content: str) -> bytes: - url = self._url(block_content) - - timeout = aiohttp.ClientTimeout(total=self._config.timeout) - async with aiohttp.ClientSession( - connector=AIOHTTP_CONNECTOR_GETTER(), - timeout=timeout, - ) as session: - async with session.get( - url, - allow_redirects=self._config.follow_redirects, - ) as response: - response.raise_for_status() - content_length = response.content_length - if content_length is not None and content_length > self._config.max_response_bytes: - raise StorageContentTooLargeError( - f"HTTP response declares {content_length} bytes; " - f"limit is {self._config.max_response_bytes}" - ) - - chunks: list[bytes] = [] - received = 0 - async for chunk in response.content.iter_chunked(READ_CHUNK_BYTES): - received += len(chunk) - if received > self._config.max_response_bytes: - raise StorageContentTooLargeError( - f"HTTP response exceeded {self._config.max_response_bytes} bytes" - ) - chunks.append(chunk) - return b"".join(chunks) +if TYPE_CHECKING: + from inkcre_core.business.info_base.storage.http import * diff --git a/app/business/info_base/storage/main.py b/app/business/info_base/storage/main.py index 59d45389..2c36ea82 100644 --- a/app/business/info_base/storage/main.py +++ b/app/business/info_base/storage/main.py @@ -1,183 +1,10 @@ -__all__ = [ - "StorageManager", - "Storage", -] +"""Legacy import; use inkcre_core for new consumers.""" -import abc -import importlib -import sqlmodel -import typing -from app.persistence.info_base.storage import StorageRepository -from app.database_contract.profile import BUILTIN_STORAGES, BUILTIN_STORAGE_TYPES_BY_ID -from app.schemas.info_base.storage import ( - StorageID, - StorageTypeID, - StorageModel, -) +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.info_base.storage.main") -ConfigTV = typing.TypeVar("ConfigTV", bound=sqlmodel.SQLModel) -ContentTV = typing.TypeVar("ContentTV") - - -class _EmptyConfig(sqlmodel.SQLModel): - """Default empty config class for storages without configuration.""" - - -class StorageManager: - _STORAGE_CLASSES: dict[StorageTypeID, type["Storage"]] = {} - """Storage type registry: type ID -> Storage class""" - - @classmethod - def register_storage(cls, storage_cls: type["Storage"]): - """Register a storage class in memory without external side effects.""" - cls._STORAGE_CLASSES[storage_cls.__stgtype__] = storage_cls - - @classmethod - async def get_storage_async(cls, storage_id: StorageID) -> "Storage": - from app.persistence.info_base.uow import graph_uow - - async with graph_uow() as uow: - record = await uow.storage.get(storage_id) - return cls.from_record(record) - - @classmethod - def from_record(cls, record: StorageModel) -> "Storage": - storage_class = cls._STORAGE_CLASSES.get(record.type) - if storage_class is None: - module_path, class_name = record.type.rsplit(".", 1) - storage_class = getattr(importlib.import_module(module_path), class_name) - return storage_class(record) - - @classmethod - async def setup_builtin_storages_async(cls) -> None: - from app.persistence.info_base.uow import graph_uow - - records = [] - for storage_cls in cls._STORAGE_CLASSES.values(): - builtin = BUILTIN_STORAGE_TYPES_BY_ID.get(storage_cls.__stgtype__) - records.append( - { - "id": storage_cls.__stgtype__, - "description": builtin.description - if builtin - else storage_cls.__doc__ or "No description.", - "config_schema": builtin.config_schema - if builtin - else storage_cls.__configschema__, - "writable": issubclass(storage_cls, WritableStorage), - } - ) - async with graph_uow() as uow: - await uow.storage.sync_types(records) - await uow.storage.sync_builtins( - [ - {"id": row.id, "type": row.type, "nickname": row.nickname, "config": row.config} - for row in BUILTIN_STORAGES - ] - ) - - -class Storage(abc.ABC, typing.Generic[ConfigTV, ContentTV]): - """Storage base. - Storage retrieves the raw ("real") content from block record. - - Generic parameters: - - ConfigTV: Configuration type variable - - ContentTV: Content type variable - """ - - __configschema__: dict - """Storage configuration JSON schema""" - __configcls__: type[ConfigTV] - __stgtype__: StorageTypeID - """Storage type identifier""" - - def __init_subclass__( - cls, - stg_type: StorageTypeID | None = None, - config_cls: type[ConfigTV] = _EmptyConfig, - **kwargs, - ) -> None: - """ - :param stg_type: Unique storage type string - :param config_cls: Configuration class for the storage - """ - # ConfigTV is bound by the concrete storage subclass. - if stg_type is not None: - cls.__configcls__ = config_cls # pyrefly: ignore[no-access] - cls.__configschema__ = config_cls.model_json_schema() - cls.__stgtype__ = stg_type - StorageManager.register_storage(cls) - return super().__init_subclass__(**kwargs) - - def __init__(self, storage_record: StorageModel): - if storage_record.id is None: - raise ValueError("Storage must be persisted before use") - self._id = storage_record.id - self._config = self.__configcls__.model_validate(storage_record.config) - - self.__post_init__() - - @property - def storage_id(self) -> StorageID: - """Return the persisted Storage reference represented by this instance.""" - return self._id - - def __post_init__(self): - """Post-initialization hook for subclasses.""" - pass - - async def get_raw_content(self, block_content: str) -> ContentTV: - """Get the raw content of the block.""" - raise NotImplementedError( - f"{self.__class__.__name__}.get_raw_content() must be implemented by subclasses." - ) - - def get_transfer_url(self, block_content: str) -> str | None: - """Return an optional external byte-transfer hint for AI/provider use. - - The URL is neither content authority nor a promise that every external - provider can fetch it. Callers must retain hydrated content as the primary - value and treat this result as an optimization hint. - """ - del block_content - return None - - -class WritableStorage(Storage[ConfigTV, ContentTV], abc.ABC): - """Storage capability for raw content owned by the current deployment.""" - - async def get_raw_content(self, block_content: str) -> ContentTV: - from app.persistence.info_base.uow import graph_uow - - async with graph_uow() as uow: - return await self.read_content(block_content, uow.storage) - - async def create_content(self, content: ContentTV, storage: StorageRepository) -> str: - return self.serialize_pointer(await self.write_content(content, storage)) - - @abc.abstractmethod - async def read_content( - self, block_content: str, storage: StorageRepository - ) -> ContentTV: ... - - @abc.abstractmethod - async def write_content( - self, content: ContentTV, storage: StorageRepository - ) -> typing.Any: ... - - @abc.abstractmethod - async def update_content( - self, block_content: str, content: ContentTV, storage: StorageRepository - ) -> bool: ... - - @abc.abstractmethod - async def delete_content( - self, block_content: str, storage: StorageRepository - ) -> bool: ... - - @abc.abstractmethod - def serialize_pointer(self, pointer: typing.Any) -> str: - """Encode a storage-owned key as one opaque block pointer string.""" - ... +if TYPE_CHECKING: + from inkcre_core.business.info_base.storage.main import * diff --git a/app/business/info_base/storage/postgresql.py b/app/business/info_base/storage/postgresql.py index 26ee5826..c6d2827f 100644 --- a/app/business/info_base/storage/postgresql.py +++ b/app/business/info_base/storage/postgresql.py @@ -1,59 +1,10 @@ -"""Writable PostgreSQL binary storage.""" +"""Legacy import; use inkcre_core for new consumers.""" -import uuid +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.business.info_base.storage.postgresql") -from .main import WritableStorage -from app.persistence.info_base.storage import StorageRepository - - -class PostgreSQLBinaryStorageConfig(sqlmodel.SQLModel): - pass - - -class PostgreSQLBlobPointer(pydantic.BaseModel): - """Minimum pointer fields accepted from storage-backed block content.""" - - model_config = pydantic.ConfigDict(extra="ignore") - - blob_id: uuid.UUID - - -class StorageBlobNotFoundError(LookupError): - pass - - -class PostgreSQLBinaryStorage( - WritableStorage[PostgreSQLBinaryStorageConfig, bytes], - stg_type="postgresql_binary", - config_cls=PostgreSQLBinaryStorageConfig, -): - """Store raw bytes in the protocol database and return an opaque UUID pointer.""" - - def serialize_pointer(self, pointer: object) -> str: - blob_id = pydantic.TypeAdapter(uuid.UUID).validate_python(pointer) - return PostgreSQLBlobPointer(blob_id=blob_id).model_dump_json() - - async def read_content( - self, block_content: str, storage: StorageRepository, *, lock: bool = False - ) -> bytes: - pointer = PostgreSQLBlobPointer.model_validate_json(block_content) - content = await storage.read_blob(pointer.blob_id, lock=lock) - if content is None: - raise StorageBlobNotFoundError(f"Storage blob {pointer.blob_id} not found") - return content - - async def write_content(self, content: bytes, storage: StorageRepository) -> uuid.UUID: - return await storage.create_blob(content) - - async def update_content( - self, block_content: str, content: bytes, storage: StorageRepository - ) -> bool: - pointer = PostgreSQLBlobPointer.model_validate_json(block_content) - return await storage.update_blob(pointer.blob_id, content) - - async def delete_content(self, block_content: str, storage: StorageRepository) -> bool: - pointer = PostgreSQLBlobPointer.model_validate_json(block_content) - return await storage.delete_blob(pointer.blob_id) +if TYPE_CHECKING: + from inkcre_core.business.info_base.storage.postgresql import * diff --git a/app/business/info_base/tools.py b/app/business/info_base/tools.py index a1dbdb0f..cbd22d81 100644 --- a/app/business/info_base/tools.py +++ b/app/business/info_base/tools.py @@ -1,152 +1,10 @@ -"""Agent Tool controllers for info-base retrieval and entity reads.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.info_base.tools") -from app.business.agent import AgentManager -from app.business.agent.projection import project_json -from app.schemas.ai import JSONValue -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.lexical_retrieval import LexicalRetrievalResult -from libs.obsrv.telemetry import emit_event - -from .main import InfoBaseManager - - -RETRIEVE_TOOL = "retrieve" -GET_ENTITIES_TOOL = "get_entities" -RetrievalMode: typing.TypeAlias = typing.Literal["lexical", "semantic", "hybrid"] - - -class RetrieveInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - query: str - mode: RetrievalMode = "hybrid" - limit: int = pydantic.Field(default=20, ge=1, le=20) - - @pydantic.field_validator("query") - @classmethod - def non_empty_query(cls, value: str) -> str: - if not value.strip(): - raise ValueError("query must not be empty") - return value - - -class EntityReference(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["block", "relation"] - id: int - - -class GetEntitiesInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entities: tuple[EntityReference, ...] = pydantic.Field(default=(), max_length=20) - random_count: int = pydantic.Field(default=1, ge=1, le=20) - - @pydantic.model_validator(mode="after") - def validate_selection(self) -> typing.Self: - if self.entities and self.random_count != 1: - raise ValueError("random_count only applies when entities is empty") - return self - - -@AgentManager.tool( - RETRIEVE_TOOL, - description="Retrieve lexical, semantic, or separate hybrid results for one query.", -) -async def retrieve(input: RetrieveInput) -> JSONValue: - branches = await InfoBaseManager.retrieve(input.query, input.mode, input.limit) - payload: dict[str, JSONValue] = {} - for name, branch in branches.items(): - if branch.error is not None: - payload[name] = { - "error": type(branch.error).__name__, - "message": str(branch.error), - } - continue - result = branch.result - assert result is not None - if isinstance(result, LexicalRetrievalResult): - block_ids = tuple( - match.block.id for match in result.matches if match.block.id is not None - ) - relation_ids: tuple[int, ...] = () - else: - block_ids = tuple( - match.entity.id - for match in result.matches - if match.type == "block" and match.entity.id is not None - ) - relation_ids = tuple( - match.entity.id - for match in result.matches - if match.type == "relation" and match.entity.id is not None - ) - emit_event( - "inkcre.retrieval.candidates", - { - "inkcre.retrieval.method": "lexical" if name == "lexical" else "semantic", - "inkcre.entity.block_ids": block_ids, - "inkcre.entity.relation_ids": relation_ids, - "inkcre.retrieval.candidate_count": len(result.matches), - }, - ) - if name == "lexical": - payload[name] = { - "matches": [ - { - "entity": {"entity_type": "block", "entity_id": match.block.id}, - **match.model_dump(mode="json", exclude={"block"}), - } - for match in typing.cast(typing.Any, result).matches - ] - } - else: - payload[name] = { - **result.model_dump(mode="json", exclude={"matches"}), - "matches": [ - { - "entity": {"entity_type": match.type, "entity_id": match.entity.id}, - "score": match.score, - } - for match in typing.cast(typing.Any, result).matches - ], - } - return payload - - -@AgentManager.tool( - GET_ENTITIES_TOOL, - description=( - "Read persisted Blocks or Relations without resolving content. " - "Null may indicate an incorrect entity type." - ), -) -async def get_entities(input: GetEntitiesInput) -> JSONValue: - result = await InfoBaseManager.get_entities( - tuple((reference.type, reference.id) for reference in input.entities), - random_count=input.random_count, - ) - emit_event( - "inkcre.entity.read", - { - "inkcre.entity.read.kind": "persisted", - "inkcre.entity.block_ids": tuple( - entity.id - for entity in result - if isinstance(entity, BlockModel) and entity.id is not None - ), - "inkcre.entity.relation_ids": tuple( - entity.id - for entity in result - if isinstance(entity, RelationModel) and entity.id is not None - ), - "inkcre.entity.read.count": sum(entity is not None for entity in result), - }, - ) - return project_json(result) +if TYPE_CHECKING: + from inkcre_core.business.info_base.tools import * diff --git a/app/business/job.py b/app/business/job.py index a85d1bbc..a804caa0 100644 --- a/app/business/job.py +++ b/app/business/job.py @@ -1,402 +1,10 @@ -"""Deep runtime for typed durable background Jobs.""" +"""Legacy import; use inkcre_core for new consumers.""" -import abc -import asyncio -import dataclasses -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import jsonschema # pyrefly: ignore[untyped-import] -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.job") -from app.database_contract.profile import BUILTIN_JOB_TYPES_BY_ID -from app.persistence.job.uow import JobUnitOfWork, job_uow -from app.scheduler import scheduler, with_trace_id -from app.schemas.job import JobID, JobModel, JobStatus, JobTypeID, JobTypeModel -from libs.obsrv.main import get_logger -from libs.obsrv.telemetry import emit_event, operation -from app.observability import submission_context, submission_links -from opentelemetry.context import Context - - -LOGGER = get_logger().getChild(__name__) -ParametersTV = typing.TypeVar("ParametersTV", bound=pydantic.BaseModel) - - -class UnknownJobTypeError(ValueError): - """A submitted exact Job type is absent from the deployment catalog.""" - - -@dataclasses.dataclass -class _Execution: - task: asyncio.Task - cancellation_requested: bool = False - closing: bool = False - - def cancel(self) -> None: - # Do not interrupt cleanup already started by timeout or an earlier abort. - if not self.cancellation_requested and not self.closing: - self.cancellation_requested = True - if not self.task.cancelling(): - self.task.cancel() - - -class JobHandler(abc.ABC, typing.Generic[ParametersTV]): - """One exact executable Job contract registered by its owning module.""" - - type: typing.ClassVar[JobTypeID] - description: typing.ClassVar[str] - parameters_model: typing.ClassVar[type[pydantic.BaseModel]] - default_timeout_seconds: typing.ClassVar[int] - - def __init_subclass__( - cls, - *, - job_type: JobTypeID, - description: str, - parameters_model: type[ParametersTV], - default_timeout_seconds: int, - **kwargs, - ) -> None: - if default_timeout_seconds <= 0: - raise ValueError("default_timeout_seconds must be positive") - cls.type = job_type - cls.description = description - cls.parameters_model = parameters_model - cls.default_timeout_seconds = default_timeout_seconds - JobManager.register_handler(cls) - super().__init_subclass__(**kwargs) - - @classmethod - def validate_parameters(cls, parameters: dict[str, typing.Any]) -> ParametersTV: - return typing.cast(ParametersTV, cls.parameters_model.model_validate(parameters)) - - @classmethod - async def normalize_parameters( - cls, parameters: dict[str, typing.Any], uow: JobUnitOfWork - ) -> dict[str, typing.Any]: - """Validate a new Job/Cron input; owners may resolve nested catalog contracts. - - Execution restores the persisted model through validate_parameters instead; - it does not repeat submission-only normalization. - """ - del uow - return cls.validate_parameters(parameters).model_dump(mode="json") - - @classmethod - @abc.abstractmethod - async def can_handle(cls, parameters: ParametersTV) -> bool: - """Return whether this runtime can execute these parameters now.""" - ... - - @classmethod - @abc.abstractmethod - async def handle(cls, job: JobModel, parameters: ParametersTV) -> None: - """Execute one already claimed Job.""" - ... - - -class JobManager: - """Own Job Handler registration, typed creation, claim and terminal closure.""" - - _handlers: dict[JobTypeID, type[JobHandler]] = {} - _active: dict[JobID, _Execution] = {} - _accepting = True - - @classmethod - def register_handler(cls, handler: type[JobHandler]) -> None: - existing = cls._handlers.get(handler.type) - if existing is handler: - return - if existing is not None: - raise ValueError( - f"Job type {handler.type!r} is already handled by " - f"{existing.__module__}.{existing.__qualname__}" - ) - cls._handlers[handler.type] = handler - - @classmethod - async def sync_job_types(cls) -> None: - rows = [] - for handler in cls._handlers.values(): - builtin = BUILTIN_JOB_TYPES_BY_ID.get(handler.type) - rows.append( - dict( - id=handler.type, - description=builtin.description if builtin is not None else handler.description, - parameters_schema=( - builtin.parameters_schema - if builtin is not None - else handler.parameters_model.model_json_schema() - ), - default_timeout_seconds=( - builtin.default_timeout_seconds - if builtin is not None - else handler.default_timeout_seconds - ), - ) - ) - async with job_uow() as uow: - await uow.jobs.sync_types(rows) - - @classmethod - async def normalize_parameters( - cls, - job_type: JobTypeID, - parameters: dict[str, typing.Any], - uow: JobUnitOfWork, - ) -> dict[str, typing.Any]: - handler = cls._handlers.get(job_type) - if handler is not None: - return await handler.normalize_parameters(parameters, uow) - - persisted_type = await uow.jobs.get_type(job_type) - if persisted_type is None: - raise UnknownJobTypeError(f"Unknown Job type: {job_type}") - jsonschema.Draft202012Validator(persisted_type.parameters_schema).validate(parameters) - return parameters - - @classmethod - async def create( - cls, - job_type: JobTypeID, - parameters: dict[str, typing.Any], - timeout_seconds: int | None = None, - ) -> JobModel: - """Validate and persist one independent pending Job.""" - async with job_uow() as uow: - job = await cls.create_in_uow(job_type, parameters, timeout_seconds, uow=uow) - emit_event("job.submitted", {"inkcre.job.id": job.id or 0}) - return job - - @classmethod - async def create_in_uow( - cls, - job_type: JobTypeID, - parameters: dict[str, typing.Any], - timeout_seconds: int | None = None, - *, - uow: JobUnitOfWork, - ) -> JobModel: - normalized = await cls.normalize_parameters(job_type, parameters, uow) - persisted_type = await uow.jobs.get_type(job_type) - if persisted_type is None: - raise UnknownJobTypeError(f"Unknown Job type: {job_type}") - effective_timeout = ( - persisted_type.default_timeout_seconds if timeout_seconds is None else timeout_seconds - ) - if effective_timeout <= 0: - raise ValueError("Job timeout_seconds must be positive") - - with operation("job.submit") as observation: - span = observation.span - carrier = submission_context() - job = JobModel( - type=job_type, - parameters=normalized, - timeout_seconds=effective_timeout, - submission_traceparent=carrier["submission_traceparent"], - submission_tracestate=carrier["submission_tracestate"], - ) - job = await uow.jobs.create(job) - span.set_attribute("inkcre.job.id", job.id or 0) - # The composing use case still owns commit; this span only proves the flush. - span.set_attribute("inkcre.job.write_phase", "flushed") - return job - - @classmethod - async def _prepare( - cls, - job: JobModel, - ) -> tuple[type[JobHandler], pydantic.BaseModel] | None: - handler = cls._handlers.get(job.type) - if handler is None: - return None - parameters = handler.validate_parameters(job.parameters) - return (handler, parameters) if await handler.can_handle(parameters) else None - - @classmethod - async def get(cls, job_id: JobID) -> JobModel | None: - async with job_uow() as uow: - return await uow.jobs.get(job_id) - - @classmethod - async def get_type(cls, type_: JobTypeID) -> JobTypeModel | None: - async with job_uow() as uow: - return await uow.jobs.get_type(type_) - - @classmethod - async def list_types( - cls, *, limit: int | None = None, cursor: str | None = None - ) -> tuple[list[JobTypeModel], str | None]: - async with job_uow() as uow: - return await uow.jobs.list_types(limit=limit, cursor=cursor) - - @classmethod - async def list_jobs( - cls, - *, - limit: int = 20, - cursor: JobID | None = None, - type_: JobTypeID | None = None, - status: JobStatus | None = None, - ) -> tuple[list[JobModel], JobID | None]: - async with job_uow() as uow: - return await uow.jobs.list_jobs( - limit=limit, cursor=cursor, type_=type_, status=status - ) - - @classmethod - async def notify_worker(cls) -> None: - """Best-effort post-commit hint; scan failure cannot undo accepted work.""" - try: - await cls.check() - except Exception: - LOGGER.exception("Job discovery hint failed; periodic discovery remains active") - - @classmethod - async def abort(cls, job_id: JobID) -> JobModel | None: - """Close pending work or request the executing Peer to stop running work.""" - async with job_uow() as uow: - return await uow.jobs.abort(job_id) - - @classmethod - async def check_abort_requests(cls) -> None: - """Read this Peer's active abort requests in one batch.""" - if not cls._active: - return - async with job_uow() as uow: - ids = await uow.jobs.abort_requests(tuple(cls._active)) - for job_id in ids: - if job_id is None: - continue - execution = cls._active.get(job_id) - if execution is not None: - execution.cancel() - - @classmethod - def start(cls) -> None: - cls._accepting = True - - @classmethod - async def shutdown(cls) -> None: - """Stop admission and drain handlers before their Extension resources close.""" - cls._accepting = False - executions = tuple(cls._active.values()) - for execution in executions: - execution.cancel() - await asyncio.gather(*(item.task for item in executions), return_exceptions=True) - - @classmethod - async def _claim(cls, job_id: JobID) -> JobModel | None: - if not cls._accepting: - return None - async with job_uow() as uow: - return await uow.jobs.claim(job_id) - - @classmethod - async def _close(cls, job: JobModel, status: JobStatus) -> bool: - if not status.terminal: - raise ValueError("Job may close only to a terminal status") - execution = cls._active.get(job.id) if job.id is not None else None - if execution is not None: - execution.closing = True - # Cleanup is independent of handler cancellation and cannot wait indefinitely. - async with asyncio.timeout(10): - async with job_uow() as uow: - closed = await uow.jobs.close(job, status) - if closed: - emit_event( - "job.closed", {"inkcre.job.id": job.id or 0, "inkcre.job.status": status.value} - ) - return closed - - @classmethod - async def run(cls, job_id: JobID) -> bool: - """Check local eligibility, atomically claim, then execute one Job.""" - if not cls._accepting: - return False - candidate = await cls.get(job_id) - if candidate is None or candidate.status != JobStatus.PENDING: - return False - try: - prepared = await cls._prepare(candidate) - except pydantic.ValidationError as error: - # A broken persisted command must be visible as failed, not remain pending - # forever. Unknown/unavailable handlers still leave work for another Peer. - LOGGER.exception("Persisted Job parameters are invalid", extra={"job_id": job_id}) - claimed = await cls._claim(job_id) - if claimed is None: - return False - claimed.state = {**claimed.state, "error": str(error)} - await cls._close(claimed, JobStatus.FAILED) - return True - if prepared is None: - return False - - claimed = await cls._claim(job_id) - if claimed is None: - return False - handler, parameters = prepared - task = asyncio.current_task() - assert task is not None - cls._active[job_id] = _Execution(task) - with operation( - "job.execute", - attributes={"inkcre.job.id": job_id}, - context=Context(), - links=submission_links(claimed.submission_traceparent, claimed.submission_tracestate), - ) as observation: - span = observation.span - emit_event("job.started", {"inkcre.job.id": job_id}) - try: - async with asyncio.timeout(claimed.timeout_seconds): - await handler.handle(claimed, parameters) - except asyncio.CancelledError: - LOGGER.info("Job execution aborted", extra={"job_id": job_id}) - observation.outcome = "cancelled" - span.set_attribute("inkcre.job.status", "aborted") - await cls._close(claimed, JobStatus.ABORTED) - except TimeoutError: - LOGGER.warning("Job execution timed out", extra={"job_id": job_id}) - observation.outcome = "error" - span.set_attribute("inkcre.job.status", "timed_out") - await cls._close(claimed, JobStatus.TIMED_OUT) - except Exception as error: - LOGGER.exception("Job execution failed", extra={"job_id": job_id}) - observation.outcome = "error" - span.set_attribute("error.type", type(error).__name__) - span.set_attribute("inkcre.job.status", "failed") - claimed.state = {**claimed.state, "error": str(error)} - await cls._close(claimed, JobStatus.FAILED) - else: - span.set_attribute("inkcre.job.status", "finished") - await cls._close(claimed, JobStatus.FINISHED) - finally: - cls._active.pop(job_id, None) - return True - - @classmethod - async def expire_overdue(cls) -> int: - """Use database time to close abandoned overdue running Jobs.""" - async with job_uow() as uow: - return await uow.jobs.expire_overdue() - - @classmethod - async def check(cls) -> None: - """Schedule locally eligible pending Jobs and converge running timeouts.""" - if not cls._accepting: - return - async with job_uow() as uow: - pending = await uow.jobs.pending() - - for job in pending: - if job.id is None or job.type not in cls._handlers: - continue - scheduler.add_job( - func=with_trace_id(f"job.{job.id}", cls.run), - args=[job.id], - id=f"job.{job.id}", - replace_existing=True, - misfire_grace_time=None, - ) - await cls.expire_overdue() +if TYPE_CHECKING: + from inkcre_core.business.job import * diff --git a/app/business/lexical_retrieval/__init__.py b/app/business/lexical_retrieval/__init__.py index 56144cdf..177a94d6 100644 --- a/app/business/lexical_retrieval/__init__.py +++ b/app/business/lexical_retrieval/__init__.py @@ -1,25 +1,3 @@ -"""Exact Block-local lexical retrieval capability.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .main import ( - LEXICAL_RETRIEVAL_CAPABILITY, - LexicalRetrievalDelegationError, - LexicalRetrievalError, - LexicalRetrievalManager, -) -from .job import ( - LEXICAL_MAINTAIN_JOB_TYPE, - LEXICAL_REBUILD_JOB_TYPE, - LexicalMaintainJobHandler, - LexicalRebuildJobHandler, -) - -__all__ = [ - "LEXICAL_RETRIEVAL_CAPABILITY", - "LexicalRetrievalDelegationError", - "LexicalRetrievalError", - "LexicalRetrievalManager", - "LEXICAL_MAINTAIN_JOB_TYPE", - "LEXICAL_REBUILD_JOB_TYPE", - "LexicalMaintainJobHandler", - "LexicalRebuildJobHandler", -] +from inkcre_core.business.lexical_retrieval import * diff --git a/app/business/lexical_retrieval/job.py b/app/business/lexical_retrieval/job.py index a724f4db..b00d9b89 100644 --- a/app/business/lexical_retrieval/job.py +++ b/app/business/lexical_retrieval/job.py @@ -1,56 +1,10 @@ -"""Exact lexical projection maintenance commands hosted by Jobs.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.info_base.resolver import ResolverManager -from app.business.job import JobHandler -from app.schemas.job import JobModel -from app.schemas.lexical_retrieval import LexicalMaintenanceJobParameters +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .main import LexicalRetrievalManager +sys.modules[__name__] = import_module("inkcre_core.business.lexical_retrieval.job") - -LEXICAL_MAINTAIN_JOB_TYPE = "core.feature_retrieval.lexical.maintain.v1" -LEXICAL_REBUILD_JOB_TYPE = "core.feature_retrieval.lexical.rebuild.v1" - - -class LexicalMaintainJobHandler( - JobHandler[LexicalMaintenanceJobParameters], - job_type=LEXICAL_MAINTAIN_JOB_TYPE, - description="Maintain missing or stale Block lexical projection records.", - parameters_model=LexicalMaintenanceJobParameters, - default_timeout_seconds=900, -): - @classmethod - async def can_handle(cls, parameters: LexicalMaintenanceJobParameters) -> bool: - del parameters - return bool(ResolverManager.RESOLVER_CLS) - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: LexicalMaintenanceJobParameters, - ) -> None: - report = await LexicalRetrievalManager.maintain(parameters.options) - job.state = report.model_dump(mode="json") - - -class LexicalRebuildJobHandler( - JobHandler[LexicalMaintenanceJobParameters], - job_type=LEXICAL_REBUILD_JOB_TYPE, - description="Rebuild Block lexical records present before invocation.", - parameters_model=LexicalMaintenanceJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: LexicalMaintenanceJobParameters) -> bool: - del parameters - return bool(ResolverManager.RESOLVER_CLS) - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: LexicalMaintenanceJobParameters, - ) -> None: - report = await LexicalRetrievalManager.rebuild(parameters.options) - job.state = report.model_dump(mode="json") +if TYPE_CHECKING: + from inkcre_core.business.lexical_retrieval.job import * diff --git a/app/business/lexical_retrieval/main.py b/app/business/lexical_retrieval/main.py index a0bc3fac..a434a12f 100644 --- a/app/business/lexical_retrieval/main.py +++ b/app/business/lexical_retrieval/main.py @@ -1,261 +1,10 @@ -"""Block-local lexical projection maintenance and exact ranked retrieval.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from dataclasses import dataclass -import datetime -import logging -import typing +sys.modules[__name__] = import_module("inkcre_core.business.lexical_retrieval.main") -import pydantic - -from app.business.info_base.resolver import ( - ResolverManager, - UnknownResolverError, - UnsupportedResolverCapability, -) -from app.business.peer import PeerManager -from app.persistence.lexical_retrieval.uow import lexical_uow -from app.schemas.info_base.block import BlockModel -from app.schemas.lexical_retrieval import ( - LexicalEvidence, - LexicalMaintenanceDiagnostic, - LexicalMaintenanceOptions, - LexicalMaintenanceReport, - LexicalRetrievalMatch, - LexicalRetrievalRequest, - LexicalRetrievalResult, -) -from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef - - -logger = logging.getLogger(__name__) - -LEXICAL_RETRIEVAL_CAPABILITY = "core.feature_retrieval.lexical.v1" -_EXCERPT_LIMIT = 280 - - -class LexicalRetrievalError(RuntimeError): - pass - - -class LexicalRetrievalDelegationError(LexicalRetrievalError): - pass - - -class _ProjectionUnavailable(Exception): - pass - - -@dataclass(frozen=True) -class _Projection: - block: int - label: str - text: str | None - - -@dataclass -class _ReportBuilder: - diagnostic_limit: int - indexed: int = 0 - unavailable: int = 0 - failed: int = 0 - diagnostics: list[LexicalMaintenanceDiagnostic] | None = None - - def __post_init__(self) -> None: - self.diagnostics = [] - - def record( - self, - block: int, - outcome: typing.Literal["unavailable", "failed"], - reason: str, - ) -> None: - if outcome == "unavailable": - self.unavailable += 1 - else: - self.failed += 1 - diagnostics = typing.cast(list[LexicalMaintenanceDiagnostic], self.diagnostics) - if len(diagnostics) < self.diagnostic_limit: - diagnostics.append( - LexicalMaintenanceDiagnostic(block=block, outcome=outcome, reason=reason) - ) - - def build(self) -> LexicalMaintenanceReport: - return LexicalMaintenanceReport( - indexed=self.indexed, - unavailable=self.unavailable, - failed=self.failed, - diagnostics=tuple(self.diagnostics or ()), - ) - - -class LexicalRetrievalManager: - """Sole owner of lexical records, maintenance, ranking, and delegation.""" - - @classmethod - async def retrieve( - cls, - query: str, - limit: int = 20, - *, - route_to_peer: PeerRef | None = None, - ) -> LexicalRetrievalResult: - request = LexicalRetrievalRequest(query=query, limit=limit) - if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): - return await cls.retrieve_local(request.query, request.limit) - - payload = PeerProtocolRequest( - body=typing.cast(typing.Any, request.model_dump(mode="json")) - ) - result = await PeerManager.delegate( - LEXICAL_RETRIEVAL_CAPABILITY, - typing.cast(typing.Any, payload.model_dump(mode="json", exclude_unset=True)), - route_to_peer=route_to_peer, - ) - try: - response = PeerProtocolResponse.model_validate(result) - if response.status != 200 or "body" not in response.model_fields_set: - raise LexicalRetrievalDelegationError( - f"Lexical retrieval Peer returned HTTP {response.status}" - ) - return LexicalRetrievalResult.model_validate(response.body) - except pydantic.ValidationError as error: - raise LexicalRetrievalDelegationError( - "Lexical retrieval Peer returned an invalid response" - ) from error - - @classmethod - async def retrieve_local(cls, query: str, limit: int = 20) -> LexicalRetrievalResult: - request = LexicalRetrievalRequest(query=query, limit=limit) - normalized = request.query.strip() - async with lexical_uow() as repository: - rows = await repository.search(normalized, request.limit) - - return LexicalRetrievalResult( - matches=tuple( - LexicalRetrievalMatch( - block=block, - label=record.label, - excerpt=cls._excerpt(record.label, record.text, normalized), - evidence=typing.cast(LexicalEvidence, evidence_value), - rank=float(rank_value), - ) - for block, record, evidence_value, rank_value in rows - ) - ) - - @classmethod - async def maintain( - cls, - options: LexicalMaintenanceOptions | None = None, - ) -> LexicalMaintenanceReport: - return await cls._maintain( - options or LexicalMaintenanceOptions(), - rebuild_cutoff=None, - ) - - @classmethod - async def rebuild( - cls, - options: LexicalMaintenanceOptions | None = None, - ) -> LexicalMaintenanceReport: - async with lexical_uow() as repository: - cutoff = await repository.database_now() - return await cls._maintain( - options or LexicalMaintenanceOptions(), - rebuild_cutoff=cutoff, - ) - - @classmethod - async def _maintain( - cls, - options: LexicalMaintenanceOptions, - *, - rebuild_cutoff: datetime.datetime | None, - ) -> LexicalMaintenanceReport: - report = _ReportBuilder(options.diagnostic_limit) - cursor = 0 - processed = 0 - while processed < options.max_records: - async with lexical_uow() as repository: - blocks, next_cursor, exhausted = await repository.candidate_page( - cursor, - min(options.scan_page_size, options.max_records - processed), - rebuild_cutoff, - ) - cursor = next_cursor - projections: list[_Projection] = [] - for block in blocks: - block_id = typing.cast(int, block.id) - processed += 1 - try: - projections.append(await cls._project(block)) - except _ProjectionUnavailable as error: - report.record(block_id, "unavailable", str(error)) - except Exception as error: - logger.exception("Lexical projection failed", extra={"block": block_id}) - report.record(block_id, "failed", type(error).__name__) - if projections: - try: - async with lexical_uow() as repository: - await repository.upsert( - [ - { - "block": projection.block, - "label": projection.label, - "text": projection.text, - } - for projection in projections - ] - ) - except Exception as error: - logger.exception( - "Lexical projection batch upsert failed", - extra={"batch_size": len(projections)}, - ) - for projection in projections: - report.record(projection.block, "failed", type(error).__name__) - else: - report.indexed += len(projections) - if exhausted: - break - return report.build() - - @classmethod - async def _project(cls, block: BlockModel) -> _Projection: - block_id = block.id - if block_id is None: - raise RuntimeError("Persisted lexical candidate is missing its ID") - try: - resolver = ResolverManager.get(block) - label = await resolver.get_label() - text = await resolver.get_text(context="lexical", materialize_missing=True) - except UnknownResolverError as error: - raise _ProjectionUnavailable("unknown_resolver") from error - except UnsupportedResolverCapability as error: - raise _ProjectionUnavailable("unsupported_lexical_text") from error - normalized_label = label.strip() - normalized_text = text.strip() if text is not None and text.strip() else None - if not normalized_label: - raise _ProjectionUnavailable("empty_label") - return _Projection(block_id, normalized_label, normalized_text) - - @staticmethod - def _excerpt(label: str, text: str | None, query: str) -> str: - source = text or label - folded_source = source.casefold() - needle = query.casefold() - position = folded_source.find(needle) - if position < 0: - positions = [ - candidate - for term in query.split() - if (candidate := folded_source.find(term.casefold())) >= 0 - ] - position = min(positions, default=0) - start = max(0, position - _EXCERPT_LIMIT // 3) - end = min(len(source), start + _EXCERPT_LIMIT) - prefix = "…" if start else "" - suffix = "…" if end < len(source) else "" - return f"{prefix}{source[start:end]}{suffix}" +if TYPE_CHECKING: + from inkcre_core.business.lexical_retrieval.main import * diff --git a/app/business/organization/__init__.py b/app/business/organization/__init__.py index 2d7c161c..f37c2a85 100644 --- a/app/business/organization/__init__.py +++ b/app/business/organization/__init__.py @@ -1,92 +1,3 @@ -"""Exact, extensible information-organization behaviors.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .bootstrap import register_core_organization_behaviors -from .contracts import ( - OrganizationAgentNotFoundError, - OrganizationBlockNotFoundError, - OrganizationBudgetExceededError, - OrganizationDelegationError, - OrganizationError, - OrganizationExecutionError, - OrganizationNotConfiguredError, -) -from .duplicate_assertion import ( - DUPLICATES_ASSERTION_RELATION, - DuplicateAssertionBehaviorResolver, -) -from .evidence_stance import ( - CHALLENGES_RELATION, - SUPPORTS_RELATION, - EvidenceStanceBehaviorResolver, -) -from .referent_anchoring import ( - HAS_MENTION_RELATION, - REFERS_TO_RELATION, - ExistingReferentAnchoringBehaviorResolver, -) -from .refinement import REFINES_RELATION, RefinementBehaviorResolver -from .rumination import ( - RUMINATION_CAPABILITY, - RUMINATION_CONFIG_KEY, - RUMINATION_CONFIG_SCHEMA, - RuminationBehaviorResolver, -) -from .supersession import ( - EDITED_RELATION, - SUPERSEDES_RELATION, - SupersessionBehaviorResolver, -) -from .synthesis import SYNTHESIS_RELATION, SynthesisBehaviorResolver -from .tools import ( - ANCHOR_EXISTING_REFERENT_TOOL, - CREATE_SYNTHESIS_TOOL, - DRAFT_GRAPH_TOOL, - GET_DRAFT_GRAPH_SCHEMA_TOOL, - RECORD_DUPLICATE_ASSERTION_TOOL, - RECORD_EVIDENCE_STANCE_TOOL, - RECORD_ORGANIZATION_CANDIDATE_TOOL, - RECORD_REFINEMENT_TOOL, - RECORD_SUPERSESSION_TOOL, - SUBMIT_GRAPH_TOOL, -) - - -__all__ = [ - "ANCHOR_EXISTING_REFERENT_TOOL", - "CHALLENGES_RELATION", - "CREATE_SYNTHESIS_TOOL", - "DRAFT_GRAPH_TOOL", - "DUPLICATES_ASSERTION_RELATION", - "DuplicateAssertionBehaviorResolver", - "EDITED_RELATION", - "EvidenceStanceBehaviorResolver", - "ExistingReferentAnchoringBehaviorResolver", - "GET_DRAFT_GRAPH_SCHEMA_TOOL", - "HAS_MENTION_RELATION", - "OrganizationAgentNotFoundError", - "OrganizationBlockNotFoundError", - "OrganizationBudgetExceededError", - "OrganizationDelegationError", - "OrganizationError", - "OrganizationExecutionError", - "OrganizationNotConfiguredError", - "RECORD_DUPLICATE_ASSERTION_TOOL", - "RECORD_EVIDENCE_STANCE_TOOL", - "RECORD_ORGANIZATION_CANDIDATE_TOOL", - "RECORD_REFINEMENT_TOOL", - "RECORD_SUPERSESSION_TOOL", - "REFERS_TO_RELATION", - "REFINES_RELATION", - "RUMINATION_CAPABILITY", - "RUMINATION_CONFIG_KEY", - "RUMINATION_CONFIG_SCHEMA", - "RuminationBehaviorResolver", - "SUBMIT_GRAPH_TOOL", - "SUPERSEDES_RELATION", - "SUPPORTS_RELATION", - "SYNTHESIS_RELATION", - "SupersessionBehaviorResolver", - "SynthesisBehaviorResolver", - "RefinementBehaviorResolver", - "register_core_organization_behaviors", -] +from inkcre_core.business.organization import * diff --git a/app/business/organization/_shared.py b/app/business/organization/_shared.py index 8d3d7313..ff6a1830 100644 --- a/app/business/organization/_shared.py +++ b/app/business/organization/_shared.py @@ -1,362 +1,10 @@ -"""Small shared mechanics for exact Organization behaviors.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from contextlib import contextmanager -import json -import logging -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization._shared") -import pydantic - -from app.business.agent import AgentManager, AgentNotFoundError, TurnTermination -from app.business.deployment_config import DeploymentConfigService -from app.business.info_base.resolver import ( - Resolver, - ResolverManager, - UnknownResolverError, - UnsupportedResolverCapability, -) -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from app.schemas.ai import TextContentPart, UserMessage -from app.schemas.info_base.block import BlockForm, BlockID, BlockModel, ResolverType -from app.schemas.info_base.relation import RelationID, RelationModel -from app.schemas.organization_behavior import CandidateWriteResult, RelationWriteResult - -from .contracts import ( - OrganizationAgentNotFoundError, - OrganizationBlockNotFoundError, - OrganizationBudgetExceededError, - OrganizationExecutionError, - OrganizationNotConfiguredError, -) - - -CANDIDATE_RELATION = "candidate for" -_CONTEXT_RELATION_LIMIT = 20 - - -def behavior_resolver_classes() -> tuple[type[Resolver], ...]: - classes: list[type[Resolver]] = [] - for resolver_cls in ResolverManager.RESOLVER_CLS.values(): - if ( - issubclass(resolver_cls, Resolver) - and isinstance(getattr(resolver_cls, "organization_description", None), str) - and callable(getattr(resolver_cls, "record_candidate", None)) - ): - classes.append(resolver_cls) - return tuple(sorted(classes, key=lambda resolver_cls: resolver_cls.__rsotype__)) - - -def get_behavior_resolver(resolver: ResolverType) -> type[Resolver] | None: - return next( - ( - resolver_cls - for resolver_cls in behavior_resolver_classes() - if resolver_cls.__rsotype__ == resolver - ), - None, - ) - - -async def get_or_create_descriptor( - behavior: type[Resolver], - uow: GraphUnitOfWork, -) -> BlockModel: - form = BlockForm(resolver=behavior.__rsotype__, content="") - existing = await ResolverManager.get(BlockModel.model_validate(form)).get_existing_async( - uow.blocks - ) - return existing if existing is not None else await uow.blocks.create(form) - - -async def record_candidate( - behavior: type[Resolver], - block_id: BlockID, -) -> CandidateWriteResult: - async with graph_uow() as uow: - if (await uow.blocks.get(block_id)) is None: - raise OrganizationBlockNotFoundError(f"Block {block_id} does not exist") - descriptor = await get_or_create_descriptor(behavior, uow) - descriptor_id = _block_id(descriptor) - if block_id == descriptor_id: - raise ValueError("An Organization behavior cannot be its own candidate") - relation, created = await fetchsert_relation( - block_id, - descriptor_id, - CANDIDATE_RELATION, - uow, - ) - return CandidateWriteResult( - descriptor_block_id=descriptor_id, - relation_id=_relation_id(relation), - created=created, - ) - - -async def candidate_seed_ids( - behavior: type[Resolver], - limit: int, -) -> tuple[BlockID, ...]: - if limit <= 0: - return () - async with graph_uow() as uow: - descriptor = await get_or_create_descriptor(behavior, uow) - descriptor_id = _block_id(descriptor) - return await uow.relations.random_incoming_sources( - descriptor_id, CANDIDATE_RELATION, limit - ) - - -async def recent_block_ids(limit: int) -> tuple[BlockID, ...]: - if limit <= 0: - return () - behavior_types = tuple( - resolver_cls.__rsotype__ for resolver_cls in behavior_resolver_classes() - ) - async with graph_uow() as uow: - return await uow.blocks.select_ids( - limit, exclude_resolvers=behavior_types, random_order=False - ) - - -async def random_block_ids(limit: int) -> tuple[BlockID, ...]: - if limit <= 0: - return () - behavior_types = tuple( - resolver_cls.__rsotype__ for resolver_cls in behavior_resolver_classes() - ) - async with graph_uow() as uow: - return await uow.blocks.select_ids( - limit, exclude_resolvers=behavior_types, random_order=True - ) - - -async def recent_relation_endpoint_ids( - limit: int, - *, - contents: typing.Collection[str] = (), -) -> tuple[BlockID, ...]: - if limit <= 0: - return () - async with graph_uow() as uow: - relations = await uow.relations.recent(limit, contents=contents) - return tuple( - dict.fromkeys( - endpoint for relation in relations for endpoint in (relation.from_, relation.to_) - ) - ) - - -def merge_seed_categories( - max_seeds: int, - *categories: typing.Collection[BlockID], -) -> tuple[BlockID, ...]: - """Reserve one position per non-empty category, then fill in priority order.""" - result: list[BlockID] = [] - normalized = [tuple(dict.fromkeys(category)) for category in categories] - for category in normalized: - if category and category[0] not in result: - result.append(category[0]) - if len(result) == max_seeds: - return tuple(result) - for category in normalized: - for block_id in category[1:]: - if block_id not in result: - result.append(block_id) - if len(result) == max_seeds: - return tuple(result) - return tuple(result) - - -async def configured_agent_available( - config_key: str, - config_type: type[pydantic.BaseModel], -) -> bool: - config = await DeploymentConfigService.get(config_key) - if config is None: - return False - if not isinstance(config, config_type): - raise TypeError(f"Organization config {config_key!r} returned the wrong model") - return await AgentManager.can_execute(typing.cast(typing.Any, config).agent, "text") - - -@contextmanager -def continue_after_seed_failure( - logger: logging.Logger, - behavior: ResolverType, - seed_block_id: BlockID, -) -> typing.Generator[None, None, None]: - """Recover only candidate-local failures in an automatic attempt.""" - try: - yield - except (OrganizationBlockNotFoundError, OrganizationBudgetExceededError) as error: - logger.warning( - "organization.seed.considered", - extra={ - "behavior": behavior, - "seed_block_ids": (seed_block_id,), - "outcome": "recoverable_failure", - "reason": ( - "seed_missing" - if isinstance(error, OrganizationBlockNotFoundError) - else "model_call_limit" - ), - }, - ) - - -async def run_configured_agent( - config_key: str, - config_type: type[pydantic.BaseModel], - message: UserMessage, -) -> None: - config = await DeploymentConfigService.get(config_key) - if config is None: - raise OrganizationNotConfiguredError( - f"Organization behavior {config_key!r} is not configured" - ) - if not isinstance(config, config_type): - raise TypeError(f"Organization config {config_key!r} returned the wrong model") - agent_id = typing.cast(typing.Any, config).agent - try: - thread = await AgentManager.run(agent_id, message) - except AgentNotFoundError as error: - raise OrganizationAgentNotFoundError( - f"Configured Organization Agent {agent_id} does not exist" - ) from error - turn = thread.current_turn - if turn is None: # pragma: no cover - AgentManager.run invariant - raise OrganizationExecutionError("Organization Agent did not start a Turn") - outcome = await turn - if outcome == TurnTermination.MAX_MODEL_CALLS: - raise OrganizationBudgetExceededError( - "Organization Agent exceeded its per-Turn model-call budget" - ) - - -async def build_seed_message( - request: str, - judgment_contract: tuple[str, ...], - seed_id: BlockID, -) -> UserMessage | None: - """Resolve one bounded seed neighborhood without holding a DB transaction.""" - async with graph_uow() as uow: - block = await uow.blocks.get(seed_id) - if block is None: - raise OrganizationBlockNotFoundError(f"Block {seed_id} does not exist") - relations = tuple( - sorted( - (await uow.relations.get(seed_id)), - key=lambda relation: relation.id or 0, - )[-_CONTEXT_RELATION_LIMIT:] - ) - neighbor_ids = { - relation.to_ if relation.from_ == seed_id else relation.from_ - for relation in relations - } - neighbors = { - neighbor.id: neighbor - for neighbor in (await uow.blocks.get_many(neighbor_ids)) - if neighbor.id is not None - } - - try: - resolver = ResolverManager.get(block) - text = await resolver.get_text(materialize_missing=False) - label = await resolver.get_label() - except (UnknownResolverError, UnsupportedResolverCapability): - return None - if text is None or not text.strip(): - return None - - relation_context: list[dict[str, typing.Any]] = [] - for relation in relations: - other_id = relation.to_ if relation.from_ == seed_id else relation.from_ - other = neighbors.get(other_id) - other_label = None - if other is not None: - try: - other_label = await ResolverManager.get(other).get_label() - except (UnknownResolverError, UnsupportedResolverCapability): - pass - relation_context.append( - { - "id": relation.id, - "direction": "outgoing" if relation.from_ == seed_id else "incoming", - "content": relation.content, - "other_block": { - "id": other_id, - "resolver": other.resolver if other is not None else None, - "label": other_label, - }, - } - ) - context = { - "request": request, - "judgment_contract": judgment_contract, - "seed_block": { - "id": seed_id, - "resolver": block.resolver, - "label": label, - "text": text, - }, - "direct_relations": relation_context, - "exploration": ( - "The seed and its direct relations are only a starting point. Use the declared " - "retrieval, Resolver, and graph tools when more evidence is needed. Persist only " - "through the exact behavior tool, or cautiously mark a different behavior candidate." - ), - } - return UserMessage( - content=( - TextContentPart( - text=json.dumps( - context, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - ), - ) - ) - - -async def fetchsert_relation( - from_: BlockID, - to_: BlockID, - content: str, - uow: GraphUnitOfWork, -) -> tuple[RelationModel, bool]: - proposed = RelationModel(from_=from_, to_=to_, content=content) - relation = await uow.relations.fetchsert(proposed) - return relation, relation is proposed - - -def relation_result(relation: RelationModel, created: bool) -> RelationWriteResult: - return RelationWriteResult(relation_id=_relation_id(relation), created=created) - - -async def require_distinct_blocks( - left: BlockID, - right: BlockID, - uow: GraphUnitOfWork, -) -> None: - if left == right: - raise ValueError("Organization relation endpoints must be different") - found = {block.id for block in (await uow.blocks.get_many((left, right)))} - missing = tuple(block_id for block_id in (left, right) if block_id not in found) - if missing: - raise OrganizationBlockNotFoundError(f"Blocks do not exist: {missing!r}") - - -def _block_id(block: BlockModel) -> BlockID: - if block.id is None: # pragma: no cover - persisted Block invariant - raise RuntimeError("Persisted Block has no ID") - return block.id - - -def _relation_id(relation: RelationModel) -> RelationID: - if relation.id is None: # pragma: no cover - persisted Relation invariant - raise RuntimeError("Persisted Relation has no ID") - return relation.id +if TYPE_CHECKING: + from inkcre_core.business.organization._shared import * diff --git a/app/business/organization/bootstrap.py b/app/business/organization/bootstrap.py index e235b03e..0de2ff4d 100644 --- a/app/business/organization/bootstrap.py +++ b/app/business/organization/bootstrap.py @@ -1,24 +1,10 @@ -"""Explicit loading of core Organization Behavior Resolvers.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.info_base.resolver import ResolverManager +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.organization.bootstrap") -def register_core_organization_behaviors() -> None: - from .duplicate_assertion import DuplicateAssertionBehaviorResolver - from .evidence_stance import EvidenceStanceBehaviorResolver - from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver - from .refinement import RefinementBehaviorResolver - from .rumination import RuminationBehaviorResolver - from .supersession import SupersessionBehaviorResolver - from .synthesis import SynthesisBehaviorResolver - - for resolver_class in ( - RuminationBehaviorResolver, - SupersessionBehaviorResolver, - RefinementBehaviorResolver, - EvidenceStanceBehaviorResolver, - SynthesisBehaviorResolver, - ExistingReferentAnchoringBehaviorResolver, - DuplicateAssertionBehaviorResolver, - ): - ResolverManager.register_resolver(resolver_class) +if TYPE_CHECKING: + from inkcre_core.business.organization.bootstrap import * diff --git a/app/business/organization/contracts.py b/app/business/organization/contracts.py index a395a533..2c044ec0 100644 --- a/app/business/organization/contracts.py +++ b/app/business/organization/contracts.py @@ -1,29 +1,10 @@ -"""Stable failures at the Organization capability boundary.""" +"""Legacy import; use inkcre_core for new consumers.""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -class OrganizationError(RuntimeError): - """Base failure at the Organization capability boundary.""" +sys.modules[__name__] = import_module("inkcre_core.business.organization.contracts") - -class OrganizationBlockNotFoundError(OrganizationError): - pass - - -class OrganizationNotConfiguredError(OrganizationError): - pass - - -class OrganizationAgentNotFoundError(OrganizationError): - pass - - -class OrganizationExecutionError(OrganizationError): - pass - - -class OrganizationBudgetExceededError(OrganizationExecutionError): - """One Agent Turn ended at its configured model-call limit.""" - - -class OrganizationDelegationError(OrganizationError): - pass +if TYPE_CHECKING: + from inkcre_core.business.organization.contracts import * diff --git a/app/business/organization/duplicate_assertion.py b/app/business/organization/duplicate_assertion.py index 7f5aa4c0..fe76a6f7 100644 --- a/app/business/organization/duplicate_assertion.py +++ b/app/business/organization/duplicate_assertion.py @@ -1,160 +1,12 @@ -"""Provenance-aware duplicate assertion behavior.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing - - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import graph_uow -from libs.obsrv.main import get_logger -from app.schemas.info_base.block import BlockID -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - RelationWriteResult, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - record_candidate, - relation_result, - require_distinct_blocks, - run_configured_agent, -) - - -LOGGER = get_logger().getChild(__name__) - -DUPLICATES_ASSERTION_RELATION = "duplicates assertion" -DUPLICATE_ASSERTION_BEHAVIOR = "core.organization.behavior.duplicate-assertion.v1" -DUPLICATE_ASSERTION_CONFIG_KEY = "core.organization.duplicate_assertion" -DUPLICATE_ASSERTION_CONFIG_SCHEMA = "core.organization.duplicate_assertion.config.v1" - -DeploymentConfigManager.register_schema( - DUPLICATE_ASSERTION_CONFIG_SCHEMA, - BehaviorAgentConfig, - keys=(DUPLICATE_ASSERTION_CONFIG_KEY,), +sys.modules[__name__] = import_module( + "inkcre_core.business.organization.duplicate_assertion" ) - -class DuplicateAssertionBehaviorResolver( - Resolver[str, str], - rso_type=DUPLICATE_ASSERTION_BEHAVIOR, -): - organization_description = ( - "Relate whole-Block assertions copied from the same provenance occurrence." - ) - judgment_contract = ( - "Both Blocks completely and independently address the compared assertion.", - "Referent, predicate, polarity, force, units, and material qualifiers match.", - "Applicable scope, time, version, environment, and attribution are compatible.", - "Both assertions ultimately derive from the same observable provenance occurrence.", - "Neither Block adds independent evidence, reasoning, or authoritative decision.", - "No material asymmetric information gain is hidden by the relation.", - "The relation prevents evidence multiplication or restores useful provenance paths.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: duplicate assertion" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def record_duplicate_assertion( - cls, - left_block_id: BlockID, - right_block_id: BlockID, - ) -> RelationWriteResult: - async with graph_uow() as uow: - await require_distinct_blocks(left_block_id, right_block_id, uow) - from_, to_ = sorted((left_block_id, right_block_id)) - relation, created = await fetchsert_relation( - from_, - to_, - DUPLICATES_ASSERTION_RELATION, - uow, - ) - return relation_result(relation, created) - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available( - DUPLICATE_ASSERTION_CONFIG_KEY, - BehaviorAgentConfig, - ) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - recent = await recent_block_ids(max_seeds) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, recent, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "recent_count": len(recent), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Record only whole-Block duplicate assertions " - "from the same provenance occurrence.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent( - DUPLICATE_ASSERTION_CONFIG_KEY, - BehaviorAgentConfig, - message, - ) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.duplicate_assertion import * diff --git a/app/business/organization/evidence_stance.py b/app/business/organization/evidence_stance.py index c92dd1ab..c60a0e1d 100644 --- a/app/business/organization/evidence_stance.py +++ b/app/business/organization/evidence_stance.py @@ -1,169 +1,10 @@ -"""Provenance-preserving evidence stance relation behavior.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.evidence_stance") - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import graph_uow -from libs.obsrv.main import get_logger -from app.schemas.info_base.block import BlockID -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - RelationWriteResult, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - recent_relation_endpoint_ids, - record_candidate, - relation_result, - require_distinct_blocks, - run_configured_agent, -) - - -LOGGER = get_logger().getChild(__name__) - -SUPPORTS_RELATION = "supports" -CHALLENGES_RELATION = "challenges" -EVIDENCE_STANCE_BEHAVIOR = "core.organization.behavior.evidence-stance.v1" -EVIDENCE_STANCE_CONFIG_KEY = "core.organization.evidence_stance" -EVIDENCE_STANCE_CONFIG_SCHEMA = "core.organization.evidence_stance.config.v1" - -DeploymentConfigManager.register_schema( - EVIDENCE_STANCE_CONFIG_SCHEMA, - BehaviorAgentConfig, - keys=(EVIDENCE_STANCE_CONFIG_KEY,), -) - - -class EvidenceStanceBehaviorResolver( - Resolver[str, str], - rso_type=EVIDENCE_STANCE_BEHAVIOR, -): - organization_description = ( - "Relate attributable evidence that supports or challenges an assertion." - ) - judgment_contract = ( - "Evidence and assertion are complete addressable information units.", - "The source is evidence and the target is an evaluable assertion.", - "Their proposition and applicable scope are comparable.", - "The evidence contributes reasons beyond establishing source fidelity; " - "source authority alone is insufficient.", - "Evidence provenance and speaker attribution remain recoverable.", - "The stance is unambiguously support or challenge for the whole assertion.", - "Duplicate, refinement, replacement, or topical proximity alone is insufficient.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: evidence stance" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def record_evidence_stance( - cls, - evidence_block_id: BlockID, - assertion_block_id: BlockID, - stance: typing.Literal["supports", "challenges"], - ) -> RelationWriteResult: - async with graph_uow() as uow: - await require_distinct_blocks(evidence_block_id, assertion_block_id, uow) - opposite = CHALLENGES_RELATION if stance == SUPPORTS_RELATION else SUPPORTS_RELATION - existing_opposite = await uow.relations.get_outgoing_many( - (evidence_block_id,), to_ids=(assertion_block_id,), content=opposite - ) - if existing_opposite: - raise ValueError("The evidence/assertion pair already has the opposite stance") - relation, created = await fetchsert_relation( - evidence_block_id, - assertion_block_id, - stance, - uow, - ) - return relation_result(relation, created) - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available(EVIDENCE_STANCE_CONFIG_KEY, BehaviorAgentConfig) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - strong = merge_seed_categories( - max_seeds, - await recent_relation_endpoint_ids(max_seeds), - await recent_block_ids(max_seeds), - ) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, strong, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "strong_count": len(strong), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Determine only attributable evidence support or challenge relations.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent( - EVIDENCE_STANCE_CONFIG_KEY, - BehaviorAgentConfig, - message, - ) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.evidence_stance import * diff --git a/app/business/organization/jobs.py b/app/business/organization/jobs.py index b7e6b555..1c34ad60 100644 --- a/app/business/organization/jobs.py +++ b/app/business/organization/jobs.py @@ -1,197 +1,10 @@ -"""Independent automatic Organization routes and explicit rumination Jobs.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.job import JobHandler -from app.schemas.job import JobModel -from app.schemas.organization import RuminationRequest -from app.schemas.organization_behavior import AutomaticOrganizationJobParameters +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .duplicate_assertion import DuplicateAssertionBehaviorResolver -from .evidence_stance import EvidenceStanceBehaviorResolver -from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver -from .refinement import RefinementBehaviorResolver -from .rumination import RuminationBehaviorResolver -from .supersession import SupersessionBehaviorResolver -from .synthesis import SynthesisBehaviorResolver +sys.modules[__name__] = import_module("inkcre_core.business.organization.jobs") - -RUMINATION_JOB_TYPE = "core.organization.rumination.automatic.v1" -EXPLICIT_RUMINATION_JOB_TYPE = "core.organization.rumination.explicit.v1" -SUPERSESSION_JOB_TYPE = "core.organization.supersession.automatic.v1" -REFINEMENT_JOB_TYPE = "core.organization.refinement.automatic.v1" -EVIDENCE_STANCE_JOB_TYPE = "core.organization.evidence-stance.automatic.v1" -SYNTHESIS_JOB_TYPE = "core.organization.synthesis.automatic.v1" -REFERENT_ANCHORING_JOB_TYPE = "core.organization.existing-referent-anchoring.automatic.v1" -DUPLICATE_ASSERTION_JOB_TYPE = "core.organization.duplicate-assertion.automatic.v1" - - -class ExplicitRuminationJobHandler( - JobHandler[RuminationRequest], - job_type=EXPLICIT_RUMINATION_JOB_TYPE, - description="Reconsider one explicitly selected Block through rumination.", - parameters_model=RuminationRequest, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: RuminationRequest) -> bool: - del parameters - return await RuminationBehaviorResolver.can_run_automatic() - - @classmethod - async def handle(cls, job: JobModel, parameters: RuminationRequest) -> None: - del job - # A claimed Job executes here; it must not delegate another execution. - await RuminationBehaviorResolver.ruminate_local(parameters.block) - - -class RuminationJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=RUMINATION_JOB_TYPE, - description="Automatically reconsider bounded information seeds through rumination.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await RuminationBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await RuminationBehaviorResolver.run_automatic(parameters.max_seeds) - - -class SupersessionJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=SUPERSESSION_JOB_TYPE, - description="Automatically judge bounded scoped-supersession candidates.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await SupersessionBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await SupersessionBehaviorResolver.run_automatic(parameters.max_seeds) - - -class RefinementJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=REFINEMENT_JOB_TYPE, - description="Automatically judge bounded non-dominating refinement candidates.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await RefinementBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await RefinementBehaviorResolver.run_automatic(parameters.max_seeds) - - -class EvidenceStanceJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=EVIDENCE_STANCE_JOB_TYPE, - description="Automatically judge bounded attributable evidence stances.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await EvidenceStanceBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await EvidenceStanceBehaviorResolver.run_automatic(parameters.max_seeds) - - -class SynthesisJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=SYNTHESIS_JOB_TYPE, - description="Automatically create bounded provenance-preserving syntheses.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await SynthesisBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await SynthesisBehaviorResolver.run_automatic(parameters.max_seeds) - - -class ExistingReferentAnchoringJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=REFERENT_ANCHORING_JOB_TYPE, - description="Automatically anchor bounded mentions to existing referents.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await ExistingReferentAnchoringBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await ExistingReferentAnchoringBehaviorResolver.run_automatic(parameters.max_seeds) - - -class DuplicateAssertionJobHandler( - JobHandler[AutomaticOrganizationJobParameters], - job_type=DUPLICATE_ASSERTION_JOB_TYPE, - description="Automatically judge bounded provenance-aware duplicate assertions.", - parameters_model=AutomaticOrganizationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: - del parameters - return await DuplicateAssertionBehaviorResolver.can_run_automatic() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: AutomaticOrganizationJobParameters, - ) -> None: - del job - await DuplicateAssertionBehaviorResolver.run_automatic(parameters.max_seeds) +if TYPE_CHECKING: + from inkcre_core.business.organization.jobs import * diff --git a/app/business/organization/referent_anchoring.py b/app/business/organization/referent_anchoring.py index 3e20a73e..f13b3ec6 100644 --- a/app/business/organization/referent_anchoring.py +++ b/app/business/organization/referent_anchoring.py @@ -1,273 +1,12 @@ -"""Existing-referent anchoring through occurrence-local selected text.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing - - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from libs.obsrv.main import get_logger -from app.schemas.info_base.block import BlockForm, BlockID -from app.schemas.info_base.relation import RelationModel -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - ExistingReferentAnchorProposal, - ExistingReferentAnchorResult, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - record_candidate, - relation_result, - require_distinct_blocks, - run_configured_agent, +sys.modules[__name__] = import_module( + "inkcre_core.business.organization.referent_anchoring" ) - -LOGGER = get_logger().getChild(__name__) - -HAS_MENTION_RELATION = "has mention" -REFERS_TO_RELATION = "refers to" -REFERENT_ANCHORING_BEHAVIOR = "core.organization.behavior.existing-referent-anchoring.v1" -REFERENT_ANCHORING_CONFIG_KEY = "core.organization.existing_referent_anchoring" -REFERENT_ANCHORING_CONFIG_SCHEMA = "core.organization.existing_referent_anchoring.config.v1" - -DeploymentConfigManager.register_schema( - REFERENT_ANCHORING_CONFIG_SCHEMA, - BehaviorAgentConfig, - keys=(REFERENT_ANCHORING_CONFIG_KEY,), -) - - -class ExistingReferentAnchoringBehaviorResolver( - Resolver[str, str], - rso_type=REFERENT_ANCHORING_BEHAVIOR, -): - organization_description = ( - "Anchor one source-grounded referring fragment to existing " - "identity-bearing information." - ) - judgment_contract = ( - "The source expression meaningfully denotes a reusable referent.", - "Selected text identifies this mention without unrelated material.", - "The referent Block already exists and was not created as a temporary label.", - "The target contains enough identity to distinguish plausible alternatives.", - "Denotation remains continuous across name, time, environment, and scope.", - "Plausible competing referents have been considered and excluded.", - "The anchor improves cross-source or cross-time use rather than graph density.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: existing referent anchoring" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def anchor_existing_referent( - cls, - source_block_id: BlockID, - selected_text: str, - referent_block_id: BlockID, - ) -> ExistingReferentAnchorResult: - proposal = ExistingReferentAnchorProposal( - source_block_id=source_block_id, - selected_text=selected_text, - referent_block_id=referent_block_id, - ) - async with graph_uow() as uow: - await require_distinct_blocks( - proposal.source_block_id, proposal.referent_block_id, uow - ) - source = await uow.blocks.get(proposal.source_block_id) - if source is None: # pragma: no cover - require_distinct_blocks invariant - raise ValueError("Source Block does not exist") - - existing = await cls._existing_path( - proposal.source_block_id, - proposal.selected_text, - proposal.referent_block_id, - uow, - ) - if existing is not None: - fragment_id, has_mention, refers_to = existing - return ExistingReferentAnchorResult( - fragment_block_id=fragment_id, - fragment_created=False, - has_mention=( - relation_result(has_mention, False) if has_mention is not None else None - ), - refers_to=relation_result(refers_to, False), - ) - - source_is_fragment = ( - source.resolver == "core.text.v1" and source.content == proposal.selected_text - ) - if source_is_fragment: - fragment_id = proposal.source_block_id - fragment_created = False - has_mention_result = None - else: - fragment = await uow.blocks.create( - BlockForm(resolver="core.text.v1", content=proposal.selected_text) - ) - if fragment.id is None: # pragma: no cover - persisted Block invariant - raise RuntimeError("Persisted referring fragment has no ID") - fragment_id = fragment.id - fragment_created = True - has_mention, created = await fetchsert_relation( - proposal.source_block_id, - fragment_id, - HAS_MENTION_RELATION, - uow, - ) - has_mention_result = relation_result(has_mention, created) - - refers_to, created = await fetchsert_relation( - fragment_id, - proposal.referent_block_id, - REFERS_TO_RELATION, - uow, - ) - return ExistingReferentAnchorResult( - fragment_block_id=fragment_id, - fragment_created=fragment_created, - has_mention=has_mention_result, - refers_to=relation_result(refers_to, created), - ) - - @staticmethod - async def _existing_path( - source_block_id: BlockID, - selected_text: str, - referent_block_id: BlockID, - uow: GraphUnitOfWork, - ) -> tuple[BlockID, RelationModel | None, RelationModel] | None: - source = await uow.blocks.get(source_block_id) - if ( - source is not None - and source.resolver == "core.text.v1" - and source.content == selected_text - ): - refers_to = next( - ( - relation - for relation in ( - await uow.relations.get( - source_block_id, include_in=False, content=REFERS_TO_RELATION - ) - ) - if relation.to_ == referent_block_id - ), - None, - ) - if refers_to is not None: - return source_block_id, None, refers_to - - for has_mention in await uow.relations.get( - source_block_id, include_in=False, content=HAS_MENTION_RELATION - ): - fragment = await uow.blocks.get(has_mention.to_) - if ( - fragment is None - or fragment.resolver != "core.text.v1" - or fragment.content != selected_text - ): - continue - refers_to = next( - ( - relation - for relation in ( - await uow.relations.get( - has_mention.to_, include_in=False, content=REFERS_TO_RELATION - ) - ) - if relation.to_ == referent_block_id - ), - None, - ) - if refers_to is not None: - return has_mention.to_, has_mention, refers_to - return None - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available( - REFERENT_ANCHORING_CONFIG_KEY, - BehaviorAgentConfig, - ) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - recent = await recent_block_ids(max_seeds) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, recent, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "recent_count": len(recent), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Anchor only resolved source mentions to existing identity-bearing Blocks.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent( - REFERENT_ANCHORING_CONFIG_KEY, - BehaviorAgentConfig, - message, - ) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.referent_anchoring import * diff --git a/app/business/organization/refinement.py b/app/business/organization/refinement.py index db380e3c..d7daa432 100644 --- a/app/business/organization/refinement.py +++ b/app/business/organization/refinement.py @@ -1,187 +1,10 @@ -"""Non-dominating refinement relation and automatic semantic judgment.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.refinement") - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from libs.obsrv.main import get_logger -from app.schemas.info_base.block import BlockID -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - RelationWriteResult, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - recent_relation_endpoint_ids, - record_candidate, - relation_result, - require_distinct_blocks, - run_configured_agent, -) -from .supersession import EDITED_RELATION - - -LOGGER = get_logger().getChild(__name__) - -REFINES_RELATION = "refines" -REFINEMENT_BEHAVIOR = "core.organization.behavior.refinement.v1" -REFINEMENT_CONFIG_KEY = "core.organization.refinement" -REFINEMENT_CONFIG_SCHEMA = "core.organization.refinement.config.v1" - -DeploymentConfigManager.register_schema( - REFINEMENT_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(REFINEMENT_CONFIG_KEY,) -) - - -class RefinementBehaviorResolver( - Resolver[str, str], - rso_type=REFINEMENT_BEHAVIOR, -): - organization_description = ( - "Relate useful compatible detail that refines but does not replace information." - ) - judgment_contract = ( - "Both endpoints are complete addressable information units.", - "They continue the same referent and evolvable subject.", - "The refinement scope equals or is visibly contained by the predecessor scope.", - "Their information roles and attribution remain compatible.", - "The refinement adds reusable detail, constraints, explanation, or precision.", - "The predecessor remains independently safe as a coarser description.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: refinement" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def record_refinement( - cls, - refinement_block_id: BlockID, - predecessor_block_id: BlockID, - ) -> RelationWriteResult: - async with graph_uow() as uow: - await require_distinct_blocks(refinement_block_id, predecessor_block_id, uow) - if await cls._has_directed_path( - predecessor_block_id, - refinement_block_id, - uow=uow, - ): - raise ValueError( - f"Cannot refine: an existing refines path runs from predecessor " - f"{predecessor_block_id} to refinement {refinement_block_id}" - ) - relation, created = await fetchsert_relation( - refinement_block_id, - predecessor_block_id, - REFINES_RELATION, - uow, - ) - return relation_result(relation, created) - - @classmethod - async def _has_directed_path( - cls, - start: BlockID, - target: BlockID, - *, - uow: GraphUnitOfWork, - ) -> bool: - frontier = {start} - visited = {start} - while frontier: - rows = tuple( - relation.to_ - for relation in await uow.relations.get_outgoing_many( - frontier, content=REFINES_RELATION - ) - ) - next_frontier = set(rows) - visited - if target in next_frontier: - return True - visited.update(next_frontier) - frontier = next_frontier - return False - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available(REFINEMENT_CONFIG_KEY, BehaviorAgentConfig) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - strong = merge_seed_categories( - max_seeds, - await recent_relation_endpoint_ids(max_seeds, contents=(EDITED_RELATION,)), - await recent_block_ids(max_seeds), - ) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, strong, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "strong_count": len(strong), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Determine only useful non-dominating refinement relations.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent(REFINEMENT_CONFIG_KEY, BehaviorAgentConfig, message) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.refinement import * diff --git a/app/business/organization/rumination.py b/app/business/organization/rumination.py index f9eeb397..841490f0 100644 --- a/app/business/organization/rumination.py +++ b/app/business/organization/rumination.py @@ -1,256 +1,10 @@ -"""Explicit and automatic rumination carried by an exact Behavior Resolver.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import json -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.rumination") -import pydantic - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import ( - Resolver, - ResolverManager, - UnknownResolverError, - UnsupportedResolverCapability, -) -from app.business.peer import PeerManager -from app.persistence.info_base.uow import graph_uow -from libs.obsrv.main import get_logger -from app.schemas.ai import JSONValue, TextContentPart, UserMessage -from app.schemas.info_base.block import BlockID -from app.schemas.info_base.relation import RelationModel -from app.schemas.organization import RuminationConfig, RuminationRequest -from app.schemas.organization_behavior import CandidateWriteResult -from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef - -from ._shared import ( - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - merge_seed_categories, - random_block_ids, - recent_block_ids, - record_candidate, - run_configured_agent, -) -from .contracts import ( - OrganizationBlockNotFoundError, - OrganizationDelegationError, - OrganizationExecutionError, -) - - -LOGGER = get_logger().getChild(__name__) - -RUMINATION_CONFIG_KEY = "core.organization.rumination" -RUMINATION_CONFIG_SCHEMA = "core.organization.rumination.config.v1" -RUMINATION_CAPABILITY = "core.organization.rumination.v1" -RUMINATION_BEHAVIOR = "core.organization.behavior.rumination.v1" - -DeploymentConfigManager.register_schema( - RUMINATION_CONFIG_SCHEMA, RuminationConfig, keys=(RUMINATION_CONFIG_KEY,) -) - - -class RuminationBehaviorResolver( - Resolver[str, str], - rso_type=RUMINATION_BEHAVIOR, -): - organization_description = ( - "Open-ended reconsideration of one information Block that may add a useful graph." - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: rumination" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available(RUMINATION_CONFIG_KEY, RuminationConfig) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - recent = await recent_block_ids(max_seeds) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, recent, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "recent_count": len(recent), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - await cls.ruminate_local(seed) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) - - @classmethod - async def ruminate( - cls, - block_id: BlockID, - *, - route_to_peer: PeerRef | None = None, - ) -> None: - """Execute locally unless the caller explicitly selects another Peer.""" - request = RuminationRequest(block=block_id) - if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): - await cls.ruminate_local(request.block) - return - - payload = PeerProtocolRequest( - body=typing.cast(JSONValue, request.model_dump(mode="json")) - ) - result = await PeerManager.delegate( - RUMINATION_CAPABILITY, - typing.cast(JSONValue, payload.model_dump(mode="json", exclude_unset=True)), - route_to_peer=route_to_peer, - ) - try: - response = PeerProtocolResponse.model_validate(result) - except pydantic.ValidationError as error: - raise OrganizationDelegationError( - "Rumination Peer returned an invalid response" - ) from error - if response.status != 204 or "body" in response.model_fields_set: - raise OrganizationDelegationError(f"Rumination Peer returned HTTP {response.status}") - - @classmethod - async def ruminate_local(cls, block_id: BlockID) -> None: - initial_message = await cls._build_initial_message(block_id) - if initial_message is None: - return - await run_configured_agent( - RUMINATION_CONFIG_KEY, - RuminationConfig, - initial_message, - ) - - @classmethod - async def _build_initial_message(cls, block_id: BlockID) -> UserMessage | None: - async with graph_uow() as uow: - block = await uow.blocks.get(block_id) - if block is None: - raise OrganizationBlockNotFoundError(f"Block {block_id} does not exist") - relations = tuple( - sorted( - (await uow.relations.get(block_id)), - key=lambda relation: relation.id or 0, - ) - ) - neighbor_ids = { - relation.to_ if relation.from_ == block_id else relation.from_ - for relation in relations - } - neighbors = { - neighbor.id: neighbor for neighbor in await uow.blocks.get_many(neighbor_ids) - } - - try: - focal_text = await ResolverManager.get(block).get_text() - except (UnknownResolverError, UnsupportedResolverCapability): - return None - except Exception as error: - raise OrganizationExecutionError( - "Rumination could not understand focal Block" - ) from error - if focal_text is None or not focal_text.strip(): - return None - - relation_context: list[dict[str, typing.Any]] = [] - for relation in relations: - neighbor_id, direction = cls._neighbor_and_direction(block_id, relation) - neighbor = neighbors.get(neighbor_id) - label: str | None = None - if neighbor is not None: - try: - label = await ResolverManager.get(neighbor).get_label() - except Exception: - LOGGER.debug( - "Could not project rumination neighbor label", - exc_info=True, - extra={"block": neighbor_id}, - ) - relation_context.append( - { - "id": relation.id, - "direction": direction, - "property": relation.content, - "other_block": { - "id": neighbor_id, - "resolver": neighbor.resolver if neighbor is not None else None, - "label": label, - }, - } - ) - - context = { - "request": "ruminate", - "focal_block": { - "id": block_id, - "resolver": block.resolver, - "text": focal_text, - }, - "direct_relations": relation_context, - "available_draft_resolvers": [ - { - "resolver": capability.resolver, - "description": capability.description, - } - for capability in ResolverManager.get_draft_capabilities() - ], - } - return UserMessage( - content=( - TextContentPart( - text=json.dumps( - context, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - ), - ) - ) - - @staticmethod - def _neighbor_and_direction( - block_id: BlockID, - relation: RelationModel, - ) -> tuple[BlockID, typing.Literal["incoming", "outgoing", "self"]]: - if relation.from_ == block_id and relation.to_ == block_id: - return block_id, "self" - if relation.from_ == block_id: - return relation.to_, "outgoing" - return relation.from_, "incoming" +if TYPE_CHECKING: + from inkcre_core.business.organization.rumination import * diff --git a/app/business/organization/supersession.py b/app/business/organization/supersession.py index 14a7af87..bd87a186 100644 --- a/app/business/organization/supersession.py +++ b/app/business/organization/supersession.py @@ -1,305 +1,10 @@ -"""Scoped supersession relation and automatic semantic judgment.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from collections import deque -from graphlib import CycleError, TopologicalSorter -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.supersession") - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from libs.obsrv.main import get_logger -from app.schemas.graph_navigation_retrieval import GraphModel -from app.schemas.info_base.block import BlockID -from app.schemas.info_base.relation import RelationID, RelationModel -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - RelationWriteResult, - SupersessionLineage, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - recent_relation_endpoint_ids, - record_candidate, - relation_result, - require_distinct_blocks, - run_configured_agent, -) - - -LOGGER = get_logger().getChild(__name__) - -SUPERSEDES_RELATION = "supersedes" -EDITED_RELATION = "edited" -SUPERSESSION_BEHAVIOR = "core.organization.behavior.supersession.v1" -SUPERSESSION_CONFIG_KEY = "core.organization.supersession" -SUPERSESSION_CONFIG_SCHEMA = "core.organization.supersession.config.v1" - -DeploymentConfigManager.register_schema( - SUPERSESSION_CONFIG_SCHEMA, - BehaviorAgentConfig, - keys=(SUPERSESSION_CONFIG_KEY,), -) - - -class SupersessionBehaviorResolver( - Resolver[str, str], - rso_type=SUPERSESSION_BEHAVIOR, -): - organization_description = ( - "Relate a semantic successor that fully replaces one predecessor in scope." - ) - judgment_contract = ( - "Both endpoints are complete addressable information units.", - "They continue the same referent and evolvable subject.", - "The successor covers the predecessor's complete applicable scope.", - "Semantic order, not collection time, identifies successor and predecessor.", - "The successor has authority for this subject and scope.", - "Continuing to use the predecessor as current would be wrong.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: supersession" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def record_supersession( - cls, - successor_block_id: BlockID, - predecessor_block_id: BlockID, - ) -> RelationWriteResult: - async with graph_uow() as uow: - await require_distinct_blocks(successor_block_id, predecessor_block_id, uow) - if await cls._has_directed_path( - predecessor_block_id, - successor_block_id, - uow=uow, - ): - raise ValueError( - f"Cannot supersede: an existing supersedes path runs from predecessor " - f"{predecessor_block_id} to successor {successor_block_id}" - ) - relation, created = await fetchsert_relation( - successor_block_id, - predecessor_block_id, - SUPERSEDES_RELATION, - uow, - ) - return relation_result(relation, created) - - @classmethod - async def _has_directed_path( - cls, - start: BlockID, - target: BlockID, - *, - uow: GraphUnitOfWork, - ) -> bool: - frontier = {start} - visited = {start} - while frontier: - rows = tuple( - relation.to_ - for relation in await uow.relations.get_outgoing_many( - frontier, content=SUPERSEDES_RELATION - ) - ) - next_frontier = set(rows) - visited - if target in next_frontier: - return True - visited.update(next_frontier) - frontier = next_frontier - return False - - async def read_lineage( - self, - focal_block_id: BlockID, - *, - max_explored_blocks: int = 1000, - max_explored_relations: int = 10000, - ) -> SupersessionLineage: - """Read supersedes history; incomplete or cyclic graphs have no current frontier.""" - if max_explored_blocks < 1 or max_explored_relations < 1: - raise ValueError("exploration bounds must be positive") - return await self._read_lineage( - focal_block_id, - max_explored_blocks=max_explored_blocks, - max_explored_relations=max_explored_relations, - ) - - async def _read_lineage( - self, - focal_block_id: BlockID, - *, - max_explored_blocks: int, - max_explored_relations: int, - ) -> SupersessionLineage: - async with graph_uow() as uow: - if (await uow.blocks.get(focal_block_id)) is None: - raise ValueError("Focal Block does not exist") - visited = {focal_block_id} - frontier = deque((focal_block_id,)) - relations: dict[RelationID, RelationModel] = {} - scanned = 0 - truncated = False - while frontier and not truncated: - current = frontier.popleft() - for endpoint in ("from", "to"): - cursor: RelationID | None = None - while not truncated: - remaining = max_explored_relations - scanned - if remaining == 0: - truncated = True - break - requested = min(200, remaining + 1) - page = await uow.relations.get_endpoint_page( - (current,), - endpoint=typing.cast(typing.Literal["from", "to"], endpoint), - contents=(SUPERSEDES_RELATION,), - cursor=cursor, - limit=requested, - ) - if len(page) > remaining: - page = page[:remaining] - truncated = True - scanned += len(page) - for relation in page: - if relation.id is None: - continue - relations[relation.id] = relation - neighbor = relation.to_ if relation.from_ == current else relation.from_ - if neighbor in visited: - continue - if len(visited) >= max_explored_blocks: - truncated = True - break - visited.add(neighbor) - frontier.append(neighbor) - if truncated or len(page) < requested: - break - cursor = typing.cast(RelationID, page[-1].id) - - cycle_detected = self._cycle_detected(visited, tuple(relations.values())) - incoming = {relation.to_ for relation in relations.values()} - current_block_ids = ( - () - if truncated or cycle_detected - else tuple(sorted(block_id for block_id in visited if block_id not in incoming)) - ) - blocks = await uow.blocks.get_many(visited) - existing = {block.id for block in blocks} - closed_relations = tuple( - relation - for relation in relations.values() - if relation.from_ in existing and relation.to_ in existing - ) - return SupersessionLineage( - graph=GraphModel(blocks=blocks, relations=closed_relations), - current_block_ids=current_block_ids, - truncated=truncated, - cycle_detected=cycle_detected, - ) - - @staticmethod - def _cycle_detected( - blocks: typing.Collection[BlockID], - relations: typing.Collection[RelationModel], - ) -> bool: - # The standard library uses an explicit stack, so long lineages do not - # consume Python call-stack depth. - sorter: TopologicalSorter[BlockID] = TopologicalSorter() - for block_id in blocks: - sorter.add(block_id) - for relation in relations: - sorter.add(relation.to_, relation.from_) - try: - sorter.prepare() - except CycleError: - return True - return False - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available(SUPERSESSION_CONFIG_KEY, BehaviorAgentConfig) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - strong = merge_seed_categories( - max_seeds, - await recent_relation_endpoint_ids(max_seeds, contents=(EDITED_RELATION,)), - await recent_block_ids(max_seeds), - ) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, strong, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "strong_count": len(strong), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Determine only well-supported scoped supersession relations.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent( - SUPERSESSION_CONFIG_KEY, - BehaviorAgentConfig, - message, - ) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.supersession import * diff --git a/app/business/organization/synthesis.py b/app/business/organization/synthesis.py index 755a1282..3cc75932 100644 --- a/app/business/organization/synthesis.py +++ b/app/business/organization/synthesis.py @@ -1,236 +1,10 @@ -"""Provenance-preserving n-ary synthesis behavior.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.synthesis") - -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.resolver import Resolver -from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow -from libs.obsrv.main import get_logger -from app.schemas.info_base.block import BlockForm, BlockID, BlockModel -from app.schemas.organization_behavior import ( - BehaviorAgentConfig, - CandidateWriteResult, - SynthesisProposal, - SynthesisWriteResult, -) - -from ._shared import ( - build_seed_message, - candidate_seed_ids, - configured_agent_available, - continue_after_seed_failure, - fetchsert_relation, - merge_seed_categories, - random_block_ids, - recent_block_ids, - recent_relation_endpoint_ids, - record_candidate, - relation_result, - run_configured_agent, -) -from .supersession import EDITED_RELATION - - -LOGGER = get_logger().getChild(__name__) - -SYNTHESIS_RELATION = "synthesis" -SYNTHESIS_BEHAVIOR = "core.organization.behavior.synthesis.v1" -SYNTHESIS_CONFIG_KEY = "core.organization.synthesis" -SYNTHESIS_CONFIG_SCHEMA = "core.organization.synthesis.config.v1" - -DeploymentConfigManager.register_schema( - SYNTHESIS_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(SYNTHESIS_CONFIG_KEY,) -) - - -class SynthesisBehaviorResolver( - Resolver[str, str], - rso_type=SYNTHESIS_BEHAVIOR, -): - organization_description = ( - "Create reusable multi-source information while preserving exact source basis." - ) - judgment_contract = ( - "The result is independently useful information, not a list of related sources.", - "Every source materially contributes content, attribution, or uncertainty.", - "Source disagreements, uncertainty, and speaker attribution remain visible.", - "Scopes are compatible or their differences are explicitly preserved.", - "Duplicate-connected copies do not multiply independent corroboration.", - "No existing synthesis already provides the same reusable distinction.", - "Observed past use and recurrence make future reuse plausible.", - ) - - async def get_text( - self, - *, - context: typing.Literal["default", "lexical"] = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context, refresh, materialize_missing - return self.organization_description - - async def get_label(self, *, refresh: bool = False) -> str: - del refresh - return "organization behavior: synthesis" - - @classmethod - async def record_candidate( - cls, - block_id: BlockID, - ) -> CandidateWriteResult: - return await record_candidate(cls, block_id) - - @classmethod - async def create_synthesis( - cls, - text: str, - source_block_ids: typing.Collection[BlockID], - previous_synthesis_block_id: BlockID | None = None, - ) -> SynthesisWriteResult: - proposal = SynthesisProposal( - text=text, - source_block_ids=tuple(source_block_ids), - previous_synthesis_block_id=previous_synthesis_block_id, - ) - async with graph_uow() as uow: - found = {block.id for block in (await uow.blocks.get_many(proposal.source_block_ids))} - missing = tuple(source for source in proposal.source_block_ids if source not in found) - if missing: - raise ValueError(f"Synthesis source Blocks do not exist: {missing!r}") - if proposal.previous_synthesis_block_id is not None: - previous = await uow.blocks.get(proposal.previous_synthesis_block_id) - if previous is None: - raise ValueError("Previous synthesis Block does not exist") - previous_basis = await cls._source_basis(previous, uow) - if len(set(previous_basis)) < 2: - raise ValueError("Previous synthesis has no valid multi-source basis") - - source_set = set(proposal.source_block_ids) - text_candidates = await uow.blocks.matching_content("core.text.v1", proposal.text) - synthesis = None - for candidate in text_candidates: - if await cls._source_basis(candidate, uow) == source_set: - synthesis = candidate - break - synthesis_created = synthesis is None - if synthesis is None: - synthesis = await uow.blocks.create( - BlockForm(resolver="core.text.v1", content=proposal.text) - ) - if synthesis.id is None: # pragma: no cover - persisted Block invariant - raise RuntimeError("Persisted synthesis Block has no ID") - - basis = [] - for source_block_id in sorted(source_set): - relation, created = await fetchsert_relation( - source_block_id, - synthesis.id, - SYNTHESIS_RELATION, - uow, - ) - basis.append(relation_result(relation, created)) - - edited = None - if ( - proposal.previous_synthesis_block_id is not None - and proposal.previous_synthesis_block_id != synthesis.id - ): - relation, created = await fetchsert_relation( - proposal.previous_synthesis_block_id, - synthesis.id, - EDITED_RELATION, - uow, - ) - edited = relation_result(relation, created) - return SynthesisWriteResult( - synthesis_block_id=synthesis.id, - synthesis_created=synthesis_created, - basis=tuple(basis), - edited=edited, - ) - - @staticmethod - async def _source_basis( - synthesis: BlockModel, - uow: GraphUnitOfWork, - ) -> set[BlockID]: - if synthesis.id is None: - return set() - return { - relation.from_ - for relation in await uow.relations.get( - synthesis.id, include_out=False, content=SYNTHESIS_RELATION - ) - } - - @classmethod - async def _change_signal_ids(cls, limit: int) -> tuple[BlockID, ...]: - endpoints = await recent_relation_endpoint_ids(limit) - if not endpoints: - return () - async with graph_uow() as uow: - affected = tuple( - relation.to_ - for relation in await uow.relations.get_outgoing_many( - endpoints, content=SYNTHESIS_RELATION - ) - ) - return tuple(dict.fromkeys((*endpoints, *affected))) - - @classmethod - async def can_run_automatic(cls) -> bool: - return await configured_agent_available(SYNTHESIS_CONFIG_KEY, BehaviorAgentConfig) - - @classmethod - async def run_automatic(cls, max_seeds: int) -> None: - candidates = await candidate_seed_ids(cls, max_seeds) - strong = merge_seed_categories( - max_seeds, - await cls._change_signal_ids(max_seeds), - await recent_block_ids(max_seeds), - ) - random = await random_block_ids(max_seeds) - seeds = merge_seed_categories(max_seeds, candidates, strong, random) - LOGGER.info( - "organization.seeds.selected", - extra={ - "behavior": cls.__rsotype__, - "max_seeds": max_seeds, - "candidate_count": len(candidates), - "strong_count": len(strong), - "random_count": len(random), - "seed_block_ids": seeds, - }, - ) - for seed in seeds: - with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): - message = await build_seed_message( - "Create only provenance-preserving, reusable multi-source synthesis.", - cls.judgment_contract, - seed, - ) - if message is None: - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "unresolved", - "reason": "text_unavailable", - }, - ) - continue - await run_configured_agent(SYNTHESIS_CONFIG_KEY, BehaviorAgentConfig, message) - LOGGER.info( - "organization.seed.considered", - extra={ - "behavior": cls.__rsotype__, - "seed_block_ids": (seed,), - "outcome": "considered", - "reason": "agent_completed", - }, - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.synthesis import * diff --git a/app/business/organization/tools.py b/app/business/organization/tools.py index d2a1f245..a65c0b06 100644 --- a/app/business/organization/tools.py +++ b/app/business/organization/tools.py @@ -1,342 +1,10 @@ -"""Owner-coherent Agent read tools and exact Organization mutation tools.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing +sys.modules[__name__] = import_module("inkcre_core.business.organization.tools") -import pydantic - -from app.business.agent import AgentManager, ToolExecutionError -from app.business.info_base import InfoBaseManager -from app.business.info_base.commands import submit_graph as persist_submitted_graph -from app.business.info_base.resolver import ( - ResolverDraftCapability, - ResolverManager, -) -from app.schemas.ai import JSONValue -from app.schemas.organization import ( - DraftGraphInput, - GetDraftGraphSchemaInput, - SubmitGraphInput, -) -from app.schemas.organization_behavior import ( - DuplicateAssertionProposal, - EvidenceStanceProposal, - ExistingReferentAnchorProposal, - RecordOrganizationCandidateInput, - RefinementProposal, - SupersessionProposal, - SynthesisProposal, -) -from ._shared import behavior_resolver_classes, get_behavior_resolver -from .contracts import OrganizationError -from .duplicate_assertion import ( - DUPLICATES_ASSERTION_RELATION, - DuplicateAssertionBehaviorResolver, -) -from .evidence_stance import EvidenceStanceBehaviorResolver -from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver -from .refinement import REFINES_RELATION, RefinementBehaviorResolver -from .supersession import SUPERSEDES_RELATION, SupersessionBehaviorResolver -from .synthesis import SynthesisBehaviorResolver - - -GET_DRAFT_GRAPH_SCHEMA_TOOL = "get_draft_graph_schema" -DRAFT_GRAPH_TOOL = "draft_graph" -SUBMIT_GRAPH_TOOL = "submit_graph" -RECORD_SUPERSESSION_TOOL = "record_supersession" -RECORD_REFINEMENT_TOOL = "record_refinement" -RECORD_EVIDENCE_STANCE_TOOL = "record_evidence_stance" -CREATE_SYNTHESIS_TOOL = "create_synthesis" -ANCHOR_EXISTING_REFERENT_TOOL = "anchor_existing_referent" -RECORD_DUPLICATE_ASSERTION_TOOL = "record_duplicate_assertion" -RECORD_ORGANIZATION_CANDIDATE_TOOL = "record_organization_candidate" - - -def _draft_capability_snapshot() -> dict[str, ResolverDraftCapability]: - return { - capability.resolver: capability - for capability in ResolverManager.get_draft_capabilities() - } - - -def _schema_discovery_input_model() -> type[pydantic.BaseModel]: - snapshot = _draft_capability_snapshot() - if not snapshot: # pragma: no cover - core.text.v1 is always registered - raise RuntimeError("No Resolver graph-drafting capability is registered") - - def add_exact_ids(schema: dict[str, typing.Any]) -> None: - schema["properties"]["resolver_types"]["items"] = { - "type": "string", - "enum": list(snapshot), - } - - class BoundGetDraftGraphSchemaInput(GetDraftGraphSchemaInput): - model_config = pydantic.ConfigDict( - extra="forbid", - frozen=True, - json_schema_extra=add_exact_ids, - ) - - @pydantic.field_validator("resolver_types") - @classmethod - def exact_resolvers(cls, resolvers: tuple[str, ...]) -> tuple[str, ...]: - unknown = tuple(resolver for resolver in resolvers if resolver not in snapshot) - if unknown: - raise ValueError(f"Unavailable draft Resolver IDs: {unknown!r}") - return resolvers - - return BoundGetDraftGraphSchemaInput - - -def _draft_graph_input_model() -> type[pydantic.BaseModel]: - snapshot = _draft_capability_snapshot() - if not snapshot: # pragma: no cover - core.text.v1 is always registered - raise RuntimeError("No Resolver graph-drafting capability is registered") - - def add_exact_ids(schema: dict[str, typing.Any]) -> None: - schema["properties"]["resolver_type"] = { - "type": "string", - "enum": list(snapshot), - } - - class BoundDraftGraphInput(DraftGraphInput): - model_config = pydantic.ConfigDict( - extra="forbid", - json_schema_extra=add_exact_ids, - ) - - @pydantic.field_validator("resolver_type") - @classmethod - def exact_resolver(cls, resolver: str) -> str: - if resolver not in snapshot: - raise ValueError(f"Unavailable draft Resolver ID: {resolver!r}") - return resolver - - @pydantic.model_validator(mode="after") - def validate_resolver_input(self) -> typing.Self: - capability = snapshot[self.resolver_type] - # Validate at the caller's actual path; an inner resolver_type error must - # not tell the Agent to remove its valid outer selector. - payload_model = pydantic.create_model( - "ResolverDraftPayload", input=(capability.input_model, ...) - ) - payload = payload_model.model_validate({"input": self.input}) - object.__setattr__(self, "_resolver_input", getattr(payload, "input")) - return self - - return BoundDraftGraphInput - - -@AgentManager.tool( - GET_DRAFT_GRAPH_SCHEMA_TOOL, - description="Describe graph-drafting inputs for selected Resolver types.", - input_model_factory=_schema_discovery_input_model, -) -async def get_draft_graph_schema(input: GetDraftGraphSchemaInput) -> JSONValue: - snapshot = _draft_capability_snapshot() - return { - "resolvers": [ - { - "resolver_type": resolver, - "description": snapshot[resolver].description, - "input_schema": typing.cast( - dict[str, JSONValue], - snapshot[resolver].input_model.model_json_schema(), - ), - } - for resolver in input.resolver_types - ] - } - - -@AgentManager.tool( - DRAFT_GRAPH_TOOL, - description="Draft one rooted GraphForm through an exact Resolver without persistence.", - input_model_factory=_draft_graph_input_model, -) -async def draft_graph(input: DraftGraphInput) -> JSONValue: - capability = ResolverManager.get_draft_capability(input.resolver_type) - resolver_input = typing.cast(pydantic.BaseModel, getattr(input, "_resolver_input")) - stars = capability.resolver_cls.create_graph(resolver_input) - graph = InfoBaseManager.normalize_graph(stars, input.local_block_id_start) - return typing.cast(JSONValue, graph.model_dump(mode="json")) - - -@AgentManager.tool( - SUBMIT_GRAPH_TOOL, - description="Persist one complete GraphForm and return local-to-persisted Block IDs.", -) -async def submit_graph(input: SubmitGraphInput) -> JSONValue: - result = await persist_submitted_graph(input.graph) - return typing.cast(JSONValue, result.model_dump(mode="json")) - - -def _candidate_input_model() -> type[pydantic.BaseModel]: - snapshot = {behavior.__rsotype__: behavior for behavior in behavior_resolver_classes()} - if not snapshot: - raise RuntimeError("No Organization Behavior Resolver is registered") - - def add_exact_behaviors(schema: dict[str, typing.Any]) -> None: - schema["properties"]["behavior"] = { - "oneOf": [ - { - "const": behavior_id, - "description": behavior.organization_description, - } - for behavior_id, behavior in snapshot.items() - ] - } - - class BoundRecordOrganizationCandidateInput(RecordOrganizationCandidateInput): - model_config = pydantic.ConfigDict( - extra="forbid", - frozen=True, - json_schema_extra=add_exact_behaviors, - ) - - @pydantic.field_validator("behavior") - @classmethod - def exact_behavior(cls, behavior: str) -> str: - if behavior not in snapshot: - raise ValueError( - f"Unavailable behavior {behavior!r}; available: {', '.join(snapshot)}" - ) - return behavior - - return BoundRecordOrganizationCandidateInput - - -async def _exact_result(operation: typing.Awaitable[pydantic.BaseModel]) -> JSONValue: - try: - result = await operation - except (ValueError, OrganizationError) as error: - raise ToolExecutionError( - {"error": type(error).__name__, "message": str(error)} - ) from error - return typing.cast(JSONValue, result.model_dump(mode="json")) - - -@AgentManager.tool( - RECORD_SUPERSESSION_TOOL, - description=( - f"Record {SUPERSEDES_RELATION!r}: the successor is authorized to replace " - "the predecessor across its entire scope on the same subject." - ), -) -async def record_supersession(input: SupersessionProposal) -> JSONValue: - return await _exact_result( - SupersessionBehaviorResolver.record_supersession( - input.successor_block_id, - input.predecessor_block_id, - ) - ) - - -@AgentManager.tool( - RECORD_REFINEMENT_TOOL, - description=( - f"Record {REFINES_RELATION!r}: new compatible detail at equal or narrower scope " - "on the same subject, not mere extraction or rewording. The predecessor " - "remains independently usable as a coarser description." - ), -) -async def record_refinement(input: RefinementProposal) -> JSONValue: - return await _exact_result( - RefinementBehaviorResolver.record_refinement( - input.refinement_block_id, - input.predecessor_block_id, - ) - ) - - -@AgentManager.tool( - RECORD_EVIDENCE_STANCE_TOOL, - description=( - "Record attributable evidence supporting or challenging a whole " - "assertion in comparable scope, without declaring it true or false. " - "Merely establishing that derived content faithfully restates its source is " - "not evidence stance, regardless of source authority. Shared provenance is " - "allowed when observation or reasoning " - "contributes reasons beyond restatement." - ), -) -async def record_evidence_stance(input: EvidenceStanceProposal) -> JSONValue: - return await _exact_result( - EvidenceStanceBehaviorResolver.record_evidence_stance( - input.evidence_block_id, - input.assertion_block_id, - input.stance, - ) - ) - - -@AgentManager.tool( - CREATE_SYNTHESIS_TOOL, - description=( - "Create reusable multi-source information preserving provenance, " - "disagreement, uncertainty and speaker attribution; copies do not " - "multiply corroboration." - ), -) -async def create_synthesis(input: SynthesisProposal) -> JSONValue: - return await _exact_result( - SynthesisBehaviorResolver.create_synthesis( - input.text, - input.source_block_ids, - input.previous_synthesis_block_id, - ) - ) - - -@AgentManager.tool( - ANCHOR_EXISTING_REFERENT_TOOL, - description=( - "Link a source's referring fragment to an existing identity-bearing " - "referent, without creating a new referent." - ), -) -async def anchor_existing_referent( - input: ExistingReferentAnchorProposal, -) -> JSONValue: - return await _exact_result( - ExistingReferentAnchoringBehaviorResolver.anchor_existing_referent( - input.source_block_id, - input.selected_text, - input.referent_block_id, - ) - ) - - -@AgentManager.tool( - RECORD_DUPLICATE_ASSERTION_TOOL, - description=( - f"Record {DUPLICATES_ASSERTION_RELATION!r}: whole assertions from the same " - "provenance occurrence with no " - "independent evidence, reasoning, decision or material gain; matching " - "words alone are insufficient." - ), -) -async def record_duplicate_assertion(input: DuplicateAssertionProposal) -> JSONValue: - return await _exact_result( - DuplicateAssertionBehaviorResolver.record_duplicate_assertion( - input.left_block_id, - input.right_block_id, - ) - ) - - -@AgentManager.tool( - RECORD_ORGANIZATION_CANDIDATE_TOOL, - description="Mark an organization candidate without executing the behavior.", - input_model_factory=_candidate_input_model, -) -async def record_organization_candidate( - input: RecordOrganizationCandidateInput, -) -> JSONValue: - behavior = get_behavior_resolver(input.behavior) - if behavior is None: - raise ToolExecutionError({"error": "behavior_unavailable"}) - return await _exact_result( - typing.cast(typing.Any, behavior).record_candidate(input.block_id) - ) +if TYPE_CHECKING: + from inkcre_core.business.organization.tools import * diff --git a/app/business/organization_job.py b/app/business/organization_job.py index 270eea94..12e12c54 100644 --- a/app/business/organization_job.py +++ b/app/business/organization_job.py @@ -1,33 +1,10 @@ -"""Exact automatic Organization commands hosted by Jobs.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.job import JobHandler -from app.business.organization_media import ( - MEDIA_INTERPRETATION_JOB_TYPE, - can_handle_media_interpretation, - interpret_missing_media, -) -from app.schemas.job import JobModel -from app.schemas.organization import MediaInterpretationJobParameters +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.organization_job") -class MediaInterpretationJobHandler( - JobHandler[MediaInterpretationJobParameters], - job_type=MEDIA_INTERPRETATION_JOB_TYPE, - description="Interpret missing image, audio, and video Blocks through configured Agents.", - parameters_model=MediaInterpretationJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: MediaInterpretationJobParameters) -> bool: - del parameters - return await can_handle_media_interpretation() - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: MediaInterpretationJobParameters, - ) -> None: - del parameters - report = await interpret_missing_media() - job.state = report.model_dump(mode="json") +if TYPE_CHECKING: + from inkcre_core.business.organization_job import * diff --git a/app/business/organization_media.py b/app/business/organization_media.py index a32fabc3..a3eb80d7 100644 --- a/app/business/organization_media.py +++ b/app/business/organization_media.py @@ -1,263 +1,10 @@ -"""System-driven media interpretation approach owned by Organization.""" +"""Legacy import; use inkcre_core for new consumers.""" -import json -import logging -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.business.organization_media") -from app.business.agent import AgentManager, TurnTermination -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.business.info_base.commands import get_related_block -from app.business.info_base.resolver import ResolverManager -from app.business.info_base.resolver.audio import AudioSolvedContent -from app.business.info_base.resolver.image import ImageSolvedContent -from app.business.info_base.resolver.video import VideoSolvedContent -from app.persistence.info_base.uow import graph_uow -from app.schemas.ai import ( - AudioContentPart, - ImageContentPart, - TextContentPart, - UserContentPart, - UserMessage, - VideoContentPart, -) -from app.schemas.info_base.block import BlockModel -from app.schemas.organization import ( - MediaInterpretationConfig, - MediaInterpretationDiagnostic, - MediaInterpretationReport, -) - - -logger = logging.getLogger(__name__) - -MEDIA_INTERPRETATION_CONFIG_KEY = "core.organization.media_interpretation" -MEDIA_INTERPRETATION_CONFIG_SCHEMA = "core.organization.media_interpretation.config.v1" -MEDIA_INTERPRETATION_JOB_TYPE = "core.organization.media_interpretation.v1" -_CANDIDATE_LIMIT = 20 -_DIAGNOSTIC_LIMIT = 20 -_RESOLVER_MODALITIES: dict[str, typing.Literal["image", "audio", "video"]] = { - "core.image.v1": "image", - "core.audio.v1": "audio", - "core.video.v1": "video", -} - - -DeploymentConfigManager.register_schema( - MEDIA_INTERPRETATION_CONFIG_SCHEMA, - MediaInterpretationConfig, - keys=(MEDIA_INTERPRETATION_CONFIG_KEY,), -) - - -async def _config() -> MediaInterpretationConfig | None: - value = await DeploymentConfigService.get(MEDIA_INTERPRETATION_CONFIG_KEY) - if value is None: - return None - if not isinstance(value, MediaInterpretationConfig): - raise TypeError("Media interpretation config registry returned the wrong model") - return value - - -def _agent( - config: MediaInterpretationConfig, - modality: typing.Literal["image", "audio", "video"], -) -> int: - return { - "image": config.image_agent, - "audio": config.audio_agent, - "video": config.video_agent, - }[modality] - - -async def can_handle_media_interpretation() -> bool: - config = await _config() - if config is None: - return False - for modality in typing.cast( - tuple[typing.Literal["image", "audio", "video"], ...], - ("image", "audio", "video"), - ): - if await AgentManager.can_execute(_agent(config, modality), modality): - return True - return False - - -async def _candidates() -> tuple[BlockModel, ...]: - async with graph_uow() as uow: - return await uow.blocks.without_outgoing_relation( - tuple(_RESOLVER_MODALITIES), "interpretation", limit=_CANDIDATE_LIMIT - ) - - -async def _relation_context(block_id: int) -> list[dict[str, typing.Any]]: - async with graph_uow() as uow: - relations = (await uow.relations.get(block_id))[:20] - neighbor_ids = { - relation.to_ if relation.from_ == block_id else relation.from_ - for relation in relations - } - neighbors = {block.id: block for block in await uow.blocks.get_many(neighbor_ids)} - context: list[dict[str, typing.Any]] = [] - for relation in relations: - other_id = relation.to_ if relation.from_ == block_id else relation.from_ - other = neighbors.get(other_id) - label = None - if other is not None: - try: - label = await ResolverManager.get(other).get_label() - except Exception: - logger.debug("Could not project media neighbor label", exc_info=True) - context.append( - { - "direction": "outgoing" if relation.from_ == block_id else "incoming", - "property": relation.content, - "other_block": {"id": other_id, "label": label}, - } - ) - return context - - -async def _message(block: BlockModel) -> UserMessage | None: - resolver = ResolverManager.get(block) - solved = await resolver.get_solved_content(materialize_missing=False) - transfer_url = await resolver.get_transfer_url() - media: UserContentPart - facts: dict[str, typing.Any] - if isinstance(solved, ImageSolvedContent): - media_type = solved.detected_media_type or ( - f"image/{solved.format}" if solved.format is not None else None - ) - if media_type is None: - return None - media = ImageContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=transfer_url, - ) - facts = {"format": solved.format, "width": solved.width, "height": solved.height} - elif isinstance(solved, AudioSolvedContent): - media_type = solved.detected_media_type or ( - f"audio/{solved.container}" if solved.container is not None else None - ) - if media_type is None: - return None - media = AudioContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=transfer_url, - ) - facts = { - "container": solved.container, - "codec": solved.codec, - "duration_ms": solved.duration_ms, - } - elif isinstance(solved, VideoSolvedContent): - media_type = solved.detected_media_type or ( - f"video/{solved.container}" if solved.container is not None else None - ) - if media_type is None: - return None - media = VideoContentPart( - data=solved.content, - mime_type=media_type, - transfer_url=transfer_url, - ) - facts = { - "container": solved.container, - "codec": solved.video_codec, - "duration_ms": solved.duration_ms, - "width": solved.width, - "height": solved.height, - } - else: - return None - - context = { - "request": "interpret media and submit only a useful additive graph", - "focal_block": { - "id": block.id, - "resolver": block.resolver, - "facts": facts, - }, - "direct_relations": await _relation_context(typing.cast(int, block.id)), - } - return UserMessage( - content=( - TextContentPart( - text=json.dumps( - context, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - ), - media, - ) - ) - - -async def interpret_missing_media() -> MediaInterpretationReport: - config = await _config() - if config is None: - return MediaInterpretationReport() - selected = interpreted = unavailable = failed = no_output = 0 - diagnostics: list[MediaInterpretationDiagnostic] = [] - - def diagnostic( - block: int, - modality: typing.Literal["image", "audio", "video"], - outcome: typing.Literal["unavailable", "failed", "no_output"], - reason: str, - ) -> None: - if len(diagnostics) < _DIAGNOSTIC_LIMIT: - diagnostics.append( - MediaInterpretationDiagnostic( - block=block, - modality=modality, - outcome=outcome, - reason=reason, - ) - ) - - for block in await _candidates(): - selected += 1 - block_id = typing.cast(int, block.id) - modality = _RESOLVER_MODALITIES[block.resolver] - agent = _agent(config, modality) - if not await AgentManager.can_execute(agent, modality): - unavailable += 1 - diagnostic(block_id, modality, "unavailable", "agent_not_locally_executable") - continue - try: - message = await _message(block) - if message is None: - unavailable += 1 - diagnostic(block_id, modality, "unavailable", "media_input_unavailable") - continue - thread = await AgentManager.run(agent, message) - turn = thread.current_turn - if turn is None: - raise RuntimeError("Media interpretation Agent did not start a Turn") - outcome = await turn - if outcome == TurnTermination.MAX_MODEL_CALLS: - raise RuntimeError("Media interpretation exceeded its model-call budget") - except Exception as error: - failed += 1 - logger.exception("Media interpretation failed", extra={"block": block_id}) - diagnostic(block_id, modality, "failed", type(error).__name__) - continue - if await get_related_block(block_id, content="interpretation") is None: - no_output += 1 - diagnostic(block_id, modality, "no_output", "missing_interpretation_relation") - else: - interpreted += 1 - - return MediaInterpretationReport( - selected=selected, - interpreted=interpreted, - unavailable=unavailable, - failed=failed, - no_output=no_output, - diagnostics=tuple(diagnostics), - ) +if TYPE_CHECKING: + from inkcre_core.business.organization_media import * diff --git a/app/business/peer/__init__.py b/app/business/peer/__init__.py index 1be40f31..bc9d585e 100644 --- a/app/business/peer/__init__.py +++ b/app/business/peer/__init__.py @@ -1,26 +1,3 @@ -"""Peer discovery and request-response capability delegation.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .contracts import ( - CapabilityDelegationUnavailable, - DuplicatePeerRegistrationError, - PeerError, - PeerOutcomeUnknown, - PeerProtocolConfigurationError, - PeerProtocolError, - PeerRequestNotExecuted, -) -from .http import PeerHTTPInbound, PeerHTTPOutbound -from .main import PeerManager - -__all__ = [ - "CapabilityDelegationUnavailable", - "DuplicatePeerRegistrationError", - "PeerError", - "PeerHTTPInbound", - "PeerHTTPOutbound", - "PeerManager", - "PeerOutcomeUnknown", - "PeerProtocolConfigurationError", - "PeerProtocolError", - "PeerRequestNotExecuted", -] +from inkcre_core.business.peer import * diff --git a/app/business/peer/contracts.py b/app/business/peer/contracts.py index 2ccf0490..da841cba 100644 --- a/app/business/peer/contracts.py +++ b/app/business/peer/contracts.py @@ -1,68 +1,10 @@ -"""Peer discovery, routing, and outbound failure contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Callable -from dataclasses import dataclass -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.schemas.ai import JSONValue -from app.schemas.peer import ( - CapabilityID, - CorePeerConfig, - PeerCapabilityAdvertisement, - PeerModel, - PeerProtocolID, -) +sys.modules[__name__] = import_module("inkcre_core.business.peer.contracts") - -class PeerError(RuntimeError): - """Base failure exposed by the Peer domain.""" - - -class DuplicatePeerRegistrationError(PeerError): - pass - - -class CapabilityDelegationUnavailable(PeerError): - pass - - -class PeerProtocolError(PeerError): - pass - - -class PeerProtocolConfigurationError(PeerProtocolError): - """One advertised inbound cannot construct its caller-local outbound.""" - - -class PeerRequestNotExecuted(PeerProtocolError): - """The selected outbound proved capability execution did not begin.""" - - -class PeerOutcomeUnknown(PeerProtocolError): - """Dispatch may have occurred but no execution outcome is known.""" - - -class PeerInbound(typing.Protocol): - @property - def capability(self) -> CapabilityID: ... - - def advertise( - self, - config: CorePeerConfig, - ) -> PeerCapabilityAdvertisement | None: ... - - -class PeerOutbound(typing.Protocol): - async def execute(self, payload: JSONValue) -> JSONValue: ... - - -PeerOutboundFactory: typing.TypeAlias = Callable[ - [PeerModel, dict[str, JSONValue]], - PeerOutbound, -] - - -@dataclass(frozen=True) -class PeerOutboundRegistration: - protocol: PeerProtocolID - factory: PeerOutboundFactory +if TYPE_CHECKING: + from inkcre_core.business.peer.contracts import * diff --git a/app/business/peer/http.py b/app/business/peer/http.py index 6a3a7c0d..af1bbd27 100644 --- a/app/business/peer/http.py +++ b/app/business/peer/http.py @@ -1,202 +1,10 @@ -"""core.peer.protocol.http.v1 inbound advertisement and one-shot outbound.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from collections import defaultdict -from dataclasses import dataclass -import json -import typing +sys.modules[__name__] = import_module("inkcre_core.business.peer.http") -import httpx -import pydantic - -from app.middleware import create_peer_jwt -from app.schemas.ai import JSONValue -from app.schemas.peer import ( - PEER_EXECUTION_HEADER, - PEER_HTTP_PROTOCOL, - PEER_NOT_EXECUTED, - CapabilityID, - CorePeerConfig, - PeerCapabilityAdvertisement, - PeerHTTPInboundParameters, - PeerInboundInterface, - PeerModel, - PeerProtocolRequest, - PeerProtocolResponse, -) -from app.settings import settings -from app.observability import inject_context -from libs.obsrv.telemetry import operation -from opentelemetry.trace import SpanKind - -from .contracts import ( - PeerOutcomeUnknown, - PeerProtocolConfigurationError, - PeerProtocolError, - PeerRequestNotExecuted, -) - - -_RESERVED_REQUEST_HEADERS = frozenset( - { - "authorization", - "connection", - "content-length", - "host", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - } -) - - -@dataclass(frozen=True) -class PeerHTTPInbound: - """One fixed Business route advertised through Peer HTTP v1.""" - - capability: CapabilityID - method: str - path: str - - def __post_init__(self) -> None: - if not self.path.startswith("/") or "?" in self.path or "#" in self.path: - raise ValueError("Peer HTTP inbound path must be one absolute path") - - def advertise( - self, - config: CorePeerConfig, - ) -> PeerCapabilityAdvertisement | None: - if config.http_public_base_url is None: - return None - parameters = PeerHTTPInboundParameters( - method=self.method, - url=f"{config.http_public_base_url}{self.path}", - ) - return PeerCapabilityAdvertisement( - id=self.capability, - inbound=PeerInboundInterface( - protocol=PEER_HTTP_PROTOCOL, - parameters=typing.cast( - dict[str, JSONValue], - parameters.model_dump(mode="json"), - ), - ), - ) - - -class PeerHTTPOutbound: - """One-shot authenticated JSON HTTP execution for one advertised inbound.""" - - def __init__( - self, - peer: PeerModel, - parameters: dict[str, JSONValue], - ) -> None: - self.peer = peer - try: - self.parameters = PeerHTTPInboundParameters.model_validate(parameters) - except pydantic.ValidationError as error: - raise PeerProtocolConfigurationError( - f"Peer {peer.id} published invalid HTTP inbound parameters" - ) from error - - async def execute(self, payload: JSONValue) -> JSONValue: - try: - request = PeerProtocolRequest.model_validate(payload) - except pydantic.ValidationError as error: - raise PeerProtocolError("Invalid Peer HTTP request envelope") from error - - reserved = _RESERVED_REQUEST_HEADERS.intersection(request.headers) - if reserved: - rendered = ", ".join(sorted(reserved)) - raise PeerProtocolError(f"Peer HTTP payload attempted reserved headers: {rendered}") - - query = [(name, value) for name, values in request.query.items() for value in values] - headers = [ - (name, value) for name, values in request.headers.items() for value in values - ] - headers.append( - ( - "authorization", - f"Bearer {create_peer_jwt(settings.jwt_secret)}", - ) - ) - kwargs: dict[str, typing.Any] = { - "method": self.parameters.method, - "url": self.parameters.url, - "params": query, - "headers": headers, - } - if "body" in request.model_fields_set: - kwargs["json"] = request.body - - with operation( - "peer.http", - attributes={"inkcre.peer.target_id": str(self.peer.id)}, - kind=SpanKind.CLIENT, - ) as observation: - span = observation.span - propagated = inject_context() - if propagated: - # The transport owns Trace Context; business payload headers cannot duplicate it. - headers[:] = [ - (key, value) - for key, value in headers - if key.lower() not in {"traceparent", "tracestate"} - ] - headers.extend(propagated.items()) - try: - async with httpx.AsyncClient( - timeout=settings.peer_http_timeout_seconds, - ) as client: - response = await client.request(**kwargs) - except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as error: - span.set_attribute("inkcre.peer.outcome", "not_executed") - raise PeerRequestNotExecuted( - f"Could not dispatch to Peer {self.peer.id}" - ) from error - except httpx.RequestError as error: - span.set_attribute("inkcre.peer.outcome", "unknown") - raise PeerOutcomeUnknown( - f"Peer {self.peer.id} dispatch outcome is unknown" - ) from error - - if ( - response.headers.get(PEER_EXECUTION_HEADER, "").strip().lower() == PEER_NOT_EXECUTED - ): - span.set_attribute("inkcre.peer.outcome", "not_executed") - raise PeerRequestNotExecuted( - f"Peer {self.peer.id} reported that execution did not begin" - ) - - span.set_attribute("inkcre.peer.outcome", "responded") - span.set_attribute("http.response.status_code", response.status_code) - if response.status_code >= 500: - observation.outcome = "error" - - grouped_headers: defaultdict[str, list[str]] = defaultdict(list) - for name, value in response.headers.multi_items(): - if name.lower() != PEER_EXECUTION_HEADER.lower(): - grouped_headers[name.lower()].append(value) - - response_fields: dict[str, typing.Any] = { - "status": response.status_code, - "headers": grouped_headers, - } - if response.content: - try: - response_fields["body"] = response.json() - except json.JSONDecodeError as error: - raise PeerProtocolError( - f"Peer {self.peer.id} returned a non-JSON HTTP body" - ) from error - normalized = PeerProtocolResponse.model_validate(response_fields) - return typing.cast( - JSONValue, - normalized.model_dump(mode="json", exclude_unset=True), - ) +if TYPE_CHECKING: + from inkcre_core.business.peer.http import * diff --git a/app/business/peer/main.py b/app/business/peer/main.py index 7de8f379..73ceec71 100644 --- a/app/business/peer/main.py +++ b/app/business/peer/main.py @@ -1,253 +1,10 @@ -"""Peer identity, local inbound publication, discovery, and delegation.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from dataclasses import dataclass -import datetime -import logging -import random -import typing +sys.modules[__name__] = import_module("inkcre_core.business.peer.main") -import pydantic - -from app.configuration import ConfigContract -from app.schemas.ai import JSONValue -from app.schemas.peer import ( - PEER_HTTP_PROTOCOL, - CapabilityID, - CorePeerConfig, - PeerCapabilityAdvertisement, - PeerModel, - PeerRef, - normalize_capability_snapshot, -) -from app.settings import settings -from app.version import APPLICATION_VERSION - -from .contracts import ( - CapabilityDelegationUnavailable, - DuplicatePeerRegistrationError, - PeerInbound, - PeerOutcomeUnknown, - PeerOutboundFactory, - PeerProtocolConfigurationError, - PeerRequestNotExecuted, -) -from .http import PeerHTTPOutbound -from app.persistence.peer.uow import peer_uow - - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class _Candidate: - peer: PeerModel - advertisement: PeerCapabilityAdvertisement - - -class PeerManager: - """Own Peer facts, local inbound registry, discovery, and one-shot routing.""" - - _INBOUNDS: dict[CapabilityID, PeerInbound] = {} - _OUTBOUNDS: dict[str, PeerOutboundFactory] = {} - _random: random.Random = random.SystemRandom() - _config_contract = ConfigContract(CorePeerConfig) - - @classmethod - async def register_self(cls) -> PeerModel: - """Upsert runtime-owned identity/schema without changing owner configuration.""" - async with peer_uow() as peers: - peer = await peers.register( - settings.peer_id, - settings.peer_name, - APPLICATION_VERSION, - cls._config_contract.json_schema(), - ) - logger.info("Peer registered", extra={"peer": str(peer.id), "name": peer.name}) - return peer - - @classmethod - def get_current_peer_ref(cls) -> PeerRef: - return settings.peer_id - - @classmethod - async def get_current_config_async(cls) -> CorePeerConfig: - peer = await cls.get_async(cls.get_current_peer_ref()) - if peer is None: - raise RuntimeError("Current Peer must be registered before reading config") - try: - return cls._config_contract.validate(peer.config) - except pydantic.ValidationError as error: - raise ValueError("Current Peer config is invalid") from error - - @classmethod - async def get_async(cls, peer: PeerRef) -> PeerModel | None: - async with peer_uow() as peers: - return await peers.get(peer) - - @classmethod - async def get_all(cls) -> tuple[PeerModel, ...]: - async with peer_uow() as peers: - return await peers.get_all() - - @classmethod - async def get_with_lease(cls, peer: PeerRef) -> dict[str, typing.Any] | None: - async with peer_uow() as peers: - rows, _ = await peers.with_leases(peer_id=peer) - return rows[0] if rows else None - - @classmethod - async def list_with_leases( - cls, *, limit: int | None = None, cursor: PeerRef | None = None - ) -> tuple[list[dict[str, typing.Any]], PeerRef | None]: - async with peer_uow() as peers: - return await peers.with_leases(limit=limit, cursor=cursor) - - @classmethod - def register_inbound(cls, inbound: PeerInbound) -> bool: - """Register one inbound and report whether this call added it.""" - existing = cls._INBOUNDS.get(inbound.capability) - if existing is inbound or existing == inbound: - return False - if existing is not None: - raise DuplicatePeerRegistrationError( - f"Capability inbound {inbound.capability!r} is already registered" - ) - cls._INBOUNDS[inbound.capability] = inbound - return True - - @classmethod - def unregister_inbound(cls, capability: CapabilityID) -> None: - cls._INBOUNDS.pop(capability, None) - - @classmethod - def register_outbound( - cls, - protocol: str, - factory: PeerOutboundFactory, - ) -> None: - existing = cls._OUTBOUNDS.get(protocol) - if existing is factory: - return - if existing is not None: - raise DuplicatePeerRegistrationError( - f"Peer outbound {protocol!r} is already registered" - ) - cls._OUTBOUNDS[protocol] = factory - - @classmethod - def setup_builtin_outbounds(cls) -> None: - cls.register_outbound(PEER_HTTP_PROTOCOL, PeerHTTPOutbound) - - @classmethod - async def publish_self(cls) -> PeerModel: - """Replace this Peer-owned capability snapshot from current config/registry.""" - async with peer_uow() as peers: - peer = await peers.get(settings.peer_id, for_update=True) - if peer is None: - raise RuntimeError("Current Peer must be registered before publication") - try: - config = cls._config_contract.validate(peer.config) - except pydantic.ValidationError as error: - raise ValueError("Current Peer config is invalid") from error - advertisements = normalize_capability_snapshot( - advertisement - for inbound in cls._INBOUNDS.values() - if (advertisement := inbound.advertise(config)) is not None - ) - normalized = [ - typing.cast(dict[str, JSONValue], advertisement.model_dump(mode="json")) - for advertisement in advertisements - ] - if peer.capabilities != normalized: - peer.capabilities = normalized - await peers.save(peer) - return peer - - @classmethod - async def renew_self_lease(cls, ttl_seconds: int) -> datetime.datetime: - """Renew liveness through the database-time helper.""" - if ttl_seconds <= 0: - raise ValueError("Peer lease TTL must be positive") - async with peer_uow() as peers: - expiry = await peers.renew_lease(settings.peer_id, ttl_seconds) - return expiry - - @classmethod - async def refresh_self(cls, ttl_seconds: int) -> PeerModel: - """Refresh advertisement, then renew its route lease.""" - peer = await cls.publish_self() - peer.lease_expires_at = await cls.renew_self_lease(ttl_seconds) - return peer - - @classmethod - async def clear_self_lease(cls) -> None: - async with peer_uow() as peers: - await peers.clear_lease(settings.peer_id) - - @classmethod - async def delegate( - cls, - capability: CapabilityID, - payload: JSONValue, - *, - route_to_peer: PeerRef | None = None, - ) -> JSONValue: - """Execute through one eligible Peer, failing over only after non-execution.""" - candidates = await cls._candidates(capability, route_to_peer) - attempted = 0 - for candidate in candidates: - factory = cls._OUTBOUNDS.get(candidate.advertisement.inbound.protocol) - if factory is None: # candidate eligibility is rechecked defensively - continue - try: - outbound = factory( - candidate.peer, - candidate.advertisement.inbound.parameters, - ) - except PeerProtocolConfigurationError: - continue - attempted += 1 - try: - return await outbound.execute(payload) - except PeerRequestNotExecuted: - if route_to_peer is not None: - break - continue - except PeerOutcomeUnknown: - raise - raise CapabilityDelegationUnavailable( - f"No eligible Peer completed capability {capability!r}; attempted={attempted}" - ) - - @classmethod - async def _candidates( - cls, - capability: CapabilityID, - route_to_peer: PeerRef | None, - ) -> tuple[_Candidate, ...]: - async with peer_uow() as peers_repository: - peers = await peers_repository.candidates(settings.peer_id, route_to_peer) - - candidates: list[_Candidate] = [] - for peer in peers: - try: - snapshots = peer.capability_snapshot() - except (pydantic.ValidationError, ValueError): - logger.warning( - "Skipping malformed Peer capability snapshot", - extra={"peer": str(peer.id)}, - ) - continue - for advertisement in snapshots: - if ( - advertisement.id == capability - and advertisement.inbound.protocol in cls._OUTBOUNDS - ): - candidates.append(_Candidate(peer, advertisement)) - break - - if route_to_peer is None: - cls._random.shuffle(candidates) - return tuple(candidates) +if TYPE_CHECKING: + from inkcre_core.business.peer.main import * diff --git a/app/business/semantic_retrieval/__init__.py b/app/business/semantic_retrieval/__init__.py index c6a14424..b8e9adb9 100644 --- a/app/business/semantic_retrieval/__init__.py +++ b/app/business/semantic_retrieval/__init__.py @@ -1,35 +1,3 @@ -"""Profile-scoped local semantic retrieval and embedding maintenance.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .main import ( - EmbeddingProfileNotFoundError, - InvalidSemanticVectorError, - SEMANTIC_RETRIEVAL_CONFIG_KEY, - SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, - SEMANTIC_RETRIEVAL_CAPABILITY, - SemanticRetrievalDelegationError, - SemanticRetrievalError, - SemanticRetrievalManager, - SemanticRetrievalNotConfiguredError, -) -from .job import ( - SEMANTIC_MAINTAIN_JOB_TYPE, - SEMANTIC_REBUILD_JOB_TYPE, - SemanticMaintainJobHandler, - SemanticRebuildJobHandler, -) - -__all__ = [ - "EmbeddingProfileNotFoundError", - "InvalidSemanticVectorError", - "SEMANTIC_RETRIEVAL_CONFIG_KEY", - "SEMANTIC_RETRIEVAL_CONFIG_SCHEMA", - "SEMANTIC_RETRIEVAL_CAPABILITY", - "SemanticRetrievalDelegationError", - "SemanticRetrievalError", - "SemanticRetrievalManager", - "SemanticRetrievalNotConfiguredError", - "SEMANTIC_MAINTAIN_JOB_TYPE", - "SEMANTIC_REBUILD_JOB_TYPE", - "SemanticMaintainJobHandler", - "SemanticRebuildJobHandler", -] +from inkcre_core.business.semantic_retrieval import * diff --git a/app/business/semantic_retrieval/job.py b/app/business/semantic_retrieval/job.py index fc0b5a0f..21e0142c 100644 --- a/app/business/semantic_retrieval/job.py +++ b/app/business/semantic_retrieval/job.py @@ -1,59 +1,10 @@ -"""Exact semantic embedding maintenance commands hosted by Jobs.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.job import JobHandler -from app.schemas.job import JobModel -from app.schemas.semantic_retrieval import EmbeddingMaintenanceJobParameters +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from .main import SemanticRetrievalManager +sys.modules[__name__] = import_module("inkcre_core.business.semantic_retrieval.job") - -SEMANTIC_MAINTAIN_JOB_TYPE = "core.semantic_retrieval.maintain.v1" -SEMANTIC_REBUILD_JOB_TYPE = "core.semantic_retrieval.rebuild.v1" - - -class SemanticMaintainJobHandler( - JobHandler[EmbeddingMaintenanceJobParameters], - job_type=SEMANTIC_MAINTAIN_JOB_TYPE, - description="Maintain missing or stale semantic embedding records.", - parameters_model=EmbeddingMaintenanceJobParameters, - default_timeout_seconds=900, -): - @classmethod - async def can_handle(cls, parameters: EmbeddingMaintenanceJobParameters) -> bool: - return await SemanticRetrievalManager.can_maintain(parameters.profile) - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: EmbeddingMaintenanceJobParameters, - ) -> None: - report = await SemanticRetrievalManager.maintain( - parameters.profile, - parameters.options, - ) - job.state = report.model_dump(mode="json") - - -class SemanticRebuildJobHandler( - JobHandler[EmbeddingMaintenanceJobParameters], - job_type=SEMANTIC_REBUILD_JOB_TYPE, - description="Rebuild semantic embedding records present before invocation.", - parameters_model=EmbeddingMaintenanceJobParameters, - default_timeout_seconds=1800, -): - @classmethod - async def can_handle(cls, parameters: EmbeddingMaintenanceJobParameters) -> bool: - return await SemanticRetrievalManager.can_maintain(parameters.profile) - - @classmethod - async def handle( - cls, - job: JobModel, - parameters: EmbeddingMaintenanceJobParameters, - ) -> None: - report = await SemanticRetrievalManager.rebuild( - parameters.profile, - parameters.options, - ) - job.state = report.model_dump(mode="json") +if TYPE_CHECKING: + from inkcre_core.business.semantic_retrieval.job import * diff --git a/app/business/semantic_retrieval/main.py b/app/business/semantic_retrieval/main.py index 709925e7..487eee50 100644 --- a/app/business/semantic_retrieval/main.py +++ b/app/business/semantic_retrieval/main.py @@ -1,548 +1,10 @@ -"""Profile-scoped embedding maintenance and exact semantic retrieval.""" +"""Legacy import; use inkcre_core for new consumers.""" -from __future__ import annotations +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from dataclasses import dataclass -import datetime -import logging -import math -import typing +sys.modules[__name__] = import_module("inkcre_core.business.semantic_retrieval.main") -import pydantic - -from app.business.ai import AIExecutionRequirement, AIManager -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService -from app.business.info_base.services import RelationService -from app.business.info_base.resolver import ( - ResolverManager, - UnknownResolverError, - UnsupportedResolverCapability, -) -from app.business.peer import PeerManager -from app.persistence.semantic_retrieval.uow import semantic_retrieval_uow -from app.schemas.ai import ( - BlockEmbeddingModel, - EmbeddingProfileID, - EmbeddingProfileModel, - RelationEmbeddingModel, -) -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef -from app.schemas.semantic_retrieval import ( - BlockSemanticRetrievalMatch, - EmbeddingMaintenanceDiagnostic, - EmbeddingMaintenanceOptions, - EmbeddingMaintenanceReport, - RelationSemanticRetrievalMatch, - SemanticEntityType, - SemanticRetrievalConfig, - SemanticRetrievalMatch, - SemanticRetrievalRequest, - SemanticRetrievalResult, - VectorRetrievalOptions, -) - - -logger = logging.getLogger(__name__) - -SEMANTIC_RETRIEVAL_CONFIG_KEY = "semantic_retrieval" -SEMANTIC_RETRIEVAL_CONFIG_SCHEMA = "core.semantic_retrieval.config.v1" -SEMANTIC_RETRIEVAL_CAPABILITY = "core.semantic_retrieval.v1" - - -class SemanticRetrievalError(RuntimeError): - pass - - -class SemanticRetrievalNotConfiguredError(SemanticRetrievalError): - pass - - -class EmbeddingProfileNotFoundError(SemanticRetrievalError): - pass - - -class InvalidSemanticVectorError(SemanticRetrievalError): - pass - - -class SemanticRetrievalDelegationError(SemanticRetrievalError): - pass - - -class _ProjectionUnavailable(Exception): - pass - - -@dataclass(frozen=True) -class _Candidate: - entity_type: SemanticEntityType - entity_id: int - entity: BlockModel | RelationModel - text: str - - -@dataclass(frozen=True) -class _CandidatePage: - entities: tuple[BlockModel | RelationModel, ...] - next_cursor: int - exhausted: bool - - -@dataclass -class _ReportBuilder: - profile: EmbeddingProfileID - diagnostic_limit: int - embedded: int = 0 - unavailable: int = 0 - failed: int = 0 - diagnostics: list[EmbeddingMaintenanceDiagnostic] | None = None - - def __post_init__(self) -> None: - self.diagnostics = [] - - def record( - self, - entity_type: SemanticEntityType, - entity: int, - outcome: typing.Literal["unavailable", "failed"], - reason: str, - ) -> None: - if outcome == "unavailable": - self.unavailable += 1 - else: - self.failed += 1 - diagnostics = typing.cast(list[EmbeddingMaintenanceDiagnostic], self.diagnostics) - if len(diagnostics) < self.diagnostic_limit: - diagnostics.append( - EmbeddingMaintenanceDiagnostic( - entity_type=entity_type, - entity=entity, - outcome=outcome, - reason=reason, - ) - ) - - def build(self) -> EmbeddingMaintenanceReport: - return EmbeddingMaintenanceReport( - profile=self.profile, - embedded=self.embedded, - unavailable=self.unavailable, - failed=self.failed, - diagnostics=tuple(self.diagnostics or ()), - ) - - -DeploymentConfigManager.register_schema( - SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, - SemanticRetrievalConfig, - keys=(SEMANTIC_RETRIEVAL_CONFIG_KEY,), -) - - -class SemanticRetrievalManager: - """Single use-domain owner for projection, records, ranking and defaults.""" - - @classmethod - async def get_profile( - cls, profile_id: EmbeddingProfileID - ) -> EmbeddingProfileModel | None: - async with semantic_retrieval_uow() as repository: - return await repository.get_profile(profile_id) - - @classmethod - async def list_profiles(cls, *, limit: int | None = None, cursor: int | None = None): - async with semantic_retrieval_uow() as repository: - return await repository.list_profiles(limit=limit, cursor=cursor) - - @classmethod - async def _configured_profile_id(cls) -> EmbeddingProfileID: - value = await DeploymentConfigService.get(SEMANTIC_RETRIEVAL_CONFIG_KEY) - if value is None: - raise SemanticRetrievalNotConfiguredError( - "Semantic retrieval default Profile is not configured" - ) - if not isinstance(value, SemanticRetrievalConfig): - raise TypeError("Semantic retrieval config registry returned the wrong model") - if value.default_profile is None: - raise SemanticRetrievalNotConfiguredError( - "Semantic retrieval default Profile is not configured" - ) - return value.default_profile - - @classmethod - async def _load_profile( - cls, - profile: EmbeddingProfileID | None, - ) -> EmbeddingProfileModel: - profile_id = await cls._configured_profile_id() if profile is None else profile - async with semantic_retrieval_uow() as repository: - result = await repository.get_profile(profile_id) - if result is None: - raise EmbeddingProfileNotFoundError(f"Embedding Profile {profile_id} does not exist") - return result - - @classmethod - async def can_maintain(cls, profile: EmbeddingProfileID | None = None) -> bool: - """Return static local Job eligibility without probing the provider.""" - try: - selected = await cls._load_profile(profile) - except (SemanticRetrievalNotConfiguredError, EmbeddingProfileNotFoundError): - return False - return await AIManager.can_execute( - selected.ai_model, - AIExecutionRequirement( - capability="embedding", - input_modalities=frozenset({"text"}), - output_modalities=frozenset({"vector"}), - ), - ) - - @classmethod - async def retrieve( - cls, - query: str, - profile: EmbeddingProfileID | None = None, - options: VectorRetrievalOptions | None = None, - *, - route_to_peer: PeerRef | None = None, - ) -> SemanticRetrievalResult: - """Execute locally unless the caller explicitly selects another Peer.""" - request = SemanticRetrievalRequest( - query=query, - profile=profile, - options=options or VectorRetrievalOptions(), - ) - if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): - return await cls.retrieve_local( - request.query, - request.profile, - request.options, - ) - - payload = PeerProtocolRequest( - body=typing.cast( - typing.Any, - request.model_dump(mode="json"), - ) - ) - result = await PeerManager.delegate( - SEMANTIC_RETRIEVAL_CAPABILITY, - typing.cast(typing.Any, payload.model_dump(mode="json", exclude_unset=True)), - route_to_peer=route_to_peer, - ) - try: - response = PeerProtocolResponse.model_validate(result) - if response.status != 200 or "body" not in response.model_fields_set: - raise SemanticRetrievalDelegationError( - f"Semantic retrieval Peer returned HTTP {response.status}" - ) - return SemanticRetrievalResult.model_validate(response.body) - except pydantic.ValidationError as error: - raise SemanticRetrievalDelegationError( - "Semantic retrieval Peer returned an invalid response" - ) from error - - @classmethod - async def retrieve_local( - cls, - query: str, - profile: EmbeddingProfileID | None = None, - options: VectorRetrievalOptions | None = None, - ) -> SemanticRetrievalResult: - """Execute exact local retrieval without entering Peer delegation.""" - if not query.strip(): - raise ValueError("query must not be empty") - retrieval_options = options or VectorRetrievalOptions() - selected = await cls._load_profile(profile) - profile_id = cls._profile_id(selected) - query_vector = ( - await AIManager.embed( - selected.ai_model, - (query,), - selected.dimensions, - ) - )[0] - cls._require_non_zero_vector(query_vector) - - matches: list[SemanticRetrievalMatch] = [] - async with semantic_retrieval_uow() as repository: - if "block" in retrieval_options.entity_types: - rows = await repository.retrieve_blocks(selected, query_vector, retrieval_options) - matches.extend( - BlockSemanticRetrievalMatch(entity=block, score=cls._score(distance)) - for block, distance in rows - ) - if "relation" in retrieval_options.entity_types: - rows = await repository.retrieve_relations( - selected, query_vector, retrieval_options - ) - matches.extend( - RelationSemanticRetrievalMatch(entity=relation, score=cls._score(distance)) - for relation, distance in rows - ) - - matches.sort( - key=lambda match: ( - -match.score, - match.type, - typing.cast(int, match.entity.id), - ) - ) - return SemanticRetrievalResult( - profile=profile_id, - matches=tuple(matches[: retrieval_options.limit]), - ) - - @classmethod - async def maintain( - cls, - profile: EmbeddingProfileID | None = None, - options: EmbeddingMaintenanceOptions | None = None, - ) -> EmbeddingMaintenanceReport: - """Embed only missing/stale records and resume through durable successes.""" - selected = await cls._load_profile(profile) - return await cls._maintain( - selected, - options or EmbeddingMaintenanceOptions(), - rebuild_cutoff=None, - ) - - @classmethod - async def rebuild( - cls, - profile: EmbeddingProfileID | None = None, - options: EmbeddingMaintenanceOptions | None = None, - ) -> EmbeddingMaintenanceReport: - """Re-embed records that were present before this invocation began.""" - selected = await cls._load_profile(profile) - async with semantic_retrieval_uow() as repository: - cutoff = await repository.database_now() - return await cls._maintain( - selected, - options or EmbeddingMaintenanceOptions(), - rebuild_cutoff=cutoff, - ) - - @classmethod - async def maintain_default( - cls, - options: EmbeddingMaintenanceOptions, - ) -> EmbeddingMaintenanceReport | None: - """Scheduled no-op when this deployment has no selected default Profile.""" - try: - return await cls.maintain(options=options) - except SemanticRetrievalNotConfiguredError: - return None - - @classmethod - async def _maintain( - cls, - profile: EmbeddingProfileModel, - options: EmbeddingMaintenanceOptions, - *, - rebuild_cutoff: datetime.datetime | None, - ) -> EmbeddingMaintenanceReport: - profile_id = cls._profile_id(profile) - report = _ReportBuilder(profile_id, options.diagnostic_limit) - batch: list[_Candidate] = [] - - async def offer(entity_type: SemanticEntityType, entity) -> bool: - entity_id = typing.cast(int | None, entity.id) - if entity_id is None: - raise RuntimeError("Persisted semantic candidate is missing its ID") - try: - text = await cls._project(entity_type, entity) - except _ProjectionUnavailable as error: - report.record(entity_type, entity_id, "unavailable", str(error)) - return True - except Exception as error: - logger.exception( - "Semantic projection failed", - extra={"entity_type": entity_type, "entity": entity_id}, - ) - report.record(entity_type, entity_id, "failed", type(error).__name__) - return True - batch.append(_Candidate(entity_type, entity_id, entity, text)) - if ( - len(batch) >= options.batch_size - or report.embedded + len(batch) >= options.max_embeddings - ): - return await flush() - return True - - async def flush() -> bool: - if not batch: - return True - current = tuple(batch) - batch.clear() - try: - await cls._embed_and_upsert(profile, current) - except Exception as error: - logger.exception( - "Semantic embedding batch failed", - extra={"profile": profile_id, "batch_size": len(current)}, - ) - for candidate in current: - report.record( - candidate.entity_type, - candidate.entity_id, - "failed", - type(error).__name__, - ) - return False - report.embedded += len(current) - return report.embedded < options.max_embeddings - - for entity_type in typing.cast(tuple[SemanticEntityType, ...], ("block", "relation")): - cursor = 0 - while report.embedded + len(batch) < options.max_embeddings: - page = await cls._candidate_page( - profile, - entity_type, - cursor, - options.scan_page_size, - rebuild_cutoff, - ) - cursor = page.next_cursor - for entity in page.entities: - if not await offer(entity_type, entity): - return report.build() - if report.embedded >= options.max_embeddings: - return report.build() - if page.exhausted: - break - await flush() - return report.build() - - @classmethod - async def _project( - cls, - entity_type: SemanticEntityType, - entity: BlockModel | RelationModel, - ) -> str: - try: - if entity_type == "block": - text = await ResolverManager.get(typing.cast(BlockModel, entity)).get_text() - else: - text = await RelationService.get_text(typing.cast(RelationModel, entity)) - except UnknownResolverError as error: - raise _ProjectionUnavailable("unknown_resolver") from error - except UnsupportedResolverCapability as error: - raise _ProjectionUnavailable("unsupported_text") from error - if text is None or not text.strip(): - raise _ProjectionUnavailable("empty_projection") - return text - - @classmethod - async def _embed_and_upsert( - cls, - profile: EmbeddingProfileModel, - candidates: tuple[_Candidate, ...], - ) -> None: - vectors = await AIManager.embed( - profile.ai_model, - tuple(candidate.text for candidate in candidates), - profile.dimensions, - ) - for vector in vectors: - cls._require_non_zero_vector(vector) - - profile_id = cls._profile_id(profile) - blocks = [] - relations = [] - for candidate, vector in zip(candidates, vectors, strict=True): - row = { - "profile": profile_id, - candidate.entity_type: candidate.entity_id, - "embedding": vector, - } - (blocks if candidate.entity_type == "block" else relations).append(row) - async with semantic_retrieval_uow() as repository: - await repository.upsert(blocks, relations) - - @classmethod - async def _candidate_page( - cls, - profile: EmbeddingProfileModel, - entity_type: SemanticEntityType, - cursor: int, - page_size: int, - rebuild_cutoff: datetime.datetime | None, - ) -> _CandidatePage: - profile_id = cls._profile_id(profile) - async with semantic_retrieval_uow() as repository: - if entity_type == "block": - rows = await repository.candidate_blocks(profile_id, cursor, page_size) - entities = tuple( - block - for block, record in rows - if cls._block_requires_embedding(profile, block, record, rebuild_cutoff) - ) - else: - rows = await repository.candidate_relations(profile_id, cursor, page_size) - entities = tuple( - relation - for relation, record, from_updated, to_updated in rows - if cls._relation_requires_embedding( - profile, relation, record, (from_updated, to_updated), rebuild_cutoff - ) - ) - next_cursor = typing.cast(int, rows[-1][0].id) if rows else cursor - return _CandidatePage(entities, next_cursor, len(rows) < page_size) - - @staticmethod - def _block_requires_embedding( - profile: EmbeddingProfileModel, - block: BlockModel, - record: BlockEmbeddingModel | None, - rebuild_cutoff: datetime.datetime | None, - ) -> bool: - if record is None: - return True - if rebuild_cutoff is not None: - return record.updated_at < rebuild_cutoff - return ( - len(record.embedding) != profile.dimensions - or record.updated_at < profile.updated_at - or record.updated_at < block.updated_at - ) - - @staticmethod - def _relation_requires_embedding( - profile: EmbeddingProfileModel, - relation: RelationModel, - record: RelationEmbeddingModel | None, - endpoint_updated_at: tuple[datetime.datetime, datetime.datetime], - rebuild_cutoff: datetime.datetime | None, - ) -> bool: - if record is None: - return True - if rebuild_cutoff is not None: - return record.updated_at < rebuild_cutoff - return ( - len(record.embedding) != profile.dimensions - or record.updated_at < profile.updated_at - or record.updated_at < relation.updated_at - or record.updated_at < endpoint_updated_at[0] - or record.updated_at < endpoint_updated_at[1] - ) - - @staticmethod - def _profile_id(profile: EmbeddingProfileModel) -> EmbeddingProfileID: - if profile.id is None: - raise RuntimeError("Persisted Embedding Profile is missing its ID") - return profile.id - - @staticmethod - def _require_non_zero_vector(vector) -> None: - if not any(value != 0 for value in vector): - raise InvalidSemanticVectorError("Semantic vectors must be non-zero") - - @staticmethod - def _score(distance: float) -> float: - score = 1.0 - distance - if not math.isfinite(score): - raise InvalidSemanticVectorError("Cosine score must be finite") - return min(1.0, max(-1.0, score)) +if TYPE_CHECKING: + from inkcre_core.business.semantic_retrieval.main import * diff --git a/app/business/sink/agent_query.py b/app/business/sink/agent_query.py index 82e2761c..31b2ac46 100644 --- a/app/business/sink/agent_query.py +++ b/app/business/sink/agent_query.py @@ -8,20 +8,20 @@ import pydantic from starlette.routing import BaseRoute -from app.business.agent import AgentManager -from app.business.job import JobHandler, JobManager +from inkcre_core.business.agent import AgentManager +from inkcre_core.business.job import JobHandler, JobManager from app.middleware import require_peer_jwt -from app.persistence.job.uow import JobUnitOfWork -from app.schemas.ai import ( +from inkcre_core.persistence.job.uow import JobUnitOfWork +from inkcre_core.schemas.ai import ( AssistantMessage, JSONValue, TextContentPart, ToolResultMessage, UserMessage, ) -from app.schemas.job import JobModel -from app.schemas.sink import SinkModel -from libs.obsrv.telemetry import emit_event +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.sink import SinkModel +from inkcre_core.libs.obsrv.telemetry import emit_event from .base import SinkBase from .errors import SinkNotFoundError, SinkStateConflictError diff --git a/app/business/sink/base.py b/app/business/sink/base.py index ffc80ff8..845abdca 100644 --- a/app/business/sink/base.py +++ b/app/business/sink/base.py @@ -5,7 +5,7 @@ import fastapi import pydantic -from app.schemas.sink import SinkModel, SinkTypeID +from inkcre_core.schemas.sink import SinkModel, SinkTypeID ConfigT = typing.TypeVar("ConfigT", bound=pydantic.BaseModel) diff --git a/app/business/sink/main.py b/app/business/sink/main.py index 80b51e5f..ab1300ee 100644 --- a/app/business/sink/main.py +++ b/app/business/sink/main.py @@ -8,9 +8,9 @@ import fastapi -from app.persistence.sink.uow import sink_uow -from app.schemas.peer import PeerRef -from app.schemas.sink import SinkID, SinkModel, SinkTypeID, SinkTypeModel +from inkcre_core.persistence.sink.uow import sink_uow +from inkcre_core.schemas.peer import PeerRef +from inkcre_core.schemas.sink import SinkID, SinkModel, SinkTypeID, SinkTypeModel from .errors import ( DuplicateSinkRegistrationError, diff --git a/app/business/sink/mcp.py b/app/business/sink/mcp.py index 2326ef6e..6cdfbe46 100644 --- a/app/business/sink/mcp.py +++ b/app/business/sink/mcp.py @@ -21,19 +21,19 @@ import pydantic from starlette.routing import Mount -from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager -from app.business.info_base.services import BlockService, get_entity_records -from app.business.info_base.resolver import ( +from inkcre_core.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from inkcre_core.business.info_base.services import BlockService, get_entity_records +from inkcre_core.business.info_base.resolver import ( ResolverManager, UnknownResolverError, UnsupportedResolverCapability, ) -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.semantic_retrieval import SemanticRetrievalManager -from app.schemas.graph_navigation_retrieval import GraphDirection, PathFound -from app.schemas.semantic_retrieval import VectorRetrievalOptions -from app.schemas.sink import SinkModel -from app.schemas.info_base.block import BlockModel +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.semantic_retrieval import SemanticRetrievalManager +from inkcre_core.schemas.graph_navigation_retrieval import GraphDirection, PathFound +from inkcre_core.schemas.semantic_retrieval import VectorRetrievalOptions +from inkcre_core.schemas.sink import SinkModel +from inkcre_core.schemas.info_base.block import BlockModel from .base import SinkBase from .contracts import ContentMode, RecallMode, ResolverMethodCall diff --git a/app/business/sink/projection.py b/app/business/sink/projection.py index e8ca7eee..b4cda815 100644 --- a/app/business/sink/projection.py +++ b/app/business/sink/projection.py @@ -11,8 +11,8 @@ import pydantic -from app.business.info_base.resolver import ResolverManager -from app.schemas.info_base.block import BlockModel +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.schemas.info_base.block import BlockModel from .contracts import ContentMode diff --git a/app/business/source/__init__.py b/app/business/source/__init__.py index 20b0d0c3..d3ee4e67 100644 --- a/app/business/source/__init__.py +++ b/app/business/source/__init__.py @@ -1,24 +1,3 @@ -from .main import SourceBase, SourceManager -from .config import ( - SOURCE_CONFIG_KEY, - SOURCE_CONFIG_SCHEMA_ID, - SourceDeploymentConfig, -) -from .job import ( - SOURCE_BACKFILL_JOB_TYPE, - SOURCE_COLLECT_JOB_TYPE, - SourceBackfillJobHandler, - SourceCollectJobHandler, -) +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "SourceBase", - "SourceManager", - "SOURCE_COLLECT_JOB_TYPE", - "SOURCE_BACKFILL_JOB_TYPE", - "SourceCollectJobHandler", - "SourceBackfillJobHandler", - "SOURCE_CONFIG_KEY", - "SOURCE_CONFIG_SCHEMA_ID", - "SourceDeploymentConfig", -] +from inkcre_core.business.source import * diff --git a/app/business/source/config.py b/app/business/source/config.py index 1ed27bd5..4915c28d 100644 --- a/app/business/source/config.py +++ b/app/business/source/config.py @@ -1,46 +1,10 @@ -"""Deployment-wide policy used by Source-owned content materialization.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.business.source.config") -from app.business.deployment_config import DeploymentConfigManager -from app.business.info_base.storage import StorageManager, WritableStorage -from app.schemas.info_base.storage import StorageID -from app.schemas.source import SourceModel -from app.persistence.source.uow import SourceUnitOfWork - - -SOURCE_CONFIG_KEY = "core.source" -SOURCE_CONFIG_SCHEMA_ID = "core.source.config.v1" -POSTGRESQL_BINARY_STORAGE_ID = -4 - - -class SourceDeploymentConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - default_storage: StorageID = POSTGRESQL_BINARY_STORAGE_ID - - -DeploymentConfigManager.register_schema( - SOURCE_CONFIG_SCHEMA_ID, SourceDeploymentConfig, keys=(SOURCE_CONFIG_KEY,) -) - - -async def resolve_writable_storage_async( - source: SourceModel, uow: SourceUnitOfWork -) -> WritableStorage: - """Resolve storage using the caller's Source/graph/config transaction.""" - storage_id = source.storage - if storage_id is None: - record = await uow.configuration.get(SOURCE_CONFIG_KEY) - persisted = None if record is None else DeploymentConfigManager._restore_record(record) - storage_id = ( - POSTGRESQL_BINARY_STORAGE_ID - if persisted is None - else typing.cast(SourceDeploymentConfig, persisted).default_storage - ) - storage = StorageManager.from_record(await uow.graph.storage.get(storage_id)) - if not isinstance(storage, WritableStorage): - raise ValueError(f"Storage {storage_id} is not writable") - return storage +if TYPE_CHECKING: + from inkcre_core.business.source.config import * diff --git a/app/business/source/job.py b/app/business/source/job.py index 3e80fc84..b84fd5b7 100644 --- a/app/business/source/job.py +++ b/app/business/source/job.py @@ -1,90 +1,10 @@ -"""Exact Source command handlers hosted by the global Job runtime.""" +"""Legacy import; use inkcre_core for new consumers.""" -from app.business.job import JobHandler -from app.persistence.job.uow import JobUnitOfWork -from app.schemas.job import JobModel -from app.schemas.source import ( - SourceBackfillParameters, - SourceCollectParameters, -) +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.validation import input_path +sys.modules[__name__] = import_module("inkcre_core.business.source.job") -from .main import SourceManager, SourceNotFoundError - - -SOURCE_COLLECT_JOB_TYPE = "core.source.collect.v1" -SOURCE_BACKFILL_JOB_TYPE = "core.source.backfill.v1" - - -async def _source_type(parameters: SourceCollectParameters) -> str | None: - source = await SourceManager.get(parameters.source) - return None if source is None else source.type - - -class SourceCollectJobHandler( - JobHandler[SourceCollectParameters], - job_type=SOURCE_COLLECT_JOB_TYPE, - description="Run one ordinary collection command for a configured Source.", - parameters_model=SourceCollectParameters, - default_timeout_seconds=300, -): - @classmethod - async def normalize_parameters(cls, parameters: dict, uow: JobUnitOfWork) -> dict: - normalized = await super().normalize_parameters(parameters, uow) - source = await uow.sources.get(normalized["source"]) - if source is None: - raise SourceNotFoundError(f"Source {normalized['source']} does not exist") - with input_path("config"): - normalized["config"] = SourceManager.normalize_config( - source.type, - normalized["config"], - await uow.sources.get_type(source.type), - command="collect", - ) - return normalized - - @classmethod - async def can_handle(cls, parameters: SourceCollectParameters) -> bool: - source_type = await _source_type(parameters) - return source_type is not None and SourceManager.has_source_type(source_type) - - @classmethod - async def handle(cls, job: JobModel, parameters: SourceCollectParameters) -> None: - source = await SourceManager.get_source_ins(parameters.source) - config = source.validate_collect_config(parameters.config) - await source.collect(job, config) - - -class SourceBackfillJobHandler( - JobHandler[SourceBackfillParameters], - job_type=SOURCE_BACKFILL_JOB_TYPE, - description="Run one exact historical collection command for a configured Source.", - parameters_model=SourceBackfillParameters, - default_timeout_seconds=1800, -): - @classmethod - async def normalize_parameters(cls, parameters: dict, uow: JobUnitOfWork) -> dict: - normalized = await super().normalize_parameters(parameters, uow) - source = await uow.sources.get(normalized["source"]) - if source is None: - raise SourceNotFoundError(f"Source {normalized['source']} does not exist") - with input_path("config"): - normalized["config"] = SourceManager.normalize_config( - source.type, - normalized["config"], - await uow.sources.get_type(source.type), - command="backfill", - ) - return normalized - - @classmethod - async def can_handle(cls, parameters: SourceBackfillParameters) -> bool: - source_type = await _source_type(parameters) - return source_type is not None and SourceManager.supports_backfill(source_type) - - @classmethod - async def handle(cls, job: JobModel, parameters: SourceBackfillParameters) -> None: - source = await SourceManager.get_source_ins(parameters.source) - config = source.validate_backfill_config(parameters.config) - await source.backfill(job, config) +if TYPE_CHECKING: + from inkcre_core.business.source.job import * diff --git a/app/business/source/main.py b/app/business/source/main.py index 2ceb8fb2..9a31a59a 100644 --- a/app/business/source/main.py +++ b/app/business/source/main.py @@ -1,314 +1,10 @@ -from __future__ import annotations +"""Legacy import; use inkcre_core for new consumers.""" -import abc -import jsonschema # pyrefly: ignore[untyped-import] -import pydantic -import typing -from typing import Optional as Opt +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.persistence.source.uow import SourceUnitOfWork, source_uow -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.job import JobModel -from app.schemas.source import SourceModel, SourceID, SourceTypesModel, SourceUpdateForm -from app.validation import input_path -from .resolver import SOURCE_RESOLVER_ID, SourceContent +sys.modules[__name__] = import_module("inkcre_core.business.source.main") -ConfigTV = typing.TypeVar("ConfigTV", bound=pydantic.BaseModel) - - -class SourceNotFoundError(LookupError): - """A Source instance or persisted type does not exist.""" - - -class UnsupportedSourceCommandError(ValueError): - """The catalog explicitly does not support the requested collection mode.""" - - -class EmptySourceCommandConfig(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - -class SourceBase(abc.ABC, typing.Generic[ConfigTV]): - """InkCre Source Base class. - - Configuration is loaded from the database. Instantiate a source when it is - used instead of caching configuration separately. - """ - - __configschema__: dict - """Source configuration JSON schema""" - __configcls__: type[ConfigTV] - __collectconfigcls__: type[pydantic.BaseModel] - __backfillconfigcls__: type[pydantic.BaseModel] | None - - def __init_subclass__( - cls, - config_cls: type[ConfigTV], - collect_config_cls: type[pydantic.BaseModel] = EmptySourceCommandConfig, - backfill_config_cls: type[pydantic.BaseModel] | None = None, - **kwargs, - ) -> None: - # ConfigTV is bound by the concrete source subclass. - cls.__configcls__ = config_cls # pyrefly: ignore[no-access] - cls.__configschema__ = config_cls.model_json_schema() - cls.__collectconfigcls__ = collect_config_cls # pyrefly: ignore[no-access] - cls.__backfillconfigcls__ = backfill_config_cls # pyrefly: ignore[no-access] - SourceManager.add_source_type(cls) - return super().__init_subclass__(**kwargs) - - def __init__(self, _id: SourceID) -> None: - self._id = _id - - @abc.abstractmethod - async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: - """Collect new data from the source. - - :param job: The collect job containing config and state. - - Notes: - - Should not surpress exceptions, raise it. - """ - - async def backfill(self, job: JobModel, config: pydantic.BaseModel) -> None: - del job, config - raise NotImplementedError(f"{self.__class__.__name__} does not support backfill") - - async def record(self, data: typing.Any) -> None: - """Record data passively (e.g., from webhook). - - :param data: The data to record from external source (e.g., webhook payload). - - Notes: - - Should not suppress exceptions, raise it. - - Used for passive collection methods like webhooks. - """ - raise NotImplementedError(f"{self.__class__.__name__} does not support passive record") - - async def get_config(self) -> ConfigTV: - """Read and validate current Source configuration in a short scope.""" - async with source_uow() as uow: - source = await uow.sources.get(self._id) - if source is None: - raise SourceNotFoundError(f"Source {self._id} does not exist") - return self.__configcls__.model_validate(source.config) - - def validate_collect_config(self, config: dict) -> pydantic.BaseModel: - return self.__collectconfigcls__.model_validate(config) - - def validate_backfill_config(self, config: dict) -> pydantic.BaseModel: - if self.__backfillconfigcls__ is None: - raise NotImplementedError(f"{self.__class__.__name__} does not support backfill") - return self.__backfillconfigcls__.model_validate(config) - - async def get_state(self) -> dict: - async with source_uow() as uow: - source = await uow.sources.get(self._id) - if source is None: - raise SourceNotFoundError(f"Source {self._id} does not exist") - return source.state or {} - - async def set_state(self, state: dict) -> None: - async with source_uow() as uow: - source = await uow.sources.get(self._id, lock=True) - if source is None: - raise SourceNotFoundError(f"Source {self._id} does not exist") - source.state = state - await uow.sources.save(source) - - -class SourceManager: - """ - - - Run collect method of all configured sources - - Add, remove and configure source instances - - Add, remove sources - """ - - SOURCES: dict[SourceID, SourceBase] = {} - _SOURCE_CLASSES: dict[str, type[SourceBase]] = {} - - @classmethod - def add_source_type(cls, source_cls: type[SourceBase]) -> None: - """Register a source type in memory without external side effects.""" - source_type = source_cls.__module__ + "." + source_cls.__qualname__ - cls._SOURCE_CLASSES[source_type] = source_cls - - @classmethod - async def sync_source_types_async( - cls, source_classes: dict[str, type[SourceBase]] | None = None - ) -> None: - registered = cls._SOURCE_CLASSES if source_classes is None else source_classes - rows = [ - dict( - id=source_type, - description=source_cls.__doc__ or "No description.", - config_schema=source_cls.__configschema__, - collect_config_schema=source_cls.__collectconfigcls__.model_json_schema(), - backfill_config_schema=( - None - if source_cls.__backfillconfigcls__ is None - else source_cls.__backfillconfigcls__.model_json_schema() - ), - ) - for source_type, source_cls in registered.items() - ] - async with source_uow() as uow: - await uow.sources.sync_types(rows) - - @classmethod - def has_source_type(cls, source_type: str) -> bool: - return source_type in cls._SOURCE_CLASSES - - @classmethod - def supports_backfill(cls, source_type: str) -> bool: - source_cls = cls._SOURCE_CLASSES.get(source_type) - return source_cls is not None and source_cls.__backfillconfigcls__ is not None - - @classmethod - async def get_source_ins(cls, source_id: SourceID) -> SourceBase: - ins = cls.SOURCES.get(source_id) - if ins is not None: - return ins - source = await cls.get(source_id) - if source is None: - raise SourceNotFoundError(f"Source {source_id} does not exist") - source_class = cls._SOURCE_CLASSES.get(source.type) - if source_class is None: - raise ValueError(f"Source class {source.type} not registered.") - ins = source_class(_id=source_id) - cls.SOURCES[source_id] = ins - return ins - - @classmethod - async def create( - cls, - type_: str, - nickname: Opt[str] = None, - config: dict | None = None, - storage: int | None = None, - ) -> SourceModel: - async with source_uow() as uow: - with input_path("config"): - normalized = cls.normalize_config( - type_, config or {}, await uow.sources.get_type(type_) - ) - source = SourceModel( - type=type_, nickname=nickname, config=normalized, storage=storage - ) - await uow.sources.save(source) - return source - - @classmethod - def normalize_config( - cls, - type_: str, - config: dict, - catalog: SourceTypesModel | None, - *, - command: typing.Literal["collect", "backfill"] | None = None, - ) -> dict: - """Accept catalog-only input without requiring local execution capability.""" - if catalog is None: - raise SourceNotFoundError(f"Source type {type_!r} does not exist") - source_class = cls._SOURCE_CLASSES.get(type_) - if command == "backfill": - schema = catalog.backfill_config_schema - model = source_class.__backfillconfigcls__ if source_class else None - elif command == "collect": - schema = catalog.collect_config_schema - model = source_class.__collectconfigcls__ if source_class else None - else: - schema = catalog.config_schema - model = source_class.__configcls__ if source_class else None # pyrefly: ignore[missing-attribute] - if schema is None: - raise UnsupportedSourceCommandError( - f"Source type {type_!r} does not support {command}" - ) - if model is not None: - return model.model_validate(config).model_dump(mode="json") - jsonschema.Draft202012Validator(schema).validate(config) - return config - - @classmethod - async def get(cls, source_id: SourceID) -> SourceModel | None: - async with source_uow() as uow: - return await uow.sources.get(source_id) - - @classmethod - async def get_type(cls, type_: str) -> SourceTypesModel | None: - async with source_uow() as uow: - return await uow.sources.get_type(type_) - - @classmethod - async def list_sources( - cls, *, limit: int | None = None, cursor: SourceID | None = None - ) -> tuple[list[SourceModel], SourceID | None]: - async with source_uow() as uow: - return await uow.sources.list_sources(limit=limit, cursor=cursor) - - @classmethod - async def list_types( - cls, *, limit: int | None = None, cursor: str | None = None - ) -> tuple[list[SourceTypesModel], str | None]: - async with source_uow() as uow: - return await uow.sources.list_types(limit=limit, cursor=cursor) - - @classmethod - async def update(cls, source_id: SourceID, form: SourceUpdateForm) -> SourceModel: - async with source_uow() as uow: - source = await uow.sources.get(source_id, lock=True) - if source is None: - raise SourceNotFoundError(f"Source {source_id} does not exist") - changes = form.model_dump(exclude_unset=True) - if "config" in changes: - with input_path("config"): - changes["config"] = cls.normalize_config( - source.type, changes["config"], await uow.sources.get_type(source.type) - ) - for field, value in changes.items(): - setattr(source, field, value) - await uow.sources.save(source) - if "nickname" in changes and source.block is not None: - await cls.ensure_block_async(source, uow) - return source - - @classmethod - async def delete(cls, source_id: SourceID) -> bool: - """Delete configuration only; collected graph and scheduled commands remain.""" - async with source_uow() as uow: - source = await uow.sources.get(source_id) - if source is None: - return False - await uow.sources.delete(source) - cls.SOURCES.pop(source_id, None) - return True - - @classmethod - async def ensure_block_async( - cls, source: SourceModel, uow: SourceUnitOfWork - ) -> BlockModel: - """Create/reuse a Source anchor in the caller's graph/state transaction.""" - if source.id is None: - raise ValueError("Source must be persisted before creating its anchor") - locked = await uow.sources.get(source.id, lock=True) - if locked is None: - raise SourceNotFoundError(f"Source {source.id} does not exist") - content = SourceContent( - id=source.id, type=locked.type, nickname=locked.nickname - ).model_dump_json() - if locked.block is None: - block = await uow.graph.blocks.create( - BlockForm(resolver=SOURCE_RESOLVER_ID, content=content) - ) - locked.block = block.id - await uow.sources.save(locked) - return block - block = await uow.graph.blocks.get(locked.block) - if block is None: - raise RuntimeError("Source anchor reference does not resolve") - if block.resolver != SOURCE_RESOLVER_ID or block.content != content: - block.resolver = SOURCE_RESOLVER_ID - block.storage = None - block.content = content - await uow.graph.blocks.save(block) - return block +if TYPE_CHECKING: + from inkcre_core.business.source.main import * diff --git a/app/business/source/resolver.py b/app/business/source/resolver.py index 78b70056..157534fc 100644 --- a/app/business/source/resolver.py +++ b/app/business/source/resolver.py @@ -1,47 +1,10 @@ -"""Use-time projection for lazy Source anchor Blocks.""" +"""Legacy import; use inkcre_core for new consumers.""" -import pydantic +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.business.info_base.resolver.label import format_label -from app.business.info_base.resolver.main import Resolver, TextProjectionContext +sys.modules[__name__] = import_module("inkcre_core.business.source.resolver") - -SOURCE_RESOLVER_ID = "core.source.v1" - - -class SourceContent(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - id: int - type: str - nickname: str | None = None - - -class SourceResolver(Resolver[SourceContent, str], rso_type=SOURCE_RESOLVER_ID): - async def _get_solved_content( - self, - *, - refresh: bool = False, - materialize_missing: bool = True, - ) -> SourceContent: - del materialize_missing - return SourceContent.model_validate_json(await self.get_raw_content(refresh=refresh)) - - async def get_text( - self, - *, - context: TextProjectionContext = "default", - refresh: bool = False, - materialize_missing: bool = True, - ) -> str: - del context - source = await self.get_solved_content( - refresh=refresh, - materialize_missing=materialize_missing, - ) - name = f" named {source.nickname}" if source.nickname else "" - return f"Source {source.id}{name} uses {source.type}." - - async def get_label(self, *, refresh: bool = False) -> str: - source = await self.get_solved_content(refresh=refresh, materialize_missing=False) - return format_label("source", source.nickname or source.type) +if TYPE_CHECKING: + from inkcre_core.business.source.resolver import * diff --git a/app/business/source/webpage.py b/app/business/source/webpage.py index 74814835..1479c42e 100644 --- a/app/business/source/webpage.py +++ b/app/business/source/webpage.py @@ -1 +1,10 @@ -"""Webpage Source""" +"""Legacy import; use inkcre_core for new consumers.""" + +from typing import TYPE_CHECKING +from importlib import import_module +import sys + +sys.modules[__name__] = import_module("inkcre_core.business.source.webpage") + +if TYPE_CHECKING: + from inkcre_core.business.source.webpage import * diff --git a/app/configuration.py b/app/configuration.py index c09431af..acac60aa 100644 --- a/app/configuration.py +++ b/app/configuration.py @@ -1,59 +1,10 @@ -"""Persistence-neutral mechanics for model-owned configuration values.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Mapping -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.configuration") - -ConfigModelT = typing.TypeVar("ConfigModelT", bound=pydantic.BaseModel) - - -class ConfigContract(typing.Generic[ConfigModelT]): - """Apply one Pydantic model as a complete configuration contract. - - This abstraction deliberately has no persistence, registry, key, or live-update - behavior. Those consequences remain with the domain that owns the config. - """ - - def __init__(self, model: type[ConfigModelT]) -> None: - self.model = model - - def validate( - self, - value: ConfigModelT | Mapping[str, typing.Any], - ) -> ConfigModelT: - """Validate new input; pass an already constructed target model through. - - Passing a model back through Pydantic can rerun its model validators. - Typed callers have already crossed that boundary; persistence consumers may - still use one native model construction to restore nested Python types. - """ - if isinstance(value, self.model): - return value - return self.model.model_validate(value) - - def normalize( - self, - value: ConfigModelT | Mapping[str, typing.Any], - ) -> dict[str, typing.Any]: - """Return the complete value in JSON-compatible canonical form.""" - return self.validate(value).model_dump(mode="json") - - def prepare_patch( - self, - current: ConfigModelT | Mapping[str, typing.Any], - patch: Mapping[str, typing.Any], - ) -> ConfigModelT: - """Shallow-merge a patch and validate the resulting complete value.""" - candidate = ( - current.model_dump(mode="json") - if isinstance(current, pydantic.BaseModel) - else dict(current) - ) - candidate.update(patch) - return self.validate(candidate) - - def json_schema(self) -> dict[str, typing.Any]: - """Project the model's JSON Schema for UI or protocol discovery.""" - return self.model.model_json_schema() +if TYPE_CHECKING: + from inkcre_core.configuration import * diff --git a/app/core.py b/app/core.py new file mode 100644 index 00000000..03abb695 --- /dev/null +++ b/app/core.py @@ -0,0 +1,39 @@ +"""Application-owned composition of the embeddable SDK.""" + +from starlette.types import ASGIApp, Receive, Scope, Send + +from inkcre_core import Core, CoreOptions +from app.settings import settings +from app.version import APPLICATION_VERSION + + +def create_core() -> Core: + """Compose one service-owned SDK instance from application settings.""" + return Core( + settings.database_url, + pool_pre_ping=settings.database_scale_0, + options=CoreOptions( + peer_id=settings.peer_id, + peer_name=settings.peer_name, + application_version=APPLICATION_VERSION, + jwt_secret=settings.jwt_secret, + jwt_default_lifetime_seconds=settings.jwt_default_lifetime_seconds, + peer_http_timeout_seconds=settings.peer_http_timeout_seconds, + agent_debug=settings.obsrv.agent_debug, + ), + ) + + +CORE = create_core() + + +class CoreMiddleware: + """Bind the application's SDK instance for requests, lifespan and child work.""" + + def __init__(self, app: ASGIApp, core: Core): + self.app = app + self.core = core + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + with self.core.bind(): + await self.app(scope, receive, send) diff --git a/app/database_contract/__init__.py b/app/database_contract/__init__.py index 8943ac4e..ed99b743 100644 --- a/app/database_contract/__init__.py +++ b/app/database_contract/__init__.py @@ -1,29 +1,3 @@ -"""Executable peer database runtime contract.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .constants import ( - AUTHENTICATED_ROLE, - AUTHENTICATOR_ROLE, - CONTRACT_REVISION, - CORE_RUNTIME_ROLE, - DEVELOPMENT_PEER_ID, - INTERNAL_SCHEMA, - JWT_ALGORITHM, - JWT_AUDIENCE, - JWT_ISSUER, - JWT_MAX_LIFETIME_SECONDS, - PROTOCOL_SCHEMA, -) - -__all__ = [ - "AUTHENTICATED_ROLE", - "AUTHENTICATOR_ROLE", - "CONTRACT_REVISION", - "CORE_RUNTIME_ROLE", - "DEVELOPMENT_PEER_ID", - "INTERNAL_SCHEMA", - "JWT_ALGORITHM", - "JWT_AUDIENCE", - "JWT_ISSUER", - "JWT_MAX_LIFETIME_SECONDS", - "PROTOCOL_SCHEMA", -] +from inkcre_core.database_contract import * diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index c5dd790f..93355ec8 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -1,57 +1,10 @@ -"""Stable names and values shared by migrations, runtimes, and consumers.""" +"""Legacy import; use inkcre_core for new consumers.""" -import uuid +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.database_contract.constants") -CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "extension-distribution-open-v1" - -PROTOCOL_SCHEMA = "inkcre" -INTERNAL_SCHEMA = "inkcre_internal" - -AUTHENTICATED_ROLE = "authenticated" -AUTHENTICATOR_ROLE = "authenticator" -ANONYMOUS_ROLE = "anonymous" -CORE_RUNTIME_ROLE = "inkcre_core" - -JWT_ALGORITHM = "HS256" -JWT_ROLE = AUTHENTICATED_ROLE -JWT_ISSUER = "inkcre-peer" -JWT_AUDIENCE = "inkcre-api" -# Frozen input for admitted historical migrations; live admission has no lifetime cap. -JWT_MAX_LIFETIME_SECONDS = 24 * 60 * 60 -JWT_MINIMUM_SECRET_BYTES = 32 - -DEVELOPMENT_PEER_ID = uuid.UUID("00000000-0000-4000-8000-000000000001") -DEVELOPMENT_PEER_NAME = "client-web-development" - -DATABASE_ENVIRONMENTS = frozenset({"runtime", "development", "preview", "production"}) -RESET_CONFIRMATION = "reset-development-data" - -APPLICATION_TABLES = ( - "agents", - "ai_dialects", - "ai_models", - "ai_providers", - "block_embeddings", - "block_lexical_records", - "blocks", - "peers", - "configs", - "extensions", - "extension_peer_participations", - "embedding_profiles", - "crons", - "jobs", - "job_types", - "logs", - "relation_embeddings", - "relations", - "sink_types", - "sinks", - "sources", - "sources_types", - "storage_types", - "storage_blobs", - "storages", -) +if TYPE_CHECKING: + from inkcre_core.database_contract.constants import * diff --git a/app/database_contract/profile.py b/app/database_contract/profile.py index bfb56505..f1990cc2 100644 --- a/app/database_contract/profile.py +++ b/app/database_contract/profile.py @@ -1,386 +1,10 @@ -"""Checked-in built-in catalog profile. +"""Legacy import; use inkcre_core for new consumers.""" -The profile is data only. Reconciliation can consume it without importing FastAPI, -application settings, extension runtimes, or the scheduler. -""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from dataclasses import dataclass -from typing import Any +sys.modules[__name__] = import_module("inkcre_core.database_contract.profile") - -JsonObject = dict[str, Any] - - -@dataclass(frozen=True) -class TypeProfile: - id: str - description: str - config_schema: JsonObject - - -@dataclass(frozen=True) -class StorageTypeProfile: - id: str - description: str - config_schema: JsonObject - writable: bool - - -@dataclass(frozen=True) -class JobTypeProfile: - id: str - description: str - parameters_schema: JsonObject - default_timeout_seconds: int - - -@dataclass(frozen=True) -class StorageProfile: - id: int - type: str - nickname: str - config: JsonObject - - -def _object_schema( - title: str, - description: str, - properties: JsonObject, -) -> JsonObject: - return { - "description": description, - "properties": properties, - "title": title, - "type": "object", - } - - -def _positive_integer(default: int) -> JsonObject: - return {"default": default, "exclusiveMinimum": 0, "type": "integer"} - - -def _boolean(default: bool) -> JsonObject: - return {"default": default, "type": "boolean"} - - -HTTP_STORAGE_SCHEMA = _object_schema( - "HTTPStorageConfig", - "Configuration for HTTP storage.", - { - "follow_redirects": _boolean(True), - "max_response_bytes": _positive_integer(64 * 1024 * 1024), - "timeout": _positive_integer(30), - }, -) - -OPENAI_COMPATIBLE_DIALECT_SCHEMA = { - "additionalProperties": False, - "description": "Connection values for one OpenAI-compatible provider instance.", - "properties": { - "api_key": { - "minLength": 1, - "title": "Api Key", - "type": "string", - }, - "base_url": { - "anyOf": [ - {"minLength": 1, "type": "string"}, - {"type": "null"}, - ], - "default": None, - "title": "Base Url", - }, - }, - "required": ["api_key"], - "title": "OpenAICompatibleConfig", - "type": "object", -} - -BUILTIN_AI_DIALECTS = ( - TypeProfile( - "core.openai-compatible.v1", - "OpenAI-compatible embedding and chat protocol.", - OPENAI_COMPATIBLE_DIALECT_SCHEMA, - ), - TypeProfile( - "core.alibaba-model-studio.v1", - "Alibaba Model Studio OpenAI-compatible multimodal chat protocol.", - OPENAI_COMPATIBLE_DIALECT_SCHEMA, - ), -) - -BUILTIN_STORAGE_TYPES = ( - StorageTypeProfile( - "http", - "HTTP storage for bounded retrieval of opaque response bytes.", - HTTP_STORAGE_SCHEMA, - False, - ), -) - -BUILTIN_STORAGE_TYPES += ( - StorageTypeProfile( - "postgresql_binary", - "PostgreSQL storage for deployment-owned binary content.", - _object_schema( - "PostgreSQLBinaryStorageConfig", - "Configuration for PostgreSQL binary storage.", - {}, - ), - True, - ), -) - - -SOURCE_COLLECT_PARAMETERS_SCHEMA = { - "additionalProperties": False, - "description": "Parameters of the exact ordinary Source Job type.", - "properties": { - "config": { - "additionalProperties": True, - "title": "Config", - "type": "object", - }, - "source": {"title": "Source", "type": "integer"}, - }, - "required": ["source"], - "title": "SourceCollectParameters", - "type": "object", -} - -SOURCE_BACKFILL_PARAMETERS_SCHEMA = { - **SOURCE_COLLECT_PARAMETERS_SCHEMA, - "description": "Parameters of the exact historical Source Job type.", - "title": "SourceBackfillParameters", -} - -LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA = { - "$defs": { - "LexicalMaintenanceOptions": { - "additionalProperties": False, - "properties": { - "diagnostic_limit": { - "default": 20, - "minimum": 0, - "title": "Diagnostic Limit", - "type": "integer", - }, - "max_records": { - "default": 100, - "minimum": 1, - "title": "Max Records", - "type": "integer", - }, - "scan_page_size": { - "default": 100, - "minimum": 1, - "title": "Scan Page Size", - "type": "integer", - }, - }, - "title": "LexicalMaintenanceOptions", - "type": "object", - } - }, - "additionalProperties": False, - "properties": { - "options": {"$ref": "#/$defs/LexicalMaintenanceOptions"}, - }, - "title": "LexicalMaintenanceJobParameters", - "type": "object", -} - -SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA = { - "$defs": { - "EmbeddingMaintenanceOptions": { - "additionalProperties": False, - "description": "Peer-local bounds for one resumable maintenance invocation.", - "properties": { - "batch_size": { - "default": 20, - "minimum": 1, - "title": "Batch Size", - "type": "integer", - }, - "diagnostic_limit": { - "default": 20, - "minimum": 0, - "title": "Diagnostic Limit", - "type": "integer", - }, - "max_embeddings": { - "default": 100, - "minimum": 1, - "title": "Max Embeddings", - "type": "integer", - }, - "scan_page_size": { - "default": 100, - "minimum": 1, - "title": "Scan Page Size", - "type": "integer", - }, - }, - "title": "EmbeddingMaintenanceOptions", - "type": "object", - } - }, - "additionalProperties": False, - "properties": { - "options": {"$ref": "#/$defs/EmbeddingMaintenanceOptions"}, - "profile": { - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": None, - "title": "Profile", - }, - }, - "title": "EmbeddingMaintenanceJobParameters", - "type": "object", -} - -MEDIA_INTERPRETATION_PARAMETERS_SCHEMA = { - "additionalProperties": False, - "properties": {}, - "title": "MediaInterpretationJobParameters", - "type": "object", -} - -AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA = { - "additionalProperties": False, - "properties": { - "max_seeds": { - "default": 10, - "maximum": 100, - "minimum": 3, - "title": "Max Seeds", - "type": "integer", - } - }, - "title": "AutomaticOrganizationJobParameters", - "type": "object", -} - -BUILTIN_JOB_TYPES = ( - JobTypeProfile( - "core.source.collect.v1", - "Run one ordinary collection command for a configured Source.", - SOURCE_COLLECT_PARAMETERS_SCHEMA, - 300, - ), - JobTypeProfile( - "core.source.backfill.v1", - "Run one exact historical collection command for a configured Source.", - SOURCE_BACKFILL_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.feature_retrieval.lexical.maintain.v1", - "Maintain missing or stale Block lexical projection records.", - LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA, - 900, - ), - JobTypeProfile( - "core.feature_retrieval.lexical.rebuild.v1", - "Rebuild Block lexical records present before invocation.", - LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.semantic_retrieval.maintain.v1", - "Maintain missing or stale semantic embedding records.", - SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA, - 900, - ), - JobTypeProfile( - "core.semantic_retrieval.rebuild.v1", - "Rebuild semantic embedding records present before invocation.", - SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.media_interpretation.v1", - "Interpret missing image, audio, and video Blocks through configured Agents.", - MEDIA_INTERPRETATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.rumination.explicit.v1", - "Reconsider one explicitly selected Block through rumination.", - { - "additionalProperties": False, - "description": "Explicit request to reconsider one focal Block.", - "properties": {"block": {"title": "Block", "type": "integer"}}, - "required": ["block"], - "title": "RuminationRequest", - "type": "object", - }, - 1800, - ), - JobTypeProfile( - "core.organization.rumination.automatic.v1", - "Automatically reconsider bounded information seeds through rumination.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.supersession.automatic.v1", - "Automatically judge bounded scoped-supersession candidates.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.refinement.automatic.v1", - "Automatically judge bounded non-dominating refinement candidates.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.evidence-stance.automatic.v1", - "Automatically judge bounded attributable evidence stances.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.synthesis.automatic.v1", - "Automatically create bounded provenance-preserving syntheses.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.existing-referent-anchoring.automatic.v1", - "Automatically anchor bounded mentions to existing referents.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.organization.duplicate-assertion.automatic.v1", - "Automatically judge bounded provenance-aware duplicate assertions.", - AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, - 1800, - ), - JobTypeProfile( - "core.sink.agent-query.v1", - "Answer one information need through an enabled Agent Query Sink.", - { - "additionalProperties": False, - "properties": { - "query": {"minLength": 1, "title": "Query", "type": "string"}, - "sink": {"title": "Sink", "type": "integer"}, - }, - "required": ["sink", "query"], - "title": "AgentQueryJobParameters", - "type": "object", - }, - 300, - ), -) - - -BUILTIN_STORAGES = ( - StorageProfile(-1, "http", "HTTP", {}), - StorageProfile(-4, "postgresql_binary", "PostgreSQL Binary", {}), -) - -BUILTIN_AI_DIALECTS_BY_ID = {item.id: item for item in BUILTIN_AI_DIALECTS} -BUILTIN_STORAGE_TYPES_BY_ID = {item.id: item for item in BUILTIN_STORAGE_TYPES} -BUILTIN_JOB_TYPES_BY_ID = {item.id: item for item in BUILTIN_JOB_TYPES} +if TYPE_CHECKING: + from inkcre_core.database_contract.profile import * diff --git a/app/engine.py b/app/engine.py index 41aefacf..842da442 100644 --- a/app/engine.py +++ b/app/engine.py @@ -1,13 +1,8 @@ -"""The process-owned async engine and session factory; lifespan disposes the pool.""" +"""Legacy import; use inkcre_core for new consumers.""" -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.core import CORE -from app.settings import settings - - -ASYNC_DB_ENGINE = create_async_engine( - settings.database_url, pool_pre_ping=settings.database_scale_0 -) -AsyncSessionFactory = async_sessionmaker(ASYNC_DB_ENGINE, expire_on_commit=False) +ASYNC_DB_ENGINE = CORE.engine +AsyncSessionFactory = CORE.sessions __all__ = ["ASYNC_DB_ENGINE", "AsyncSessionFactory"] diff --git a/app/http.py b/app/http.py index 992b0f17..3328f500 100644 --- a/app/http.py +++ b/app/http.py @@ -1,6 +1,6 @@ """Public HTTP address supplied by this Core's deployment configuration.""" -from app.business.peer import PeerManager +from inkcre_core.business.peer import PeerManager async def get_public_http_base_url() -> str | None: diff --git a/app/middleware.py b/app/middleware.py index 7a16662e..62249f99 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -1,6 +1,5 @@ """Middleware for logging and request tracking.""" -import math import time from typing import Callable import uuid @@ -10,15 +9,10 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp -from app.database_contract.constants import ( - JWT_ALGORITHM, - JWT_AUDIENCE, - JWT_ISSUER, - JWT_ROLE, -) +from inkcre_core.auth import decode_peer_jwt, create_peer_jwt as sign_peer_jwt from app.settings import settings -from libs.obsrv.log_record import TRACE_ID -from libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.log_record import TRACE_ID +from inkcre_core.libs.obsrv.main import get_logger SENSITIVE_QUERY_PARAMETERS = frozenset( @@ -34,75 +28,15 @@ def logged_query_params(request: Request) -> str: ) -def decode_peer_jwt( - token: str, - secret: str, - *, - now: float | None = None, -) -> dict: - """Validate the canonical peer JWT contract used by every HTTP surface.""" - claims = jwt.decode( - token, - secret, - algorithms=[JWT_ALGORITHM], - audience=JWT_AUDIENCE, - issuer=JWT_ISSUER, - options={ - "require": ["role", "iss", "aud", "iat", "exp"], - "verify_exp": False, - "verify_iat": False, - }, - ) - issued_at = claims["iat"] - expires_at = claims["exp"] - valid_numeric_dates = ( - isinstance(issued_at, (int, float)) - and not isinstance(issued_at, bool) - and isinstance(expires_at, (int, float)) - and not isinstance(expires_at, bool) - ) - if not valid_numeric_dates: - raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") - try: - issued_at_number = float(issued_at) - expires_at_number = float(expires_at) - except OverflowError as error: - raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") from error - current_epoch = time.time() if now is None else now - if ( - claims["role"] != JWT_ROLE - or not math.isfinite(issued_at_number) - or not math.isfinite(expires_at_number) - or issued_at_number > current_epoch + 60 - or expires_at_number <= current_epoch - or expires_at_number <= issued_at_number - ): - raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") - return claims - - def create_peer_jwt( - secret: str, - *, - now: float | None = None, - lifetime_seconds: int | None = None, + secret: str, *, now: float | None = None, lifetime_seconds: int | None = None ) -> str: - """Sign a Peer JWT using the configured default when no lifetime is supplied.""" - if lifetime_seconds is None: - lifetime_seconds = settings.jwt_default_lifetime_seconds - if lifetime_seconds <= 0: - raise ValueError("Peer JWT lifetime must be positive") - issued_at = int(time.time() if now is None else now) - return jwt.encode( - { - "role": JWT_ROLE, - "iss": JWT_ISSUER, - "aud": JWT_AUDIENCE, - "iat": issued_at, - "exp": issued_at + lifetime_seconds, - }, + return sign_peer_jwt( secret, - algorithm=JWT_ALGORITHM, + now=now, + lifetime_seconds=settings.jwt_default_lifetime_seconds + if lifetime_seconds is None + else lifetime_seconds, ) diff --git a/app/observability.py b/app/observability.py index d3a2fda2..7b31e1ef 100644 --- a/app/observability.py +++ b/app/observability.py @@ -1,23 +1,27 @@ """Runtime projection of optional deployment telemetry and context boundaries.""" import asyncio -from collections.abc import Mapping -from opentelemetry.context import Context -from opentelemetry.trace import Link, SpanKind -from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator -from opentelemetry import trace +from opentelemetry.trace import SpanKind from starlette.types import ASGIApp, Message, Receive, Scope, Send -from app.business.deployment_config import DeploymentConfigManager, DeploymentConfigService +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) from app.settings import settings -from app.schemas.observability import CONFIG_KEY, CONFIG_SCHEMA, ObservabilityConfig +from inkcre_core.schemas.observability import CONFIG_KEY, CONFIG_SCHEMA, ObservabilityConfig from app.version import APPLICATION_VERSION -from libs.obsrv.main import get_logger -from libs.obsrv.telemetry import emit_event, is_enabled, operation, start_telemetry +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.telemetry import is_enabled, operation, start_telemetry -PROPAGATOR = TraceContextTextMapPropagator() +from inkcre_core.libs.obsrv.propagation import ( + extract_context, + inject_context as inject_context, + submission_context as submission_context, + submission_links as submission_links, +) DeploymentConfigManager.register_schema( @@ -54,48 +58,6 @@ async def initialize_telemetry() -> None: get_logger().warning("Telemetry initialization failed (%s)", type(error).__name__) -def inject_context() -> dict[str, str]: - """Inject only standard Trace Context when this runtime explicitly enabled telemetry.""" - carrier: dict[str, str] = {} - if is_enabled(): - PROPAGATOR.inject(carrier) - return carrier - - -def submission_context() -> dict[str, str | None]: - carrier = inject_context() - state = carrier.get("tracestate") - if state is not None and len(state.encode("utf-8")) > 512: - state = None - emit_event("telemetry.carrier.dropped", {"reason": "tracestate_capacity"}) - return { - "submission_traceparent": carrier.get("traceparent"), - "submission_tracestate": state, - } - - -def extract_context(carrier: Mapping[str, str]) -> Context: - """Use the SDK parser within the application's bounded, two-header carrier.""" - if not is_enabled(): - return Context() - bounded = { - key: value - for key in ("traceparent", "tracestate") - if (value := carrier.get(key)) is not None and len(value.encode("utf-8")) <= 512 - } - return PROPAGATOR.extract(bounded, context=Context()) - - -def submission_links(parent: str | None, state: str | None) -> tuple[Link, ...]: - carrier = {} - if parent is not None: - carrier["traceparent"] = parent - if state is not None: - carrier["tracestate"] = state - context = trace.get_current_span(extract_context(carrier)).get_span_context() - return (Link(context),) if context.is_valid else () - - class TelemetryMiddleware: """Observe complete HTTP responses without recording paths, headers or bodies.""" diff --git a/app/persistence/__init__.py b/app/persistence/__init__.py index e69de29b..3688a954 100644 --- a/app/persistence/__init__.py +++ b/app/persistence/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence import * diff --git a/app/persistence/agent/__init__.py b/app/persistence/agent/__init__.py index e69de29b..f3363a88 100644 --- a/app/persistence/agent/__init__.py +++ b/app/persistence/agent/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.agent import * diff --git a/app/persistence/agent/repository.py b/app/persistence/agent/repository.py index 3c7ca031..1cd24d2f 100644 --- a/app/persistence/agent/repository.py +++ b/app/persistence/agent/repository.py @@ -1,49 +1,10 @@ -"""Persisted Agent definitions; Thread runtime state has a separate owner.""" +"""Legacy import; use inkcre_core for new consumers.""" -import sqlmodel -from sqlalchemy.ext.asyncio import AsyncSession -from app.schemas.agent import AgentDefinitionModel, AgentForm +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.persistence.agent.repository") -class AgentRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get( - self, agent_id: int, *, for_update: bool = False - ) -> AgentDefinitionModel | None: - statement = sqlmodel.select(AgentDefinitionModel).where( - AgentDefinitionModel.id == agent_id - ) - if for_update: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def list( - self, *, limit: int | None, cursor: int | None - ) -> tuple[list[AgentDefinitionModel], int | None]: - statement = sqlmodel.select(AgentDefinitionModel).order_by( - sqlmodel.col(AgentDefinitionModel.id) - ) - if cursor is not None: - statement = statement.where(sqlmodel.col(AgentDefinitionModel.id) > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def create(self, form: AgentForm) -> AgentDefinitionModel: - record = AgentDefinitionModel(**form.model_dump()) - self._session.add(record) - await self._session.flush() - return record - - async def save(self, record: AgentDefinitionModel) -> None: - self._session.add(record) - await self._session.flush() - await self._session.refresh(record) - - async def delete(self, record: AgentDefinitionModel) -> None: - await self._session.delete(record) +if TYPE_CHECKING: + from inkcre_core.persistence.agent.repository import * diff --git a/app/persistence/agent/uow.py b/app/persistence/agent/uow.py index f438b94a..ed952b06 100644 --- a/app/persistence/agent/uow.py +++ b/app/persistence/agent/uow.py @@ -1,13 +1,10 @@ -"""Short transaction for persisted Agent definitions.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import AgentRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.agent.uow") - -@asynccontextmanager -async def agent_uow() -> AsyncGenerator[AgentRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield AgentRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.agent.uow import * diff --git a/app/persistence/ai/__init__.py b/app/persistence/ai/__init__.py index e69de29b..bab590fc 100644 --- a/app/persistence/ai/__init__.py +++ b/app/persistence/ai/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.ai import * diff --git a/app/persistence/ai/repository.py b/app/persistence/ai/repository.py index b26272a4..4547bbb4 100644 --- a/app/persistence/ai/repository.py +++ b/app/persistence/ai/repository.py @@ -1,47 +1,10 @@ -"""AI registry facts inside the caller's transaction.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Sequence -from typing import Any +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlmodel -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession +sys.modules[__name__] = import_module("inkcre_core.persistence.ai.repository") -from app.schemas.ai import AIDialectModel, AIModelModel, AIProviderModel - - -class AIRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get_model(self, model_id: int) -> AIModelModel | None: - return await self._session.get(AIModelModel, model_id) - - async def get_provider(self, provider_id: int) -> AIProviderModel | None: - return await self._session.get(AIProviderModel, provider_id) - - async def list_models( - self, *, limit: int | None, cursor: int | None - ) -> tuple[list[AIModelModel], int | None]: - statement = sqlmodel.select(AIModelModel).order_by(sqlmodel.col(AIModelModel.id)) - if cursor is not None: - statement = statement.where(sqlmodel.col(AIModelModel.id) > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def sync_dialects(self, records: Sequence[dict[str, Any]]) -> None: - if not records: - return - statement = insert(AIDialectModel).values(records) - statement = statement.on_conflict_do_update( - index_elements=[AIDialectModel.id], - set_={ - "description": statement.excluded.description, - "config_schema": statement.excluded.config_schema, - }, - ) - await self._session.execute(statement) +if TYPE_CHECKING: + from inkcre_core.persistence.ai.repository import * diff --git a/app/persistence/ai/uow.py b/app/persistence/ai/uow.py index 0f1f40d5..d3abef8d 100644 --- a/app/persistence/ai/uow.py +++ b/app/persistence/ai/uow.py @@ -1,13 +1,10 @@ -"""Short database scope for AI facts; provider calls outlive this scope.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import AIRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.ai.uow") - -@asynccontextmanager -async def ai_uow() -> AsyncGenerator[AIRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield AIRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.ai.uow import * diff --git a/app/persistence/cron/__init__.py b/app/persistence/cron/__init__.py index e69de29b..50253c5c 100644 --- a/app/persistence/cron/__init__.py +++ b/app/persistence/cron/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.cron import * diff --git a/app/persistence/cron/repository.py b/app/persistence/cron/repository.py index 06b43f00..4e8ce3bd 100644 --- a/app/persistence/cron/repository.py +++ b/app/persistence/cron/repository.py @@ -1,50 +1,10 @@ -"""Locked Cron occurrences and database-clock observations.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlmodel -from sqlalchemy.ext.asyncio import AsyncSession +sys.modules[__name__] = import_module("inkcre_core.persistence.cron.repository") -from app.schemas.cron import CronModel - - -class CronRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get(self, cron_id: int, *, lock: bool = False, skip_locked: bool = False): - statement = sqlmodel.select(CronModel).where(CronModel.id == cron_id) - if lock: - statement = statement.with_for_update(skip_locked=skip_locked) - return (await self._session.scalars(statement)).one_or_none() - - async def database_now(self) -> datetime.datetime: - return ( - await self._session.execute(sqlmodel.select(sqlmodel.func.statement_timestamp())) - ).scalar_one() - - async def enabled_ids(self): - return tuple( - await self._session.scalars( - sqlmodel.select(CronModel.id).where(sqlmodel.col(CronModel.enabled).is_(True)) - ) - ) - - async def list(self, *, limit: int | None, cursor: int | None): - statement = sqlmodel.select(CronModel).order_by(sqlmodel.col(CronModel.id)) - if cursor is not None: - statement = statement.where(sqlmodel.col(CronModel.id) > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def save(self, cron: CronModel) -> None: - self._session.add(cron) - await self._session.flush() - await self._session.refresh(cron) - - async def delete(self, cron: CronModel) -> None: - await self._session.delete(cron) +if TYPE_CHECKING: + from inkcre_core.persistence.cron.repository import * diff --git a/app/persistence/cron/uow.py b/app/persistence/cron/uow.py index 23a96dd2..6d2cf1cd 100644 --- a/app/persistence/cron/uow.py +++ b/app/persistence/cron/uow.py @@ -1,24 +1,10 @@ -"""A Cron occurrence and its admitted Job commit together.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from app.persistence.job.repository import JobRepository -from app.persistence.job.uow import JobUnitOfWork -from app.persistence.source.repository import SourceRepository -from .repository import CronRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.cron.uow") - -@dataclass(frozen=True) -class CronUnitOfWork(JobUnitOfWork): - crons: CronRepository - - -@asynccontextmanager -async def cron_uow() -> AsyncGenerator[CronUnitOfWork, None]: - async with AsyncSessionFactory.begin() as session: - yield CronUnitOfWork( - JobRepository(session), SourceRepository(session), CronRepository(session) - ) +if TYPE_CHECKING: + from inkcre_core.persistence.cron.uow import * diff --git a/app/persistence/deployment_config/__init__.py b/app/persistence/deployment_config/__init__.py index e69de29b..ceeb82fa 100644 --- a/app/persistence/deployment_config/__init__.py +++ b/app/persistence/deployment_config/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.deployment_config import * diff --git a/app/persistence/deployment_config/repository.py b/app/persistence/deployment_config/repository.py index fe1d7498..90a8112c 100644 --- a/app/persistence/deployment_config/repository.py +++ b/app/persistence/deployment_config/repository.py @@ -1,68 +1,12 @@ -"""Deployment config persistence and transaction composition.""" +"""Legacy import; use inkcre_core for new consumers.""" -from typing import Any +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlmodel -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession +sys.modules[__name__] = import_module( + "inkcre_core.persistence.deployment_config.repository" +) -from app.schemas.deployment_config import DeploymentConfigModel - - -class DeploymentConfigRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get( - self, key: str, *, for_update: bool = False - ) -> DeploymentConfigModel | None: - statement = sqlmodel.select(DeploymentConfigModel).where( - DeploymentConfigModel.key == key - ) - if for_update: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def list( - self, *, limit: int | None, cursor: str | None - ) -> tuple[list[DeploymentConfigModel], str | None]: - statement = sqlmodel.select(DeploymentConfigModel).order_by(DeploymentConfigModel.key) - if cursor is not None: - statement = statement.where(DeploymentConfigModel.key > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].key if more else None - - async def replace( - self, key: str, schema_id: str, value: dict[str, Any] - ) -> tuple[DeploymentConfigModel, bool]: - statement = insert(DeploymentConfigModel).values( - key=key, schema_id=schema_id, value=value - ) - statement = statement.on_conflict_do_nothing( - index_elements=[DeploymentConfigModel.key] - ).returning(DeploymentConfigModel) - record = (await self._session.scalars(statement)).one_or_none() - if record is not None: - return record, True - # The insert conflict, rather than a preceding read, determines creation status. - statement = ( - sqlmodel.update(DeploymentConfigModel) - .where(sqlmodel.col(DeploymentConfigModel.key) == key) - .values(schema_id=schema_id, value=value) - .returning(DeploymentConfigModel) - ) - record = (await self._session.scalars(statement)).one() - return record, False - - async def save(self, record: DeploymentConfigModel) -> None: - self._session.add(record) - await self._session.flush() - # UPDATE timestamps are managed by database triggers, not Python defaults. - await self._session.refresh(record) - - async def delete(self, record: DeploymentConfigModel) -> None: - await self._session.delete(record) +if TYPE_CHECKING: + from inkcre_core.persistence.deployment_config.repository import * diff --git a/app/persistence/deployment_config/uow.py b/app/persistence/deployment_config/uow.py index 425a9c15..aa0968a6 100644 --- a/app/persistence/deployment_config/uow.py +++ b/app/persistence/deployment_config/uow.py @@ -1,12 +1,10 @@ -"""Deployment configuration transaction composition.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from app.engine import AsyncSessionFactory -from .repository import DeploymentConfigRepository +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.persistence.deployment_config.uow") -@asynccontextmanager -async def configuration_transaction() -> AsyncGenerator[DeploymentConfigRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield DeploymentConfigRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.deployment_config.uow import * diff --git a/app/persistence/extension/__init__.py b/app/persistence/extension/__init__.py index e69de29b..251f0360 100644 --- a/app/persistence/extension/__init__.py +++ b/app/persistence/extension/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.extension import * diff --git a/app/persistence/extension/repository.py b/app/persistence/extension/repository.py index 92107b1d..c7b54c3b 100644 --- a/app/persistence/extension/repository.py +++ b/app/persistence/extension/repository.py @@ -1,72 +1,10 @@ -"""Session-bound access to canonical Extension rows and the enabled RPC.""" +"""Legacy import; use inkcre_core for new consumers.""" -import uuid +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -import sqlmodel -from sqlalchemy.ext.asyncio import AsyncSession +sys.modules[__name__] = import_module("inkcre_core.persistence.extension.repository") -from app.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel - - -class ExtensionRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def list(self): - rows = await self._session.scalars( - sqlmodel.select(ExtensionModel).order_by(ExtensionModel.name) - ) - return tuple(rows) - - async def get(self, name: str, *, lock: bool = False): - statement = sqlmodel.select(ExtensionModel).where(ExtensionModel.name == name) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def try_install_lock(self, name: str) -> bool: - result = await self._session.execute( - sqlalchemy.text("SELECT pg_try_advisory_xact_lock(hashtextextended(:name, 0))"), - {"name": name}, - ) - return bool(result.scalar_one()) - - async def save(self, row: ExtensionModel) -> None: - self._session.add(row) - await self._session.flush() - - async def delete(self, row: ExtensionModel) -> None: - await self._session.delete(row) - - async def list_participations(self, peer_id: uuid.UUID): - rows = await self._session.scalars( - sqlmodel.select(ExtensionPeerParticipationModel) - .where(ExtensionPeerParticipationModel.peer_id == peer_id) - .order_by(ExtensionPeerParticipationModel.name) - ) - return tuple(rows) - - async def list_participations_for_extension(self, name: str): - rows = await self._session.scalars( - sqlmodel.select(ExtensionPeerParticipationModel).where( - ExtensionPeerParticipationModel.name == name - ) - ) - return tuple(rows) - - async def get_participation(self, name: str, peer_id: uuid.UUID, *, lock: bool = False): - statement = sqlmodel.select(ExtensionPeerParticipationModel).where( - ExtensionPeerParticipationModel.name == name, - ExtensionPeerParticipationModel.peer_id == peer_id, - ) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def save_participation(self, row: ExtensionPeerParticipationModel) -> None: - self._session.add(row) - await self._session.flush() - - async def delete_participation(self, row: ExtensionPeerParticipationModel) -> None: - await self._session.delete(row) +if TYPE_CHECKING: + from inkcre_core.persistence.extension.repository import * diff --git a/app/persistence/extension/uow.py b/app/persistence/extension/uow.py index 2cefad01..903afc73 100644 --- a/app/persistence/extension/uow.py +++ b/app/persistence/extension/uow.py @@ -1,13 +1,10 @@ -"""Short Extension transaction; publication and acquisition remain outside.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import ExtensionRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.extension.uow") - -@asynccontextmanager -async def extension_uow() -> AsyncGenerator[ExtensionRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield ExtensionRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.extension.uow import * diff --git a/app/persistence/info_base/__init__.py b/app/persistence/info_base/__init__.py index e69de29b..69343b5f 100644 --- a/app/persistence/info_base/__init__.py +++ b/app/persistence/info_base/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.info_base import * diff --git a/app/persistence/info_base/repository.py b/app/persistence/info_base/repository.py index 282bed81..b8b0cbe1 100644 --- a/app/persistence/info_base/repository.py +++ b/app/persistence/info_base/repository.py @@ -1,501 +1,10 @@ -"""Graph persistence within a caller-owned asynchronous transaction.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Iterable -import random -import typing -from typing import Optional as Opt -import sqlalchemy -import sqlmodel -from utils.types_ import Undefined, _undefined -from app.schemas.info_base.block import BlockID, ResolverType -from app.schemas.info_base.relation import RelationID -from app.schemas.info_base.storage import StorageID +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from sqlalchemy.ext.asyncio import AsyncSession +sys.modules[__name__] = import_module("inkcre_core.persistence.info_base.repository") -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.relation import RelationCreateForm, RelationModel - - -class BlockRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def create_many(self, forms: Iterable[BlockForm]) -> tuple[BlockModel, ...]: - """Insert producer fields and load database identities in one flush.""" - blocks = tuple( - BlockModel.model_validate(form.model_dump(include=set(BlockForm.model_fields))) - for form in forms - ) - if blocks: - self._session.add_all(blocks) - await self._session.flush() - return blocks - - async def get_many(self, block_ids: typing.Collection[BlockID]) -> tuple[BlockModel, ...]: - """Return the existing Blocks from a bounded identity set.""" - if not block_ids: - return () - return tuple( - ( - await self._session.scalars( - sqlmodel.select(BlockModel).where( - sqlmodel.col(BlockModel.id).in_(tuple(block_ids)) - ) - ) - ).all() - ) - - async def get_by_resolvers( - self, resolvers: typing.Collection[str], *, lock: bool = False - ) -> tuple[BlockModel, ...]: - if not resolvers: - return () - statement = sqlmodel.select(BlockModel).where( - sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)) - ) - if lock: - statement = statement.order_by(sqlmodel.col(BlockModel.id)).with_for_update() - return tuple(await self._session.scalars(statement)) - - async def save_many(self, blocks: Iterable[BlockModel]) -> None: - self._session.add_all(blocks) - await self._session.flush() - - async def get_random_many(self, count: int) -> tuple[BlockModel, ...]: - """Return up to count distinct random Blocks.""" - if count <= 0: - return () - return tuple( - ( - await self._session.scalars( - sqlmodel.select(BlockModel).order_by(sqlmodel.func.random()).limit(count) - ) - ).all() - ) - - async def get_random(self) -> Opt[BlockModel]: - """Return one existing Block without transferring all identities.""" - block_id_column = sqlmodel.col(BlockModel.id) - count = ( - await self._session.scalars(sqlmodel.select(sqlmodel.func.count(block_id_column))) - ).one() - if count == 0: - return None - offset = random.SystemRandom().randrange(count) - return ( - await self._session.scalars( - sqlmodel.select(BlockModel).order_by(block_id_column).offset(offset).limit(1) - ) - ).one() - - async def get(self, block_id: BlockID, *, lock: bool = False) -> Opt[BlockModel]: - statement = sqlmodel.select(BlockModel).where(BlockModel.id == block_id) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def save(self, block: BlockModel) -> None: - self._session.add(block) - await self._session.flush() - - async def edit_block( - self, - block_id: BlockID, - content: Opt[str] = None, - resolver: Opt[ResolverType] = None, - storage: Opt[StorageID] | Undefined = _undefined, - ) -> BlockModel: - """编辑块""" - block = await self.get(block_id) - if block is None: - raise ValueError("Block not found") - if content is not None: - block.content = content - if resolver is not None: - block.resolver = resolver - if storage is not _undefined: - block.storage = typing.cast(StorageID | None, storage) - self._session.add(block) - await self._session.flush() - await self._session.refresh(block) - return block - - async def delete(self, block_id: BlockID) -> bool: - """Delete a block, within the surrounding transaction.""" - block = await self.get(block_id) - if block is None: - return False - await self._session.delete(block) - await self._session.flush() - return True - - async def get_recent( - self, num: int = 10, resolver: Opt[ResolverType] = None - ) -> tuple[BlockModel, ...]: - statement = ( - sqlmodel.select(BlockModel).order_by(sqlmodel.desc(BlockModel.created_at)).limit(num) - ) - if resolver: - statement = statement.where(BlockModel.resolver == resolver) - return tuple((await self._session.scalars(statement)).all()) - - async def create(self, form: BlockForm) -> BlockModel: - return (await self.create_many((form,)))[0] - - async def find_content(self, resolver: str, content: str) -> BlockModel | None: - return ( - await self._session.scalars( - sqlmodel.select(BlockModel).where( - BlockModel.resolver == resolver, - BlockModel.content == content, - ) - ) - ).one_or_none() - - async def find_json_field( - self, - resolver: str, - field: str, - value: str, - *, - block_ids: typing.Collection[BlockID] | None = None, - ) -> tuple[BlockModel, ...]: - from utils.sql import find_by_json_field - - statement = sqlmodel.select(BlockModel).where( - BlockModel.resolver == resolver, - find_by_json_field(BlockModel.content, field, value), - ) - if block_ids is not None: - statement = statement.where(sqlmodel.col(BlockModel.id).in_(tuple(block_ids))) - return tuple(await self._session.scalars(statement)) - - async def find_json_field_many( - self, - resolvers: typing.Collection[str], - field: str, - values: typing.Collection[str], - ) -> tuple[BlockModel, ...]: - """Find inline JSON records in a bounded native-identity set.""" - from sqlalchemy.dialects.postgresql import JSONB - - if not resolvers or not values: - return () - return tuple( - await self._session.scalars( - sqlmodel.select(BlockModel) - .where( - sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)), - sqlalchemy.case( - ( - sqlalchemy.func.pg_input_is_valid(BlockModel.content, "jsonb"), - sqlalchemy.cast(BlockModel.content, JSONB)[field].astext, - ) - ).in_(tuple(values)), - sqlmodel.col(BlockModel.storage).is_(None), - ) - .order_by(sqlmodel.col(BlockModel.id)) - ) - ) - - async def acquire_transaction_lock(self, namespace: str, *, shared: bool = False) -> None: - """Serialize an owning use case until its surrounding transaction ends.""" - function = ( - sqlmodel.func.pg_advisory_xact_lock_shared - if shared - else sqlmodel.func.pg_advisory_xact_lock - ) - await self._session.execute( - sqlmodel.select(function(sqlmodel.func.hashtextextended(namespace, 0))) - ) - - async def get_related( - self, block_id: BlockID, *, content: str, outgoing: bool = True - ) -> BlockModel | None: - endpoint = RelationModel.from_ if outgoing else RelationModel.to_ - other = RelationModel.to_ if outgoing else RelationModel.from_ - statement = ( - sqlmodel.select(BlockModel) - .join(RelationModel, sqlmodel.col(BlockModel.id) == other) - .where( - endpoint == block_id, - RelationModel.content == content, - ) - .limit(1) - ) - return (await self._session.scalars(statement)).first() - - async def select_ids( - self, - limit: int, - *, - exclude_resolvers: typing.Collection[str], - random_order: bool = False, - ) -> tuple[BlockID, ...]: - statement = sqlmodel.select(BlockModel.id).where( - sqlmodel.col(BlockModel.resolver).not_in(tuple(exclude_resolvers)) - ) - statement = ( - statement.order_by(sqlalchemy.func.random()) - if random_order - else statement.order_by( - sqlmodel.desc(BlockModel.updated_at), sqlmodel.desc(BlockModel.id) - ) - ) - return tuple( - typing.cast(BlockID, value) - for value in await self._session.scalars(statement.limit(limit)) - ) - - async def matching_content(self, resolver: str, content: str) -> tuple[BlockModel, ...]: - return tuple( - await self._session.scalars( - sqlmodel.select(BlockModel).where( - BlockModel.resolver == resolver, BlockModel.content == content - ) - ) - ) - - async def without_outgoing_relation( - self, resolvers: typing.Collection[str], content: str, *, limit: int - ) -> tuple[BlockModel, ...]: - exists = sqlalchemy.exists( - sqlmodel.select(RelationModel.id).where( - RelationModel.from_ == BlockModel.id, RelationModel.content == content - ) - ) - return tuple( - await self._session.scalars( - sqlmodel.select(BlockModel) - .where(sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)), ~exists) - .order_by(sqlmodel.col(BlockModel.id)) - .limit(limit) - ) - ) - - -class RelationRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def create_many( - self, forms: Iterable[RelationCreateForm] - ) -> tuple[RelationModel, ...]: - """Insert directed relations without committing the surrounding operation.""" - relations = tuple(RelationModel.model_validate(form) for form in forms) - if relations: - self._session.add_all(relations) - await self._session.flush() - return relations - - async def get_many( - self, relation_ids: typing.Collection[RelationID] - ) -> tuple[RelationModel, ...]: - """Return the existing Relations from a bounded identity set.""" - if not relation_ids: - return () - return tuple( - ( - await self._session.scalars( - sqlmodel.select(RelationModel).where( - sqlmodel.col(RelationModel.id).in_(tuple(relation_ids)) - ) - ) - ).all() - ) - - async def get_for_endpoints( - self, block_ids: typing.Collection[BlockID], *, contents: typing.Collection[str] - ) -> tuple[RelationModel, ...]: - if not block_ids: - return () - return tuple( - await self._session.scalars( - sqlmodel.select(RelationModel).where( - sqlmodel.col(RelationModel.content).in_(tuple(contents)), - sqlalchemy.or_( - sqlmodel.col(RelationModel.from_).in_(tuple(block_ids)), - sqlmodel.col(RelationModel.to_).in_(tuple(block_ids)), - ), - ) - ) - ) - - async def delete_many(self, relation_ids: typing.Collection[RelationID]) -> None: - if relation_ids: - await self._session.execute( - sqlalchemy.delete(RelationModel).where( - sqlmodel.col(RelationModel.id).in_(tuple(relation_ids)) - ) - ) - - async def get_outgoing_many( - self, - block_ids: typing.Collection[BlockID], - *, - to_ids: typing.Collection[BlockID] | None = None, - content: str | None = None, - lock: bool = False, - ) -> tuple[RelationModel, ...]: - if not block_ids: - return () - statement = sqlmodel.select(RelationModel).where( - sqlmodel.col(RelationModel.from_).in_(tuple(block_ids)) - ) - if to_ids is not None: - statement = statement.where(sqlmodel.col(RelationModel.to_).in_(tuple(to_ids))) - if content is not None: - statement = statement.where(RelationModel.content == content) - if lock: - statement = statement.order_by(sqlmodel.col(RelationModel.id)).with_for_update() - return tuple(await self._session.scalars(statement)) - - async def save(self, relation: RelationModel) -> None: - self._session.add(relation) - await self._session.flush() - - async def remove(self, relation: RelationModel) -> None: - await self._session.delete(relation) - - async def get_by_id(self, relation_id: RelationID) -> RelationModel | None: - return await self._session.get(RelationModel, relation_id) - - async def get_endpoint_page( - self, - block_ids: typing.Collection[BlockID], - *, - endpoint: typing.Literal["from", "to"], - contents: typing.Collection[str] = (), - cursor: RelationID | None = None, - limit: int = 21, - ) -> tuple[RelationModel, ...]: - """Read a bounded Relation page for one persisted endpoint direction.""" - if not block_ids or limit <= 0: - return () - endpoint_column = RelationModel.from_ if endpoint == "from" else RelationModel.to_ - statement = sqlmodel.select(RelationModel).where( - sqlmodel.col(endpoint_column).in_(tuple(block_ids)) - ) - if contents: - statement = statement.where(sqlmodel.col(RelationModel.content).in_(tuple(contents))) - relation_id_column = sqlmodel.col(RelationModel.id) - if cursor is not None: - statement = statement.where(relation_id_column < cursor) - statement = statement.order_by(sqlmodel.desc(relation_id_column)).limit(limit) - return tuple((await self._session.scalars(statement)).all()) - - async def fetchsert(self, relation: RelationModel) -> RelationModel: - """Create if not exists, else return the existing one. - - Will NOT commit the session. - """ - existing = ( - await self._session.scalars( - sqlmodel.select(RelationModel).where( - RelationModel.content == relation.content, - RelationModel.from_ == relation.from_, - RelationModel.to_ == relation.to_, - ) - ) - ).one_or_none() - if existing is not None: - return existing - self._session.add(relation) - await self._session.flush() - return relation - - async def get( - self, - block_id: BlockID, - include_in: bool = True, - include_out: bool = True, - content: Opt[str] = None, - ) -> tuple[RelationModel, ...]: - """Get relations from/to a block - - :param include_in: Include the relations where the block is the target - :param include_out: Include the relations where the block is the source - :param content: If specified, filter relations by content (eq) - """ - if not include_in and (not include_out): - return () - directions: list[typing.Any] = [] - if include_in: - directions.append(RelationModel.to_ == block_id) - if include_out: - directions.append(RelationModel.from_ == block_id) - statement = sqlmodel.select(RelationModel).where(sqlalchemy.or_(*directions)) - if content is not None: - statement = statement.where(RelationModel.content == content) - res = (await self._session.scalars(statement)).all() - return tuple(res) - - async def update( - self, - relation_id: RelationID, - *, - from_: BlockID | Undefined = _undefined, - to_: BlockID | Undefined = _undefined, - content: str | Undefined = _undefined, - ) -> RelationModel: - """Update selected relation facts in the surrounding transaction.""" - relation = ( - await self._session.scalars( - sqlmodel.select(RelationModel).where(RelationModel.id == relation_id) - ) - ).one_or_none() - if relation is None: - raise ValueError("Relation not found") - if from_ is not _undefined: - relation.from_ = typing.cast(BlockID, from_) - if to_ is not _undefined: - relation.to_ = typing.cast(BlockID, to_) - if content is not _undefined: - relation.content = typing.cast(str, content) - self._session.add(relation) - await self._session.flush() - await self._session.refresh(relation) - return relation - - async def delete(self, relation_id: RelationID) -> bool: - """Delete a relation, within the surrounding transaction.""" - relation = ( - await self._session.scalars( - sqlmodel.select(RelationModel).where(RelationModel.id == relation_id) - ) - ).one_or_none() - if relation is None: - return False - await self._session.delete(relation) - await self._session.flush() - return True - - async def create(self, from_: BlockID, to_: BlockID, content: str) -> RelationModel: - return ( - await self.create_many((RelationCreateForm(from_=from_, to_=to_, content=content),)) - )[0] - - async def random_incoming_sources( - self, block_id: BlockID, content: str, limit: int - ) -> tuple[BlockID, ...]: - return tuple( - await self._session.scalars( - sqlmodel.select(RelationModel.from_) - .where(RelationModel.to_ == block_id, RelationModel.content == content) - .order_by(sqlalchemy.func.random()) - .limit(limit) - ) - ) - - async def recent( - self, limit: int, *, contents: typing.Collection[str] = () - ) -> tuple[RelationModel, ...]: - statement = ( - sqlmodel.select(RelationModel) - .order_by(sqlmodel.desc(RelationModel.updated_at), sqlmodel.desc(RelationModel.id)) - .limit(limit) - ) - if contents: - statement = statement.where(sqlmodel.col(RelationModel.content).in_(tuple(contents))) - return tuple(await self._session.scalars(statement)) +if TYPE_CHECKING: + from inkcre_core.persistence.info_base.repository import * diff --git a/app/persistence/info_base/storage.py b/app/persistence/info_base/storage.py index 445be112..cd852c37 100644 --- a/app/persistence/info_base/storage.py +++ b/app/persistence/info_base/storage.py @@ -1,92 +1,10 @@ -"""Storage catalog and byte persistence bound to the enclosing graph transaction.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Sequence -from typing import Any -from uuid import UUID +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.persistence.info_base.storage") -from app.schemas.info_base.storage import StorageBlobModel, StorageModel, StorageTypesModel - - -class StorageRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get(self, storage_id: int) -> StorageModel: - return ( - await self._session.scalars( - sqlmodel.select(StorageModel).where(StorageModel.id == storage_id) - ) - ).one() - - async def sync_types(self, values: Sequence[dict[str, Any]]) -> None: - if not values: - return - statement = insert(StorageTypesModel).values(values) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=[StorageTypesModel.id], - set_={ - "description": statement.excluded.description, - "config_schema": statement.excluded.config_schema, - "writable": statement.excluded.writable, - }, - ) - ) - - async def sync_builtins(self, values: Sequence[dict[str, Any]]) -> None: - if not values: - return - statement = insert(StorageModel).values(values) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=["id"], - set_={ - "type": statement.excluded.type, - "nickname": statement.excluded.nickname, - "config": statement.excluded.config, - }, - ) - ) - - async def create(self, storage: StorageModel) -> StorageModel: - self._session.add(storage) - await self._session.flush() - return storage - - async def read_blob(self, blob_id: UUID, *, lock: bool = False) -> bytes | None: - statement = sqlmodel.select(StorageBlobModel.data).where(StorageBlobModel.id == blob_id) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def create_blob(self, content: bytes) -> UUID: - blob = StorageBlobModel(data=content) - self._session.add(blob) - await self._session.flush() - return blob.id - - async def update_blob(self, blob_id: UUID, content: bytes) -> bool: - result = await self._session.scalars( - sqlalchemy.update(StorageBlobModel) - .where( - sqlmodel.col(StorageBlobModel.id) == blob_id, - ) - .values(data=content) - .returning(sqlmodel.col(StorageBlobModel.id)) - ) - return result.one_or_none() is not None - - async def delete_blob(self, blob_id: UUID) -> bool: - result = await self._session.scalars( - sqlalchemy.delete(StorageBlobModel) - .where( - sqlmodel.col(StorageBlobModel.id) == blob_id, - ) - .returning(sqlmodel.col(StorageBlobModel.id)) - ) - return result.one_or_none() is not None +if TYPE_CHECKING: + from inkcre_core.persistence.info_base.storage import * diff --git a/app/persistence/info_base/uow.py b/app/persistence/info_base/uow.py index 112e65ed..66285a0d 100644 --- a/app/persistence/info_base/uow.py +++ b/app/persistence/info_base/uow.py @@ -1,30 +1,10 @@ -"""Graph transaction composition; repositories share one private session.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory +sys.modules[__name__] = import_module("inkcre_core.persistence.info_base.uow") -from .repository import BlockRepository, RelationRepository -from .storage import StorageRepository - - -@dataclass(frozen=True) -class GraphUnitOfWork: - blocks: BlockRepository - relations: RelationRepository - storage: StorageRepository - - -@asynccontextmanager -async def graph_uow( - *, repeatable_read: bool = False -) -> AsyncGenerator[GraphUnitOfWork, None]: - """Commit on success; roll back on failure and always close the session.""" - async with AsyncSessionFactory.begin() as session: - if repeatable_read: - await session.connection(execution_options={"isolation_level": "REPEATABLE READ"}) - yield GraphUnitOfWork( - BlockRepository(session), RelationRepository(session), StorageRepository(session) - ) +if TYPE_CHECKING: + from inkcre_core.persistence.info_base.uow import * diff --git a/app/persistence/job/__init__.py b/app/persistence/job/__init__.py index e69de29b..0d8afbc4 100644 --- a/app/persistence/job/__init__.py +++ b/app/persistence/job/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.job import * diff --git a/app/persistence/job/repository.py b/app/persistence/job/repository.py index 85862415..fbb8a655 100644 --- a/app/persistence/job/repository.py +++ b/app/persistence/job/repository.py @@ -1,140 +1,10 @@ -"""Session-bound durable Job catalog, claim and terminal writes.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.persistence.job.repository") -from app.schemas.job import JobModel, JobStatus, JobTypeModel - - -class JobRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def sync_types(self, rows: list[dict]) -> None: - if not rows: - return - statement = insert(JobTypeModel).values(rows) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=[JobTypeModel.id], - set_={ - key: getattr(statement.excluded, key) - for key in ("description", "parameters_schema", "default_timeout_seconds") - }, - ) - ) - - async def get(self, job_id: int) -> JobModel | None: - return await self._session.get(JobModel, job_id) - - async def get_type(self, type_: str) -> JobTypeModel | None: - return await self._session.get(JobTypeModel, type_) - - async def create(self, job: JobModel) -> JobModel: - self._session.add(job) - await self._session.flush() - await self._session.refresh(job) - return job - - async def list_types(self, *, limit: int | None, cursor: str | None): - statement = sqlmodel.select(JobTypeModel).order_by(JobTypeModel.id) - if cursor is not None: - statement = statement.where(JobTypeModel.id > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def list_jobs( - self, *, limit: int, cursor: int | None, type_: str | None, status: JobStatus | None - ): - statement = sqlmodel.select(JobModel).order_by(sqlmodel.col(JobModel.id).desc()) - if cursor is not None: - statement = statement.where(sqlmodel.col(JobModel.id) < cursor) - if type_ is not None: - statement = statement.where(JobModel.type == type_) - if status is not None: - statement = statement.where(JobModel.status == status) - rows = list((await self._session.scalars(statement.limit(limit + 1))).all()) - more = len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def abort(self, job_id: int) -> JobModel | None: - table = typing.cast(typing.Any, getattr(JobModel, "__table__")) - await self._session.execute( - sqlalchemy.update(table) - .where( - table.c.id == job_id, - table.c.status.in_((JobStatus.PENDING, JobStatus.RUNNING)), - ) - .values( - abort_requested=True, - status=sqlalchemy.case( - ( - table.c.status == JobStatus.PENDING, - sqlalchemy.cast(JobStatus.ABORTED.value, table.c.status.type), - ), - else_=table.c.status, - ), - ) - ) - return await self.get(job_id) - - async def abort_requests(self, active_ids: tuple[int, ...]): - return tuple( - await self._session.scalars( - sqlmodel.select(JobModel.id).where( - sqlmodel.col(JobModel.id).in_(active_ids), - sqlmodel.col(JobModel.abort_requested).is_(True), - ) - ) - ) - - async def claim(self, job_id: int) -> JobModel | None: - result = await self._session.scalars( - sqlalchemy.update(JobModel) - .where( - sqlmodel.col(JobModel.id) == job_id, - sqlmodel.col(JobModel.status) == JobStatus.PENDING, - ) - .values(status=JobStatus.RUNNING) - .returning(JobModel) - ) - return result.one_or_none() - - async def close(self, job: JobModel, status: JobStatus) -> bool: - table = typing.cast(typing.Any, getattr(JobModel, "__table__")) - result = await self._session.execute( - sqlalchemy.update(table) - .where(table.c.id == job.id, table.c.status == JobStatus.RUNNING) - .values(status=status, state=job.state) - ) - return bool(typing.cast(typing.Any, result).rowcount) - - async def expire_overdue(self) -> int: - table = typing.cast(typing.Any, getattr(JobModel, "__table__")) - result = await self._session.execute( - sqlalchemy.update(table) - .where( - table.c.status == JobStatus.RUNNING, - sqlalchemy.text( - "started_at + timeout_seconds * interval '1 second' <= statement_timestamp()" - ), - ) - .values(status=JobStatus.TIMED_OUT) - ) - return typing.cast(typing.Any, result).rowcount or 0 - - async def pending(self): - return tuple( - await self._session.scalars( - sqlmodel.select(JobModel).where(JobModel.status == JobStatus.PENDING) - ) - ) +if TYPE_CHECKING: + from inkcre_core.persistence.job.repository import * diff --git a/app/persistence/job/uow.py b/app/persistence/job/uow.py index 11641f3f..10c30c67 100644 --- a/app/persistence/job/uow.py +++ b/app/persistence/job/uow.py @@ -1,21 +1,10 @@ -"""Job admission also reads Source contracts within its transaction.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from app.persistence.source.repository import SourceRepository -from .repository import JobRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.job.uow") - -@dataclass(frozen=True) -class JobUnitOfWork: - jobs: JobRepository - sources: SourceRepository - - -@asynccontextmanager -async def job_uow() -> AsyncGenerator[JobUnitOfWork, None]: - async with AsyncSessionFactory.begin() as session: - yield JobUnitOfWork(JobRepository(session), SourceRepository(session)) +if TYPE_CHECKING: + from inkcre_core.persistence.job.uow import * diff --git a/app/persistence/lexical_retrieval/__init__.py b/app/persistence/lexical_retrieval/__init__.py index 05dca4e7..6eee1fd6 100644 --- a/app/persistence/lexical_retrieval/__init__.py +++ b/app/persistence/lexical_retrieval/__init__.py @@ -1 +1,3 @@ -"""Lexical projection persistence.""" +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.lexical_retrieval import * diff --git a/app/persistence/lexical_retrieval/repository.py b/app/persistence/lexical_retrieval/repository.py index 52413a52..d2520dcc 100644 --- a/app/persistence/lexical_retrieval/repository.py +++ b/app/persistence/lexical_retrieval/repository.py @@ -1,145 +1,12 @@ -"""Session-bound lexical ranking, candidate scans and projection batch writes.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession -import sqlmodel +sys.modules[__name__] = import_module( + "inkcre_core.persistence.lexical_retrieval.repository" +) -from app.schemas.info_base.block import BlockModel -from app.schemas.lexical_retrieval import BlockLexicalRecordModel - - -class LexicalRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def search(self, normalized: str, limit: int): - literal_pattern = f"%{self._escape_like(normalized)}%" - - block_columns = typing.cast( - typing.Any, - BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - BlockLexicalRecordModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - query_terms = sqlalchemy.func.plainto_tsquery("simple", normalized) - label_exact = sqlalchemy.func.lower(record_columns.label) == normalized.lower() - label_substring = record_columns.label.ilike(literal_pattern, escape="\\") - text_substring = record_columns.text.ilike(literal_pattern, escape="\\") - term_match = record_columns.search_vector.op("@@")(query_terms) - evidence = sqlalchemy.case( - (label_exact, "label_exact"), - (label_substring, "label_substring"), - (text_substring, "text_substring"), - else_="terms", - ).label("evidence") - evidence_class = sqlalchemy.case( - (label_exact, 4.0), - (label_substring, 3.0), - (text_substring, 2.0), - else_=1.0, - ) - term_rank = sqlalchemy.func.ts_rank_cd(record_columns.search_vector, query_terms) - rank = (evidence_class + term_rank).label("rank") - - statement = ( - sqlmodel.select(BlockModel, BlockLexicalRecordModel, evidence, rank) - .join( - BlockLexicalRecordModel, - record_columns.block == block_columns.id, - ) - .where( - record_columns.updated_at >= block_columns.updated_at, - sqlalchemy.or_( - label_exact, - label_substring, - text_substring, - term_match, - ), - ) - .order_by( - sqlalchemy.desc(evidence_class), - sqlalchemy.desc(term_rank), - block_columns.id, - ) - .limit(limit) - ) - return (await self._session.execute(statement)).all() - - async def candidate_page( - self, - cursor: int, - page_size: int, - rebuild_cutoff: datetime.datetime | None, - ) -> tuple[tuple[BlockModel, ...], int, bool]: - block_columns = typing.cast( - typing.Any, - BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - BlockLexicalRecordModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - needs_projection = sqlalchemy.or_( - record_columns.block.is_(None), - record_columns.updated_at < block_columns.updated_at, - ) - if rebuild_cutoff is not None: - needs_projection = sqlalchemy.or_( - needs_projection, - record_columns.updated_at < rebuild_cutoff, - ) - statement = ( - sqlmodel.select(BlockModel) - .outerjoin( - BlockLexicalRecordModel, - record_columns.block == block_columns.id, - ) - .where(block_columns.id > cursor, needs_projection) - .order_by(block_columns.id) - .limit(page_size) - ) - blocks = tuple(await self._session.scalars(statement)) - next_cursor = typing.cast(int, blocks[-1].id) if blocks else cursor - return blocks, next_cursor, len(blocks) < page_size - - async def database_now(self) -> datetime.datetime: - return ( - await self._session.execute(sqlmodel.select(sqlalchemy.func.current_timestamp())) - ).scalar_one() - - async def upsert(self, projections: list[dict[str, typing.Any]]) -> None: - if not projections: - return - rows = [] - for projection in projections: - label_vector = sqlalchemy.func.setweight( - sqlalchemy.func.to_tsvector("simple", projection["label"]), - sqlalchemy.literal_column("'A'::\"char\""), - ) - text_vector = sqlalchemy.func.setweight( - sqlalchemy.func.to_tsvector("simple", projection["text"] or ""), - sqlalchemy.literal_column("'D'::\"char\""), - ) - rows.append({**projection, "search_vector": label_vector.op("||")(text_vector)}) - statement = insert(BlockLexicalRecordModel).values(rows) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=["block"], - set_={ - "label": statement.excluded.label, - "text": statement.excluded.text, - "search_vector": statement.excluded.search_vector, - "updated_at": sqlalchemy.func.current_timestamp(), - }, - ) - ) - - @staticmethod - def _escape_like(value: str) -> str: - return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +if TYPE_CHECKING: + from inkcre_core.persistence.lexical_retrieval.repository import * diff --git a/app/persistence/lexical_retrieval/uow.py b/app/persistence/lexical_retrieval/uow.py index 3a1338c3..b5ed6aad 100644 --- a/app/persistence/lexical_retrieval/uow.py +++ b/app/persistence/lexical_retrieval/uow.py @@ -1,13 +1,10 @@ -"""Short lexical database scope, separate from Resolver projection.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import LexicalRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.lexical_retrieval.uow") - -@asynccontextmanager -async def lexical_uow() -> AsyncGenerator[LexicalRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield LexicalRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.lexical_retrieval.uow import * diff --git a/app/persistence/observability/__init__.py b/app/persistence/observability/__init__.py index 302d1365..6f9aab43 100644 --- a/app/persistence/observability/__init__.py +++ b/app/persistence/observability/__init__.py @@ -1 +1,3 @@ -"""Isolated asynchronous telemetry persistence.""" +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.observability import * diff --git a/app/persistence/observability/uow.py b/app/persistence/observability/uow.py index a5a21f95..fe555525 100644 --- a/app/persistence/observability/uow.py +++ b/app/persistence/observability/uow.py @@ -1,11 +1,10 @@ -"""Log batches commit independently of application transactions.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import Sequence +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from libs.obsrv.log_record import LogModel +sys.modules[__name__] = import_module("inkcre_core.persistence.observability.uow") - -async def write_logs(entries: Sequence[LogModel]) -> None: - async with AsyncSessionFactory.begin() as session: - session.add_all(entries) +if TYPE_CHECKING: + from inkcre_core.persistence.observability.uow import * diff --git a/app/persistence/peer/__init__.py b/app/persistence/peer/__init__.py index e69de29b..6d1c7c64 100644 --- a/app/persistence/peer/__init__.py +++ b/app/persistence/peer/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.peer import * diff --git a/app/persistence/peer/repository.py b/app/persistence/peer/repository.py index 886d3e7c..e31b9143 100644 --- a/app/persistence/peer/repository.py +++ b/app/persistence/peer/repository.py @@ -1,106 +1,10 @@ -"""Peer persistence; all lease comparisons use the database clock.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -from typing import Any +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.persistence.peer.repository") -from app.database_contract import PROTOCOL_SCHEMA -from app.schemas.peer import PeerModel, PeerRef - - -class PeerRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def register( - self, - peer_id: PeerRef, - name: str, - application_version: str, - schema: dict, - ) -> PeerModel: - statement = insert(PeerModel).values( - id=peer_id, - name=name, - application_version=application_version, - labels=[], - config={}, - config_schema=schema, - capabilities=[], - ) - statement = statement.on_conflict_do_update( - index_elements=["id"], - set_={ - "application_version": statement.excluded.application_version, - "config_schema": statement.excluded.config_schema, - }, - ).returning(PeerModel) - return (await self._session.scalars(statement)).one() - - async def get(self, peer_id: PeerRef, *, for_update: bool = False) -> PeerModel | None: - statement = sqlmodel.select(PeerModel).where(PeerModel.id == peer_id) - if for_update: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def get_all(self) -> tuple[PeerModel, ...]: - return tuple((await self._session.scalars(sqlmodel.select(PeerModel))).all()) - - async def with_leases( - self, - *, - peer_id: PeerRef | None = None, - limit: int | None = None, - cursor: PeerRef | None = None, - ) -> tuple[list[dict[str, Any]], PeerRef | None]: - statement = sqlmodel.select( - PeerModel, - sqlalchemy.func.coalesce( - PeerModel.lease_expires_at > sqlalchemy.func.statement_timestamp(), False - ), - ).order_by(sqlmodel.col(PeerModel.id)) - if peer_id is not None: - statement = statement.where(PeerModel.id == peer_id) - if cursor is not None: - statement = statement.where(PeerModel.id > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.execute(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return [ - {**row.model_dump(mode="json"), "lease_active": active} for row, active in rows - ], rows[-1][0].id if more else None - - async def save(self, peer: PeerModel) -> None: - self._session.add(peer) - await self._session.flush() - await self._session.refresh(peer) - - async def renew_lease(self, peer_id: PeerRef, ttl_seconds: int) -> datetime.datetime: - statement = sqlalchemy.select( - getattr(sqlalchemy.func, PROTOCOL_SCHEMA).renew_peer_lease(peer_id, ttl_seconds) - ) - return (await self._session.execute(statement)).scalar_one() - - async def clear_lease(self, peer_id: PeerRef) -> None: - await self._session.execute( - sqlalchemy.update(PeerModel) - .where(sqlmodel.col(PeerModel.id) == peer_id) - .values(lease_expires_at=None) - ) - - async def candidates( - self, self_id: PeerRef, route_to_peer: PeerRef | None - ) -> tuple[PeerModel, ...]: - statement = sqlmodel.select(PeerModel).where( - PeerModel.id != self_id, - PeerModel.lease_expires_at > sqlalchemy.func.statement_timestamp(), - ) - if route_to_peer is not None: - statement = statement.where(PeerModel.id == route_to_peer) - return tuple((await self._session.scalars(statement)).all()) +if TYPE_CHECKING: + from inkcre_core.persistence.peer.repository import * diff --git a/app/persistence/peer/uow.py b/app/persistence/peer/uow.py index 3a18b553..9e0780bb 100644 --- a/app/persistence/peer/uow.py +++ b/app/persistence/peer/uow.py @@ -1,13 +1,10 @@ -"""Transaction scopes for Peer facts and database-owned leases.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import PeerRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.peer.uow") - -@asynccontextmanager -async def peer_uow() -> AsyncGenerator[PeerRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield PeerRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.peer.uow import * diff --git a/app/persistence/semantic_retrieval/__init__.py b/app/persistence/semantic_retrieval/__init__.py index d396cf8b..8df71f07 100644 --- a/app/persistence/semantic_retrieval/__init__.py +++ b/app/persistence/semantic_retrieval/__init__.py @@ -1 +1,3 @@ -"""Semantic retrieval persistence.""" +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.semantic_retrieval import * diff --git a/app/persistence/semantic_retrieval/repository.py b/app/persistence/semantic_retrieval/repository.py index e2619d92..fc3d23db 100644 --- a/app/persistence/semantic_retrieval/repository.py +++ b/app/persistence/semantic_retrieval/repository.py @@ -1,203 +1,12 @@ -"""Session-bound embedding profiles and derived retrieval records.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import sqlalchemy -import sqlalchemy.orm -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession -import sqlmodel - -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.semantic_retrieval import VectorRetrievalOptions - -from app.schemas.ai import ( - EmbeddingProfileID, - EmbeddingProfileModel, - BlockEmbeddingModel, - RelationEmbeddingModel, +sys.modules[__name__] = import_module( + "inkcre_core.persistence.semantic_retrieval.repository" ) - -class SemanticRetrievalRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def get_profile( - self, profile_id: EmbeddingProfileID - ) -> EmbeddingProfileModel | None: - return await self._session.get(EmbeddingProfileModel, profile_id) - - async def list_profiles(self, *, limit: int | None, cursor: int | None): - statement = sqlmodel.select(EmbeddingProfileModel).order_by( - sqlmodel.col(EmbeddingProfileModel.id) - ) - if cursor is not None: - statement = statement.where(sqlmodel.col(EmbeddingProfileModel.id) > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list(await self._session.scalars(statement)) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def database_now(self) -> datetime.datetime: - return ( - await self._session.execute(sqlmodel.select(sqlalchemy.func.current_timestamp())) - ).scalar_one() - - async def candidate_blocks(self, profile_id: int, cursor: int, page_size: int): - block_columns = typing.cast( - typing.Any, - BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - BlockEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - statement = ( - sqlmodel.select(BlockModel, BlockEmbeddingModel) - .outerjoin( - BlockEmbeddingModel, - sqlalchemy.and_( - record_columns.profile == profile_id, - record_columns.block == block_columns.id, - ), - ) - .where(block_columns.id > cursor) - .order_by(block_columns.id) - .limit(page_size) - ) - return (await self._session.execute(statement)).all() - - async def candidate_relations(self, profile_id: int, cursor: int, page_size: int): - from_block = sqlalchemy.orm.aliased(BlockModel) - to_block = sqlalchemy.orm.aliased(BlockModel) - from_columns = typing.cast(typing.Any, from_block) - to_columns = typing.cast(typing.Any, to_block) - relation_columns = typing.cast( - typing.Any, - RelationModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - RelationEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - statement = ( - sqlmodel.select( - RelationModel, - RelationEmbeddingModel, - from_block.updated_at, - to_block.updated_at, - ) - .outerjoin( - RelationEmbeddingModel, - sqlalchemy.and_( - record_columns.profile == profile_id, - record_columns.relation == relation_columns.id, - ), - ) - .join(from_block, from_columns.id == relation_columns.from_) - .join(to_block, to_columns.id == relation_columns.to_) - .where(relation_columns.id > cursor) - .order_by(relation_columns.id) - .limit(page_size) - ) - return (await self._session.execute(statement)).all() - - async def retrieve_blocks( - self, - profile: EmbeddingProfileModel, - query_vector, - options: VectorRetrievalOptions, - ): - profile_id = profile.id - block_columns = typing.cast( - typing.Any, - BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - BlockEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - distance = record_columns.embedding.cosine_distance(query_vector).label("distance") - statement = ( - sqlmodel.select(BlockModel, distance) - .join(BlockEmbeddingModel, record_columns.block == block_columns.id) - .where( - record_columns.profile == profile_id, - record_columns.updated_at >= profile.updated_at, - record_columns.updated_at >= block_columns.updated_at, - sqlalchemy.func.vector_dims(record_columns.embedding) == profile.dimensions, - sqlalchemy.func.vector_norm(record_columns.embedding) > 0, - ) - .order_by(distance, block_columns.id) - .limit(options.limit) - ) - if options.min_score is not None: - statement = statement.where(distance <= 1 - options.min_score) - return (await self._session.execute(statement)).all() - - async def retrieve_relations( - self, - profile: EmbeddingProfileModel, - query_vector, - options: VectorRetrievalOptions, - ): - profile_id = profile.id - from_block = sqlalchemy.orm.aliased(BlockModel) - to_block = sqlalchemy.orm.aliased(BlockModel) - from_columns = typing.cast(typing.Any, from_block) - to_columns = typing.cast(typing.Any, to_block) - relation_columns = typing.cast( - typing.Any, - RelationModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - record_columns = typing.cast( - typing.Any, - RelationEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] - ) - distance = record_columns.embedding.cosine_distance(query_vector).label("distance") - statement = ( - sqlmodel.select(RelationModel, distance) - .join( - RelationEmbeddingModel, - record_columns.relation == relation_columns.id, - ) - .join(from_block, from_columns.id == relation_columns.from_) - .join(to_block, to_columns.id == relation_columns.to_) - .where( - record_columns.profile == profile_id, - record_columns.updated_at >= profile.updated_at, - record_columns.updated_at >= relation_columns.updated_at, - record_columns.updated_at >= from_columns.updated_at, - record_columns.updated_at >= to_columns.updated_at, - sqlalchemy.func.vector_dims(record_columns.embedding) == profile.dimensions, - sqlalchemy.func.vector_norm(record_columns.embedding) > 0, - ) - .order_by(distance, relation_columns.id) - .limit(options.limit) - ) - if options.min_score is not None: - statement = statement.where(distance <= 1 - options.min_score) - return (await self._session.execute(statement)).all() - - async def upsert(self, blocks: list[dict], relations: list[dict]) -> None: - for model, key, rows in ( - (BlockEmbeddingModel, "block", blocks), - (RelationEmbeddingModel, "relation", relations), - ): - if not rows: - continue - statement = insert(model).values(rows) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=["profile", key], - set_={ - "embedding": statement.excluded.embedding, - "updated_at": sqlalchemy.func.current_timestamp(), - }, - ) - ) +if TYPE_CHECKING: + from inkcre_core.persistence.semantic_retrieval.repository import * diff --git a/app/persistence/semantic_retrieval/uow.py b/app/persistence/semantic_retrieval/uow.py index 81e2f460..5021ea29 100644 --- a/app/persistence/semantic_retrieval/uow.py +++ b/app/persistence/semantic_retrieval/uow.py @@ -1,13 +1,10 @@ -"""Short persistence scope; provider calls run after it closes.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import SemanticRetrievalRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.semantic_retrieval.uow") - -@asynccontextmanager -async def semantic_retrieval_uow() -> AsyncGenerator[SemanticRetrievalRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield SemanticRetrievalRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.semantic_retrieval.uow import * diff --git a/app/persistence/sink/__init__.py b/app/persistence/sink/__init__.py index e69de29b..d54f3682 100644 --- a/app/persistence/sink/__init__.py +++ b/app/persistence/sink/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.sink import * diff --git a/app/persistence/sink/repository.py b/app/persistence/sink/repository.py index 52cb3f11..3586e188 100644 --- a/app/persistence/sink/repository.py +++ b/app/persistence/sink/repository.py @@ -1,55 +1,10 @@ -"""Session-bound Sink catalog and instance records.""" +"""Legacy import; use inkcre_core for new consumers.""" -import sqlmodel -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.schemas.sink import SinkModel, SinkTypeModel +sys.modules[__name__] = import_module("inkcre_core.persistence.sink.repository") - -class SinkRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def sync_types(self, rows: list[dict]) -> None: - if not rows: - return - statement = insert(SinkTypeModel).values(rows) - await self._session.execute( - statement.on_conflict_do_update( - index_elements=["id"], - set_={ - "description": statement.excluded.description, - "config_schema": statement.excluded.config_schema, - }, - ) - ) - - async def list_types(self): - return tuple( - await self._session.scalars(sqlmodel.select(SinkTypeModel).order_by(SinkTypeModel.id)) - ) - - async def get_type(self, sink_type: str) -> SinkTypeModel | None: - return await self._session.get(SinkTypeModel, sink_type) - - async def list(self): - return tuple( - await self._session.scalars( - sqlmodel.select(SinkModel).order_by(sqlmodel.col(SinkModel.id)) - ) - ) - - async def get(self, sink_id: int, *, lock: bool = False) -> SinkModel | None: - statement = sqlmodel.select(SinkModel).where(SinkModel.id == sink_id) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def save(self, sink: SinkModel) -> None: - self._session.add(sink) - await self._session.flush() - await self._session.refresh(sink) - - async def delete(self, sink: SinkModel) -> None: - await self._session.delete(sink) +if TYPE_CHECKING: + from inkcre_core.persistence.sink.repository import * diff --git a/app/persistence/sink/uow.py b/app/persistence/sink/uow.py index b6128d8f..2469d6c7 100644 --- a/app/persistence/sink/uow.py +++ b/app/persistence/sink/uow.py @@ -1,13 +1,10 @@ -"""Short Sink persistence scope; runtime start/close happen after commit.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from .repository import SinkRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.sink.uow") - -@asynccontextmanager -async def sink_uow() -> AsyncGenerator[SinkRepository, None]: - async with AsyncSessionFactory.begin() as session: - yield SinkRepository(session) +if TYPE_CHECKING: + from inkcre_core.persistence.sink.uow import * diff --git a/app/persistence/source/__init__.py b/app/persistence/source/__init__.py index e69de29b..f57c089a 100644 --- a/app/persistence/source/__init__.py +++ b/app/persistence/source/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.persistence.source import * diff --git a/app/persistence/source/repository.py b/app/persistence/source/repository.py index 2fd9d1ec..04992082 100644 --- a/app/persistence/source/repository.py +++ b/app/persistence/source/repository.py @@ -1,76 +1,10 @@ -"""Persistence for Source catalog and instances.""" +"""Legacy import; use inkcre_core for new consumers.""" -import sqlmodel -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.schemas.source import SourceModel, SourceTypesModel +sys.modules[__name__] = import_module("inkcre_core.persistence.source.repository") - -class SourceRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def sync_types(self, rows: list[dict]) -> None: - if not rows: - return - statement = insert(SourceTypesModel).values(rows) - statement = statement.on_conflict_do_update( - index_elements=[SourceTypesModel.id], - set_={ - key: getattr(statement.excluded, key) - for key in ( - "description", - "config_schema", - "collect_config_schema", - "backfill_config_schema", - ) - }, - ) - await self._session.execute(statement) - - async def get(self, source_id: int, *, lock: bool = False): - statement = sqlmodel.select(SourceModel).where(SourceModel.id == source_id) - if lock: - statement = statement.with_for_update() - return (await self._session.scalars(statement)).one_or_none() - - async def get_by_block(self, block_id: int) -> SourceModel | None: - return ( - await self._session.scalars( - sqlmodel.select(SourceModel).where(SourceModel.block == block_id) - ) - ).one_or_none() - - async def save(self, source: SourceModel) -> None: - self._session.add(source) - await self._session.flush() - await self._session.refresh(source) - - async def get_type(self, type_: str) -> SourceTypesModel | None: - return await self._session.get(SourceTypesModel, type_) - - async def list_sources(self, *, limit: int | None, cursor: int | None): - statement = sqlmodel.select(SourceModel).order_by(sqlmodel.col(SourceModel.id)) - if cursor is not None: - statement = statement.where(sqlmodel.col(SourceModel.id) > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def list_types(self, *, limit: int | None, cursor: str | None): - statement = sqlmodel.select(SourceTypesModel).order_by(SourceTypesModel.id) - if cursor is not None: - statement = statement.where(SourceTypesModel.id > cursor) - if limit is not None: - statement = statement.limit(limit + 1) - rows = list((await self._session.scalars(statement)).all()) - more = limit is not None and len(rows) > limit - rows = rows[:limit] - return rows, rows[-1].id if more else None - - async def delete(self, source: SourceModel) -> None: - await self._session.delete(source) +if TYPE_CHECKING: + from inkcre_core.persistence.source.repository import * diff --git a/app/persistence/source/uow.py b/app/persistence/source/uow.py index 3e3b13bf..ec520a9d 100644 --- a/app/persistence/source/uow.py +++ b/app/persistence/source/uow.py @@ -1,31 +1,10 @@ -"""Source state, graph and storage participate in one short transaction.""" +"""Legacy import; use inkcre_core for new consumers.""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from dataclasses import dataclass +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.engine import AsyncSessionFactory -from app.persistence.info_base.uow import GraphUnitOfWork -from app.persistence.info_base.repository import BlockRepository, RelationRepository -from app.persistence.info_base.storage import StorageRepository -from app.persistence.deployment_config.repository import DeploymentConfigRepository -from .repository import SourceRepository +sys.modules[__name__] = import_module("inkcre_core.persistence.source.uow") - -@dataclass(frozen=True) -class SourceUnitOfWork: - sources: SourceRepository - graph: GraphUnitOfWork - configuration: DeploymentConfigRepository - - -@asynccontextmanager -async def source_uow() -> AsyncGenerator[SourceUnitOfWork, None]: - async with AsyncSessionFactory.begin() as session: - yield SourceUnitOfWork( - SourceRepository(session), - GraphUnitOfWork( - BlockRepository(session), RelationRepository(session), StorageRepository(session) - ), - DeploymentConfigRepository(session), - ) +if TYPE_CHECKING: + from inkcre_core.persistence.source.uow import * diff --git a/app/routes/agent.py b/app/routes/agent.py index 18dcb15a..002f56c8 100644 --- a/app/routes/agent.py +++ b/app/routes/agent.py @@ -4,10 +4,10 @@ import fastapi -from app.business.agent.main import AgentManager -from app.business.agent.contracts import AgentNotFoundError, MissingAgentToolError -from app.schemas.agent import AgentDefinitionModel, AgentForm, AgentUpdateForm -from app.schemas.ai import FunctionTool +from inkcre_core.business.agent.main import AgentManager +from inkcre_core.business.agent.contracts import AgentNotFoundError, MissingAgentToolError +from inkcre_core.schemas.agent import AgentDefinitionModel, AgentForm, AgentUpdateForm +from inkcre_core.schemas.ai import FunctionTool from .validation import database_write, request_input diff --git a/app/routes/ai.py b/app/routes/ai.py index 1faa7882..1b17fb02 100644 --- a/app/routes/ai.py +++ b/app/routes/ai.py @@ -4,8 +4,8 @@ import fastapi -from app.business.ai import AIManager -from app.schemas.ai import AIModelModel +from inkcre_core.business.ai import AIManager +from inkcre_core.schemas.ai import AIModelModel ROUTER = fastapi.APIRouter(prefix="/ai", tags=["ai"]) diff --git a/app/routes/block.py b/app/routes/block.py index 43c2f139..c63ba46f 100644 --- a/app/routes/block.py +++ b/app/routes/block.py @@ -3,10 +3,10 @@ __all__ = ["ROUTER"] import fastapi -from app.business.info_base.services import BlockService -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.block import BlockForm -from app.schemas.info_base.rest import BlockUpdateForm +from inkcre_core.business.info_base.services import BlockService +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.block import BlockForm +from inkcre_core.schemas.info_base.rest import BlockUpdateForm from .validation import database_write ROUTER = fastapi.APIRouter( diff --git a/app/routes/cron.py b/app/routes/cron.py index ab498eea..44b3553c 100644 --- a/app/routes/cron.py +++ b/app/routes/cron.py @@ -5,11 +5,18 @@ import fastapi -from app.business.cron import CronManager, CronNotFoundError, InvalidCronScheduleError -from app.business.job import JobManager, UnknownJobTypeError -from app.business.source.main import SourceNotFoundError, UnsupportedSourceCommandError -from app.schemas.cron import CronForm, CronModel, CronUpdateForm -from app.schemas.job import JobModel +from inkcre_core.business.cron import ( + CronManager, + CronNotFoundError, + InvalidCronScheduleError, +) +from inkcre_core.business.job import JobManager, UnknownJobTypeError +from inkcre_core.business.source.main import ( + SourceNotFoundError, + UnsupportedSourceCommandError, +) +from inkcre_core.schemas.cron import CronForm, CronModel, CronUpdateForm +from inkcre_core.schemas.job import JobModel from .validation import request_input diff --git a/app/routes/deployment_config.py b/app/routes/deployment_config.py index 327d6733..0e32273f 100644 --- a/app/routes/deployment_config.py +++ b/app/routes/deployment_config.py @@ -6,13 +6,13 @@ import fastapi -from app.business.deployment_config import ( +from inkcre_core.business.deployment_config import ( DeploymentConfigManager, DeploymentConfigService, DeploymentConfigNotFoundError, UnknownDeploymentConfigSchemaError, ) -from app.schemas.deployment_config import ( +from inkcre_core.schemas.deployment_config import ( DeploymentConfigKey, DeploymentConfigReplaceForm, DeploymentConfigView, diff --git a/app/routes/entities.py b/app/routes/entities.py index e3882f68..416019d4 100644 --- a/app/routes/entities.py +++ b/app/routes/entities.py @@ -4,10 +4,10 @@ import fastapi -from app.business.info_base import get_entity_records -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.rest import EntitiesGetForm -from libs.obsrv.main import get_logger +from inkcre_core.business.info_base import get_entity_records +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.rest import EntitiesGetForm +from inkcre_core.libs.obsrv.main import get_logger from .content import CONTENT_RESPONSES, content_response diff --git a/app/routes/extension.py b/app/routes/extension.py index efc71c2e..31e9e17a 100644 --- a/app/routes/extension.py +++ b/app/routes/extension.py @@ -11,8 +11,8 @@ InstalledExtension, ) from app.business.extension.errors import ExtensionHostError -from app.business.peer import PeerHTTPInbound, PeerManager -from app.schemas.extension import ( +from inkcre_core.business.peer import PeerHTTPInbound, PeerManager +from inkcre_core.schemas.extension import ( DisableExtensionCommand, EnableExtensionCommand, ExtensionManagementCommand, @@ -21,7 +21,7 @@ SelectExtensionInput, UnselectExtensionCommand, ) -from app.schemas.peer import PeerRef +from inkcre_core.schemas.peer import PeerRef from .validation import request_input diff --git a/app/routes/graph_navigation.py b/app/routes/graph_navigation.py index c182b409..233738e9 100644 --- a/app/routes/graph_navigation.py +++ b/app/routes/graph_navigation.py @@ -3,14 +3,14 @@ import fastapi import pydantic -from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager -from app.schemas.graph_navigation_retrieval import ( +from inkcre_core.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from inkcre_core.schemas.graph_navigation_retrieval import ( GraphDirection, GraphModel, DEFAULT_NEIGHBORHOOD_LIMIT, MAX_NEIGHBORHOOD_LIMIT, ) -from app.schemas.info_base.rest import GraphPathForm, GraphComponentsForm +from inkcre_core.schemas.info_base.rest import GraphPathForm, GraphComponentsForm from .entities import relation_record diff --git a/app/routes/info_base.py b/app/routes/info_base.py index aea2abcb..6af39b08 100644 --- a/app/routes/info_base.py +++ b/app/routes/info_base.py @@ -4,8 +4,8 @@ import fastapi -from app.business.info_base.commands import submit_graph as submit_graph_command -from app.schemas.info_base.main import GraphForm, SubmitGraphResult +from inkcre_core.business.info_base.commands import submit_graph as submit_graph_command +from inkcre_core.schemas.info_base.main import GraphForm, SubmitGraphResult from .validation import database_write diff --git a/app/routes/job.py b/app/routes/job.py index 55d6fddd..01f0db11 100644 --- a/app/routes/job.py +++ b/app/routes/job.py @@ -4,10 +4,13 @@ import fastapi -from app.business.job import JobManager, UnknownJobTypeError -from app.business.source.main import SourceNotFoundError, UnsupportedSourceCommandError +from inkcre_core.business.job import JobManager, UnknownJobTypeError +from inkcre_core.business.source.main import ( + SourceNotFoundError, + UnsupportedSourceCommandError, +) from app.business.sink import SinkError, SinkNotFoundError -from app.schemas.job import JobCreateForm, JobModel, JobStatus, JobTypeModel +from inkcre_core.schemas.job import JobCreateForm, JobModel, JobStatus, JobTypeModel from .validation import request_input diff --git a/app/routes/lexical_retrieval.py b/app/routes/lexical_retrieval.py index 5d541852..23bf6e5a 100644 --- a/app/routes/lexical_retrieval.py +++ b/app/routes/lexical_retrieval.py @@ -4,12 +4,12 @@ import fastapi -from app.business.lexical_retrieval import ( +from inkcre_core.business.lexical_retrieval import ( LEXICAL_RETRIEVAL_CAPABILITY, LexicalRetrievalManager, ) -from app.business.peer import PeerHTTPInbound -from app.schemas.lexical_retrieval import ( +from inkcre_core.business.peer import PeerHTTPInbound +from inkcre_core.schemas.lexical_retrieval import ( LexicalRetrievalRequest, LexicalRetrievalResult, ) diff --git a/app/routes/organization.py b/app/routes/organization.py index 903fe0ec..84e459f9 100644 --- a/app/routes/organization.py +++ b/app/routes/organization.py @@ -4,9 +4,12 @@ import fastapi -from app.business.organization import RUMINATION_CAPABILITY, RuminationBehaviorResolver -from app.business.peer import PeerHTTPInbound -from app.schemas.organization import RuminationRequest +from inkcre_core.business.organization import ( + RUMINATION_CAPABILITY, + RuminationBehaviorResolver, +) +from inkcre_core.business.peer import PeerHTTPInbound +from inkcre_core.schemas.organization import RuminationRequest ROUTER = fastapi.APIRouter(tags=["organization"]) diff --git a/app/routes/peer.py b/app/routes/peer.py index cf844771..8fe03988 100644 --- a/app/routes/peer.py +++ b/app/routes/peer.py @@ -4,8 +4,8 @@ import fastapi -from app.business.peer import PeerManager -from app.schemas.peer import PeerRef +from inkcre_core.business.peer import PeerManager +from inkcre_core.schemas.peer import PeerRef ROUTER = fastapi.APIRouter(prefix="/peers", tags=["peer"]) diff --git a/app/routes/relation.py b/app/routes/relation.py index f5f888d7..82dfdecb 100644 --- a/app/routes/relation.py +++ b/app/routes/relation.py @@ -5,9 +5,9 @@ import fastapi -from app.business.info_base.services import RelationService -from app.schemas.info_base.relation import RelationCreateForm -from app.schemas.info_base.rest import RelationUpdateForm +from inkcre_core.business.info_base.services import RelationService +from inkcre_core.schemas.info_base.relation import RelationCreateForm +from inkcre_core.schemas.info_base.rest import RelationUpdateForm from .entities import relation_record from .validation import database_write diff --git a/app/routes/resolver.py b/app/routes/resolver.py index 816966af..224fe077 100644 --- a/app/routes/resolver.py +++ b/app/routes/resolver.py @@ -5,8 +5,8 @@ import fastapi from fastapi.exceptions import RequestValidationError -from app.business.info_base.services import BlockService -from app.business.info_base.resolver import ( +from inkcre_core.business.info_base.services import BlockService +from inkcre_core.business.info_base.resolver import ( ResolverManager, ResolverMethodInputError, UnknownResolverError, diff --git a/app/routes/retrieval.py b/app/routes/retrieval.py index ec511744..7574c928 100644 --- a/app/routes/retrieval.py +++ b/app/routes/retrieval.py @@ -4,16 +4,19 @@ import fastapi -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.semantic_retrieval import ( +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.semantic_retrieval import ( SemanticRetrievalManager, SemanticRetrievalNotConfiguredError, EmbeddingProfileNotFoundError, ) -from app.schemas.ai import EmbeddingProfileModel -from app.schemas.peer import PeerRef -from app.schemas.lexical_retrieval import LexicalRetrievalRequest, LexicalRetrievalResult -from app.schemas.semantic_retrieval import SemanticRetrievalRequest +from inkcre_core.schemas.ai import EmbeddingProfileModel +from inkcre_core.schemas.peer import PeerRef +from inkcre_core.schemas.lexical_retrieval import ( + LexicalRetrievalRequest, + LexicalRetrievalResult, +) +from inkcre_core.schemas.semantic_retrieval import SemanticRetrievalRequest from .entities import relation_record diff --git a/app/routes/semantic_retrieval.py b/app/routes/semantic_retrieval.py index 8241bcc6..d56eea67 100644 --- a/app/routes/semantic_retrieval.py +++ b/app/routes/semantic_retrieval.py @@ -4,12 +4,12 @@ import fastapi -from app.business.peer import PeerHTTPInbound -from app.business.semantic_retrieval import ( +from inkcre_core.business.peer import PeerHTTPInbound +from inkcre_core.business.semantic_retrieval import ( SEMANTIC_RETRIEVAL_CAPABILITY, SemanticRetrievalManager, ) -from app.schemas.semantic_retrieval import ( +from inkcre_core.schemas.semantic_retrieval import ( SemanticRetrievalRequest, SemanticRetrievalResult, ) diff --git a/app/routes/sink.py b/app/routes/sink.py index 6dc570d2..af25cc7f 100644 --- a/app/routes/sink.py +++ b/app/routes/sink.py @@ -5,7 +5,7 @@ import fastapi import pydantic -from app.business.peer import PeerManager +from inkcre_core.business.peer import PeerManager from app.business.sink import ( SinkError, SinkManager, @@ -13,7 +13,7 @@ SinkStateConflictError, UnknownSinkTypeError, ) -from app.schemas.sink import SinkCreateForm, SinkID, SinkModel, SinkTypeModel +from inkcre_core.schemas.sink import SinkCreateForm, SinkID, SinkModel, SinkTypeModel ROUTER = fastapi.APIRouter(tags=["sink"]) diff --git a/app/routes/source.py b/app/routes/source.py index cb19a94b..ced8e0b1 100644 --- a/app/routes/source.py +++ b/app/routes/source.py @@ -4,15 +4,15 @@ import fastapi -from app.business.job import JobManager -from app.business.source import SOURCE_BACKFILL_JOB_TYPE, SOURCE_COLLECT_JOB_TYPE -from app.business.source.main import ( +from inkcre_core.business.job import JobManager +from inkcre_core.business.source import SOURCE_BACKFILL_JOB_TYPE, SOURCE_COLLECT_JOB_TYPE +from inkcre_core.business.source.main import ( SourceManager, SourceNotFoundError, UnsupportedSourceCommandError, ) -from app.schemas.job import JobModel -from app.schemas.source import ( +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.source import ( SourceCreateForm, SourceModel, SourceTypesModel, diff --git a/app/routes/telemetry.py b/app/routes/telemetry.py index a80e5b61..535e6d6f 100644 --- a/app/routes/telemetry.py +++ b/app/routes/telemetry.py @@ -19,7 +19,7 @@ ExportTraceServiceResponse, ) -from libs.obsrv.telemetry import get_export_connection +from inkcre_core.libs.obsrv.telemetry import get_export_connection ROUTER = fastapi.APIRouter(prefix="/telemetry", tags=["telemetry"]) diff --git a/app/scheduler.py b/app/scheduler.py index 9b5db306..716e6029 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,10 +1,11 @@ __all__ = ["scheduler", "with_trace_id"] import asyncio +from app.core import CORE import apscheduler.schedulers.asyncio -from libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID +from inkcre_core.libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() @@ -42,7 +43,8 @@ async def wrapper(*args, **kwargs): token = TRACE_ID.set(trace_id) token_backend = ENABLE_LOG_BACKEND.set(enable_backend) try: - return await coro(*args, **kwargs) + with CORE.bind(): + return await coro(*args, **kwargs) finally: _running.discard(task) TRACE_ID.reset(token) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 0e79a007..0ca92c8e 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -1,56 +1,3 @@ -__all__ = [ - "Base", - "BlockModel", - "StorageModel", - "StorageBlobModel", - "StorageTypesModel", - "RelationModel", - "SourceModel", - "JobTypeModel", - "JobModel", - "CronModel", - "ExtensionModel", - "ExtensionPeerParticipationModel", - "AIDialectModel", - "AIProviderModel", - "AIModelModel", - "EmbeddingProfileModel", - "RelationEmbeddingModel", - "BlockEmbeddingModel", - "BlockLexicalRecordModel", - "AgentDefinitionModel", - "PeerModel", - "DeploymentConfigModel", - "SinkModel", - "SinkTypeModel", -] +"""Legacy import; use inkcre_core for new consumers.""" -import sqlalchemy.orm -import sqlmodel - -from app.database_contract import PROTOCOL_SCHEMA - - -Base = sqlalchemy.orm.declarative_base(metadata=sqlalchemy.MetaData(schema=PROTOCOL_SCHEMA)) -sqlmodel.SQLModel.metadata = Base.metadata - -from .info_base.block import BlockModel -from .info_base.storage import StorageBlobModel, StorageModel, StorageTypesModel -from .info_base.relation import RelationModel -from .source import SourceModel -from .job import JobModel, JobTypeModel -from .cron import CronModel -from .extension.main import ExtensionModel, ExtensionPeerParticipationModel -from .ai import ( - AIDialectModel, - AIModelModel, - AIProviderModel, - BlockEmbeddingModel, - EmbeddingProfileModel, - RelationEmbeddingModel, -) -from .agent import AgentDefinitionModel -from .peer import PeerModel -from .deployment_config import DeploymentConfigModel -from .lexical_retrieval import BlockLexicalRecordModel -from .sink import SinkModel, SinkTypeModel +from inkcre_core.schemas import * diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 5151700d..c9ba0054 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -1,173 +1,10 @@ -"""Reusable Agent definitions persisted as shared deployment facts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.agent") -from app.schemas.ai import AIModelID, ToolChoice - - -AgentID: typing.TypeAlias = int - -_TOOL_CHOICE_ADAPTER = pydantic.TypeAdapter(ToolChoice | None) - - -def normalize_agent_tools(value: typing.Iterable[str]) -> tuple[str, ...]: - """Validate and canonicalize Agent Tool IDs with set semantics.""" - tools = tuple(value) - if any(not tool for tool in tools): - raise ValueError("Agent Tool IDs must not be empty") - if len(tools) != len(set(tools)): - raise ValueError("Agent Tool IDs must be unique") - return tuple(sorted(tools)) - - -class AgentToolsType(sqlalchemy.TypeDecorator): - """Round-trip canonical Agent Tool ID sets through PostgreSQL text arrays.""" - - impl = sqlalchemy.dialects.postgresql.ARRAY(sqlalchemy.Text) - cache_ok = True - - def process_bind_param(self, value, dialect): - del dialect - return list(normalize_agent_tools(value or ())) - - def process_result_value(self, value, dialect): - del dialect - return tuple(value or ()) - - -class ToolChoiceType(sqlalchemy.TypeDecorator): - """Round-trip the canonical nullable ToolChoice union through JSONB.""" - - impl = sqlalchemy.dialects.postgresql.JSONB - cache_ok = True - - def load_dialect_impl(self, dialect): - return dialect.type_descriptor(sqlalchemy.dialects.postgresql.JSONB(none_as_null=True)) - - def process_bind_param(self, value, dialect): - del dialect - choice = _TOOL_CHOICE_ADAPTER.validate_python(value) - if isinstance(choice, pydantic.BaseModel): - return choice.model_dump(mode="json") - return choice - - def process_result_value(self, value, dialect): - del dialect - return _TOOL_CHOICE_ADAPTER.validate_python(value) - - -class AgentForm(pydantic.BaseModel): - """Editable Agent definition, without execution or database-managed fields.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - name: str - system_prompt: str - model: AIModelID - tools: tuple[str, ...] = () - tool_choice: ToolChoice | None = None - max_model_calls_per_turn: int = pydantic.Field(gt=0) - - @pydantic.field_validator("tools") - @classmethod - def canonical_tools(cls, value: tuple[str, ...]) -> tuple[str, ...]: - return normalize_agent_tools(value) - - -class AgentUpdateForm(pydantic.BaseModel): - """PATCH omission preserves a field; only tool_choice accepts explicit null.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - name: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - system_prompt: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - model: AIModelID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - tools: tuple[str, ...] = () - tool_choice: ToolChoice | None = None - max_model_calls_per_turn: int = pydantic.Field(default=None, gt=0) # pyrefly: ignore[bad-assignment] - - -class AgentDefinitionModel(sqlmodel.SQLModel, table=True): - """One reusable system-prompt, model, Tool and turn-budget composition.""" - - __tablename__ = "agents" # type: ignore - __table_args__ = ( - sqlalchemy.CheckConstraint( - "max_model_calls_per_turn > 0", - name="ck_agents_max_model_calls_per_turn_positive", - ), - ) - - id: AgentID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) - system_prompt: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - tools: tuple[str, ...] = sqlmodel.Field( - default=(), - sa_column=sqlalchemy.Column( - AgentToolsType(), - nullable=False, - server_default=sqlalchemy.text("'{}'::text[]"), - ), - ) - tool_choice: ToolChoice | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(ToolChoiceType(), nullable=True), - ) - model: AIModelID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey("ai_models.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - max_model_calls_per_turn: int = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - @pydantic.field_validator("tools", mode="before") - @classmethod - def canonical_tools(cls, value: typing.Iterable[str]) -> tuple[str, ...]: - return normalize_agent_tools(value) - - @pydantic.field_validator("tool_choice", mode="before") - @classmethod - def canonical_tool_choice(cls, value: typing.Any) -> ToolChoice | None: - return _TOOL_CHOICE_ADAPTER.validate_python(value) - - @pydantic.field_validator("max_model_calls_per_turn") - @classmethod - def positive_model_call_budget(cls, value: int) -> int: - if value <= 0: - raise ValueError("max_model_calls_per_turn must be positive") - return value +if TYPE_CHECKING: + from inkcre_core.schemas.agent import * diff --git a/app/schemas/ai/__init__.py b/app/schemas/ai/__init__.py index e226e18e..b76622f7 100644 --- a/app/schemas/ai/__init__.py +++ b/app/schemas/ai/__init__.py @@ -1,80 +1,3 @@ -"""Canonical AI contracts and shared persistence models.""" +"""Legacy import; use inkcre_core for new consumers.""" -from .capability import ( - AICapability, - AICapabilityType, - AIFeature, - AIModality, - AIModelCapability, - ChatCapability, - EmbeddingCapability, - normalize_capabilities, -) -from .chat import ( - AudioContentPart, - AssistantMessage, - FunctionTool, - ImageContentPart, - JSONValue, - Message, - NamedToolChoice, - SystemMessage, - TextContentPart, - ToolCall, - ToolChoice, - ToolResult, - ToolResultMessage, - UserContentPart, - UserMessage, - VideoContentPart, - validate_message_history, -) -from .embedding import BlockEmbeddingModel, RelationEmbeddingModel -from .main import ( - AIDialectID, - AIDialectModel, - AIModelID, - AIModelModel, - AIProviderID, - AIProviderModel, - EmbeddingProfileID, - EmbeddingProfileModel, -) - -__all__ = [ - "AICapability", - "AICapabilityType", - "AIDialectID", - "AIDialectModel", - "AIFeature", - "AIModality", - "AIModelCapability", - "AIModelID", - "AIModelModel", - "AIProviderID", - "AIProviderModel", - "AudioContentPart", - "AssistantMessage", - "BlockEmbeddingModel", - "ChatCapability", - "EmbeddingCapability", - "EmbeddingProfileID", - "EmbeddingProfileModel", - "FunctionTool", - "ImageContentPart", - "JSONValue", - "Message", - "NamedToolChoice", - "RelationEmbeddingModel", - "SystemMessage", - "TextContentPart", - "ToolCall", - "ToolChoice", - "ToolResult", - "ToolResultMessage", - "UserContentPart", - "UserMessage", - "VideoContentPart", - "normalize_capabilities", - "validate_message_history", -] +from inkcre_core.schemas.ai import * diff --git a/app/schemas/ai/capability.py b/app/schemas/ai/capability.py index 4a52afcf..0f97ce38 100644 --- a/app/schemas/ai/capability.py +++ b/app/schemas/ai/capability.py @@ -1,67 +1,10 @@ -"""Provider-neutral AI model capability declarations.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.ai.capability") - -AICapabilityType: typing.TypeAlias = typing.Literal["embedding", "chat"] -AIModality: typing.TypeAlias = str -AIFeature: typing.TypeAlias = str - - -def _canonical_string_set(value: typing.Any) -> tuple[str, ...]: - if not isinstance(value, (list, tuple, set, frozenset)): - raise ValueError("value must be an array of strings") - items = tuple(value) - if not all(isinstance(item, str) and item for item in items): - raise ValueError("values must be non-empty strings") - if len(items) != len(set(items)): - raise ValueError("duplicate values are not allowed") - return tuple(sorted(items)) - - -class AICapability(pydantic.BaseModel): - """Fields shared by every typed capability declaration.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: AICapabilityType - input_modalities: tuple[AIModality, ...] - output_modalities: tuple[AIModality, ...] - features: tuple[AIFeature, ...] = () - - _normalize_input_modalities = pydantic.field_validator("input_modalities", mode="before")( - _canonical_string_set - ) - _normalize_output_modalities = pydantic.field_validator( - "output_modalities", mode="before" - )(_canonical_string_set) - _normalize_features = pydantic.field_validator("features", mode="before")( - _canonical_string_set - ) - - -class EmbeddingCapability(AICapability): - type: typing.Literal["embedding"] = "embedding" - - -class ChatCapability(AICapability): - type: typing.Literal["chat"] = "chat" - - -AIModelCapability: typing.TypeAlias = typing.Annotated[ - EmbeddingCapability | ChatCapability, - pydantic.Field(discriminator="type"), -] - -CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[AIModelCapability, ...]) - - -def normalize_capabilities(value: typing.Any) -> tuple[AIModelCapability, ...]: - """Validate and canonically order one declaration per capability type.""" - capabilities = CAPABILITIES_ADAPTER.validate_python(value) - types = tuple(capability.type for capability in capabilities) - if len(types) != len(set(types)): - raise ValueError("duplicate AI capability types are not allowed") - return tuple(sorted(capabilities, key=lambda capability: capability.type)) +if TYPE_CHECKING: + from inkcre_core.schemas.ai.capability import * diff --git a/app/schemas/ai/chat.py b/app/schemas/ai/chat.py index 013ef9ad..48402005 100644 --- a/app/schemas/ai/chat.py +++ b/app/schemas/ai/chat.py @@ -1,214 +1,10 @@ -"""Canonical AI chat, Tool and ToolResult contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing -from urllib.parse import urlsplit +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.ai.chat") - -JSONValue: typing.TypeAlias = pydantic.JsonValue - - -class FunctionTool(pydantic.BaseModel): - """One caller-owned function Tool exposed to an AI model.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - id: str = pydantic.Field(min_length=1) - description: str - input_schema: dict[str, JSONValue] - - -class ToolCall(pydantic.BaseModel): - """One model-authored request to invoke a caller-owned Tool.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - id: str = pydantic.Field(min_length=1) - tool: str = pydantic.Field(min_length=1) - arguments: dict[str, JSONValue] - - -class ToolResult(pydantic.BaseModel): - """One caller-side result correlated to a ToolCall.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - tool_call_id: str = pydantic.Field(min_length=1) - content: JSONValue - is_error: bool = False - - -class NamedToolChoice(pydantic.BaseModel): - """Require one exact caller-owned Tool.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["function"] = "function" - tool: str = pydantic.Field(min_length=1) - - -ToolChoice: typing.TypeAlias = typing.Literal["none", "auto", "required"] | NamedToolChoice - - -class SystemMessage(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["system"] = "system" - content: str - - -class TextContentPart(pydantic.BaseModel): - """One authored text item in an ordered multimodal User message.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["text"] = "text" - text: str = pydantic.Field(min_length=1) - - -class _MediaContentPart(pydantic.BaseModel): - """AI-owned media transfer value, independent of Block and Storage identity.""" - - model_config = pydantic.ConfigDict( - extra="forbid", - frozen=True, - ser_json_bytes="base64", - val_json_bytes="base64", - ) - - data: bytes = pydantic.Field(min_length=1) - mime_type: str = pydantic.Field(min_length=1) - transfer_url: str | None = None - - @pydantic.field_validator("mime_type") - @classmethod - def normalized_mime_type(cls, value: str) -> str: - normalized = value.partition(";")[0].strip().lower() - if "/" not in normalized or any(character.isspace() for character in normalized): - raise ValueError("mime_type must be one concrete media type") - return normalized - - @pydantic.field_validator("transfer_url") - @classmethod - def http_transfer_url(cls, value: str | None) -> str | None: - if value is None: - return None - parsed = urlsplit(value) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - raise ValueError("transfer_url must be one absolute HTTP(S) URL") - if parsed.username is not None or parsed.password is not None: - raise ValueError("transfer_url must not contain user-info credentials") - return value - - -class ImageContentPart(_MediaContentPart): - type: typing.Literal["image"] = "image" - - @pydantic.field_validator("mime_type") - @classmethod - def image_mime_type(cls, value: str) -> str: - if not value.startswith("image/"): - raise ValueError("ImageContentPart requires an image MIME type") - return value - - -class AudioContentPart(_MediaContentPart): - type: typing.Literal["audio"] = "audio" - - @pydantic.field_validator("mime_type") - @classmethod - def audio_mime_type(cls, value: str) -> str: - if not value.startswith("audio/"): - raise ValueError("AudioContentPart requires an audio MIME type") - return value - - -class VideoContentPart(_MediaContentPart): - type: typing.Literal["video"] = "video" - - @pydantic.field_validator("mime_type") - @classmethod - def video_mime_type(cls, value: str) -> str: - if not value.startswith("video/"): - raise ValueError("VideoContentPart requires a video MIME type") - return value - - -UserContentPart: typing.TypeAlias = typing.Annotated[ - TextContentPart | ImageContentPart | AudioContentPart | VideoContentPart, - pydantic.Field(discriminator="type"), -] - - -class UserMessage(pydantic.BaseModel): - model_config = pydantic.ConfigDict( - extra="forbid", - frozen=True, - ser_json_bytes="base64", - val_json_bytes="base64", - ) - - type: typing.Literal["user"] = "user" - content: tuple[UserContentPart, ...] = pydantic.Field(min_length=1) - - -class AssistantMessage(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["assistant"] = "assistant" - content: str | None = None - tool_calls: tuple[ToolCall, ...] = () - - @pydantic.model_validator(mode="after") - def unique_tool_call_ids(self) -> "AssistantMessage": - call_ids = tuple(call.id for call in self.tool_calls) - if len(call_ids) != len(set(call_ids)): - raise ValueError("AssistantMessage ToolCall IDs must be unique") - return self - - -class ToolResultMessage(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["tool_result"] = "tool_result" - results: tuple[ToolResult, ...] = pydantic.Field(min_length=1) - - @pydantic.model_validator(mode="after") - def unique_tool_call_ids(self) -> "ToolResultMessage": - call_ids = tuple(result.tool_call_id for result in self.results) - if len(call_ids) != len(set(call_ids)): - raise ValueError("ToolResultMessage ToolCall IDs must be unique") - return self - - -Message: typing.TypeAlias = typing.Annotated[ - SystemMessage | UserMessage | AssistantMessage | ToolResultMessage, - pydantic.Field(discriminator="type"), -] - -_MESSAGES_ADAPTER = pydantic.TypeAdapter(tuple[Message, ...]) - - -def validate_message_history(value: typing.Any) -> tuple[Message, ...]: - """Validate canonical types and every closed Assistant/ToolResult pair.""" - messages = _MESSAGES_ADAPTER.validate_python(value) - index = 0 - while index < len(messages): - message = messages[index] - if isinstance(message, ToolResultMessage): - raise ValueError("ToolResultMessage must follow an AssistantMessage with ToolCalls") - if isinstance(message, AssistantMessage) and message.tool_calls: - if index + 1 >= len(messages) or not isinstance( - messages[index + 1], ToolResultMessage - ): - raise ValueError("AssistantMessage ToolCalls require an adjacent ToolResultMessage") - result_message = typing.cast(ToolResultMessage, messages[index + 1]) - call_ids = {call.id for call in message.tool_calls} - result_ids = {result.tool_call_id for result in result_message.results} - if result_ids != call_ids: - raise ValueError("ToolResultMessage must exactly cover preceding ToolCall IDs") - index += 2 - continue - index += 1 - return messages +if TYPE_CHECKING: + from inkcre_core.schemas.ai.chat import * diff --git a/app/schemas/ai/embedding.py b/app/schemas/ai/embedding.py index 061d6d28..7ca31a95 100644 --- a/app/schemas/ai/embedding.py +++ b/app/schemas/ai/embedding.py @@ -1,91 +1,10 @@ -"""Profile-scoped Block and Relation embedding records.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pgvector.sqlalchemy -import sqlalchemy -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.ai.embedding") -from app.schemas.info_base.main import Vector - -from .main import EmbeddingProfileID - - -class BlockEmbeddingModel(sqlmodel.SQLModel, table=True): - __tablename__ = "block_embeddings" # type: ignore - - profile: EmbeddingProfileID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey( - "embedding_profiles.id", onupdate="CASCADE", ondelete="RESTRICT" - ), - primary_key=True, - ) - ) - block: int = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="CASCADE"), - primary_key=True, - ) - ) - embedding: Vector = sqlmodel.Field( - sa_column=sqlalchemy.Column(pgvector.sqlalchemy.VECTOR(), nullable=False) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - -class RelationEmbeddingModel(sqlmodel.SQLModel, table=True): - __tablename__ = "relation_embeddings" # type: ignore - - profile: EmbeddingProfileID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey( - "embedding_profiles.id", onupdate="CASCADE", ondelete="RESTRICT" - ), - primary_key=True, - ) - ) - relation: int = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("relations.id", onupdate="CASCADE", ondelete="CASCADE"), - primary_key=True, - ) - ) - embedding: Vector = sqlmodel.Field( - sa_column=sqlalchemy.Column(pgvector.sqlalchemy.VECTOR(), nullable=False) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) +if TYPE_CHECKING: + from inkcre_core.schemas.ai.embedding import * diff --git a/app/schemas/ai/main.py b/app/schemas/ai/main.py index f189d7eb..62ee2ba7 100644 --- a/app/schemas/ai/main.py +++ b/app/schemas/ai/main.py @@ -1,227 +1,10 @@ -"""Shared AI dialect, provider, model and embedding-profile facts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.ai.main") -from .capability import CAPABILITIES_ADAPTER, AIModelCapability, normalize_capabilities - - -AIDialectID: typing.TypeAlias = str -AIProviderID: typing.TypeAlias = int -AIModelID: typing.TypeAlias = int -EmbeddingProfileID: typing.TypeAlias = int - - -class AICapabilitiesType(sqlalchemy.TypeDecorator): - """Round-trip typed, canonical capability declarations through JSONB.""" - - impl = sqlalchemy.dialects.postgresql.JSONB - cache_ok = True - - def process_bind_param(self, value, dialect): - del dialect - return [ - capability.model_dump(mode="json") for capability in normalize_capabilities(value) - ] - - def process_result_value(self, value, dialect): - del dialect - return CAPABILITIES_ADAPTER.validate_python(value or ()) - - -class AIDialectModel(sqlmodel.SQLModel, table=True): - """One exact shared AI wire/config dialect contract.""" - - __tablename__ = "ai_dialects" # type: ignore - - id: AIDialectID = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - description: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - config_schema: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - - -class AIProviderModel(sqlmodel.SQLModel, table=True): - """One deployment-scoped configuration of an AI dialect.""" - - __tablename__ = "ai_providers" # type: ignore - - id: AIProviderID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) - dialect: AIDialectID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("ai_dialects.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - config: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - enabled: bool = sqlmodel.Field( - default=True, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, - nullable=False, - server_default=sqlalchemy.true(), - ), - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - -class AIModelModel(sqlmodel.SQLModel, table=True): - """One provider-bound native model offering and its effective capabilities.""" - - __tablename__ = "ai_models" # type: ignore - __table_args__ = ( - sqlalchemy.UniqueConstraint( - "provider", "native_model_id", name="uq_ai_models_provider_native_model" - ), - ) - - id: AIModelID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - provider: AIProviderID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey("ai_providers.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - native_model_id: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - name: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - capabilities: tuple[AIModelCapability, ...] = sqlmodel.Field( - default=(), - sa_column=sqlalchemy.Column(AICapabilitiesType(), nullable=False), - ) - enabled: bool = sqlmodel.Field( - default=True, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, - nullable=False, - server_default=sqlalchemy.true(), - ), - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - @pydantic.field_validator("capabilities", mode="before") - @classmethod - def canonical_capabilities(cls, value: typing.Any) -> tuple[AIModelCapability, ...]: - return normalize_capabilities(value) - - -class EmbeddingProfileModel(sqlmodel.SQLModel, table=True): - """One durable vector-space compatibility contract.""" - - __tablename__ = "embedding_profiles" # type: ignore - __table_args__ = ( - sqlalchemy.CheckConstraint( - "dimensions > 0", name="ck_embedding_profiles_dimensions_positive" - ), - ) - - id: EmbeddingProfileID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - name: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - ai_model: AIModelID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey("ai_models.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - dimensions: int = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) +if TYPE_CHECKING: + from inkcre_core.schemas.ai.main import * diff --git a/app/schemas/cron.py b/app/schemas/cron.py index bf452e15..1fa18e66 100644 --- a/app/schemas/cron.py +++ b/app/schemas/cron.py @@ -1,120 +1,10 @@ -"""Deployment-wide recurring Job-template schemas.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.cron") -from .job import JobID, JobTypeID - - -CronID: typing.TypeAlias = int - - -class CronModel(sqlmodel.SQLModel, table=True): - """One five-field UNIX schedule that materializes typed Jobs.""" - - __tablename__ = "crons" # type: ignore - __table_args__ = ( - sqlalchemy.Index("crons_enabled_idx", "enabled"), - sqlalchemy.CheckConstraint( - "job_timeout_seconds IS NULL OR job_timeout_seconds > 0", - name="crons_job_timeout_seconds_positive", - ), - ) - - id: CronID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - schedule: str = sqlmodel.Field( - min_length=1, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False), - ) - enabled: bool = sqlmodel.Field( - default=True, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, - nullable=False, - server_default=sqlalchemy.true(), - ), - ) - job_type: JobTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("job_types.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - job_parameters: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - job_timeout_seconds: int | None = sqlmodel.Field( - default=None, - gt=0, - sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=True), - ) - last_job: JobID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.ForeignKey("jobs.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=True, - ), - ) - last_scheduled_for: datetime.datetime | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - -class CronForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - schedule: str = pydantic.Field(min_length=1) - enabled: bool = True - job_type: JobTypeID - job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) - - -class CronUpdateForm(pydantic.BaseModel): - """Optional submitted fields; null only clears the timeout override.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - # Omission is not null input: exclude_unset removes these defaults, whereas - # an explicitly submitted null still fails the non-null field type. - schedule: str = pydantic.Field(default=None, min_length=1) # pyrefly: ignore[bad-assignment] - enabled: bool = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - job_type: JobTypeID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) +if TYPE_CHECKING: + from inkcre_core.schemas.cron import * diff --git a/app/schemas/deployment_config.py b/app/schemas/deployment_config.py index 0c4e4c61..f11766ff 100644 --- a/app/schemas/deployment_config.py +++ b/app/schemas/deployment_config.py @@ -1,69 +1,10 @@ -"""Deployment-scoped shared configuration persistence and API models.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.deployment_config") - -DeploymentConfigKey: typing.TypeAlias = str -DeploymentConfigSchemaID: typing.TypeAlias = str - - -class DeploymentConfigModel(sqlmodel.SQLModel, table=True): - """One deployment-owned config value under an exact schema contract.""" - - __tablename__ = "configs" # type: ignore - - key: DeploymentConfigKey = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - schema_id: DeploymentConfigSchemaID = sqlmodel.Field( - sa_column=sqlalchemy.Column("schema", sqlalchemy.Text, nullable=False) - ) - value: dict[str, typing.Any] = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - ) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - -class DeploymentConfigReplaceForm(pydantic.BaseModel): - """Complete PUT body; schema changes are allowed only through this form.""" - - model_config = pydantic.ConfigDict(populate_by_name=True) - - schema_id: DeploymentConfigSchemaID = pydantic.Field(alias="schema") - value: dict[str, typing.Any] - - -class DeploymentConfigView(pydantic.BaseModel): - """Stored deployment config and metadata; reading does not revalidate value.""" - - model_config = pydantic.ConfigDict(populate_by_name=True) - - key: DeploymentConfigKey - schema_id: DeploymentConfigSchemaID = pydantic.Field(alias="schema") - value: dict[str, typing.Any] - created_at: datetime.datetime - updated_at: datetime.datetime +if TYPE_CHECKING: + from inkcre_core.schemas.deployment_config import * diff --git a/app/schemas/extension/__init__.py b/app/schemas/extension/__init__.py index 4a6fac60..de2f6925 100644 --- a/app/schemas/extension/__init__.py +++ b/app/schemas/extension/__init__.py @@ -1,37 +1,3 @@ -from .main import ( - DisableExtensionCommand, - EnableExtensionCommand, - ExtensionPeerStatus, - ExtensionPeerStatusRow, - EXTENSION_NAME_PATTERN, - EXTENSION_SEMVER_PATTERN, - ExtensionManagementCommand, - ExtensionModel, - ExtensionPeerParticipationModel, - ExtensionName, - InstallExtensionCommand, - PatchExtensionConfigCommand, - SelectExtensionInput, - SelectExtensionCommand, - ExtensionStatusCommand, - UnselectExtensionCommand, -) +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "DisableExtensionCommand", - "EnableExtensionCommand", - "ExtensionPeerStatus", - "ExtensionPeerStatusRow", - "EXTENSION_NAME_PATTERN", - "EXTENSION_SEMVER_PATTERN", - "ExtensionManagementCommand", - "ExtensionModel", - "ExtensionPeerParticipationModel", - "ExtensionName", - "InstallExtensionCommand", - "PatchExtensionConfigCommand", - "SelectExtensionInput", - "SelectExtensionCommand", - "ExtensionStatusCommand", - "UnselectExtensionCommand", -] +from inkcre_core.schemas.extension import * diff --git a/app/schemas/extension/main.py b/app/schemas/extension/main.py index 0d6322ef..cc410f7c 100644 --- a/app/schemas/extension/main.py +++ b/app/schemas/extension/main.py @@ -1,215 +1,10 @@ -import typing -import uuid +"""Legacy import; use inkcre_core for new consumers.""" -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.schemas.extension.main") -ExtensionName: typing.TypeAlias = str -NativeDistribution = typing.Annotated[str, pydantic.Field(min_length=1)] -EXTENSION_NAME_PATTERN = ( - r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?/" - r"[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$" -) -EXTENSION_SEMVER_PATTERN = ( - r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)" - r"(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" - r"([.](0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$" -) - - -class InstallExtensionCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["install"] - extension: ExtensionName - version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) - - -class SelectExtensionCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["select"] - extension: ExtensionName - distribution: NativeDistribution - version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) - - -class ExtensionStatusCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["status"] - extension: ExtensionName | None = None - - -class UnselectExtensionCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["unselect"] - extension: ExtensionName - - -class EnableExtensionCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["enable"] - extension: ExtensionName - - -class DisableExtensionCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["disable"] - extension: ExtensionName - - -class PatchExtensionConfigCommand(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["patch_config"] - extension: ExtensionName - patch: dict[str, typing.Any] - - -class ExtensionPeerStatusRow(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - name: ExtensionName - peer_id: uuid.UUID - distribution: NativeDistribution | None - version: str | None - enabled: bool - config_schema: dict[str, typing.Any] | None - supported_distributions: tuple[NativeDistribution, ...] - loaded_version: str | None - running: bool - restart_required: bool - operation: str | None - error: str | None - - -class ExtensionPeerStatus(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - peer_id: uuid.UUID - supported_distributions: tuple[NativeDistribution, ...] - extensions: tuple[ExtensionPeerStatusRow, ...] - - -class SelectExtensionInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - distribution: NativeDistribution - version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) - - -ExtensionManagementCommand: typing.TypeAlias = typing.Annotated[ - SelectExtensionCommand - | ExtensionStatusCommand - | UnselectExtensionCommand - | EnableExtensionCommand - | DisableExtensionCommand - | PatchExtensionConfigCommand, - pydantic.Field(discriminator="action"), -] - - -class ExtensionModel(sqlmodel.SQLModel, table=True): - """Shared Extension identity, configuration, and producer-owned state.""" - - __tablename__: str = "extensions" # type: ignore - __table_args__ = ( - sqlalchemy.CheckConstraint( - f"name ~ '{EXTENSION_NAME_PATTERN}'", - name="extensions_name_canonical", - ), - sqlalchemy.CheckConstraint( - "jsonb_typeof(state) = 'object'", - name="extensions_state_object", - ), - ) - - name: ExtensionName = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - nickname: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - config: dict = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - ) - state: dict = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - ) - - -class ExtensionPeerParticipationModel(sqlmodel.SQLModel, table=True): - """One Peer’s exact native distribution selection and enable intent.""" - - __tablename__: str = "extension_peer_participations" # type: ignore - __table_args__ = ( - sqlalchemy.CheckConstraint( - f"name ~ '{EXTENSION_NAME_PATTERN}'", - name="extension_peer_participations_name_canonical", - ), - sqlalchemy.CheckConstraint( - f"version ~ '{EXTENSION_SEMVER_PATTERN}'", - name="extension_peer_participations_version_canonical", - ), - sqlalchemy.CheckConstraint( - "config_schema IS NULL OR jsonb_typeof(config_schema) = 'object'", - name="extension_peer_participations_config_schema_object", - ), - sqlalchemy.CheckConstraint( - "distribution IS NOT NULL OR config_schema IS NULL", - name="extension_peer_participations_unresolved_schema_null", - ), - sqlalchemy.ForeignKeyConstraint( - ["name"], ["inkcre.extensions.name"], ondelete="CASCADE" - ), - sqlalchemy.ForeignKeyConstraint(["peer_id"], ["inkcre.peers.id"], ondelete="CASCADE"), - sqlalchemy.PrimaryKeyConstraint( - "name", "peer_id", name="extension_peer_participations_pkey" - ), - ) - - name: ExtensionName = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - peer_id: uuid.UUID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.UUID(as_uuid=True), nullable=False - ) - ) - distribution: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - version: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - enabled: bool = sqlmodel.Field( - default=False, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false() - ), - ) - config_schema: dict | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB(none_as_null=True), nullable=True - ), - ) +if TYPE_CHECKING: + from inkcre_core.schemas.extension.main import * diff --git a/app/schemas/graph_navigation_retrieval.py b/app/schemas/graph_navigation_retrieval.py index c141619e..4dd32978 100644 --- a/app/schemas/graph_navigation_retrieval.py +++ b/app/schemas/graph_navigation_retrieval.py @@ -1,90 +1,10 @@ -"""Presentation-neutral graph-navigation retrieval contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.graph_navigation_retrieval") -from app.schemas.info_base.block import BlockID, BlockModel -from app.schemas.info_base.relation import RelationID, RelationModel - - -GraphDirection: typing.TypeAlias = typing.Literal["in", "out", "both"] - -DEFAULT_NEIGHBORHOOD_LIMIT = 20 -MAX_NEIGHBORHOOD_LIMIT = 100 -DEFAULT_MAX_HOPS = 4 -MAX_MAX_HOPS = 8 -DEFAULT_MAX_EXPLORED_BLOCKS = 1000 -MAX_MAX_EXPLORED_BLOCKS = 10000 -DEFAULT_MAX_EXPLORED_RELATIONS = 10000 - - -class GraphModel(pydantic.BaseModel): - """Endpoint-closed persisted graph read model.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - blocks: tuple[BlockModel, ...] - relations: tuple[RelationModel, ...] - - -class BlockNeighborhood(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - focal_block: BlockID - graph: GraphModel - next_cursor: RelationID | None = None - - -class RelationNeighborhood(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - focal_relation: RelationID - graph: GraphModel - - -class ConnectedSeedComponent(pydantic.BaseModel): - """One observed undirected component containing one or more input seeds.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - seed_block_ids: tuple[BlockID, ...] - member_block_ids: tuple[BlockID, ...] - - -class ConnectedComponentsResult(pydantic.BaseModel): - """Bounded seed partition plus an endpoint-closed spanning proof.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - components: tuple[ConnectedSeedComponent, ...] - proof_graph: GraphModel - missing_seed_block_ids: tuple[BlockID, ...] - truncated: bool - - -class PathFound(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - status: typing.Literal["found"] = "found" - graph: GraphModel - block_path: tuple[BlockID, ...] - relation_path: tuple[RelationID, ...] - - -class PathNotFound(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - status: typing.Literal["not_found"] = "not_found" - - -class PathLimitReached(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - status: typing.Literal["limit_reached"] = "limit_reached" - - -PathResult: typing.TypeAlias = typing.Annotated[ - PathFound | PathNotFound | PathLimitReached, - pydantic.Field(discriminator="status"), -] +if TYPE_CHECKING: + from inkcre_core.schemas.graph_navigation_retrieval import * diff --git a/app/schemas/info_base/__init__.py b/app/schemas/info_base/__init__.py index abcd96c5..c596294e 100644 --- a/app/schemas/info_base/__init__.py +++ b/app/schemas/info_base/__init__.py @@ -1,36 +1,3 @@ -from .block import BlockForm, BlockModel, BlockID, ResolverType -from .relation import RelationCreateForm, RelationForm, RelationModel, RelationID -from .storage import StorageBlobModel, StorageModel, StorageID, StorageTypesModel -from .main import ( - GraphBlockForm, - GraphForm, - GraphRelationForm, - InArcForm, - OutArcForm, - StarsGraphForm, - SubmitGraphResult, - Vector, -) +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "BlockModel", - "BlockForm", - "BlockID", - "ResolverType", - "RelationModel", - "RelationForm", - "RelationCreateForm", - "RelationID", - "StorageModel", - "StorageBlobModel", - "StorageID", - "StorageTypesModel", - "StarsGraphForm", - "OutArcForm", - "InArcForm", - "GraphBlockForm", - "GraphRelationForm", - "GraphForm", - "SubmitGraphResult", - "Vector", -] +from inkcre_core.schemas.info_base import * diff --git a/app/schemas/info_base/block.py b/app/schemas/info_base/block.py index ffa3f8b4..b794ecd3 100644 --- a/app/schemas/info_base/block.py +++ b/app/schemas/info_base/block.py @@ -1,111 +1,10 @@ -import datetime -import typing -import sqlalchemy -import sqlmodel -from pydantic import PrivateAttr -from typing import Optional as Opt -from .storage import StorageID +"""Legacy import; use inkcre_core for new consumers.""" -ResolverType: typing.TypeAlias = str -"""Type of resolver. +from typing import TYPE_CHECKING +from importlib import import_module +import sys -Extension resolvers should follows `extensions.{extension_id}.{resolver_name}` pattern. -""" -BlockID: typing.TypeAlias = int -HydratedContent: typing.TypeAlias = str | bytes -ResolverTV = typing.TypeVar("ResolverTV", bound="ResolverType") -"""Resolver type variable""" +sys.modules[__name__] = import_module("inkcre_core.schemas.info_base.block") - -class BlockForm(sqlmodel.SQLModel): - """Producer-owned values for creating one Block.""" - - model_config = {"extra": "forbid"} - - storage: Opt[StorageID] = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("storages.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=True, - ), - ) - resolver: ResolverType = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - content: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - """Content of the block. - - Block content stored as text in database. - For runtime usage, use storage to get the raw content. - """ - - -class BlockModel(BlockForm, table=True): - __tablename__ = "blocks" # type: ignore - - id: Opt[BlockID] = sqlmodel.Field( - sa_column=sqlmodel.Column(sqlmodel.Integer, primary_key=True, autoincrement=True), - default=None, - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - _hydrated_content: HydratedContent | None = PrivateAttr(default=None) - _hydrated_content_source: tuple[StorageID | None, str] | None = PrivateAttr(default=None) - - async def get_hydrated_content(self, *, refresh: bool = False) -> HydratedContent: - """Return inline content or bytes loaded through the configured storage. - - The cache belongs only to this block instance. It is keyed by the persisted - storage pointer fields and is replaced when ``refresh`` is requested. - """ - source = (self.storage, self.content) - private_state = self.__pydantic_private__ - if private_state is None: - private_state = {} - object.__setattr__(self, "__pydantic_private__", private_state) - if ( - not refresh - and private_state.get("_hydrated_content_source") == source - and private_state.get("_hydrated_content") is not None - ): - return typing.cast(HydratedContent, private_state["_hydrated_content"]) - - if self.storage is None: - hydrated_content: HydratedContent = self.content - else: - from app.business.info_base.storage import StorageManager - - storage = await StorageManager.get_storage_async(self.storage) - stored_content = await storage.get_raw_content(self.content) - if not isinstance(stored_content, bytes): - raise TypeError( - f"Storage {storage.__class__.__name__} returned " - f"{type(stored_content).__name__}; configured storage must return bytes" - ) - hydrated_content = stored_content - - private_state["_hydrated_content"] = hydrated_content - private_state["_hydrated_content_source"] = source - return hydrated_content - - async def get_context_as_text(self) -> str | None: - from app.business.info_base.resolver import ResolverManager - - resolver = ResolverManager.get(self) - return await resolver.get_text() +if TYPE_CHECKING: + from inkcre_core.schemas.info_base.block import * diff --git a/app/schemas/info_base/main.py b/app/schemas/info_base/main.py index 8bd4b75e..a2f6c7fb 100644 --- a/app/schemas/info_base/main.py +++ b/app/schemas/info_base/main.py @@ -1,116 +1,10 @@ -"""Producer graph forms and graph-command results.""" +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "Vector", - "OutArcForm", - "InArcForm", - "StarsGraphForm", - "GraphBlockForm", - "GraphRelationForm", - "GraphForm", - "GraphBlockIDMapping", - "SubmitGraphResult", -] +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import typing +sys.modules[__name__] = import_module("inkcre_core.schemas.info_base.main") -import pydantic -import sqlmodel - -from app.schemas.info_base.block import BlockForm, BlockID -from app.schemas.info_base.relation import RelationForm - - -Vector: typing.TypeAlias = tuple[float, ...] -NegativeBlockID: typing.TypeAlias = typing.Annotated[int, pydantic.Field(lt=0)] -NonZeroBlockID: typing.TypeAlias = int - - -class OutArcForm(sqlmodel.SQLModel): - """One outgoing relation from the enclosing star to a recursive star.""" - - model_config = {"extra": "forbid"} - - relation: RelationForm - to_graph: "StarsGraphForm" - - -class InArcForm(sqlmodel.SQLModel): - """One incoming relation from a recursive star to the enclosing star.""" - - model_config = {"extra": "forbid"} - - relation: RelationForm - from_graph: "StarsGraphForm" - - -class StarsGraphForm(sqlmodel.SQLModel): - """Recursive star-centered authoring form used by Resolvers and extensions.""" - - model_config = {"extra": "forbid"} - - block: BlockForm - out_arcs: tuple[OutArcForm, ...] = () - in_arcs: tuple[InArcForm, ...] = () - - -class GraphBlockForm(BlockForm): - """A new Block declaration under one GraphForm-local negative ID.""" - - id: NegativeBlockID - - -class GraphRelationForm(RelationForm): - """A Relation declaration over the GraphForm signed Block-ID namespace.""" - - from_: NonZeroBlockID - to_: NonZeroBlockID - - @pydantic.field_validator("from_", "to_") - @classmethod - def require_non_zero_endpoint(cls, value: int) -> int: - if value == 0: - raise ValueError("GraphForm Relation endpoints must be non-zero") - return value - - -class GraphForm(sqlmodel.SQLModel): - """Flat command for adding arbitrarily connected Blocks and Relations.""" - - model_config = {"extra": "forbid"} - - blocks: tuple[GraphBlockForm, ...] = () - relations: tuple[GraphRelationForm, ...] = () - - @pydantic.model_validator(mode="after") - def validate_local_block_references(self) -> "GraphForm": - local_ids = [block.id for block in self.blocks] - if len(local_ids) != len(set(local_ids)): - raise ValueError("GraphForm new Block IDs must be unique") - - unresolved = { - endpoint - for relation in self.relations - for endpoint in (relation.from_, relation.to_) - if endpoint < 0 and endpoint not in local_ids - } - if unresolved: - rendered = ", ".join(str(value) for value in sorted(unresolved)) - raise ValueError(f"GraphForm contains unresolved local Block IDs: {rendered}") - return self - - -class GraphBlockIDMapping(sqlmodel.SQLModel): - """Map one command-local Block identity to its persisted identity.""" - - local_id: NegativeBlockID - id: BlockID - - -class SubmitGraphResult(sqlmodel.SQLModel): - """Minimal successful graph-command result used by producers and Agent Tools.""" - - blocks: tuple[GraphBlockIDMapping, ...] - - -StarsGraphForm.model_rebuild() +if TYPE_CHECKING: + from inkcre_core.schemas.info_base.main import * diff --git a/app/schemas/info_base/relation.py b/app/schemas/info_base/relation.py index 6712dba7..0b219e9c 100644 --- a/app/schemas/info_base/relation.py +++ b/app/schemas/info_base/relation.py @@ -1,68 +1,10 @@ -import typing -from typing import Optional as Opt -import datetime -import sqlalchemy -import sqlmodel +"""Legacy import; use inkcre_core for new consumers.""" -from app.schemas.info_base.block import BlockID +from typing import TYPE_CHECKING +from importlib import import_module +import sys -RelationID: typing.TypeAlias = int +sys.modules[__name__] = import_module("inkcre_core.schemas.info_base.relation") - -class RelationForm(sqlmodel.SQLModel): - """Producer-owned values for creating one Relation.""" - - model_config = {"extra": "forbid"} - - content: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - - -class RelationCreateForm(RelationForm): - """Standalone Relation creation command with persisted Block endpoints.""" - - from_: BlockID - to_: BlockID - - -class RelationModel(RelationForm, table=True): - __tablename__ = "relations" # type: ignore - __table_args__ = ( - sqlalchemy.Index( - "relations_from_id_desc_idx", - "from_", - sqlalchemy.desc("id"), - ), - sqlalchemy.Index( - "relations_to_id_desc_idx", - "to_", - sqlalchemy.desc("id"), - ), - ) - - id: Opt[RelationID] = sqlmodel.Field( - sa_column=sqlmodel.Column(sqlmodel.Integer, primary_key=True, autoincrement=True), - default=None, - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - from_: int = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("blocks.id", ondelete="CASCADE", onupdate="CASCADE"), - ), - default=0, - ) - to_: int = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("blocks.id", ondelete="CASCADE", onupdate="CASCADE"), - ), - default=0, - ) +if TYPE_CHECKING: + from inkcre_core.schemas.info_base.relation import * diff --git a/app/schemas/info_base/rest.py b/app/schemas/info_base/rest.py index 8981040e..9008a042 100644 --- a/app/schemas/info_base/rest.py +++ b/app/schemas/info_base/rest.py @@ -1,60 +1,10 @@ -"""Ordinary REST input forms; persisted entities and Peer wire formats stay separate.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.info_base.rest") -from app.schemas.graph_navigation_retrieval import ( - DEFAULT_MAX_HOPS, - MAX_MAX_HOPS, - DEFAULT_MAX_EXPLORED_BLOCKS, - MAX_MAX_EXPLORED_BLOCKS, - DEFAULT_MAX_EXPLORED_RELATIONS, - GraphDirection, -) - - -class EntityReference(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - type: typing.Literal["block", "relation"] - id: int - - -class EntitiesGetForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - entities: list[EntityReference] - content: typing.Literal["none", "raw", "hydrated"] = "raw" - - -class BlockUpdateForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - resolver: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - storage: int | None = None - - -class RelationUpdateForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - from_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - to_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] - - -class GraphPathForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - from_block_id: int - to_block_id: int - direction: GraphDirection = "both" - contents: tuple[str, ...] = () - max_hops: int = pydantic.Field(DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) - max_explored_blocks: int = pydantic.Field( - DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS - ) - - -class GraphComponentsForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - seed_block_ids: tuple[int, ...] - contents: tuple[str, ...] = pydantic.Field(min_length=1) - max_explored_blocks: int = pydantic.Field(DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) - max_explored_relations: int = pydantic.Field(DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) +if TYPE_CHECKING: + from inkcre_core.schemas.info_base.rest import * diff --git a/app/schemas/info_base/storage.py b/app/schemas/info_base/storage.py index 36a281b0..01b17e4f 100644 --- a/app/schemas/info_base/storage.py +++ b/app/schemas/info_base/storage.py @@ -1,94 +1,10 @@ -import typing -import uuid -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel -from typing import Optional as Opt +"""Legacy import; use inkcre_core for new consumers.""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -StorageTypeID: typing.TypeAlias = str -StorageID: typing.TypeAlias = int +sys.modules[__name__] = import_module("inkcre_core.schemas.info_base.storage") - -class StorageBlobModel(sqlmodel.SQLModel, table=True): - """Raw PostgreSQL-owned bytes addressed by an opaque generated pointer.""" - - __tablename__: str = "storage_blobs" # type: ignore - - id: uuid.UUID = sqlmodel.Field( - default_factory=uuid.uuid4, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.UUID(as_uuid=True), - primary_key=True, - server_default=sqlalchemy.text("gen_random_uuid()"), - ), - ) - data: bytes = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.LargeBinary, nullable=False) - ) - - -class StorageTypesModel(sqlmodel.SQLModel, table=True): - __tablename__: str = "storage_types" # type: ignore - - id: StorageTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - """Type of storage. - - A custom string to identify the storage type. - For extension storages, must follow `extensions.{extension_id}.{type}` format. - """ - description: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - """Description of this storage type.""" - config_schema: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default=dict, - ) - writable: bool = sqlmodel.Field( - default=False, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, - nullable=False, - server_default=sqlalchemy.false(), - ), - ) - - -class StorageModel(sqlmodel.SQLModel, table=True): - __tablename__: str = "storages" # type: ignore - - id: Opt[StorageID] = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True), - default=None, - ) - type: str = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("storage_types.id", onupdate="CASCADE", ondelete="CASCADE"), - nullable=False, - ) - ) - """Type of storage. - - An absolute import path to the module where storage class at. - When delete a storage type, all storages of this type will be deleted too. - """ - nickname: Opt[str] = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - config: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default=dict, - ) +if TYPE_CHECKING: + from inkcre_core.schemas.info_base.storage import * diff --git a/app/schemas/job.py b/app/schemas/job.py index 740c0a88..41c93fea 100644 --- a/app/schemas/job.py +++ b/app/schemas/job.py @@ -1,173 +1,10 @@ -"""Durable background-command schemas.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import enum -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.job") - -JobTypeID: typing.TypeAlias = str -JobID: typing.TypeAlias = int - - -class JobStatus(enum.StrEnum): - PENDING = "pending" - RUNNING = "running" - FINISHED = "finished" - FAILED = "failed" - TIMED_OUT = "timed_out" - ABORTED = "aborted" - - @property - def terminal(self) -> bool: - return self not in {self.PENDING, self.RUNNING} - - -class JobTypeModel(sqlmodel.SQLModel, table=True): - """Database projection of one exact runtime Job Handler contract.""" - - __tablename__ = "job_types" # type: ignore - __table_args__ = ( - sqlalchemy.CheckConstraint( - "default_timeout_seconds > 0", - name="job_types_default_timeout_seconds_positive", - ), - ) - - id: JobTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - description: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - parameters_schema: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - default_timeout_seconds: int = sqlmodel.Field( - gt=0, - sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False), - ) - - -class JobModel(sqlmodel.SQLModel, table=True): - """One durable execution opportunity for an exact registered command.""" - - __tablename__ = "jobs" # type: ignore - __table_args__ = ( - sqlalchemy.Index("jobs_status_idx", "status"), - sqlalchemy.CheckConstraint( - "octet_length(submission_traceparent) <= 512", - name="jobs_submission_traceparent_capacity", - ), - sqlalchemy.CheckConstraint( - "octet_length(submission_tracestate) <= 512", - name="jobs_submission_tracestate_capacity", - ), - sqlalchemy.CheckConstraint( - "timeout_seconds > 0", - name="jobs_timeout_seconds_positive", - ), - sqlalchemy.CheckConstraint( - "(status = 'pending' AND started_at IS NULL AND closed_at IS NULL) OR " - "(status = 'running' AND started_at IS NOT NULL AND closed_at IS NULL) OR " - "(status IN ('finished', 'failed', 'timed_out', 'aborted') " - "AND closed_at IS NOT NULL)", - name="jobs_lifecycle_timestamps_valid", - ), - ) - - id: JobID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - type: JobTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("job_types.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - parameters: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - submission_traceparent: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - submission_tracestate: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - state: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - abort_requested: bool = sqlmodel.Field( - default=False, - sa_column=sqlalchemy.Column( - sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false() - ), - ) - timeout_seconds: int = sqlmodel.Field( - gt=0, - sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False), - ) - status: JobStatus = sqlmodel.Field( - default=JobStatus.PENDING, - sa_column=sqlalchemy.Column( - sqlalchemy.Enum( - JobStatus, - name="jobstatus", - values_callable=lambda values: [value.value for value in values], - inherit_schema=True, - ), - nullable=False, - server_default=JobStatus.PENDING.value, - ), - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - started_at: datetime.datetime | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), - ) - closed_at: datetime.datetime | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), - ) - - -class JobCreateForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - type: JobTypeID - parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - timeout_seconds: int | None = pydantic.Field(default=None, gt=0) +if TYPE_CHECKING: + from inkcre_core.schemas.job import * diff --git a/app/schemas/lexical_retrieval.py b/app/schemas/lexical_retrieval.py index 60b6576b..a045fc64 100644 --- a/app/schemas/lexical_retrieval.py +++ b/app/schemas/lexical_retrieval.py @@ -1,150 +1,10 @@ -"""Block-local lexical projection, maintenance, and ranked result contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import math -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.lexical_retrieval") -from app.schemas.info_base.block import BlockModel - - -LexicalEvidence: typing.TypeAlias = typing.Literal[ - "label_exact", - "label_substring", - "text_substring", - "terms", -] - - -class BlockLexicalRecordModel(sqlmodel.SQLModel, table=True): - """Rebuildable Resolver projection owned only by lexical retrieval.""" - - __tablename__ = "block_lexical_records" # type: ignore - __table_args__ = ( - sqlalchemy.Index( - "block_lexical_records_search_vector_idx", - "search_vector", - postgresql_using="gin", - ), - sqlalchemy.Index( - "block_lexical_records_label_trgm_idx", - "label", - postgresql_using="gin", - postgresql_ops={"label": "gin_trgm_ops"}, - ), - sqlalchemy.Index( - "block_lexical_records_text_trgm_idx", - "text", - postgresql_using="gin", - postgresql_ops={"text": "gin_trgm_ops"}, - ), - ) - - block: int = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="CASCADE"), - primary_key=True, - ) - ) - label: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) - text: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - search_vector: str = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.TSVECTOR, - nullable=False, - ) - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - -class LexicalRetrievalRequest(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - query: str - limit: int = pydantic.Field(default=20, ge=1, le=20) - - @pydantic.field_validator("query") - @classmethod - def non_empty_query(cls, value: str) -> str: - if not value.strip(): - raise ValueError("query must not be empty") - return value - - -class LexicalRetrievalMatch(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block: BlockModel - label: str - excerpt: str - evidence: LexicalEvidence - rank: float = pydantic.Field(ge=0) - - @pydantic.field_validator("rank") - @classmethod - def finite_rank(cls, value: float) -> float: - if not math.isfinite(value): - raise ValueError("rank must be finite") - return value - - -class LexicalRetrievalResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - matches: tuple[LexicalRetrievalMatch, ...] - - -class LexicalMaintenanceOptions(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - max_records: int = pydantic.Field(default=100, ge=1) - scan_page_size: int = pydantic.Field(default=100, ge=1) - diagnostic_limit: int = pydantic.Field(default=20, ge=0) - - -class LexicalMaintenanceDiagnostic(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block: int - outcome: typing.Literal["unavailable", "failed"] - reason: str - - -class LexicalMaintenanceReport(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - indexed: int = 0 - unavailable: int = 0 - failed: int = 0 - diagnostics: tuple[LexicalMaintenanceDiagnostic, ...] = () - - -class LexicalMaintenanceJobParameters(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - options: LexicalMaintenanceOptions = pydantic.Field( - default_factory=LexicalMaintenanceOptions - ) +if TYPE_CHECKING: + from inkcre_core.schemas.lexical_retrieval import * diff --git a/app/schemas/observability.py b/app/schemas/observability.py index b8e588ad..f48b1ac8 100644 --- a/app/schemas/observability.py +++ b/app/schemas/observability.py @@ -1,37 +1,10 @@ -"""Non-secret deployment telemetry configuration shared by admitted Peers.""" +"""Legacy import; use inkcre_core for new consumers.""" -import uuid -import pydantic +from typing import TYPE_CHECKING +from importlib import import_module +import sys -CONFIG_KEY = "inkcre.observability" -CONFIG_SCHEMA = "inkcre.observability.v1" +sys.modules[__name__] = import_module("inkcre_core.schemas.observability") - -def _credential_free_url(value: pydantic.AnyHttpUrl | None): - if value is not None and ( - value.username or value.password or value.query or value.fragment - ): - raise ValueError("observability URLs cannot contain credentials, queries or fragments") - return value - - -class TelemetryEndpoints(pydantic.BaseModel): - """Complete OTLP/HTTP destinations; authentication stays in local runtime config.""" - - model_config = pydantic.ConfigDict(extra="forbid") - traces: pydantic.AnyHttpUrl | None = None - logs: pydantic.AnyHttpUrl | None = None - metrics: pydantic.AnyHttpUrl | None = None - - _urls = pydantic.field_validator("traces", "logs", "metrics")(_credential_free_url) - - -class ObservabilityConfig(pydantic.BaseModel): - """Stable deployment identity and optional metadata-only diagnostic destinations.""" - - model_config = pydantic.ConfigDict(extra="forbid") - deployment_id: uuid.UUID - otlp_http_endpoints: TelemetryEndpoints | None = None - diagnostics_url: pydantic.AnyHttpUrl | None = None - - _url = pydantic.field_validator("diagnostics_url")(_credential_free_url) +if TYPE_CHECKING: + from inkcre_core.schemas.observability import * diff --git a/app/schemas/organization.py b/app/schemas/organization.py index e1183482..afb363a0 100644 --- a/app/schemas/organization.py +++ b/app/schemas/organization.py @@ -1,89 +1,10 @@ -"""Organization rumination configuration, inbound, and Agent Tool forms.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.organization") -from app.schemas.ai import JSONValue -from app.schemas.info_base.block import BlockID -from app.schemas.info_base.main import GraphForm, NegativeBlockID - - -class RuminationConfig(pydantic.BaseModel): - """Deployment selection of one reusable Agent definition.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - agent: int - - -class MediaInterpretationConfig(pydantic.BaseModel): - """Deployment selection of independent modality-oriented Agents.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - image_agent: int - audio_agent: int - video_agent: int - - -class MediaInterpretationJobParameters(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - -class MediaInterpretationDiagnostic(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block: int - modality: typing.Literal["image", "audio", "video"] - outcome: typing.Literal["unavailable", "failed", "no_output"] - reason: str - - -class MediaInterpretationReport(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - selected: int = 0 - interpreted: int = 0 - unavailable: int = 0 - failed: int = 0 - no_output: int = 0 - diagnostics: tuple[MediaInterpretationDiagnostic, ...] = () - - -class RuminationRequest(pydantic.BaseModel): - """Explicit request to reconsider one focal Block.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block: BlockID - - -class GetDraftGraphSchemaInput(pydantic.BaseModel): - """Generic schema-discovery input narrowed when an Agent run binds Tools.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - resolver_types: tuple[str, ...] - - -class DraftGraphInput(pydantic.BaseModel): - """Generic draft request; Resolver input is validated by its bound run model.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - resolver_type: str - input: dict[str, JSONValue] = pydantic.Field( - description="Arguments matching the selected Resolver's input_schema." - ) - local_block_id_start: NegativeBlockID = pydantic.Field( - default=-1, description="First temporary ID; keep IDs disjoint when combining drafts." - ) - - -class SubmitGraphInput(pydantic.BaseModel): - """The sole rumination Tool input that can mutate the info-base graph.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - graph: GraphForm +if TYPE_CHECKING: + from inkcre_core.schemas.organization import * diff --git a/app/schemas/organization_behavior.py b/app/schemas/organization_behavior.py index 43915bf9..2bbcae1c 100644 --- a/app/schemas/organization_behavior.py +++ b/app/schemas/organization_behavior.py @@ -1,146 +1,10 @@ -"""Contracts for exact Organization behaviors and their Agent adapters.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.organization_behavior") -from app.schemas.graph_navigation_retrieval import ( - GraphModel, -) -from app.schemas.info_base.block import BlockID, ResolverType -from app.schemas.info_base.relation import RelationID - - -class BehaviorAgentConfig(pydantic.BaseModel): - """Deployment selection of one purpose-built Agent definition.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - agent: int - - -class AutomaticOrganizationJobParameters(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - max_seeds: int = pydantic.Field(default=10, ge=3, le=100) - - -class RelationWriteResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - relation_id: RelationID - created: bool - - -class CandidateWriteResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - descriptor_block_id: BlockID - relation_id: RelationID - created: bool - - -class SupersessionProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - successor_block_id: BlockID - predecessor_block_id: BlockID - - -class RefinementProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - refinement_block_id: BlockID - predecessor_block_id: BlockID - - -class EvidenceStanceProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - evidence_block_id: BlockID - assertion_block_id: BlockID - stance: typing.Literal["supports", "challenges"] - - -class SynthesisProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - text: str - source_block_ids: tuple[BlockID, ...] = pydantic.Field( - min_length=2, description="Actual contributing sources, not all inspected context." - ) - previous_synthesis_block_id: BlockID | None = pydantic.Field( - default=None, description="Earlier synthesis revised through an edited relation." - ) - - @pydantic.field_validator("text") - @classmethod - def non_empty_text(cls, value: str) -> str: - if not value.strip(): - raise ValueError("text must not be empty") - return value - - @pydantic.field_validator("source_block_ids") - @classmethod - def distinct_sources(cls, value: tuple[BlockID, ...]) -> tuple[BlockID, ...]: - if len(set(value)) != len(value): - raise ValueError("source_block_ids must be distinct") - return value - - -class SynthesisWriteResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - synthesis_block_id: BlockID - synthesis_created: bool - basis: tuple[RelationWriteResult, ...] - edited: RelationWriteResult | None = None - - -class ExistingReferentAnchorProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - source_block_id: BlockID - selected_text: str = pydantic.Field( - description="Minimal sufficient referring fragment from the source." - ) - referent_block_id: BlockID - - @pydantic.field_validator("selected_text") - @classmethod - def non_empty_selected_text(cls, value: str) -> str: - if not value.strip(): - raise ValueError("selected_text must not be empty") - return value - - -class ExistingReferentAnchorResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - fragment_block_id: BlockID - fragment_created: bool - has_mention: RelationWriteResult | None - refers_to: RelationWriteResult - - -class DuplicateAssertionProposal(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - left_block_id: BlockID - right_block_id: BlockID - - -class RecordOrganizationCandidateInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block_id: BlockID - behavior: ResolverType - - -class SupersessionLineage(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - graph: GraphModel - current_block_ids: tuple[BlockID, ...] - truncated: bool - cycle_detected: bool +if TYPE_CHECKING: + from inkcre_core.schemas.organization_behavior import * diff --git a/app/schemas/peer/__init__.py b/app/schemas/peer/__init__.py index 6c1618f4..f0937bae 100644 --- a/app/schemas/peer/__init__.py +++ b/app/schemas/peer/__init__.py @@ -1,35 +1,3 @@ -from .main import ( - CapabilityID, - CorePeerConfig, - PeerCapabilityAdvertisement, - PeerInboundInterface, - PeerModel, - PeerProtocolID, - PeerRef, - normalize_capability_snapshot, -) -from .protocol import ( - PEER_EXECUTION_HEADER, - PEER_HTTP_PROTOCOL, - PEER_NOT_EXECUTED, - PeerHTTPInboundParameters, - PeerProtocolRequest, - PeerProtocolResponse, -) +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = [ - "CapabilityID", - "CorePeerConfig", - "PEER_EXECUTION_HEADER", - "PEER_HTTP_PROTOCOL", - "PEER_NOT_EXECUTED", - "PeerCapabilityAdvertisement", - "PeerHTTPInboundParameters", - "PeerInboundInterface", - "PeerModel", - "PeerProtocolID", - "PeerProtocolRequest", - "PeerProtocolResponse", - "PeerRef", - "normalize_capability_snapshot", -] +from inkcre_core.schemas.peer import * diff --git a/app/schemas/peer/main.py b/app/schemas/peer/main.py index 060fd7e2..7822de78 100644 --- a/app/schemas/peer/main.py +++ b/app/schemas/peer/main.py @@ -1,178 +1,10 @@ -"""Peer identity, configuration, capability advertisement, and persistence.""" +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import typing -from urllib.parse import urlsplit, urlunsplit -import uuid +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.peer.main") -from app.schemas.ai import JSONValue - - -PeerRef: typing.TypeAlias = uuid.UUID -CapabilityID: typing.TypeAlias = str -PeerProtocolID: typing.TypeAlias = str - - -class PeerInboundInterface(pydantic.BaseModel): - """One protocol-discriminated construction descriptor for a caller outbound.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - protocol: PeerProtocolID = pydantic.Field(min_length=1) - parameters: dict[str, JSONValue] - - -class PeerCapabilityAdvertisement(pydantic.BaseModel): - """One exact capability and its provider-owned inbound interface.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - id: CapabilityID = pydantic.Field(min_length=1) - inbound: PeerInboundInterface - - -_CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[PeerCapabilityAdvertisement, ...]) - - -def normalize_capability_snapshot( - value: typing.Any, -) -> tuple[PeerCapabilityAdvertisement, ...]: - """Validate, de-duplicate, and deterministically order one full snapshot.""" - capabilities = _CAPABILITIES_ADAPTER.validate_python(value or ()) - ids = tuple(capability.id for capability in capabilities) - if len(ids) != len(set(ids)): - raise ValueError("Peer capability IDs must be unique") - return tuple(sorted(capabilities, key=lambda capability: capability.id)) - - -class CorePeerConfig(pydantic.BaseModel): - """impl-py-owned values stored in this Peer row's config object.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - http_public_base_url: str | None = pydantic.Field(default=None, title="Public HTTP URL") - extension_registry_url: str | None = pydantic.Field( - default=None, title="Extension Registry URL" - ) - - @pydantic.field_validator("http_public_base_url") - @classmethod - def valid_public_http_base(cls, value: str | None) -> str | None: - if value is None: - return None - parts = urlsplit(value) - if ( - parts.scheme not in {"http", "https"} - or not parts.netloc - or parts.username is not None - or parts.password is not None - or parts.query - or parts.fragment - ): - raise ValueError( - "http_public_base_url must be an absolute HTTP(S) URL without " - "credentials, query, or fragment" - ) - path = parts.path.rstrip("/") - return urlunsplit((parts.scheme, parts.netloc, path, "", "")) - - @pydantic.field_validator("extension_registry_url") - @classmethod - def valid_extension_registry_origin(cls, value: str | None) -> str | None: - if value is None or not value.strip(): - return None - parts = urlsplit(value.strip()) - if ( - parts.scheme not in {"http", "https"} - or not parts.netloc - or parts.username is not None - or parts.password is not None - or parts.path not in {"", "/"} - or parts.query - or parts.fragment - ): - raise ValueError("extension_registry_url must be one HTTP(S) origin") - return urlunsplit((parts.scheme, parts.netloc, "", "", "")) - - -class PeerModel(sqlmodel.SQLModel, table=True): - """One equal deployment Peer with a runtime-owned capability/lease snapshot.""" - - __tablename__ = "peers" # type: ignore - - id: PeerRef = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.UUID(as_uuid=True), - primary_key=True, - default=uuid.uuid4, - ), - ) - name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) - application_version: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - labels: list[str] = sqlmodel.Field( - default_factory=list, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.ARRAY(sqlalchemy.Text), - nullable=False, - server_default=sqlalchemy.text("'{}'::text[]"), - ), - ) - config: dict[str, JSONValue] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - config_schema: dict[str, JSONValue] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - capabilities: list[dict[str, JSONValue]] = sqlmodel.Field( - default_factory=list, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'[]'::jsonb"), - ), - ) - lease_expires_at: datetime.datetime | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=True, - ), - ) - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - - def capability_snapshot(self) -> tuple[PeerCapabilityAdvertisement, ...]: - """Validate a possibly externally edited persisted snapshot at use time.""" - return _CAPABILITIES_ADAPTER.validate_python(self.capabilities or ()) +if TYPE_CHECKING: + from inkcre_core.schemas.peer.main import * diff --git a/app/schemas/peer/protocol.py b/app/schemas/peer/protocol.py index 38f40e12..874ceadf 100644 --- a/app/schemas/peer/protocol.py +++ b/app/schemas/peer/protocol.py @@ -1,99 +1,10 @@ -"""Normalized Peer HTTP v1 request/response envelopes.""" +"""Legacy import; use inkcre_core for new consumers.""" -import re -import typing -from urllib.parse import urlsplit +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.peer.protocol") -from app.schemas.ai import JSONValue - - -PEER_HTTP_PROTOCOL = "core.peer.protocol.http.v1" -PEER_EXECUTION_HEADER = "InkCre-Peer-Execution" -PEER_NOT_EXECUTED = "not-executed" - -_HTTP_TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") - - -def _normalized_multimap(value: typing.Any) -> dict[str, tuple[str, ...]]: - if value is None: - return {} - if not isinstance(value, dict): - raise ValueError("normalized map must be an object") - normalized: dict[str, tuple[str, ...]] = {} - for raw_name, raw_values in value.items(): - if not isinstance(raw_name, str) or not raw_name: - raise ValueError("normalized map names must be non-empty strings") - name = raw_name.lower() - if name in normalized: - raise ValueError(f"normalized map contains duplicate name: {name}") - if not isinstance(raw_values, (list, tuple)) or not all( - isinstance(item, str) for item in raw_values - ): - raise ValueError("normalized map values must be string arrays") - normalized[name] = tuple(raw_values) - return normalized - - -class PeerProtocolRequest(pydantic.BaseModel): - """One protocol payload; query, headers, and JSON body may coexist.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - query: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) - headers: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) - body: JSONValue = None - - @pydantic.field_validator("query", "headers", mode="before") - @classmethod - def normalized_maps(cls, value: typing.Any) -> dict[str, tuple[str, ...]]: - return _normalized_multimap(value) - - -class PeerProtocolResponse(pydantic.BaseModel): - """One executed HTTP response projected back into the normalized protocol.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - status: int = pydantic.Field(ge=100, le=599) - headers: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) - body: JSONValue = None - - @pydantic.field_validator("headers", mode="before") - @classmethod - def normalized_headers(cls, value: typing.Any) -> dict[str, tuple[str, ...]]: - return _normalized_multimap(value) - - -class PeerHTTPInboundParameters(pydantic.BaseModel): - """Static parameters published by one HTTP business inbound.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - method: str - url: str - - @pydantic.field_validator("method") - @classmethod - def valid_method(cls, value: str) -> str: - method = value.upper() - if not _HTTP_TOKEN.fullmatch(method): - raise ValueError("HTTP method must be one valid token") - return method - - @pydantic.field_validator("url") - @classmethod - def valid_absolute_url(cls, value: str) -> str: - parts = urlsplit(value) - if ( - parts.scheme not in {"http", "https"} - or not parts.netloc - or parts.username is not None - or parts.password is not None - or parts.fragment - ): - raise ValueError( - "Peer HTTP inbound URL must be absolute HTTP(S) without credentials or fragment" - ) - return value +if TYPE_CHECKING: + from inkcre_core.schemas.peer.protocol import * diff --git a/app/schemas/semantic_retrieval.py b/app/schemas/semantic_retrieval.py index 5b59e868..ab06b679 100644 --- a/app/schemas/semantic_retrieval.py +++ b/app/schemas/semantic_retrieval.py @@ -1,129 +1,10 @@ -"""Semantic retrieval configuration, maintenance and ranked result contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import math -import typing +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic +sys.modules[__name__] = import_module("inkcre_core.schemas.semantic_retrieval") -from app.schemas.ai import EmbeddingProfileID -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel - - -SemanticEntityType: typing.TypeAlias = typing.Literal["block", "relation"] - - -def _default_entity_types() -> frozenset[SemanticEntityType]: - return frozenset(("block", "relation")) - - -class SemanticRetrievalConfig(pydantic.BaseModel): - """Deployment-owned selection of the default vector space.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - default_profile: EmbeddingProfileID | None = None - - -class VectorRetrievalOptions(pydantic.BaseModel): - """Query-scoped controls over one already compatible vector space.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - limit: int = pydantic.Field(default=20, ge=1, le=100) - min_score: float | None = pydantic.Field(default=None, ge=-1, le=1) - entity_types: frozenset[SemanticEntityType] = pydantic.Field( - default_factory=_default_entity_types, - min_length=1, - ) - - @pydantic.field_validator("min_score") - @classmethod - def finite_min_score(cls, value: float | None) -> float | None: - if value is not None and not math.isfinite(value): - raise ValueError("min_score must be finite") - return value - - -class SemanticRetrievalRequest(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - query: str - profile: EmbeddingProfileID | None = None - options: VectorRetrievalOptions = pydantic.Field(default_factory=VectorRetrievalOptions) - - @pydantic.field_validator("query") - @classmethod - def non_empty_query(cls, value: str) -> str: - if not value.strip(): - raise ValueError("query must not be empty") - return value - - -class BlockSemanticRetrievalMatch(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["block"] = "block" - entity: BlockModel - score: float = pydantic.Field(ge=-1, le=1) - - -class RelationSemanticRetrievalMatch(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["relation"] = "relation" - entity: RelationModel - score: float = pydantic.Field(ge=-1, le=1) - - -SemanticRetrievalMatch: typing.TypeAlias = typing.Annotated[ - BlockSemanticRetrievalMatch | RelationSemanticRetrievalMatch, - pydantic.Field(discriminator="type"), -] - - -class SemanticRetrievalResult(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - profile: EmbeddingProfileID - metric: typing.Literal["cosine"] = "cosine" - matches: tuple[SemanticRetrievalMatch, ...] - - -class EmbeddingMaintenanceOptions(pydantic.BaseModel): - """Peer-local bounds for one resumable maintenance invocation.""" - - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - max_embeddings: int = pydantic.Field(default=100, ge=1) - batch_size: int = pydantic.Field(default=20, ge=1) - scan_page_size: int = pydantic.Field(default=100, ge=1) - diagnostic_limit: int = pydantic.Field(default=20, ge=0) - - -class EmbeddingMaintenanceDiagnostic(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entity_type: SemanticEntityType - entity: int - outcome: typing.Literal["unavailable", "failed"] - reason: str - - -class EmbeddingMaintenanceReport(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - profile: EmbeddingProfileID - embedded: int = 0 - unavailable: int = 0 - failed: int = 0 - diagnostics: tuple[EmbeddingMaintenanceDiagnostic, ...] = () - - -class EmbeddingMaintenanceJobParameters(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - profile: EmbeddingProfileID | None = None - options: EmbeddingMaintenanceOptions = pydantic.Field( - default_factory=EmbeddingMaintenanceOptions - ) +if TYPE_CHECKING: + from inkcre_core.schemas.semantic_retrieval import * diff --git a/app/schemas/sink.py b/app/schemas/sink.py index ff3739be..cdaa0c34 100644 --- a/app/schemas/sink.py +++ b/app/schemas/sink.py @@ -1,82 +1,10 @@ -"""Persisted Sink catalog and instance contracts.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing -import uuid +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import pydantic -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlmodel +sys.modules[__name__] = import_module("inkcre_core.schemas.sink") - -SinkTypeID: typing.TypeAlias = str -SinkID: typing.TypeAlias = int - - -class SinkTypeModel(sqlmodel.SQLModel, table=True): - __tablename__ = "sink_types" # type: ignore - - id: SinkTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) - ) - description: str = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) - ) - config_schema: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - - -class SinkModel(sqlmodel.SQLModel, table=True): - __tablename__ = "sinks" # type: ignore - - id: SinkID | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.BigInteger, - sqlalchemy.Identity(), - primary_key=True, - ), - ) - type: SinkTypeID = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("sink_types.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False, - ) - ) - nickname: str | None = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - config: dict[str, typing.Any] = sqlmodel.Field( - default_factory=dict, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=False, - server_default=sqlalchemy.text("'{}'::jsonb"), - ), - ) - enabled: list[uuid.UUID] = sqlmodel.Field( - default_factory=list, - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.ARRAY( - sqlalchemy.dialects.postgresql.UUID(as_uuid=True) - ), - nullable=False, - server_default=sqlalchemy.text("'{}'::uuid[]"), - ), - ) - - -class SinkCreateForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: SinkTypeID - nickname: str | None = None - config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) +if TYPE_CHECKING: + from inkcre_core.schemas.sink import * diff --git a/app/schemas/source/__init__.py b/app/schemas/source/__init__.py index a400ba16..4c184826 100644 --- a/app/schemas/source/__init__.py +++ b/app/schemas/source/__init__.py @@ -1,19 +1,3 @@ -__all__ = [ - "SourceID", - "SourceModel", - "SourceTypesModel", - "SourceCollectParameters", - "SourceBackfillParameters", - "SourceCreateForm", - "SourceUpdateForm", -] +"""Legacy import; use inkcre_core for new consumers.""" -from .main import ( - SourceBackfillParameters, - SourceCollectParameters, - SourceID, - SourceModel, - SourceTypesModel, - SourceCreateForm, - SourceUpdateForm, -) +from inkcre_core.schemas.source import * diff --git a/app/schemas/source/main.py b/app/schemas/source/main.py index 04792aaf..0022f741 100644 --- a/app/schemas/source/main.py +++ b/app/schemas/source/main.py @@ -1,186 +1,11 @@ -__all__ = [ - "SourceID", - "SourceModel", - "SourceTypesModel", - "SourceCollectParameters", - "SourceBackfillParameters", -] +"""Legacy import; use inkcre_core for new consumers.""" -import datetime -import pydantic -import sqlalchemy -import sqlalchemy.dialects -import sqlalchemy.dialects.postgresql -import sqlmodel -import typing -from typing import Optional as Opt +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.schemas.info_base.block import BlockID -from app.schemas.info_base.storage import StorageID +sys.modules[__name__] = import_module("inkcre_core.schemas.source.main") -SourceID: typing.TypeAlias = int - - -class SourceCreateForm(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - type: str - nickname: str | None = None - config: dict = pydantic.Field(default_factory=dict) - storage: StorageID | None = None - - -class SourceUpdateForm(pydantic.BaseModel): - """Only submitted fields are written; config is replaced, not deep-merged.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - nickname: str | None = None - config: dict = pydantic.Field(default_factory=dict) - storage: StorageID | None = None - - -class CollectAt(sqlmodel.SQLModel): - """Historical migration-only value retained for the append-only baseline.""" - - day_of_week: int | None = None - hour: int = 0 - minute: int = 0 - - -class CollectAtType(sqlmodel.TypeDecorator): - """Historical migration-only PostgreSQL type; not a current Source field.""" - - impl = sqlalchemy.dialects.postgresql.JSONB - cache_ok = True - - def process_bind_param(self, value, dialect): - del dialect - return value.model_dump() if isinstance(value, CollectAt) else value - - def process_result_value(self, value, dialect): - del dialect - return CollectAt.model_validate(value) if value else None - - -class SourceCollectParameters(pydantic.BaseModel): - """Parameters of the exact ordinary Source Job type.""" - - model_config = pydantic.ConfigDict(extra="forbid") - - source: SourceID - config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - - -class SourceBackfillParameters(SourceCollectParameters): - """Parameters of the exact historical Source Job type.""" - - -class SourceTypesModel(sqlmodel.SQLModel, table=True): - __tablename__: str = "sources_types" # type: ignore - - id: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True)) - """Type of source. - - An absolute import path to the module where souce class at. - """ - description: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text)) - """Description of this source type.""" - config_schema: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default=dict, - ) - collect_config_schema: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default=dict, - ) - backfill_config_schema: dict | None = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - nullable=True, - ), - default=None, - ) - - -class SourceModel(sqlmodel.SQLModel, table=True): - __tablename__: str = "sources" # type: ignore - - id: Opt[SourceID] = sqlmodel.Field( - sa_column=sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True), - default=None, - ) - type: str = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.Text, - sqlalchemy.ForeignKey("sources_types.id", onupdate="CASCADE", ondelete="CASCADE"), - ) - ) - """Type of source. - - An absolute import path to the module where souce class at. - When delete a source type, all sources of this type will be deleted too. - """ - nickname: Opt[str] = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), - ) - config: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default=dict, - ) - storage: Opt[StorageID] = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("storages.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=True, - ), - ) - block: Opt[BlockID] = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.Integer, - sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="SET NULL"), - nullable=True, - unique=True, - ), - ) - state: dict = sqlmodel.Field( - sa_column=sqlalchemy.Column( - sqlalchemy.dialects.postgresql.JSONB, - server_default=sqlalchemy.text("'{}'::jsonb"), - nullable=False, - ), - default_factory=dict, - ) - """Store source-specific state (e.g., last_uid for mail, - latest_tweet_id for twitter).""" - created_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) - updated_at: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - sqlalchemy.TIMESTAMP(timezone=True), - nullable=False, - server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), - ), - ) +if TYPE_CHECKING: + from inkcre_core.schemas.source.main import * + from inkcre_core.schemas.source.main import CollectAtType as CollectAtType diff --git a/app/settings.py b/app/settings.py index 88d3ff2a..fc07a862 100644 --- a/app/settings.py +++ b/app/settings.py @@ -9,8 +9,8 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from app.database_contract.constants import JWT_MINIMUM_SECRET_BYTES -from libs.obsrv.setting import ObsrvSetting +from inkcre_core.database_contract.constants import JWT_MINIMUM_SECRET_BYTES +from inkcre_core.libs.obsrv.setting import ObsrvSetting class Settings(BaseSettings): diff --git a/app/validation.py b/app/validation.py index beaee8e2..db4ed96e 100644 --- a/app/validation.py +++ b/app/validation.py @@ -1,31 +1,5 @@ -"""Path projection for nested input contracts; validation stays with their owners.""" +"""Legacy import; use inkcre_core for new consumers.""" -import contextlib -import typing +from inkcre_core.validation import input_path -import jsonschema # pyrefly: ignore[untyped-import] -import pydantic - - -@contextlib.contextmanager -def input_path(*prefix: str | int): - """Attach a form nesting path to native errors without validating again.""" - try: - yield - except pydantic.ValidationError as error: - details = [ - {**item, "loc": (*prefix, *item["loc"])} for item in error.errors(include_url=False) - ] - raise pydantic.ValidationError.from_exception_data( - error.title, typing.cast(typing.Any, details) - ) from error - except jsonschema.ValidationError as error: - raise jsonschema.ValidationError( - error.message, - validator=error.validator, - validator_value=error.validator_value, - instance=error.instance, - schema=error.schema, - path=(*prefix, *error.absolute_path), - schema_path=error.absolute_schema_path, - ) from error +__all__ = ["input_path"] diff --git a/docs/30-unit-tdd/business-pipeline-and-authority.md b/docs/30-unit-tdd/business-pipeline-and-authority.md index caab6cf9..ea1b7778 100644 --- a/docs/30-unit-tdd/business-pipeline-and-authority.md +++ b/docs/30-unit-tdd/business-pipeline-and-authority.md @@ -17,9 +17,9 @@ implementation direction; it must not redefine Peer wire behavior or shared capa 在以下情况先读这里: - 修改 `app/business/extension/` -- 修改 `app/business/source/` -- 修改 `app/business/info_base/` -- 修改 `app/business/ai/` 或 application/use capability +- 修改 `inkcre_core/business/source/` +- 修改 `inkcre_core/business/info_base/` +- 修改 `inkcre_core/business/ai/` 或 application/use capability - 修改这些子树之间的 ownership / dependency / authority ## Structure @@ -59,7 +59,7 @@ implementation direction; it must not redefine Peer wire behavior or shared capa ### 数据库事务边界 -`app/business` 拥有应用用例、业务规则及事务组合;`app/persistence` 按业务责任分组,拥有 +`inkcre_core/business` 拥有应用用例、业务规则及事务组合;`inkcre_core/persistence` 按业务责任分组,拥有 session-bound repository 和 UoW 的数据库工厂实现。用例决定何时进入、退出作用域,UoW 工厂负责 实际创建 session 和原生事务 framing。repository 不反向依赖 business。 HTTP route 只解析请求并调用 business 入口,不直接查询 repository;例如 `get_entity_records` @@ -83,12 +83,12 @@ AI 调用前释放读取 scope,返回后重新检查已有派生,再在一 配置 HTTP 使用 DeploymentConfigService,AI、Agent 定义和 Peer 的数据库入口各自使用所属 UoW。 AI provider 调用和 Peer outbound 执行前结束数据库作用域;Peer 的内存 capability 注册仍是同步函数。 -`app/engine.py` 是异步 factory 的 authority,lifespan 在业务运行资源退出后 dispose 异步 engine。 +`inkcre_core.Core` 每实例拥有异步 factory;`app/core.py` 只负责服务装配与 ASGI 绑定。lifespan 在业务运行资源退出后 await Core.aclose(),外部借用的 engine 不由 Core dispose。 UoW 不跨并发任务共享。应用操作不接受可选 session;共同原子提交必须组合事务内操作,而不调用 独立提交入口。`expire_on_commit=False` 允许已提交的已加载字段在 scope 外读取,不授权隐式延迟查询。 ExtensionStateService 与 Host 的数据库操作已异步化,state/config transform 在行锁内同步执行, -SQL 位于 app/persistence/extension;Source catalog 批量同步提供 SDK async startup 所需能力。 +SQL 位于 inkcre_core/persistence/extension;Source catalog 批量同步提供 SDK async startup 所需能力。 启用/停用的持久化失败补偿和启动取消清理属于 Host;Peer 广播失败不撤销已提交的 disabled 状态。 Registry origin 查询结束后才执行网络/wheel 获取。旧同步 Host 合同通过 Host 0.2 窗口隔离。 diff --git a/docs/30-unit-tdd/lexical-retrieval.md b/docs/30-unit-tdd/lexical-retrieval.md index 810a2b95..11e92353 100644 --- a/docs/30-unit-tdd/lexical-retrieval.md +++ b/docs/30-unit-tdd/lexical-retrieval.md @@ -109,5 +109,5 @@ contains corpus aliases, expected phrases or test-shaped IDs. - recursive graph indexing - implicit maintenance during a retrieval request -数据库查询与 projection upsert 由 `app/persistence/lexical_retrieval/` 执行。业务入口原生 await, +数据库查询与 projection upsert 由 `inkcre_core/persistence/lexical_retrieval/` 执行。业务入口原生 await, 每个维护 batch 在短事务中一次 upsert 全部有效投影;Resolver 投影不占用该写事务。 diff --git a/docs/30-unit-tdd/semantic-retrieval.md b/docs/30-unit-tdd/semantic-retrieval.md index cb3e4f18..7d78a274 100644 --- a/docs/30-unit-tdd/semantic-retrieval.md +++ b/docs/30-unit-tdd/semantic-retrieval.md @@ -101,6 +101,6 @@ vectors, and generated graph rows are never committed as authority. - generic capability invocation endpoints or delegation jobs - persistent Agent Thread/checkpoint infrastructure -数据库查询与 embedding upsert 由 `app/persistence/semantic_retrieval/` 执行。每个 provider batch +数据库查询与 embedding upsert 由 `inkcre_core/persistence/semantic_retrieval/` 执行。每个 provider batch 先完成向量数量与维度校验,再在同一短事务中分别批量写入 Block 和 Relation embeddings; 任一种写入失败都会回滚整个 batch。profile 与检索读取同样原生 await,不使用同步 session。 diff --git a/docs/40-deployment/development-environment.md b/docs/40-deployment/development-environment.md index 42dcbd34..34521c8d 100644 --- a/docs/40-deployment/development-environment.md +++ b/docs/40-deployment/development-environment.md @@ -93,7 +93,7 @@ For Python-only iteration against an already initialized database: ```bash cp .env.example .env -pdm install -G dev --frozen-lockfile +pdm install -G app -G dev --frozen-lockfile pdm run doctor pdm run check pdm run dev diff --git a/docs/_shared b/docs/_shared index 4e323b49..b3334622 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 4e323b49ffcd00011f7e008fbb53866c6bec5a27 +Subproject commit b33346225b669a6ac93fcac12a135178a46533c8 diff --git a/inkcre_core/AGENTS.md b/inkcre_core/AGENTS.md new file mode 100644 index 00000000..0c4b865b --- /dev/null +++ b/inkcre_core/AGENTS.md @@ -0,0 +1,7 @@ +# Independent Python SDK + +- Ordinary SDK imports and operations must not depend on `app`, FastAPI, Starlette, the service scheduler, environment loading, or an application singleton. +- `Core` owns its engine factory and task-local binding. Each operation still creates its own UoW; never put a live session in the context or share it across concurrent tasks. +- Keep shared protocol IDs, model fields and transaction guarantees stable when moving modules. Legacy service aliases must resolve to the same model/service objects. +- Consumer entrypoints and ownership are documented in the repository README. Do not expose persistence internals as a stable public facade merely because they are packaged. +- Check the built wheel with `pdm run check:core-package`; it is consumed outside this checkout with application imports rejected. This check does not contact a database. diff --git a/inkcre_core/__init__.py b/inkcre_core/__init__.py new file mode 100644 index 00000000..cd38d654 --- /dev/null +++ b/inkcre_core/__init__.py @@ -0,0 +1,16 @@ +"""InKCre SDK. Importing contracts never initializes application resources.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .context import Core, CoreOptions, current_core + +__all__ = ["Core", "CoreOptions", "current_core"] + + +def __getattr__(name: str): + if name not in __all__: + raise AttributeError(name) + from . import context + + return getattr(context, name) diff --git a/inkcre_core/auth.py b/inkcre_core/auth.py new file mode 100644 index 00000000..8b6a9734 --- /dev/null +++ b/inkcre_core/auth.py @@ -0,0 +1,78 @@ +"""Peer JWT encoding and validation without application configuration.""" + +import math +import time +import jwt +from .database_contract.constants import JWT_ALGORITHM, JWT_AUDIENCE, JWT_ISSUER, JWT_ROLE + + +def decode_peer_jwt( + token: str, + secret: str, + *, + now: float | None = None, +) -> dict: + """Validate the canonical peer JWT contract used by every HTTP surface.""" + claims = jwt.decode( + token, + secret, + algorithms=[JWT_ALGORITHM], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + options={ + "require": ["role", "iss", "aud", "iat", "exp"], + "verify_exp": False, + "verify_iat": False, + }, + ) + issued_at = claims["iat"] + expires_at = claims["exp"] + valid_numeric_dates = ( + isinstance(issued_at, (int, float)) + and not isinstance(issued_at, bool) + and isinstance(expires_at, (int, float)) + and not isinstance(expires_at, bool) + ) + if not valid_numeric_dates: + raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") + try: + issued_at_number = float(issued_at) + expires_at_number = float(expires_at) + except OverflowError as error: + raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") from error + current_epoch = time.time() if now is None else now + if ( + claims["role"] != JWT_ROLE + or not math.isfinite(issued_at_number) + or not math.isfinite(expires_at_number) + or issued_at_number > current_epoch + 60 + or expires_at_number <= current_epoch + or expires_at_number <= issued_at_number + ): + raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") + return claims + + +def create_peer_jwt( + secret: str, + *, + now: float | None = None, + lifetime_seconds: int = 60, +) -> str: + """Sign a Peer JWT; the default is client policy, not an admission limit.""" + if not secret: + raise ValueError("Peer JWT secret is required") + if lifetime_seconds <= 0: + raise ValueError("Peer JWT lifetime must be positive") + issued_at = int(time.time() if now is None else now) + return jwt.encode( + { + "role": JWT_ROLE, + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "iat": issued_at, + "exp": issued_at + lifetime_seconds, + }, + secret, + algorithm=JWT_ALGORITHM, + ) diff --git a/inkcre_core/business/AGENTS.md b/inkcre_core/business/AGENTS.md new file mode 100644 index 00000000..3bbce370 --- /dev/null +++ b/inkcre_core/business/AGENTS.md @@ -0,0 +1,12 @@ +# Business Units + +> Applies to `inkcre_core/business/`; deeper guides own local hazards. + +- Read `docs/30-unit-tdd/business-pipeline-and-authority.md` before a change crosses business subtrees. +- `info_base` owns graph persistence; sources and extensions propose commands but do not copy generic persistence. +- AI execution remains graph-blind. Agent and organization layers may compose it but must not move graph or resolver policy into AI. +- Semantic and lexical retrieval own separate derived records and ranking policy; neither owns graph facts or mutates the graph while reading. +- Global Job/Cron machinery owns durable execution occurrence and claim lifecycle; domain units retain typed payload and effect semantics. +- Peer owns identity, capability, lease, and delegation transport facts; typed business codecs remain with their business owner. +- Application use cases choose UoW scopes; raw sessions stay in persistence. Composed operations require a UoW and never end its transaction. Each concurrent task owns a separate UoW; external I/O runs outside DB scopes. +- Required check: run tests for every affected business owner and the repository import-boundary checks. diff --git a/inkcre_core/business/__init__.py b/inkcre_core/business/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/business/agent/AGENTS.md b/inkcre_core/business/agent/AGENTS.md similarity index 100% rename from app/business/agent/AGENTS.md rename to inkcre_core/business/agent/AGENTS.md diff --git a/inkcre_core/business/agent/__init__.py b/inkcre_core/business/agent/__init__.py new file mode 100644 index 00000000..33fcb1d3 --- /dev/null +++ b/inkcre_core/business/agent/__init__.py @@ -0,0 +1,40 @@ +"""Reusable Agent definitions, typed Tools and in-memory Thread execution.""" + +from .contracts import ( + AgentError, + AgentNotFoundError, + AgentToolBindingError, + AgentTurnActiveError, + BoundAgentTool, + DuplicateAgentToolRegistrationError, + MissingAgentToolError, + ToolExecutionError, +) +from .main import AgentManager +from .bootstrap import register_core_agent_tools +from .persistence import ( + InMemoryThreadPersistenceBackend, + ThreadID, + ThreadPersistenceBackend, + ThreadState, +) +from .thread import Thread, TurnTermination + +__all__ = [ + "AgentError", + "AgentManager", + "register_core_agent_tools", + "AgentNotFoundError", + "AgentToolBindingError", + "AgentTurnActiveError", + "BoundAgentTool", + "DuplicateAgentToolRegistrationError", + "InMemoryThreadPersistenceBackend", + "MissingAgentToolError", + "Thread", + "ThreadID", + "ThreadPersistenceBackend", + "ThreadState", + "ToolExecutionError", + "TurnTermination", +] diff --git a/inkcre_core/business/agent/bootstrap.py b/inkcre_core/business/agent/bootstrap.py new file mode 100644 index 00000000..0f69e4e4 --- /dev/null +++ b/inkcre_core/business/agent/bootstrap.py @@ -0,0 +1,16 @@ +"""Explicit registration entrypoint for core-owned Agent Tool controllers.""" + + +def register_core_agent_tools() -> None: + """Import every core Tool owner, independent of HTTP Sink delivery. + + Decorators register Tools when their modules load; Python's import cache makes + repeated bootstrap calls harmless. Entry points must call this explicitly, + rather than rely on routes or package re-exports importing the owners first. + """ + from inkcre_core.business.graph_navigation_retrieval import tools as _graph_tools + from inkcre_core.business.info_base import tools as _info_base_tools + from inkcre_core.business.info_base.resolver import tools as _resolver_tools + from inkcre_core.business.organization import tools as _organization_tools + + del _graph_tools, _info_base_tools, _resolver_tools, _organization_tools diff --git a/inkcre_core/business/agent/contracts.py b/inkcre_core/business/agent/contracts.py new file mode 100644 index 00000000..5e5e05e5 --- /dev/null +++ b/inkcre_core/business/agent/contracts.py @@ -0,0 +1,59 @@ +"""Agent Tool binding and runtime failure contracts.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import typing + +import pydantic + +from inkcre_core.schemas.ai import FunctionTool, JSONValue + + +AgentToolHandler: typing.TypeAlias = Callable[ + [pydantic.BaseModel], + JSONValue | Awaitable[JSONValue], +] +AgentToolInputModelFactory: typing.TypeAlias = Callable[[], type[pydantic.BaseModel]] + + +class AgentError(RuntimeError): + """Base failure exposed by the Agent domain.""" + + +class AgentNotFoundError(AgentError): + pass + + +class AgentToolBindingError(AgentError): + pass + + +class DuplicateAgentToolRegistrationError(AgentToolBindingError): + pass + + +class MissingAgentToolError(AgentToolBindingError): + pass + + +class AgentTurnActiveError(AgentError): + pass + + +class ToolExecutionError(Exception): + """A Tool-owned actionable failure intended for the model.""" + + def __init__(self, content: JSONValue): + super().__init__("Agent Tool execution failed") + self.content = content + + +@dataclass(frozen=True) +class BoundAgentTool: + """One run-local Tool schema, validation model and code-owned handler.""" + + definition: FunctionTool + input_model: type[pydantic.BaseModel] + handler: AgentToolHandler diff --git a/inkcre_core/business/agent/debug.py b/inkcre_core/business/agent/debug.py new file mode 100644 index 00000000..9d17001d --- /dev/null +++ b/inkcre_core/business/agent/debug.py @@ -0,0 +1,51 @@ +"""Opt-in development traces through the existing logging backend.""" + +import asyncio +import datetime +import json +import sys +import typing +import uuid + +import pydantic + +from inkcre_core.context import current_core +from inkcre_core.libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID +from inkcre_core.libs.obsrv.main import get_logger + + +def _json_value(value: typing.Any) -> typing.Any: + if isinstance(value, pydantic.BaseModel): + return value.model_dump(mode="python") + if isinstance(value, bytes): + return {"omitted_binary_bytes": len(value)} + if isinstance(value, datetime.datetime | datetime.date): + return value.isoformat() + if isinstance(value, uuid.UUID): + return str(value) + raise TypeError(f"Unsupported Agent trace value: {type(value).__name__}") + + +def _emit(event: str, thread_id: str, payload: dict[str, typing.Any]) -> None: + token = ENABLE_LOG_BACKEND.set(True) + try: + envelope = { + "event": event, + "thread_id": thread_id, + "trace_id": TRACE_ID.get(), + **payload, + } + get_logger().getChild("agent.debug").info( + json.dumps(envelope, ensure_ascii=False, default=_json_value), + extra={"event": event, "agent_thread_id": thread_id}, + ) + except Exception as error: + # A debug destination must never replace the Agent's real outcome. + sys.stderr.write(f"Agent debug trace unavailable: {type(error).__name__}\n") + finally: + ENABLE_LOG_BACKEND.reset(token) + + +async def trace(event: str, thread_id: uuid.UUID, **payload: typing.Any) -> None: + if current_core().options.agent_debug: + await asyncio.to_thread(_emit, event, str(thread_id), payload) diff --git a/inkcre_core/business/agent/main.py b/inkcre_core/business/agent/main.py new file mode 100644 index 00000000..5a6a776c --- /dev/null +++ b/inkcre_core/business/agent/main.py @@ -0,0 +1,244 @@ +"""Persisted Agent definition loading and exact Tool registry binding.""" + +from __future__ import annotations + +from dataclasses import dataclass +import inspect +import typing + +import pydantic + +from inkcre_core.business.ai import AIExecutionRequirement, AIManager +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.agent import AgentID, AgentForm, AgentUpdateForm +from inkcre_core.schemas.ai import FunctionTool, SystemMessage, UserMessage + +from inkcre_core.persistence.agent.uow import agent_uow + +from .contracts import ( + AgentNotFoundError, + AgentToolBindingError, + AgentToolHandler, + AgentToolInputModelFactory, + BoundAgentTool, + DuplicateAgentToolRegistrationError, + MissingAgentToolError, +) +from .persistence import ( + InMemoryThreadPersistenceBackend, + ThreadPersistenceBackend, + ThreadState, +) +from .thread import Thread +from .debug import trace + + +HandlerT = typing.TypeVar("HandlerT", bound=typing.Callable[..., typing.Any]) + + +@dataclass(frozen=True) +class _ToolRegistration: + handler: AgentToolHandler + original_handler: typing.Callable[..., typing.Any] + description: str + input_model: type[pydantic.BaseModel] + input_model_factory: AgentToolInputModelFactory | None + + def bind(self, tool_id: str) -> BoundAgentTool: + input_model = ( + self.input_model_factory() + if self.input_model_factory is not None + else self.input_model + ) + if not isinstance(input_model, type) or not issubclass(input_model, pydantic.BaseModel): + raise AgentToolBindingError( + f"Agent Tool {tool_id!r} did not produce a Pydantic input model" + ) + return BoundAgentTool( + definition=FunctionTool( + id=tool_id, + description=self.description, + input_schema=input_model.model_json_schema(), + ), + input_model=input_model, + handler=self.handler, + ) + + +class AgentManager: + """Agent-definition execution and peer-local exact Tool registry.""" + + _TOOLS: dict[str, _ToolRegistration] = {} + _persistence: ThreadPersistenceBackend = InMemoryThreadPersistenceBackend() + + @classmethod + async def get_definition(cls, agent_id: AgentID) -> AgentDefinitionModel | None: + async with agent_uow() as agents: + return await agents.get(agent_id) + + @classmethod + async def list_definitions( + cls, *, limit: int | None = None, cursor: int | None = None + ) -> tuple[list[AgentDefinitionModel], int | None]: + async with agent_uow() as agents: + return await agents.list(limit=limit, cursor=cursor) + + @classmethod + async def create_definition(cls, form: AgentForm) -> AgentDefinitionModel: + async with agent_uow() as agents: + record = await agents.create(form) + return record + + @classmethod + async def update_definition( + cls, agent_id: AgentID, form: AgentUpdateForm + ) -> AgentDefinitionModel: + async with agent_uow() as agents: + record = await agents.get(agent_id, for_update=True) + if record is None: + raise AgentNotFoundError(f"Agent {agent_id} does not exist") + changes = form.model_dump(exclude_unset=True) + candidate = AgentForm.model_validate( + {**{field: getattr(record, field) for field in AgentForm.model_fields}, **changes} + ) + for field in changes: + setattr(record, field, getattr(candidate, field)) + await agents.save(record) + return record + + @classmethod + async def delete_definition(cls, agent_id: AgentID) -> bool: + async with agent_uow() as agents: + record = await agents.get(agent_id) + if record is None: + return False + await agents.delete(record) + return True + + @classmethod + def list_tools( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[dict[str, str]], str | None]: + ids = sorted(key for key in cls._TOOLS if cursor is None or key > cursor) + more = limit is not None and len(ids) > limit + ids = ids[:limit] + return [{"id": key, "description": cls._TOOLS[key].description} for key in ids], ids[ + -1 + ] if more else None + + @classmethod + def get_tool(cls, tool_id: str) -> FunctionTool: + registration = cls._TOOLS.get(tool_id) + if registration is None: + raise MissingAgentToolError(f"Agent Tool {tool_id!r} is not registered") + return registration.bind(tool_id).definition + + @classmethod + async def can_execute(cls, agent_id: AgentID, input_modality: str) -> bool: + """Return static local eligibility for one Agent and canonical input modality.""" + definition = await cls.get_definition(agent_id) + if definition is None: + return False + try: + cls._bind_tools(definition.tools) + except MissingAgentToolError: + return False + requires_tools = bool(definition.tools) or definition.tool_choice is not None + return await AIManager.can_execute( + definition.model, + AIExecutionRequirement( + capability="chat", + input_modalities=frozenset({"text", input_modality}), + output_modalities=frozenset({"text"}), + features=frozenset({"tool_calling"}) if requires_tools else frozenset(), + tool_choice=definition.tool_choice, + ), + ) + + @classmethod + def tool( + cls, + tool_id: str, + *, + description: str, + input_model_factory: AgentToolInputModelFactory | None = None, + ) -> typing.Callable[[HandlerT], HandlerT]: + """Register one function as a typed Agent Tool and return it unchanged.""" + if not tool_id: + raise AgentToolBindingError("Agent Tool ID must not be empty") + + def decorator(handler: HandlerT) -> HandlerT: + signature = inspect.signature(handler) + parameters = tuple(signature.parameters.values()) + if len(parameters) != 1: + raise AgentToolBindingError( + f"Agent Tool {tool_id!r} handler must accept exactly one input model" + ) + hints = typing.get_type_hints(handler) + annotation = hints.get(parameters[0].name) + if not isinstance(annotation, type) or not issubclass(annotation, pydantic.BaseModel): + raise AgentToolBindingError( + f"Agent Tool {tool_id!r} input must be annotated with a Pydantic model" + ) + + existing = cls._TOOLS.get(tool_id) + if existing is not None: + if ( + existing.original_handler is handler + and existing.description == description + and existing.input_model_factory is input_model_factory + ): + return handler + raise DuplicateAgentToolRegistrationError( + f"Agent Tool {tool_id!r} is already registered" + ) + + cls._TOOLS[tool_id] = _ToolRegistration( + handler=typing.cast(AgentToolHandler, handler), + original_handler=handler, + description=description, + input_model=annotation, + input_model_factory=input_model_factory, + ) + return handler + + return decorator + + @classmethod + async def run(cls, agent_id: AgentID, initial_message: UserMessage) -> Thread: + """Create one active Thread from a persisted Agent definition snapshot.""" + definition = await cls.get_definition(agent_id) + if definition is None: + raise AgentNotFoundError(f"Agent {agent_id} does not exist") + + bound_tools = cls._bind_tools(definition.tools) + state = ThreadState( + model=definition.model, + tools=tuple(tool.definition for tool in bound_tools), + tool_choice=definition.tool_choice, + max_model_calls_per_turn=definition.max_model_calls_per_turn, + messages=(SystemMessage(content=definition.system_prompt),), + ) + thread_id, persisted = await cls._persistence.create(state) + await trace( + "agent.thread.created", + thread_id, + agent_id=agent_id, + agent_name=definition.name, + state=persisted, + ) + thread = Thread(thread_id, persisted, cls._persistence, bound_tools) + thread.start_turn(initial_message) + return thread + + @classmethod + def _bind_tools(cls, tool_ids: tuple[str, ...]) -> tuple[BoundAgentTool, ...]: + bound: list[BoundAgentTool] = [] + for tool_id in tool_ids: + registration = cls._TOOLS.get(tool_id) + if registration is None: + raise MissingAgentToolError( + f"Agent definition references unavailable Tool {tool_id!r}" + ) + bound.append(registration.bind(tool_id)) + return tuple(bound) diff --git a/inkcre_core/business/agent/persistence.py b/inkcre_core/business/agent/persistence.py new file mode 100644 index 00000000..46565f92 --- /dev/null +++ b/inkcre_core/business/agent/persistence.py @@ -0,0 +1,147 @@ +"""Replaceable whole-Thread persistence with an in-memory MVP backend.""" + +from __future__ import annotations + +import asyncio +from abc import ABC, abstractmethod +import typing +import uuid + +import pydantic + +from inkcre_core.schemas.ai import ( + AIModelID, + AssistantMessage, + FunctionTool, + Message, + ToolChoice, + validate_message_history, +) + + +ThreadID: typing.TypeAlias = uuid.UUID + + +def validate_thread_message_history(value: typing.Any) -> tuple[Message, ...]: + """Allow strict history plus one recoverable trailing ToolCall message.""" + messages = tuple(value) + if messages and isinstance(messages[-1], AssistantMessage) and messages[-1].tool_calls: + validate_message_history(messages[:-1]) + return messages + return validate_message_history(messages) + + +class ThreadState(pydantic.BaseModel): + """Serializable Thread snapshot owned by a persistence backend.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + model: AIModelID + tools: tuple[FunctionTool, ...] + tool_choice: ToolChoice | None + max_model_calls_per_turn: int = pydantic.Field(gt=0) + messages: tuple[Message, ...] + + @pydantic.field_validator("tools") + @classmethod + def unique_tools(cls, value: tuple[FunctionTool, ...]) -> tuple[FunctionTool, ...]: + tool_ids = tuple(tool.id for tool in value) + if len(tool_ids) != len(set(tool_ids)): + raise ValueError("Thread Tool IDs must be unique") + return value + + @pydantic.field_validator("messages") + @classmethod + def closed_or_recoverable_history(cls, value: tuple[Message, ...]) -> tuple[Message, ...]: + return validate_thread_message_history(value) + + +class ThreadPersistenceBackend(ABC): + """Atomic persistence boundary for complete Thread snapshots.""" + + @abstractmethod + async def create(self, state: ThreadState) -> tuple[ThreadID, ThreadState]: + raise NotImplementedError + + @abstractmethod + async def read(self, thread_id: ThreadID) -> ThreadState: + raise NotImplementedError + + @abstractmethod + async def append( + self, + thread_id: ThreadID, + messages: tuple[Message, ...], + ) -> ThreadState: + raise NotImplementedError + + @abstractmethod + async def discard_trailing_incomplete_tool_calls( + self, + thread_id: ThreadID, + ) -> ThreadState: + raise NotImplementedError + + +class InMemoryThreadPersistenceBackend(ThreadPersistenceBackend): + """Process-local backend preserving atomic Thread message updates.""" + + def __init__(self) -> None: + self._states: dict[ThreadID, ThreadState] = {} + self._locks: dict[ThreadID, asyncio.Lock] = {} + + async def create(self, state: ThreadState) -> tuple[ThreadID, ThreadState]: + thread_id = uuid.uuid4() + self._states[thread_id] = state + self._locks[thread_id] = asyncio.Lock() + return thread_id, state + + async def read(self, thread_id: ThreadID) -> ThreadState: + try: + return self._states[thread_id] + except KeyError as error: + raise KeyError(f"Thread {thread_id} does not exist") from error + + async def append( + self, + thread_id: ThreadID, + messages: tuple[Message, ...], + ) -> ThreadState: + async with self._lock(thread_id): + current = await self.read(thread_id) + updated = ThreadState( + model=current.model, + tools=current.tools, + tool_choice=current.tool_choice, + max_model_calls_per_turn=current.max_model_calls_per_turn, + messages=current.messages + messages, + ) + self._states[thread_id] = updated + return updated + + async def discard_trailing_incomplete_tool_calls( + self, + thread_id: ThreadID, + ) -> ThreadState: + async with self._lock(thread_id): + current = await self.read(thread_id) + if not current.messages: + return current + trailing = current.messages[-1] + if not isinstance(trailing, AssistantMessage) or not trailing.tool_calls: + return current + updated = ThreadState( + model=current.model, + tools=current.tools, + tool_choice=current.tool_choice, + max_model_calls_per_turn=current.max_model_calls_per_turn, + messages=current.messages[:-1], + ) + self._states[thread_id] = updated + return updated + + def _lock(self, thread_id: ThreadID) -> asyncio.Lock: + try: + return self._locks[thread_id] + except KeyError as error: + raise KeyError(f"Thread {thread_id} does not exist") from error diff --git a/inkcre_core/business/agent/projection.py b/inkcre_core/business/agent/projection.py new file mode 100644 index 00000000..c1ea3c49 --- /dev/null +++ b/inkcre_core/business/agent/projection.py @@ -0,0 +1,45 @@ +"""JSON projection shared by domain-owned Agent Tool controllers.""" + +import dataclasses +import typing + +import pydantic + +from inkcre_core.schemas.ai import JSONValue + + +_JSON_ADAPTER = pydantic.TypeAdapter(JSONValue) + + +def project_json(value: typing.Any) -> JSONValue: + """Project ordinary domain values without admitting binary Tool results.""" + if _contains_bytes(value): + raise TypeError("Binary values are unavailable through JSON Agent Tools") + if isinstance(value, pydantic.BaseModel): + projected = value.model_dump(mode="json") + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + projected = dataclasses.asdict(value) + else: + projected = pydantic.TypeAdapter(typing.Any).dump_python( + typing.cast(typing.Any, value), + mode="json", + ) + return _JSON_ADAPTER.validate_python(projected) + + +def _contains_bytes(value: typing.Any) -> bool: + if isinstance(value, bytes): + return True + if isinstance(value, pydantic.BaseModel): + return any( + _contains_bytes(getattr(value, field)) for field in value.__class__.model_fields + ) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return any( + _contains_bytes(getattr(value, field.name)) for field in dataclasses.fields(value) + ) + if isinstance(value, dict): + return any(_contains_bytes(item) for item in value.values()) + if isinstance(value, list | tuple | set | frozenset): + return any(_contains_bytes(item) for item in value) + return False diff --git a/inkcre_core/business/agent/thread.py b/inkcre_core/business/agent/thread.py new file mode 100644 index 00000000..a6d2aa13 --- /dev/null +++ b/inkcre_core/business/agent/thread.py @@ -0,0 +1,316 @@ +"""Cancellable Agent Thread and per-turn model/Tool orchestration.""" + +from __future__ import annotations + +import asyncio +from enum import StrEnum +import inspect +import hashlib +import json +import time +import traceback +import typing + +import pydantic + +from inkcre_core.business.ai import AIManager +from inkcre_core.schemas.ai import ( + AssistantMessage, + JSONValue, + ToolCall, + ToolResult, + ToolResultMessage, + UserMessage, +) + +from .contracts import AgentTurnActiveError, BoundAgentTool, ToolExecutionError +from .persistence import ThreadID, ThreadPersistenceBackend, ThreadState +from .debug import trace +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.telemetry import operation + + +logger = get_logger().getChild("agent.thread") + + +class TurnTermination(StrEnum): + COMPLETED = "completed" + MAX_MODEL_CALLS = "max_model_calls" + + +class Thread: + """One persisted Agent-definition snapshot with at most one active Turn.""" + + def __init__( + self, + thread_id: ThreadID, + state: ThreadState, + persistence: ThreadPersistenceBackend, + tools: tuple[BoundAgentTool, ...], + ) -> None: + self.id = thread_id + self._state = state + self._persistence = persistence + self._tools = {tool.definition.id: tool for tool in tools} + self.current_turn: asyncio.Task[TurnTermination] | None = None + self._turn_index = 0 + self._model_calls = 0 + + @property + def messages(self): + return self._state.messages + + @property + def model(self): + return self._state.model + + @property + def tools(self): + return self._state.tools + + @property + def tool_choice(self): + return self._state.tool_choice + + @property + def max_model_calls_per_turn(self): + return self._state.max_model_calls_per_turn + + async def refresh(self) -> ThreadState: + """Refresh the local view from the persistence authority.""" + self._state = await self._persistence.read(self.id) + return self._state + + def start_turn(self, input: UserMessage) -> asyncio.Task[TurnTermination]: + """Schedule one complete model/Tool turn and return its cancellation handle.""" + if self.current_turn is not None and not self.current_turn.done(): + raise AgentTurnActiveError(f"Thread {self.id} already has an active Turn") + self.current_turn = asyncio.create_task( + self._run_turn(input), + name=f"agent-thread-{self.id}", + ) + return self.current_turn + + async def _run_turn(self, input: UserMessage) -> TurnTermination: + self._turn_index += 1 + self._model_calls = 0 + with operation( + "agent.turn", + attributes={ + "gen_ai.conversation.id": str(self.id), + "inkcre.agent.turn": self._turn_index, + }, + ) as observation: + span = observation.span + started = time.monotonic() + await trace( + "agent.turn.started", + self.id, + turn=self._turn_index, + input=input, + model=self.model, + max_model_calls=self.max_model_calls_per_turn, + ) + try: + outcome = await self._execute_turn(input) + except asyncio.CancelledError: + span.set_attribute("inkcre.agent.outcome", "cancelled") + await trace( + "agent.turn.finished", + self.id, + turn=self._turn_index, + model_calls=self._model_calls, + outcome="cancelled", + elapsed_seconds=time.monotonic() - started, + ) + raise + except Exception as error: + span.set_attribute("inkcre.agent.outcome", "failed") + await trace( + "agent.turn.finished", + self.id, + turn=self._turn_index, + model_calls=self._model_calls, + outcome="failed", + error_type=type(error).__name__, + error=str(error), + traceback=traceback.format_exc(), + elapsed_seconds=time.monotonic() - started, + ) + raise + await trace( + "agent.turn.finished", + self.id, + turn=self._turn_index, + model_calls=self._model_calls, + outcome=outcome, + elapsed_seconds=time.monotonic() - started, + ) + span.set_attribute("inkcre.agent.outcome", outcome.value) + span.set_attribute("inkcre.agent.model_calls", self._model_calls) + return outcome + + async def _execute_turn(self, input: UserMessage) -> TurnTermination: + self._state = await self._persistence.discard_trailing_incomplete_tool_calls(self.id) + self._state = await self._persistence.append(self.id, (input,)) + + while True: + self._model_calls += 1 + with operation( + "agent.step", + attributes={ + "gen_ai.conversation.id": str(self.id), + "inkcre.agent.turn": self._turn_index, + "inkcre.agent.model_call": self._model_calls, + }, + ): + await trace( + "agent.model.started", + self.id, + turn=self._turn_index, + call=self._model_calls, + ) + started = time.monotonic() + assistant = await AIManager.chat( + self._state.model, + self._state.messages, + self._state.tools, + self._state.tool_choice, + ) + await trace( + "agent.model.completed", + self.id, + turn=self._turn_index, + call=self._model_calls, + response=assistant, + elapsed_seconds=time.monotonic() - started, + ) + if not assistant.tool_calls: + self._state = await self._persistence.append(self.id, (assistant,)) + return TurnTermination.COMPLETED + + results = await self._execute_tool_batch(assistant) + self._state = await self._persistence.append( + self.id, + (assistant, ToolResultMessage(results=results)), + ) + if self._model_calls >= self._state.max_model_calls_per_turn: + return TurnTermination.MAX_MODEL_CALLS + + async def _execute_tool_batch( + self, + assistant: AssistantMessage, + ) -> tuple[ToolResult, ...]: + tasks = tuple( + asyncio.create_task( + self._execute_tool_call(call), + name=f"agent-tool-{call.tool}-{call.id}", + ) + for call in assistant.tool_calls + ) + return tuple(await asyncio.gather(*tasks)) + + async def _execute_tool_call(self, call: ToolCall) -> ToolResult: + with operation( + "agent.tool", + attributes={ + "gen_ai.operation.name": "execute_tool", + "gen_ai.conversation.id": str(self.id), + "inkcre.agent.turn": self._turn_index, + "inkcre.agent.model_call": self._model_calls, + # Provider-generated IDs may contain content; retain correlation as a digest. + "inkcre.agent.tool_call.id_hash": hashlib.sha256( + call.id.encode(errors="replace") + ).hexdigest(), + }, + ) as observation: + span = observation.span + tool = self._tools.get(call.tool) + if tool is not None: + span.set_attribute("gen_ai.tool.name", tool.definition.id) + await trace( + "agent.tool.started", + self.id, + turn=self._turn_index, + call=self._model_calls, + tool_call=call, + ) + started = time.monotonic() + try: + result = await self._invoke_tool_call(call) + except asyncio.CancelledError: + span.set_attribute("inkcre.agent.outcome", "cancelled") + await trace( + "agent.tool.cancelled", + self.id, + turn=self._turn_index, + call=self._model_calls, + tool_call_id=call.id, + tool=call.tool, + elapsed_seconds=time.monotonic() - started, + ) + raise + await trace( + "agent.tool.completed", + self.id, + turn=self._turn_index, + call=self._model_calls, + tool=call.tool, + result=result, + elapsed_seconds=time.monotonic() - started, + ) + span.set_attribute( + "inkcre.agent.outcome", "error" if result.is_error else "completed" + ) + if result.is_error: + span.set_attribute("error.type", "ToolResultError") + observation.outcome = "error" + return result + + async def _invoke_tool_call(self, call: ToolCall) -> ToolResult: + tool = self._tools.get(call.tool) + if tool is None: + return ToolResult( + tool_call_id=call.id, + content={"error": "unknown_tool", "tool": call.tool}, + is_error=True, + ) + + try: + tool_input = tool.input_model.model_validate(call.arguments) + except pydantic.ValidationError as error: + return ToolResult( + tool_call_id=call.id, + content=typing.cast(JSONValue, json.loads(error.json())), + is_error=True, + ) + + try: + content = tool.handler(tool_input) + if inspect.isawaitable(content): + content = await content + return ToolResult(tool_call_id=call.id, content=content) + except ToolExecutionError as error: + return ToolResult( + tool_call_id=call.id, + content=error.content, + is_error=True, + ) + except Exception as error: + await trace( + "agent.tool.exception", + self.id, + turn=self._turn_index, + call=self._model_calls, + tool_call_id=call.id, + tool=call.tool, + error_type=type(error).__name__, + error=str(error), + traceback=traceback.format_exc(), + ) + logger.exception("Unexpected Agent Tool failure", extra={"tool": call.tool}) + return ToolResult( + tool_call_id=call.id, + content={"error": "tool_execution_failed"}, + is_error=True, + ) diff --git a/inkcre_core/business/ai/__init__.py b/inkcre_core/business/ai/__init__.py new file mode 100644 index 00000000..df830957 --- /dev/null +++ b/inkcre_core/business/ai/__init__.py @@ -0,0 +1,45 @@ +"""AI registry facts and peer-local capability execution.""" + +from .contracts import ( + AICapabilityUnavailableError, + AIDialectAdapter, + AIExecutionRequirement, + AIFeatureUnavailableError, + AIInputUnavailableError, + AIModelDisabledError, + AIModelNotFoundError, + AIOutputContractError, + AIProviderDisabledError, + AIProviderNotFoundError, + DuplicateAIDialectRegistrationError, + InvalidAIProviderConfigError, + UnknownAIDialectError, +) +from .main import AIManager + +# Import installed core dialects only after AIManager exists so decorators register. +from .dialects import ( + AlibabaModelStudioDialect, + OpenAICompatibleConfig, + OpenAICompatibleDialect, +) + +__all__ = [ + "AICapabilityUnavailableError", + "AIDialectAdapter", + "AIExecutionRequirement", + "AIFeatureUnavailableError", + "AIInputUnavailableError", + "AIManager", + "AIModelDisabledError", + "AIModelNotFoundError", + "AIOutputContractError", + "AIProviderDisabledError", + "AIProviderNotFoundError", + "AlibabaModelStudioDialect", + "DuplicateAIDialectRegistrationError", + "InvalidAIProviderConfigError", + "OpenAICompatibleConfig", + "OpenAICompatibleDialect", + "UnknownAIDialectError", +] diff --git a/inkcre_core/business/ai/contracts.py b/inkcre_core/business/ai/contracts.py new file mode 100644 index 00000000..a1d4eac4 --- /dev/null +++ b/inkcre_core/business/ai/contracts.py @@ -0,0 +1,111 @@ +"""AI dialect adapter boundary and stable execution failures.""" + +import abc +from collections.abc import Sequence +from dataclasses import dataclass + +import pydantic + +from inkcre_core.schemas.ai import ( + AICapabilityType, + AssistantMessage, + FunctionTool, + Message, + ToolChoice, +) +from inkcre_core.schemas.info_base.main import Vector + + +@dataclass(frozen=True) +class AIExecutionRequirement: + """Static capability shape used for peer-local execution eligibility.""" + + capability: AICapabilityType + input_modalities: frozenset[str] + output_modalities: frozenset[str] + features: frozenset[str] = frozenset() + tool_choice: ToolChoice | None = None + + +class DuplicateAIDialectRegistrationError(ValueError): + """Two adapter implementations claimed the same exact dialect ID.""" + + +class UnknownAIDialectError(LookupError): + """The current peer has no adapter for an exact persisted dialect ID.""" + + +class AIProviderNotFoundError(LookupError): + """A requested AI model references no provider visible to this peer.""" + + +class AIModelNotFoundError(LookupError): + """A requested AI model does not exist.""" + + +class AIProviderDisabledError(RuntimeError): + """A disabled provider rejected a new operation.""" + + +class AIModelDisabledError(RuntimeError): + """A disabled model rejected a new operation.""" + + +class InvalidAIProviderConfigError(RuntimeError): + """Persisted provider config does not satisfy its dialect contract.""" + + +class AICapabilityUnavailableError(RuntimeError): + """The selected provider-bound model does not declare an operation.""" + + +class AIFeatureUnavailableError(RuntimeError): + """Effective model/adapter support lacks a requested feature.""" + + +class AIInputUnavailableError(RuntimeError): + """A canonical input cannot be conveyed through the selected dialect.""" + + +class AIOutputContractError(RuntimeError): + """A provider response violated the canonical capability result contract.""" + + +class AIDialectAdapter(abc.ABC): + """Graph-blind provider config/client construction and wire translation.""" + + supported_features: dict[AICapabilityType, frozenset[str]] = {} + supported_input_modalities: dict[AICapabilityType, frozenset[str]] = {} + + @abc.abstractmethod + async def embed( + self, + config: pydantic.BaseModel, + native_model_id: str, + inputs: Sequence[str], + dimensions: int, + ) -> tuple[Vector, ...]: ... + + @abc.abstractmethod + async def chat( + self, + config: pydantic.BaseModel, + native_model_id: str, + messages: Sequence[Message], + tools: Sequence[FunctionTool], + tool_choice: ToolChoice | None, + ) -> AssistantMessage: ... + + def supports_feature(self, capability: AICapabilityType, feature: str) -> bool: + return feature in self.supported_features.get(capability, frozenset()) + + def supports_input_modality( + self, + capability: AICapabilityType, + modality: str, + ) -> bool: + return modality in self.supported_input_modalities.get(capability, frozenset()) + + def supports_tool_choice(self, tool_choice: ToolChoice) -> bool: + del tool_choice + return False diff --git a/inkcre_core/business/ai/dialects/__init__.py b/inkcre_core/business/ai/dialects/__init__.py new file mode 100644 index 00000000..93f92271 --- /dev/null +++ b/inkcre_core/business/ai/dialects/__init__.py @@ -0,0 +1,10 @@ +"""Installed core AI dialect adapters.""" + +from .alibaba_model_studio import AlibabaModelStudioDialect +from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleDialect + +__all__ = [ + "AlibabaModelStudioDialect", + "OpenAICompatibleConfig", + "OpenAICompatibleDialect", +] diff --git a/inkcre_core/business/ai/dialects/alibaba_model_studio.py b/inkcre_core/business/ai/dialects/alibaba_model_studio.py new file mode 100644 index 00000000..48512bc6 --- /dev/null +++ b/inkcre_core/business/ai/dialects/alibaba_model_studio.py @@ -0,0 +1,181 @@ +"""Alibaba Model Studio chat dialect, including native multimodal parts.""" + +from collections.abc import Sequence +import json +import typing +import time + +from openai import AsyncStream +from openai.types.chat import ChatCompletionChunk +from opentelemetry import trace +from opentelemetry.trace import INVALID_SPAN +import pydantic + +from inkcre_core.schemas.ai import ( + AudioContentPart, + AssistantMessage, + FunctionTool, + ImageContentPart, + Message, + TextContentPart, + ToolCall, + ToolChoice, + UserMessage, + VideoContentPart, +) + +from inkcre_core.libs.obsrv.telemetry import is_enabled + +from ..contracts import AIOutputContractError +from ..main import AIManager +from ..telemetry import record_response_usage +from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleDialect + + +@AIManager.register_dialect( + "core.alibaba-model-studio.v1", + description="Alibaba Model Studio OpenAI-compatible multimodal chat protocol.", + config_model=OpenAICompatibleConfig, +) +class AlibabaModelStudioDialect(OpenAICompatibleDialect): + """Translate canonical chat through Model Studio's multimodal extensions.""" + + supported_features = {"chat": frozenset({"tool_calling"})} + supported_input_modalities = { + "embedding": frozenset({"text"}), + "chat": frozenset({"text", "image", "audio", "video"}), + } + + @classmethod + def _user_content_params(cls, message: UserMessage) -> list[dict[str, typing.Any]]: + result: list[dict[str, typing.Any]] = [] + for part in message.content: + if isinstance(part, TextContentPart): + result.append({"type": "text", "text": part.text}) + elif isinstance(part, ImageContentPart): + result.append( + { + "type": "image_url", + "image_url": { + "url": cls._url_or_inline_data( + mime_type=part.mime_type, + data=part.data, + transfer_url=part.transfer_url, + ) + }, + } + ) + elif isinstance(part, AudioContentPart): + result.append( + { + "type": "input_audio", + "input_audio": { + "data": cls._url_or_inline_data( + mime_type=part.mime_type, + data=part.data, + transfer_url=part.transfer_url, + ), + "format": part.mime_type.removeprefix("audio/"), + }, + } + ) + elif isinstance(part, VideoContentPart): + result.append( + { + "type": "video_url", + "video_url": { + "url": cls._url_or_inline_data( + mime_type=part.mime_type, + data=part.data, + transfer_url=part.transfer_url, + ) + }, + } + ) + return result + + async def chat( + self, + config: pydantic.BaseModel, + native_model_id: str, + messages: Sequence[Message], + tools: Sequence[FunctionTool], + tool_choice: ToolChoice | None, + ) -> AssistantMessage: + client = self._client_factory(self._config(config)) + arguments: dict[str, typing.Any] = { + "model": native_model_id, + "messages": self._message_params(messages), + "stream": True, + "stream_options": {"include_usage": True}, + "modalities": ["text"], + } + if tools: + arguments["tools"] = self._tool_params(tools) + if tool_choice is not None: + arguments["tool_choice"] = self._tool_choice_param(tool_choice) + + span = trace.get_current_span() if is_enabled() else INVALID_SPAN + span.set_attribute("gen_ai.request.stream", True) + started = time.monotonic() + usage = None + finish_reasons: list[str] = [] + first_chunk = True + try: + stream = typing.cast( + AsyncStream[ChatCompletionChunk], + await client.chat.completions.create(**arguments), + ) + text_parts: list[str] = [] + calls: dict[int, dict[str, str]] = {} + async for chunk in stream: + if first_chunk: + span.set_attribute( + "gen_ai.response.time_to_first_chunk", time.monotonic() - started + ) + first_chunk = False + # Usage-only terminal chunks have no choices. Totals replace earlier totals. + if chunk.usage is not None: + usage = chunk.usage + if not chunk.choices: + continue + if chunk.choices[0].finish_reason is not None: + finish_reasons = [chunk.choices[0].finish_reason] + delta = chunk.choices[0].delta + if isinstance(delta.content, str): + text_parts.append(delta.content) + for call in delta.tool_calls or (): + current = calls.setdefault(call.index, {"id": "", "name": "", "arguments": ""}) + if call.id: + current["id"] = call.id + if call.function is not None: + if call.function.name: + current["name"] += call.function.name + if call.function.arguments: + current["arguments"] += call.function.arguments + finally: + record_response_usage("chat", usage, finish_reasons) + await client.close() + + parsed_calls: list[ToolCall] = [] + for index in sorted(calls): + call = calls[index] + if not call["id"] or not call["name"]: + raise AIOutputContractError("Alibaba chat stream returned an incomplete ToolCall") + try: + arguments_object = json.loads(call["arguments"]) + except json.JSONDecodeError as error: + raise AIOutputContractError( + f"ToolCall {call['id']!r} arguments are not valid JSON" + ) from error + if not isinstance(arguments_object, dict): + raise AIOutputContractError( + f"ToolCall {call['id']!r} arguments must be a JSON object" + ) + parsed_calls.append( + ToolCall(id=call["id"], tool=call["name"], arguments=arguments_object) + ) + content = "".join(text_parts) or None + if content is None and not parsed_calls: + raise AIOutputContractError("Alibaba chat stream returned no content or ToolCalls") + return AssistantMessage(content=content, tool_calls=tuple(parsed_calls)) diff --git a/inkcre_core/business/ai/dialects/openai_compatible.py b/inkcre_core/business/ai/dialects/openai_compatible.py new file mode 100644 index 00000000..f1882df6 --- /dev/null +++ b/inkcre_core/business/ai/dialects/openai_compatible.py @@ -0,0 +1,323 @@ +"""OpenAI-compatible AI dialect across embedding and chat capabilities.""" + +import base64 +from collections.abc import Callable, Sequence +import json +import typing + +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletion, ChatCompletionMessageParam +import pydantic + +from inkcre_core.schemas.ai import ( + AudioContentPart, + AssistantMessage, + FunctionTool, + ImageContentPart, + Message, + NamedToolChoice, + SystemMessage, + TextContentPart, + ToolCall, + ToolChoice, + ToolResultMessage, + UserMessage, + VideoContentPart, +) +from inkcre_core.schemas.info_base.main import Vector + +from ..contracts import AIDialectAdapter, AIInputUnavailableError, AIOutputContractError +from ..main import AIManager +from ..telemetry import record_response_usage + +INLINE_MEDIA_MAX_BYTES = 7 * 1024 * 1024 + + +class OpenAICompatibleConfig(pydantic.BaseModel): + """Connection values for one OpenAI-compatible provider instance.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + api_key: pydantic.SecretStr + base_url: str | None = pydantic.Field(default=None, min_length=1) + + +OpenAIClientFactory: typing.TypeAlias = Callable[[OpenAICompatibleConfig], AsyncOpenAI] + + +@AIManager.register_dialect( + "core.openai-compatible.v1", + description="OpenAI-compatible embedding and chat protocol.", + config_model=OpenAICompatibleConfig, +) +class OpenAICompatibleDialect(AIDialectAdapter): + """Translate canonical AI contracts through the OpenAI Python SDK.""" + + supported_features = {"chat": frozenset({"tool_calling"})} + supported_input_modalities = { + "embedding": frozenset({"text"}), + "chat": frozenset({"text", "image", "audio"}), + } + + def __init__(self, client_factory: OpenAIClientFactory | None = None) -> None: + self._client_factory = client_factory or self._create_client + + @staticmethod + def _create_client(config: OpenAICompatibleConfig) -> AsyncOpenAI: + return AsyncOpenAI( + api_key=config.api_key.get_secret_value(), + base_url=config.base_url, + ) + + @staticmethod + def _config(config: pydantic.BaseModel) -> OpenAICompatibleConfig: + return typing.cast(OpenAICompatibleConfig, config) + + async def embed( + self, + config: pydantic.BaseModel, + native_model_id: str, + inputs: Sequence[str], + dimensions: int, + ) -> tuple[Vector, ...]: + client = self._client_factory(self._config(config)) + usage = None + try: + response = await client.embeddings.create( + model=native_model_id, + input=tuple(inputs), + dimensions=dimensions, + encoding_format="float", + ) + usage = getattr(response, "usage", None) + finally: + record_response_usage("embeddings", usage) + await client.close() + + by_index = {item.index: tuple(item.embedding) for item in response.data} + expected = set(range(len(inputs))) + if set(by_index) != expected: + raise AIOutputContractError( + "OpenAI-compatible embedding response indices do not cover the input batch" + ) + return tuple(by_index[index] for index in range(len(inputs))) + + @staticmethod + def _data_url(mime_type: str, data: bytes) -> str: + encoded = base64.b64encode(data).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + @classmethod + def _url_or_inline_data( + cls, + *, + mime_type: str, + data: bytes, + transfer_url: str | None, + ) -> str: + if len(data) <= INLINE_MEDIA_MAX_BYTES: + return cls._data_url(mime_type, data) + if transfer_url is not None: + return transfer_url + raise AIInputUnavailableError( + "Media exceeds the inline transfer bound and has no transfer URL" + ) + + @staticmethod + def _audio_format(mime_type: str) -> str: + formats = { + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/wav": "wav", + "audio/x-wav": "wav", + } + try: + return formats[mime_type] + except KeyError as error: + raise AIInputUnavailableError( + f"OpenAI Chat input_audio does not support {mime_type!r}" + ) from error + + @classmethod + def _user_content_params(cls, message: UserMessage) -> list[dict[str, typing.Any]]: + result: list[dict[str, typing.Any]] = [] + for part in message.content: + if isinstance(part, TextContentPart): + result.append({"type": "text", "text": part.text}) + elif isinstance(part, ImageContentPart): + result.append( + { + "type": "image_url", + "image_url": { + "url": cls._url_or_inline_data( + mime_type=part.mime_type, + data=part.data, + transfer_url=part.transfer_url, + ) + }, + } + ) + elif isinstance(part, AudioContentPart): + if len(part.data) > INLINE_MEDIA_MAX_BYTES: + raise AIInputUnavailableError( + "OpenAI Chat input_audio cannot use a transfer URL and exceeds the " + "inline transfer bound" + ) + result.append( + { + "type": "input_audio", + "input_audio": { + "data": base64.b64encode(part.data).decode("ascii"), + "format": cls._audio_format(part.mime_type), + }, + } + ) + elif isinstance(part, VideoContentPart): + raise AIInputUnavailableError( + "OpenAI Chat does not define a canonical video input part" + ) + return result + + @classmethod + def _message_params(cls, messages: Sequence[Message]) -> list[ChatCompletionMessageParam]: + result: list[ChatCompletionMessageParam] = [] + for message in messages: + if isinstance(message, SystemMessage): + result.append({"role": "system", "content": message.content}) + elif isinstance(message, UserMessage): + result.append( + typing.cast( + ChatCompletionMessageParam, + {"role": "user", "content": cls._user_content_params(message)}, + ) + ) + elif isinstance(message, AssistantMessage): + result.append( + { + "role": "assistant", + "content": message.content, + "tool_calls": [ + { + "id": call.id, + "type": "function", + "function": { + "name": call.tool, + "arguments": json.dumps( + call.arguments, + separators=(",", ":"), + sort_keys=True, + ), + }, + } + for call in message.tool_calls + ], + } + ) + elif isinstance(message, ToolResultMessage): + result.extend( + { + "role": "tool", + "tool_call_id": tool_result.tool_call_id, + "content": json.dumps( + { + "content": tool_result.content, + "is_error": tool_result.is_error, + }, + separators=(",", ":"), + sort_keys=True, + ), + } + for tool_result in message.results + ) + return result + + @staticmethod + def _tool_params(tools: Sequence[FunctionTool]) -> list[dict[str, typing.Any]]: + return [ + { + "type": "function", + "function": { + "name": tool.id, + "description": tool.description, + "parameters": tool.input_schema, + }, + } + for tool in tools + ] + + @staticmethod + def _tool_choice_param(tool_choice: ToolChoice) -> typing.Any: + if isinstance(tool_choice, NamedToolChoice): + return { + "type": "function", + "function": {"name": tool_choice.tool}, + } + return tool_choice + + def supports_tool_choice(self, tool_choice: ToolChoice) -> bool: + return isinstance(tool_choice, NamedToolChoice) or tool_choice in { + "none", + "auto", + "required", + } + + @staticmethod + def _assistant_message(message: typing.Any) -> AssistantMessage: + calls: list[ToolCall] = [] + for call in message.tool_calls or (): + if call.type != "function": + raise AIOutputContractError( + f"Unsupported OpenAI-compatible ToolCall type: {call.type!r}" + ) + try: + parsed_arguments = json.loads(call.function.arguments) + except (TypeError, json.JSONDecodeError) as error: + raise AIOutputContractError( + f"ToolCall {call.id!r} arguments are not valid JSON" + ) from error + if not isinstance(parsed_arguments, dict): + raise AIOutputContractError(f"ToolCall {call.id!r} arguments must be a JSON object") + calls.append( + ToolCall( + id=call.id, + tool=call.function.name, + arguments=parsed_arguments, + ) + ) + return AssistantMessage(content=message.content, tool_calls=tuple(calls)) + + async def chat( + self, + config: pydantic.BaseModel, + native_model_id: str, + messages: Sequence[Message], + tools: Sequence[FunctionTool], + tool_choice: ToolChoice | None, + ) -> AssistantMessage: + client = self._client_factory(self._config(config)) + arguments: dict[str, typing.Any] = { + "model": native_model_id, + "messages": self._message_params(messages), + "stream": False, + } + if tools: + arguments["tools"] = self._tool_params(tools) + if tool_choice is not None: + arguments["tool_choice"] = self._tool_choice_param(tool_choice) + + usage = None + finish_reasons: list[str] = [] + try: + response = typing.cast( + ChatCompletion, + await client.chat.completions.create(**arguments), + ) + usage = response.usage + finish_reasons = [choice.finish_reason for choice in response.choices] + finally: + record_response_usage("chat", usage, finish_reasons) + await client.close() + + if not response.choices: + raise AIOutputContractError("OpenAI-compatible chat response has no choices") + return self._assistant_message(response.choices[0].message) diff --git a/inkcre_core/business/ai/main.py b/inkcre_core/business/ai/main.py new file mode 100644 index 00000000..2cfae318 --- /dev/null +++ b/inkcre_core/business/ai/main.py @@ -0,0 +1,396 @@ +"""Shared AI fact management and peer-local capability routing.""" + +from collections.abc import Sequence +from dataclasses import dataclass +import math +import typing + +import pydantic +from opentelemetry.trace import SpanKind + +from inkcre_core.database_contract.profile import BUILTIN_AI_DIALECTS_BY_ID +from inkcre_core.schemas.ai import ( + AICapabilityType, + AIDialectID, + AIModelCapability, + AIModelID, + AIModelModel, + AIProviderModel, + AssistantMessage, + FunctionTool, + ImageContentPart, + Message, + AudioContentPart, + TextContentPart, + ToolChoice, + UserMessage, + VideoContentPart, + validate_message_history, +) +from inkcre_core.schemas.info_base.main import Vector + +from inkcre_core.persistence.ai.uow import ai_uow +from inkcre_core.libs.obsrv.telemetry import operation + +from .telemetry import model_attributes + +from .contracts import ( + AICapabilityUnavailableError, + AIDialectAdapter, + AIExecutionRequirement, + AIFeatureUnavailableError, + AIInputUnavailableError, + AIModelDisabledError, + AIModelNotFoundError, + AIOutputContractError, + AIProviderDisabledError, + AIProviderNotFoundError, + DuplicateAIDialectRegistrationError, + InvalidAIProviderConfigError, + UnknownAIDialectError, +) + + +ConfigModelT = typing.TypeVar("ConfigModelT", bound=pydantic.BaseModel) +AdapterT = typing.TypeVar("AdapterT", bound=type[AIDialectAdapter]) + + +@dataclass(frozen=True) +class _DialectRegistration: + adapter: AIDialectAdapter + adapter_type: type[AIDialectAdapter] + config_model: type[pydantic.BaseModel] + description: str + + +@dataclass(frozen=True) +class _ExecutionTarget: + model: AIModelModel + provider: AIProviderModel + adapter: AIDialectAdapter + config: pydantic.BaseModel + + +class AIManager: + """Sole domain manager for AI dialects, shared facts and execution.""" + + _DIALECTS: dict[AIDialectID, _DialectRegistration] = {} + + @classmethod + async def get_model(cls, model_id: AIModelID) -> AIModelModel | None: + """Read a model without requiring its provider/dialect to execute here.""" + async with ai_uow() as models: + return await models.get_model(model_id) + + @classmethod + async def list_models( + cls, *, limit: int | None = None, cursor: int | None = None + ) -> tuple[list[AIModelModel], int | None]: + async with ai_uow() as models: + return await models.list_models(limit=limit, cursor=cursor) + + @classmethod + def register_dialect( + cls, + dialect_id: AIDialectID, + *, + description: str, + config_model: type[ConfigModelT], + ) -> typing.Callable[[AdapterT], AdapterT]: + """Bind one adapter class to an exact ID and return it unchanged.""" + + def decorator(adapter_type: AdapterT) -> AdapterT: + existing = cls._DIALECTS.get(dialect_id) + if existing is not None: + if ( + existing.adapter_type is adapter_type + and existing.config_model is config_model + and existing.description == description + ): + return adapter_type + raise DuplicateAIDialectRegistrationError( + f"AI dialect {dialect_id!r} is already registered by " + f"{existing.adapter_type.__qualname__}" + ) + cls._DIALECTS[dialect_id] = _DialectRegistration( + adapter=adapter_type(), + adapter_type=adapter_type, + config_model=config_model, + description=description, + ) + return adapter_type + + return decorator + + @classmethod + async def sync_dialects_async(cls) -> None: + records = [] + for dialect_id, registration in cls._DIALECTS.items(): + builtin = BUILTIN_AI_DIALECTS_BY_ID.get(dialect_id) + records.append( + { + "id": dialect_id, + "description": builtin.description + if builtin is not None + else registration.description, + "config_schema": builtin.config_schema + if builtin is not None + else registration.config_model.model_json_schema(), + } + ) + async with ai_uow() as models: + await models.sync_dialects(records) + + @classmethod + def _registration(cls, dialect_id: AIDialectID) -> _DialectRegistration: + try: + return cls._DIALECTS[dialect_id] + except KeyError as error: + raise UnknownAIDialectError( + f"No local adapter implements AI dialect {dialect_id!r}" + ) from error + + @classmethod + async def _load_target_async(cls, model_id: AIModelID) -> _ExecutionTarget: + async with ai_uow() as models: + model = await models.get_model(model_id) + provider = None if model is None else await models.get_provider(model.provider) + return cls._execution_target(model_id, model, provider) + + @classmethod + def _execution_target( + cls, model_id: AIModelID, model: AIModelModel | None, provider: AIProviderModel | None + ) -> _ExecutionTarget: + if model is None: + raise AIModelNotFoundError(f"AI model {model_id} does not exist") + if not model.enabled: + raise AIModelDisabledError(f"AI model {model_id} is disabled") + if provider is None: + raise AIProviderNotFoundError( + f"AI model {model_id} references missing provider {model.provider}" + ) + if not provider.enabled: + raise AIProviderDisabledError(f"AI provider {provider.id} is disabled") + registration = cls._registration(provider.dialect) + try: + config = registration.config_model.model_validate(provider.config) + except pydantic.ValidationError as error: + raise InvalidAIProviderConfigError( + f"AI provider {provider.id} does not satisfy dialect {provider.dialect!r}" + ) from error + return _ExecutionTarget(model, provider, registration.adapter, config) + + @staticmethod + def _capability( + model: AIModelModel, + capability_type: AICapabilityType, + ) -> AIModelCapability: + capability = next( + (item for item in model.capabilities if item.type == capability_type), + None, + ) + if capability is None: + raise AICapabilityUnavailableError( + f"AI model {model.id} does not declare {capability_type!r}" + ) + return capability + + @staticmethod + def _require_modalities( + model: AIModelModel, + capability: AIModelCapability, + *, + input_: str, + output: str, + ) -> None: + if ( + input_ not in capability.input_modalities + or output not in capability.output_modalities + ): + raise AICapabilityUnavailableError( + f"AI model {model.id} {capability.type!r} does not support {input_!r} -> {output!r}" + ) + + @classmethod + async def can_execute( + cls, + model: AIModelID, + requirement: AIExecutionRequirement, + ) -> bool: + """Return static peer-local eligibility without probing a remote provider.""" + try: + target = await cls._load_target_async(model) + capability = cls._capability(target.model, requirement.capability) + except ( + AIModelNotFoundError, + AIModelDisabledError, + AIProviderNotFoundError, + AIProviderDisabledError, + UnknownAIDialectError, + InvalidAIProviderConfigError, + AICapabilityUnavailableError, + ): + return False + requested_inputs = requirement.input_modalities + requested_outputs = requirement.output_modalities + requested_features = requirement.features + if not requested_inputs <= set(capability.input_modalities): + return False + if not requested_outputs <= set(capability.output_modalities): + return False + if not requested_features <= set(capability.features): + return False + if any( + not target.adapter.supports_input_modality(requirement.capability, modality) + for modality in requested_inputs + ): + return False + if any( + not target.adapter.supports_feature(requirement.capability, feature) + for feature in requested_features + ): + return False + return requirement.tool_choice is None or target.adapter.supports_tool_choice( + requirement.tool_choice + ) + + @classmethod + async def embed( + cls, + model: AIModelID, + inputs: Sequence[str], + dimensions: int, + ) -> tuple[Vector, ...]: + """Embed one ordered non-empty text batch through the selected model.""" + with operation( + "ai.embed", + kind=SpanKind.CLIENT, + attributes={ + "gen_ai.operation.name": "embeddings", + "inkcre.ai.model.id": model, + "inkcre.ai.usage.input.source": "unavailable", + "inkcre.ai.usage.output.source": "unavailable", + }, + ) as observation: + span = observation.span + if not inputs: + raise ValueError("embedding inputs must not be empty") + if dimensions <= 0: + raise ValueError("embedding dimensions must be positive") + target = await cls._load_target_async(model) + span.set_attributes( + model_attributes( + target.model.native_model_id, target.provider.dialect, target.model.provider + ) + ) + capability = cls._capability(target.model, "embedding") + cls._require_modalities( + target.model, + capability, + input_="text", + output="vector", + ) + vectors = await target.adapter.embed( + target.config, + target.model.native_model_id, + tuple(inputs), + dimensions, + ) + if len(vectors) != len(inputs): + raise AIOutputContractError( + f"Embedding result count {len(vectors)} does not match input count {len(inputs)}" + ) + for vector in vectors: + if len(vector) != dimensions: + raise AIOutputContractError( + f"Embedding dimension {len(vector)} does not match requested {dimensions}" + ) + if not vector or not all(math.isfinite(value) for value in vector): + raise AIOutputContractError("Embedding vectors must be finite and non-empty") + if not any(value != 0 for value in vector): + raise AIOutputContractError("Embedding vectors must be non-zero") + return vectors + + @classmethod + async def chat( + cls, + model: AIModelID, + messages: Sequence[Message], + tools: Sequence[FunctionTool] = (), + tool_choice: ToolChoice | None = None, + ) -> AssistantMessage: + """Execute one provider-neutral chat model call without owning history.""" + with operation( + "ai.chat", + kind=SpanKind.CLIENT, + attributes={ + "gen_ai.operation.name": "chat", + "inkcre.ai.model.id": model, + "inkcre.ai.usage.input.source": "unavailable", + "inkcre.ai.usage.output.source": "unavailable", + }, + ) as observation: + span = observation.span + history = validate_message_history(messages) + target = await cls._load_target_async(model) + span.set_attributes( + model_attributes( + target.model.native_model_id, target.provider.dialect, target.model.provider + ) + ) + capability = cls._capability(target.model, "chat") + # System instructions, Tool schemas/results and Assistant history all travel + # through the textual chat channel even when the latest User turn is media-only. + input_modalities: set[str] = {"text"} + for message in history: + if not isinstance(message, UserMessage): + continue + for part in message.content: + if isinstance(part, TextContentPart): + input_modalities.add("text") + elif isinstance(part, ImageContentPart): + input_modalities.add("image") + elif isinstance(part, AudioContentPart): + input_modalities.add("audio") + elif isinstance(part, VideoContentPart): + input_modalities.add("video") + for modality in sorted(input_modalities): + cls._require_modalities( + target.model, + capability, + input_=modality, + output="text", + ) + if not target.adapter.supports_input_modality("chat", modality): + raise AIInputUnavailableError( + f"AI dialect {target.provider.dialect!r} cannot convey " + f"chat modality {modality!r}" + ) + + tool_ids = tuple(tool.id for tool in tools) + if len(tool_ids) != len(set(tool_ids)): + raise ValueError("Tool IDs must be unique") + requires_tool_calling = bool(tools) or tool_choice is not None + if requires_tool_calling: + if "tool_calling" not in capability.features or not target.adapter.supports_feature( + "chat", "tool_calling" + ): + raise AIFeatureUnavailableError( + f"AI model {model} and dialect {target.provider.dialect!r} do not jointly " + "support tool_calling" + ) + if tool_choice is not None: + if not tools: + raise ValueError("tool_choice requires at least one Tool") + if not target.adapter.supports_tool_choice(tool_choice): + raise AIFeatureUnavailableError( + f"AI dialect {target.provider.dialect!r} cannot represent tool_choice" + ) + + return await target.adapter.chat( + target.config, + target.model.native_model_id, + history, + tuple(tools), + tool_choice, + ) diff --git a/inkcre_core/business/ai/telemetry.py b/inkcre_core/business/ai/telemetry.py new file mode 100644 index 00000000..46830bde --- /dev/null +++ b/inkcre_core/business/ai/telemetry.py @@ -0,0 +1,86 @@ +"""Content-free mapping for the pinned GenAI semantic conventions. + +Keys follow semantic-conventions-genai e07f4ebacb08f56db8c4c882d117720333fbca04. +Provider responses are untrusted: copy numeric usage and known finish reasons only. +""" + +from collections.abc import Sequence +import re + +from openai.types.completion_usage import CompletionUsage +from openai.types.create_embedding_response import Usage as EmbeddingUsage +from opentelemetry import trace +from opentelemetry.util.types import AttributeValue + +from inkcre_core.libs.obsrv.telemetry import is_enabled, record_ai_usage + + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}\Z") +_FINISH_REASONS = frozenset( + {"stop", "length", "tool_calls", "content_filter", "function_call"} +) + + +def model_attributes( + native_model_id: str, dialect: str, provider_id: int +) -> dict[str, AttributeValue]: + attributes: dict[str, AttributeValue] = { + "inkcre.ai.provider.id": provider_id, + "inkcre.ai.dialect": dialect if _IDENTIFIER.fullmatch(dialect) else "other", + "gen_ai.provider.name": { + "core.openai-compatible.v1": "openai", + "core.alibaba-model-studio.v1": "alibaba", + }.get(dialect, "other"), + } + # This is the configured model identifier, never the provider's arbitrary echo. + if _IDENTIFIER.fullmatch(native_model_id): + attributes["gen_ai.request.model"] = native_model_id + return attributes + + +def record_response_usage( + operation: str, + usage: CompletionUsage | EmbeddingUsage | None, + finish_reasons: Sequence[str] = (), +) -> None: + """Record one response's totals once; absent or invalid totals stay unknown.""" + if not is_enabled(): + return + span = trace.get_current_span() + input_tokens = getattr(usage, "prompt_tokens", None) + output_tokens = getattr(usage, "completion_tokens", None) + input_tokens = ( + input_tokens if type(input_tokens) is int and 0 <= input_tokens < 2**63 else None + ) + output_tokens = ( + output_tokens if type(output_tokens) is int and 0 <= output_tokens < 2**63 else None + ) + for direction, value in (("input", input_tokens), ("output", output_tokens)): + span.set_attribute( + f"inkcre.ai.usage.{direction}.source", + "provider" if value is not None else "unavailable", + ) + if value is not None: + span.set_attribute(f"gen_ai.usage.{direction}_tokens", value) + if isinstance(usage, CompletionUsage): + for key, value in ( + ( + "gen_ai.usage.cache_read.input_tokens", + getattr(usage.prompt_tokens_details, "cached_tokens", None), + ), + ( + "gen_ai.usage.reasoning.output_tokens", + getattr(usage.completion_tokens_details, "reasoning_tokens", None), + ), + ): + if type(value) is int and 0 <= value < 2**63: + span.set_attribute(key, value) + if finish_reasons: + span.set_attribute( + "gen_ai.response.finish_reasons", + tuple( + reason if isinstance(reason, str) and reason in _FINISH_REASONS else "other" + for reason in finish_reasons[:16] + ), + ) + record_ai_usage(operation, input_tokens=input_tokens, output_tokens=output_tokens) diff --git a/inkcre_core/business/cron.py b/inkcre_core/business/cron.py new file mode 100644 index 00000000..745a7542 --- /dev/null +++ b/inkcre_core/business/cron.py @@ -0,0 +1,234 @@ +"""Application-owned materialization of deployment-wide Cron occurrences.""" + +from __future__ import annotations + +import datetime +import typing +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +import croniter # pyrefly: ignore[untyped-import] +import pydantic + +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.business.job import JobManager +from inkcre_core.persistence.cron.uow import cron_uow +from inkcre_core.schemas.cron import CronForm, CronID, CronModel, CronUpdateForm +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.telemetry import operation, emit_event +from inkcre_core.validation import input_path + + +CRON_CONFIG_KEY = "core.cron" +CRON_CONFIG_SCHEMA_ID = "core.cron.config.v1" +LOGGER = get_logger().getChild(__name__) + + +class CronNotFoundError(LookupError): + """A Cron template does not exist.""" + + +class InvalidCronScheduleError(ValueError): + """A submitted schedule is not a five-field UNIX Cron expression.""" + + +class CronDeploymentConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + timezone: str = "UTC" + + @pydantic.field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as error: + raise ValueError("timezone must be an installed IANA timezone") from error + return value + + +DeploymentConfigManager.register_schema( + CRON_CONFIG_SCHEMA_ID, CronDeploymentConfig, keys=(CRON_CONFIG_KEY,) +) + + +class InvalidCronTemplateError(ValueError): + """A failed template must leave its complete transaction before discovery continues.""" + + +class CronManager: + """Evaluate only the current minute and atomically create typed Jobs.""" + + @classmethod + async def _timezone(cls) -> ZoneInfo: + config = await DeploymentConfigService.get(CRON_CONFIG_KEY) + if config is None: + return ZoneInfo("UTC") + return ZoneInfo(typing.cast(CronDeploymentConfig, config).timezone) + + @classmethod + def _matches(cls, schedule: str, now: datetime.datetime) -> bool: + return croniter.croniter.match(schedule, now) + + @classmethod + async def check(cls) -> int: + """Materialize each matching occurrence in one independent transaction.""" + async with cron_uow() as uow: + cron_ids = await uow.crons.enabled_ids() + timezone = await cls._timezone() + created = 0 + for cron_id in cron_ids: + if cron_id is None: + continue + try: + created += await cls._materialize(cron_id, timezone) + except InvalidCronTemplateError: + LOGGER.exception("Cron Job template is invalid", extra={"cron_id": cron_id}) + return created + + @classmethod + async def _materialize(cls, cron_id: CronID, timezone: ZoneInfo) -> int: + with operation("cron.materialize", attributes={"inkcre.cron.id": cron_id}): + return await cls._materialize_occurrence(cron_id, timezone) + + @classmethod + async def _materialize_occurrence(cls, cron_id: CronID, timezone: ZoneInfo) -> int: + async with cron_uow() as uow: + cron = await uow.crons.get(cron_id, lock=True, skip_locked=True) + if cron is None or not cron.enabled: + return 0 + database_now = await uow.crons.database_now() + occurrence = database_now.astimezone(datetime.UTC).replace(second=0, microsecond=0) + local_minute = database_now.astimezone(timezone).replace(second=0, microsecond=0) + try: + matches = cls._matches(cron.schedule, local_minute) + except (ValueError, KeyError): + LOGGER.exception("Invalid persisted Cron schedule", extra={"cron_id": cron.id}) + return 0 + if not matches or cron.last_scheduled_for == occurrence: + return 0 + if cron.last_job is not None: + last_job = await uow.jobs.get(cron.last_job) + if last_job is not None and last_job.status in { + JobStatus.PENDING, + JobStatus.RUNNING, + }: + return 0 + try: + job = await JobManager.create_in_uow( + cron.job_type, cron.job_parameters, cron.job_timeout_seconds, uow=uow + ) + except Exception as error: + raise InvalidCronTemplateError(f"Invalid Cron {cron.id} template") from error + cron.last_job = job.id + cron.last_scheduled_for = occurrence + await uow.crons.save(cron) + emit_event("job.submitted", {"inkcre.job.id": job.id or 0, "inkcre.cron.id": cron_id}) + return 1 + + @classmethod + async def run_now(cls, cron_id: CronID) -> JobModel: + """Create a Job from one Cron template without changing Cron progress.""" + async with cron_uow() as uow: + cron = await uow.crons.get(cron_id) + if cron is None: + raise CronNotFoundError(f"Cron {cron_id} does not exist") + job = await JobManager.create_in_uow( + cron.job_type, + cron.job_parameters, + cron.job_timeout_seconds, + uow=uow, + ) + emit_event("job.submitted", {"inkcre.job.id": job.id or 0, "inkcre.cron.id": cron_id}) + return job + + @classmethod + async def create(cls, form: CronForm) -> CronModel: + """Validate and create one Cron template.""" + if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) + async with cron_uow() as uow: + cron = CronModel(**form.model_dump()) + with input_path("job_parameters"): + cron.job_parameters = await JobManager.normalize_parameters( + form.job_type, form.job_parameters, uow + ) + await uow.crons.save(cron) + return cron + + @classmethod + async def update(cls, cron_id: CronID, form: CronForm) -> CronModel: + """Validate and replace the editable fields of one Cron template.""" + if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) + async with cron_uow() as uow: + cron = await uow.crons.get(cron_id) + if cron is None: + raise CronNotFoundError(f"Cron {cron_id} does not exist") + cron.schedule = form.schedule + cron.enabled = form.enabled + cron.job_type = form.job_type + with input_path("job_parameters"): + cron.job_parameters = await JobManager.normalize_parameters( + form.job_type, form.job_parameters, uow + ) + cron.job_timeout_seconds = form.job_timeout_seconds + await uow.crons.save(cron) + return cron + + @classmethod + async def get(cls, cron_id: CronID) -> CronModel | None: + async with cron_uow() as uow: + return await uow.crons.get(cron_id) + + @classmethod + async def list_crons( + cls, *, limit: int | None = None, cursor: CronID | None = None + ) -> tuple[list[CronModel], CronID | None]: + async with cron_uow() as uow: + return await uow.crons.list(limit=limit, cursor=cursor) + + @classmethod + async def patch(cls, cron_id: CronID, form: CronUpdateForm) -> CronModel: + async with cron_uow() as uow: + cron = await uow.crons.get(cron_id, lock=True) + if cron is None: + raise CronNotFoundError(f"Cron {cron_id} does not exist") + changes = form.model_dump(exclude_unset=True) + candidate = CronForm.model_validate( + { + **{field: getattr(cron, field) for field in CronForm.model_fields}, + **changes, + } + ) + if len(candidate.schedule.split()) != 5 or not croniter.croniter.is_valid( + candidate.schedule + ): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) + with input_path("job_parameters"): + parameters = await JobManager.normalize_parameters( + candidate.job_type, candidate.job_parameters, uow + ) + for field in changes: + setattr(cron, field, getattr(candidate, field)) + cron.job_parameters = parameters + await uow.crons.save(cron) + return cron + + @classmethod + async def delete(cls, cron_id: CronID) -> bool: + async with cron_uow() as uow: + cron = await uow.crons.get(cron_id) + if cron is None: + return False + await uow.crons.delete(cron) + return True diff --git a/inkcre_core/business/deployment_config.py b/inkcre_core/business/deployment_config.py new file mode 100644 index 00000000..cf50bd24 --- /dev/null +++ b/inkcre_core/business/deployment_config.py @@ -0,0 +1,186 @@ +"""Deployment-scoped configuration registry and persistence.""" + +import typing +import dataclasses + +import pydantic + +from inkcre_core.persistence.deployment_config.uow import configuration_transaction +from inkcre_core.configuration import ConfigContract +from inkcre_core.schemas.deployment_config import ( + DeploymentConfigKey, + DeploymentConfigModel, + DeploymentConfigSchemaID, + DeploymentConfigView, +) + + +class UnknownDeploymentConfigSchemaError(LookupError): + """The current peer has no validator for an exact persisted schema ID.""" + + +class DeploymentConfigSchemaCollisionError(ValueError): + """Two different local models claimed the same exact schema ID.""" + + +class DeploymentConfigNotFoundError(LookupError): + """A patch addressed a deployment config that does not exist.""" + + +@dataclasses.dataclass(frozen=True) +class _SchemaRegistration: + contract: ConfigContract + keys: tuple[str, ...] + + +class DeploymentConfigManager: + """Own exact schema registration and the shared ``configs`` relation.""" + + _contracts: dict[DeploymentConfigSchemaID, _SchemaRegistration] = {} + + @classmethod + def register_schema( + cls, + schema_id: DeploymentConfigSchemaID, + model: type[pydantic.BaseModel], + *, + keys: tuple[str, ...] = (), + ) -> None: + """Register one exact schema ID idempotently for the same model.""" + existing = cls._contracts.get(schema_id) + if existing is not None: + if existing.contract.model is model: + cls._contracts[schema_id] = _SchemaRegistration( + existing.contract, tuple(sorted(set(existing.keys) | set(keys))) + ) + return + raise DeploymentConfigSchemaCollisionError( + f"Deployment config schema {schema_id!r} is already registered " + f"by {existing.contract.model.__qualname__}" + ) + cls._contracts[schema_id] = _SchemaRegistration(ConfigContract(model), keys) + + @classmethod + def _contract( + cls, + schema_id: DeploymentConfigSchemaID, + ) -> ConfigContract: + try: + return cls._contracts[schema_id].contract + except KeyError as error: + raise UnknownDeploymentConfigSchemaError( + f"Unknown deployment config schema: {schema_id}" + ) from error + + @classmethod + def _restore_record( + cls, + record: DeploymentConfigModel, + ) -> pydantic.BaseModel: + return cls._contract(record.schema_id).validate(record.value) + + @classmethod + def _view(cls, record: DeploymentConfigModel) -> DeploymentConfigView: + return DeploymentConfigView( + key=record.key, + schema=record.schema_id, + value=record.value, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + @classmethod + def get_schema(cls, schema_id: str, *, include_schema: bool = True) -> dict: + contract = cls._contract(schema_id) + entry = cls._contracts[schema_id] + result: dict[str, typing.Any] = { + "id": schema_id, + "keys": entry.keys, + "description": contract.model.__doc__ or "", + } + if include_schema: + result["input_schema"] = contract.json_schema() + return result + + @classmethod + def list_schemas( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[dict], str | None]: + ids = sorted(key for key in cls._contracts if cursor is None or key > cursor) + more = limit is not None and len(ids) > limit + ids = ids[:limit] + return [cls.get_schema(key, include_schema=False) for key in ids], ids[ + -1 + ] if more else None + + +class DeploymentConfigService: + """Deployment configuration use cases over the process-local schema registry.""" + + @staticmethod + async def get(key: DeploymentConfigKey) -> pydantic.BaseModel | None: + async with configuration_transaction() as configs: + record = await configs.get(key) + return None if record is None else DeploymentConfigManager._restore_record(record) + + @staticmethod + async def read(key: DeploymentConfigKey) -> DeploymentConfigView | None: + async with configuration_transaction() as configs: + record = await configs.get(key) + return None if record is None else DeploymentConfigManager._view(record) + + @staticmethod + async def replace( + key: DeploymentConfigKey, + schema_id: DeploymentConfigSchemaID, + complete_value: dict[str, typing.Any], + ) -> DeploymentConfigView: + result, _ = await DeploymentConfigService.replace_with_status( + key, schema_id, complete_value + ) + return result + + @staticmethod + async def replace_with_status( + key: DeploymentConfigKey, + schema_id: DeploymentConfigSchemaID, + complete_value: dict[str, typing.Any], + ) -> tuple[DeploymentConfigView, bool]: + normalized = DeploymentConfigManager._contract(schema_id).normalize(complete_value) + async with configuration_transaction() as configs: + record, created = await configs.replace(key, schema_id, normalized) + result = DeploymentConfigManager._view(record) + return result, created + + @staticmethod + async def list_configs( + *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[DeploymentConfigView], str | None]: + async with configuration_transaction() as configs: + rows, next_cursor = await configs.list(limit=limit, cursor=cursor) + return [DeploymentConfigManager._view(row) for row in rows], next_cursor + + @staticmethod + async def delete(key: DeploymentConfigKey) -> bool: + async with configuration_transaction() as configs: + record = await configs.get(key) + if record is None: + return False + await configs.delete(record) + return True + + @staticmethod + async def patch( + key: DeploymentConfigKey, partial_value: dict[str, typing.Any] + ) -> DeploymentConfigView: + async with configuration_transaction() as configs: + record = await configs.get(key, for_update=True) + if record is None: + raise DeploymentConfigNotFoundError(f"Deployment config {key!r} does not exist") + contract = DeploymentConfigManager._contract(record.schema_id) + record.value = contract.prepare_patch(record.value, partial_value).model_dump( + mode="json" + ) + await configs.save(record) + result = DeploymentConfigManager._view(record) + return result diff --git a/inkcre_core/business/graph_navigation_retrieval/__init__.py b/inkcre_core/business/graph_navigation_retrieval/__init__.py new file mode 100644 index 00000000..0b10b714 --- /dev/null +++ b/inkcre_core/business/graph_navigation_retrieval/__init__.py @@ -0,0 +1,3 @@ +from .main import GraphNavigationRetrievalManager + +__all__ = ["GraphNavigationRetrievalManager"] diff --git a/inkcre_core/business/graph_navigation_retrieval/main.py b/inkcre_core/business/graph_navigation_retrieval/main.py new file mode 100644 index 00000000..1da4c943 --- /dev/null +++ b/inkcre_core/business/graph_navigation_retrieval/main.py @@ -0,0 +1,476 @@ +"""Bounded, presentation-neutral navigation over persisted graph authority.""" + +from collections import deque +import typing + + +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.schemas.graph_navigation_retrieval import ( + DEFAULT_NEIGHBORHOOD_LIMIT, + MAX_NEIGHBORHOOD_LIMIT, + DEFAULT_MAX_HOPS, + MAX_MAX_HOPS, + DEFAULT_MAX_EXPLORED_BLOCKS, + MAX_MAX_EXPLORED_BLOCKS, + DEFAULT_MAX_EXPLORED_RELATIONS, + BlockNeighborhood, + ConnectedComponentsResult, + ConnectedSeedComponent, + GraphDirection, + GraphModel, + PathFound, + PathLimitReached, + PathNotFound, + PathResult, + RelationNeighborhood, +) +from inkcre_core.schemas.info_base.block import BlockID, BlockModel +from inkcre_core.schemas.info_base.relation import RelationID, RelationModel + + +FRONTIER_QUERY_SIZE = 200 + + +class GraphNavigationRetrievalManager: + """Own bounded graph-navigation queries over persisted entities.""" + + @classmethod + async def get_random_block( + cls, + ) -> BlockModel | None: + async with graph_uow() as uow: + return await uow.blocks.get_random() + + @classmethod + async def get_block_neighborhood( + cls, + focal_block: BlockID, + *, + direction: GraphDirection = "both", + contents: typing.Collection[str] = (), + limit: int = DEFAULT_NEIGHBORHOOD_LIMIT, + cursor: RelationID | None = None, + ) -> BlockNeighborhood | None: + if not 1 <= limit <= MAX_NEIGHBORHOOD_LIMIT: + raise ValueError(f"limit must be between 1 and {MAX_NEIGHBORHOOD_LIMIT}") + async with graph_uow() as uow: + focal = await uow.blocks.get(focal_block) + if focal is None: + return None + + requested = limit + 1 + branches: list[tuple[RelationModel, ...]] = [] + if direction in ("out", "both"): + branches.append( + await uow.relations.get_endpoint_page( + (focal_block,), + endpoint="from", + contents=contents, + cursor=cursor, + limit=requested, + ) + ) + if direction in ("in", "both"): + branches.append( + await uow.relations.get_endpoint_page( + (focal_block,), endpoint="to", contents=contents, cursor=cursor, limit=requested + ) + ) + by_id = { + relation.id: relation + for branch in branches + for relation in branch + if relation.id is not None + } + ordered = sorted( + by_id.values(), + key=lambda relation: typing.cast(int, relation.id), + reverse=True, + ) + page = ordered[:requested] + has_more = len(page) > limit + page = page[:limit] + endpoint_ids = {focal_block} + for relation in page: + endpoint_ids.update((relation.from_, relation.to_)) + blocks = await uow.blocks.get_many(endpoint_ids) + existing_ids = {block.id for block in blocks} + closed_relations = tuple( + relation + for relation in page + if relation.from_ in existing_ids and relation.to_ in existing_ids + ) + next_cursor = typing.cast(RelationID, page[-1].id) if has_more and page else None + return BlockNeighborhood( + focal_block=focal_block, + graph=GraphModel(blocks=blocks, relations=closed_relations), + next_cursor=next_cursor, + ) + + @classmethod + async def get_relation_neighborhood( + cls, + focal_relation: RelationID, + ) -> RelationNeighborhood | None: + async with graph_uow() as uow: + relation = await uow.relations.get_by_id(focal_relation) + if relation is None: + return None + blocks = await uow.blocks.get_many((relation.from_, relation.to_)) + if {block.id for block in blocks} != {relation.from_, relation.to_}: + return None + return RelationNeighborhood( + focal_relation=focal_relation, + graph=GraphModel(blocks=blocks, relations=(relation,)), + ) + + @classmethod + async def get_connected_components( + cls, + seed_block_ids: typing.Collection[BlockID], + *, + contents: typing.Collection[str], + max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS, + max_explored_relations: int = DEFAULT_MAX_EXPLORED_RELATIONS, + ) -> ConnectedComponentsResult: + """Partition existing seeds by bounded undirected exact-content reachability.""" + seeds = tuple(dict.fromkeys(seed_block_ids)) + relation_contents = tuple(dict.fromkeys(contents)) + if not relation_contents: + raise ValueError("contents must not be empty") + if max_explored_blocks < 1 or max_explored_relations < 1: + raise ValueError("exploration bounds must be positive") + if len(seeds) > max_explored_blocks: + raise ValueError("seed blocks exceed max_explored_blocks") + async with graph_uow() as uow: + existing_blocks = await uow.blocks.get_many(seeds) + existing_seed_ids = {block.id for block in existing_blocks if block.id is not None} + missing = tuple(seed for seed in seeds if seed not in existing_seed_ids) + assigned_seeds: set[BlockID] = set() + explored_blocks = set(existing_seed_ids) + seen_relations: set[RelationID] = set() + explored_relation_count = 0 + proof_relations: dict[RelationID, RelationModel] = {} + components: list[ConnectedSeedComponent] = [] + truncated = False + + for seed in seeds: + if seed not in existing_seed_ids or seed in assigned_seeds: + continue + if truncated: + components.append( + ConnectedSeedComponent(seed_block_ids=(seed,), member_block_ids=(seed,)) + ) + assigned_seeds.add(seed) + continue + + members = {seed} + frontier = deque((seed,)) + while frontier and not truncated: + current = frontier.popleft() + for endpoint in ("from", "to"): + cursor: RelationID | None = None + while not truncated: + remaining = max_explored_relations - explored_relation_count + if remaining == 0: + truncated = True + break + requested = min(FRONTIER_QUERY_SIZE, remaining + 1) + page = await uow.relations.get_endpoint_page( + (current,), + endpoint=typing.cast(typing.Literal["from", "to"], endpoint), + contents=relation_contents, + cursor=cursor, + limit=requested, + ) + if len(page) > remaining: + page = page[:remaining] + truncated = True + explored_relation_count += len(page) + for relation in page: + if relation.id is None or relation.id in seen_relations: + continue + relation_id = relation.id + seen_relations.add(relation_id) + neighbor = relation.to_ if relation.from_ == current else relation.from_ + if neighbor in members: + continue + if ( + neighbor not in explored_blocks + and len(explored_blocks) >= max_explored_blocks + ): + truncated = True + break + members.add(neighbor) + explored_blocks.add(neighbor) + frontier.append(neighbor) + proof_relations[relation_id] = relation + if truncated or len(page) < requested: + break + cursor = typing.cast(RelationID, page[-1].id) + + component_seeds = tuple(seed_id for seed_id in seeds if seed_id in members) + assigned_seeds.update(component_seeds) + components.append( + ConnectedSeedComponent( + seed_block_ids=component_seeds, + member_block_ids=tuple(sorted(members)), + ) + ) + + proof_blocks = await uow.blocks.get_many(explored_blocks) + return ConnectedComponentsResult( + components=tuple(components), + proof_graph=GraphModel( + blocks=proof_blocks, + relations=tuple(proof_relations.values()), + ), + missing_seed_block_ids=missing, + truncated=truncated, + ) + + @classmethod + async def find_path( # noqa: PLR0913 + cls, + from_block: BlockID, + to_block: BlockID, + *, + direction: GraphDirection = "both", + contents: typing.Collection[str] = (), + max_hops: int = DEFAULT_MAX_HOPS, + max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS, + ) -> PathResult: + if not 0 <= max_hops <= MAX_MAX_HOPS: + raise ValueError(f"max_hops must be between 0 and {MAX_MAX_HOPS}") + if not 1 <= max_explored_blocks <= MAX_MAX_EXPLORED_BLOCKS: + raise ValueError( + f"max_explored_blocks must be between 1 and {MAX_MAX_EXPLORED_BLOCKS}" + ) + async with graph_uow() as uow: + endpoints = await uow.blocks.get_many((from_block, to_block)) + if {block.id for block in endpoints} != {from_block, to_block}: + return PathNotFound() + if from_block == to_block: + return PathFound( + graph=GraphModel(blocks=endpoints, relations=()), + block_path=(from_block,), + relation_path=(), + ) + + forward_parents: dict[BlockID, tuple[BlockID, RelationID] | None] = {from_block: None} + backward_next: dict[BlockID, tuple[BlockID, RelationID] | None] = {to_block: None} + forward_depths = {from_block: 0} + backward_depths = {to_block: 0} + forward_frontier = {from_block} + backward_frontier = {to_block} + traversed_relations: dict[RelationID, RelationModel] = {} + + while forward_frontier and backward_frontier: + forward_level = forward_depths[next(iter(forward_frontier))] + backward_level = backward_depths[next(iter(backward_frontier))] + if forward_level + backward_level >= max_hops: + return PathLimitReached() + + expand_forward = len(forward_frontier) <= len(backward_frontier) + if expand_forward: + next_frontier, meeting = await cls._expand_path_frontier( + forward_frontier, + direction=direction, + contents=contents, + reverse=False, + own_steps=forward_parents, + own_depths=forward_depths, + other_depths=backward_depths, + max_hops=max_hops, + traversed_relations=traversed_relations, + uow=uow, + ) + forward_frontier = next_frontier + else: + next_frontier, meeting = await cls._expand_path_frontier( + backward_frontier, + direction=direction, + contents=contents, + reverse=True, + own_steps=backward_next, + own_depths=backward_depths, + other_depths=forward_depths, + max_hops=max_hops, + traversed_relations=traversed_relations, + uow=uow, + ) + backward_frontier = next_frontier + explored = set(forward_parents) | set(backward_next) + if len(explored) > max_explored_blocks: + return PathLimitReached() + if meeting is not None: + return await cls._assemble_bidirectional_path( + from_block, + to_block, + meeting=meeting, + forward_parents=forward_parents, + backward_next=backward_next, + traversed_relations=traversed_relations, + uow=uow, + ) + return PathNotFound() + + @classmethod + async def _expand_path_frontier( # noqa: PLR0913 + cls, + frontier: set[BlockID], + *, + direction: GraphDirection, + contents: typing.Collection[str], + reverse: bool, + own_steps: dict[BlockID, tuple[BlockID, RelationID] | None], + own_depths: dict[BlockID, int], + other_depths: dict[BlockID, int], + max_hops: int, + traversed_relations: dict[RelationID, RelationModel], + uow: GraphUnitOfWork, + ) -> tuple[set[BlockID], BlockID | None]: + next_frontier: set[BlockID] = set() + meeting: BlockID | None = None + best_hops: int | None = None + frontier_items = tuple(frontier) + for chunk_start in range(0, len(frontier), FRONTIER_QUERY_SIZE): + chunk = frontier_items[chunk_start : chunk_start + FRONTIER_QUERY_SIZE] + chunk_members = set(chunk) + relations = await cls._frontier_relations( + chunk, + direction=direction, + contents=contents, + reverse=reverse, + uow=uow, + ) + for relation in relations: + relation_id = typing.cast(RelationID, relation.id) + traversed_relations[relation_id] = relation + for current, neighbor in cls._relation_steps( + relation, frontier=chunk_members, direction=direction, reverse=reverse + ): + if neighbor in own_steps: + continue + own_steps[neighbor] = (current, relation_id) + own_depths[neighbor] = own_depths[current] + 1 + next_frontier.add(neighbor) + if neighbor not in other_depths: + continue + hops = own_depths[neighbor] + other_depths[neighbor] + if hops <= max_hops and (best_hops is None or hops < best_hops): + best_hops = hops + meeting = neighbor + return next_frontier, meeting + + @staticmethod + def _relation_steps( + relation: RelationModel, + *, + frontier: set[BlockID], + direction: GraphDirection, + reverse: bool, + ) -> tuple[tuple[BlockID, BlockID], ...]: + steps: list[tuple[BlockID, BlockID]] = [] + if not reverse: + if direction in ("out", "both") and relation.from_ in frontier: + steps.append((relation.from_, relation.to_)) + if direction in ("in", "both") and relation.to_ in frontier: + steps.append((relation.to_, relation.from_)) + else: + if direction in ("out", "both") and relation.to_ in frontier: + steps.append((relation.to_, relation.from_)) + if direction in ("in", "both") and relation.from_ in frontier: + steps.append((relation.from_, relation.to_)) + return tuple(steps) + + @classmethod + async def _frontier_relations( + cls, + frontier: typing.Collection[BlockID], + *, + direction: GraphDirection, + contents: typing.Collection[str], + reverse: bool = False, + uow: GraphUnitOfWork, + ) -> tuple[RelationModel, ...]: + relations: dict[RelationID, RelationModel] = {} + needs_from = ( + direction in ("out", "both") + if not reverse + else direction + in ( + "in", + "both", + ) + ) + needs_to = ( + direction in ("in", "both") + if not reverse + else direction + in ( + "out", + "both", + ) + ) + if needs_from: + for relation in await uow.relations.get_endpoint_page( + frontier, endpoint="from", contents=contents, limit=MAX_MAX_EXPLORED_BLOCKS + ): + if relation.id is not None: + relations[relation.id] = relation + if needs_to: + for relation in await uow.relations.get_endpoint_page( + frontier, endpoint="to", contents=contents, limit=MAX_MAX_EXPLORED_BLOCKS + ): + if relation.id is not None: + relations[relation.id] = relation + return tuple(relations.values()) + + @classmethod + async def _assemble_bidirectional_path( # noqa: PLR0913 + cls, + from_block: BlockID, + to_block: BlockID, + *, + meeting: BlockID, + forward_parents: dict[BlockID, tuple[BlockID, RelationID] | None], + backward_next: dict[BlockID, tuple[BlockID, RelationID] | None], + traversed_relations: dict[RelationID, RelationModel], + uow: GraphUnitOfWork, + ) -> PathResult: + block_path: list[BlockID] = [meeting] + relation_path: list[RelationID] = [] + current = meeting + while current != from_block: + parent = forward_parents[current] + if parent is None: # pragma: no cover - reconstruction invariant + raise RuntimeError("Path reconstruction lost its parent") + current, relation_id = parent + block_path.append(current) + relation_path.append(relation_id) + block_path.reverse() + relation_path.reverse() + current = meeting + while current != to_block: + next_step = backward_next[current] + if next_step is None: # pragma: no cover - reconstruction invariant + raise RuntimeError("Path reconstruction lost its next step") + current, relation_id = next_step + block_path.append(current) + relation_path.append(relation_id) + blocks = await uow.blocks.get_many(block_path) + relations = tuple( + relation + for relation_id in relation_path + if (relation := traversed_relations.get(relation_id)) is not None + ) + if {block.id for block in blocks} != set(block_path) or len(relations) != len( + relation_path + ): + return PathNotFound() + return PathFound( + graph=GraphModel(blocks=blocks, relations=relations), + block_path=tuple(block_path), + relation_path=tuple(relation_path), + ) diff --git a/inkcre_core/business/graph_navigation_retrieval/tools.py b/inkcre_core/business/graph_navigation_retrieval/tools.py new file mode 100644 index 00000000..837bab78 --- /dev/null +++ b/inkcre_core/business/graph_navigation_retrieval/tools.py @@ -0,0 +1,148 @@ +"""Agent Tool controllers for graph-navigation retrieval.""" + +import typing + +import pydantic + +from inkcre_core.business.agent import AgentManager, ToolExecutionError +from inkcre_core.business.agent.projection import project_json +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.graph_navigation_retrieval import ( + DEFAULT_MAX_EXPLORED_BLOCKS, + DEFAULT_MAX_EXPLORED_RELATIONS, + DEFAULT_MAX_HOPS, + DEFAULT_NEIGHBORHOOD_LIMIT, + MAX_MAX_EXPLORED_BLOCKS, + MAX_MAX_HOPS, + MAX_NEIGHBORHOOD_LIMIT, + GraphDirection, +) +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.info_base.relation import RelationID + +from .main import GraphNavigationRetrievalManager + + +GET_ENTITY_NEIGHBORHOOD_TOOL = "get_entity_neighborhood" +FIND_PATH_TOOL = "find_path" +GET_CONNECTED_COMPONENTS_TOOL = "get_connected_components" + + +class BlockNeighborhoodInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entity_type: typing.Literal["block"] + entity_id: BlockID + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + limit: int = pydantic.Field( + default=DEFAULT_NEIGHBORHOOD_LIMIT, ge=1, le=MAX_NEIGHBORHOOD_LIMIT + ) + cursor: RelationID | None = None + + +class RelationNeighborhoodInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entity_type: typing.Literal["relation"] + entity_id: RelationID + + +class EntityNeighborhoodInput( + pydantic.RootModel[ + typing.Annotated[ + BlockNeighborhoodInput | RelationNeighborhoodInput, + pydantic.Field(discriminator="entity_type"), + ] + ] +): + model_config = pydantic.ConfigDict(json_schema_extra={"type": "object"}) + + @classmethod + def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: + schema = super().model_json_schema(*args, **kwargs) + properties = BlockNeighborhoodInput.model_json_schema(*args, **kwargs)["properties"] + properties["entity_type"] = {"type": "string", "enum": ["block", "relation"]} + schema["properties"] = properties + return schema + + +class FindPathInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + from_block_id: BlockID + to_block_id: BlockID + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + max_hops: int = pydantic.Field(default=DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) + max_explored_blocks: int = pydantic.Field( + default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS + ) + + +class ConnectedComponentsInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + seed_block_ids: tuple[BlockID, ...] + contents: tuple[str, ...] = pydantic.Field(min_length=1) + max_explored_blocks: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) + max_explored_relations: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) + + +@AgentManager.tool( + GET_ENTITY_NEIGHBORHOOD_TOOL, + description=( + "Read a Block's direct neighborhood or a Relation with its endpoints. " + "Null may indicate an incorrect entity type." + ), +) +async def get_entity_neighborhood(input: EntityNeighborhoodInput) -> JSONValue: + request = input.root + if request.entity_type == "block": + result = await GraphNavigationRetrievalManager.get_block_neighborhood( + request.entity_id, + direction=request.direction, + contents=request.contents, + limit=request.limit, + cursor=request.cursor, + ) + else: + result = await GraphNavigationRetrievalManager.get_relation_neighborhood( + request.entity_id + ) + return project_json(result) + + +@AgentManager.tool( + FIND_PATH_TOOL, + description="Find a bounded graph path; an exploration limit is not proof of absence.", +) +async def find_path(input: FindPathInput) -> JSONValue: + return project_json( + await GraphNavigationRetrievalManager.find_path( + input.from_block_id, + input.to_block_id, + direction=input.direction, + contents=input.contents, + max_hops=input.max_hops, + max_explored_blocks=input.max_explored_blocks, + ) + ) + + +@AgentManager.tool( + GET_CONNECTED_COMPONENTS_TOOL, + description=( + "Partition seeds by bounded undirected reachability through exact Relation contents." + ), +) +async def get_connected_components(input: ConnectedComponentsInput) -> JSONValue: + try: + result = await GraphNavigationRetrievalManager.get_connected_components( + **input.model_dump() + ) + except ValueError as error: + raise ToolExecutionError( + {"error": type(error).__name__, "message": str(error)} + ) from error + return project_json(result) diff --git a/app/business/info_base/AGENTS.md b/inkcre_core/business/info_base/AGENTS.md similarity index 92% rename from app/business/info_base/AGENTS.md rename to inkcre_core/business/info_base/AGENTS.md index 7245ba4d..9d2f722a 100644 --- a/app/business/info_base/AGENTS.md +++ b/inkcre_core/business/info_base/AGENTS.md @@ -1,6 +1,6 @@ # info_base/ Local Guide -本文件只描述 `app/business/info_base/` 的局部事实、术语和编辑边界。跨 +本文件只描述 `inkcre_core/business/info_base/` 的局部事实、术语和编辑边界。跨 `extension/source/info_base/application` 的结构先读 [business-pipeline-and-authority.md](../../../docs/30-unit-tdd/business-pipeline-and-authority.md)。 @@ -18,7 +18,7 @@ - `relation.py`:relation CRUD/fetchsert、direct direction query 与 endpoint-label dynamic-property projection; - `resolver/`:exact decoder registry 与 use projection; - `storage/`:opaque pointer → actual bytes; -- `app/schemas/info_base/block.py`:persisted block 与 instance-local hydration cache。 +- `inkcre_core/schemas/info_base/block.py`:persisted block 与 instance-local hydration cache。 ## Content Vocabulary @@ -90,6 +90,6 @@ ## 异步数据库边界 - 新 Graph 应用入口位于 `commands.py`,事务内组合使用必填 `GraphUnitOfWork`,不能传 raw session。 -- `app/persistence/info_base/repository.py` 只接收 session 并执行查询/写入/flush,不允许创建 session 或 commit/rollback。 -- `app/persistence/info_base/uow.py` 拥有工厂组合,使用 SQLAlchemy 原生 framing,不跨 task 共享。 +- `inkcre_core/persistence/info_base/repository.py` 只接收 session 并执行查询/写入/flush,不允许创建 session 或 commit/rollback。 +- `inkcre_core/persistence/info_base/uow.py` 拥有工厂组合,使用 SQLAlchemy 原生 framing,不跨 task 共享。 - 独立入口使用 BlockService/RelationService;组合写入调用 persist_graph/persist_stars。不保留同步或可选 session 接口。 diff --git a/inkcre_core/business/info_base/__init__.py b/inkcre_core/business/info_base/__init__.py new file mode 100644 index 00000000..72c49b13 --- /dev/null +++ b/inkcre_core/business/info_base/__init__.py @@ -0,0 +1,14 @@ +from .main import InfoBaseManager +from .commands import persist_graph, submit_graph, persist_stars, submit_stars +from .services import BlockService, RelationService, get_entity_records + +__all__ = [ + "InfoBaseManager", + "persist_graph", + "submit_graph", + "persist_stars", + "submit_stars", + "get_entity_records", + "BlockService", + "RelationService", +] diff --git a/inkcre_core/business/info_base/commands.py b/inkcre_core/business/info_base/commands.py new file mode 100644 index 00000000..f53e6e91 --- /dev/null +++ b/inkcre_core/business/info_base/commands.py @@ -0,0 +1,79 @@ +"""Graph application operations shared by transports and composing use cases.""" + +from inkcre_core.schemas.info_base.main import ( + GraphBlockIDMapping, + GraphForm, + SubmitGraphResult, + StarsGraphForm, +) +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.relation import RelationCreateForm + +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow + + +async def persist_graph(graph: GraphForm, uow: GraphUnitOfWork) -> SubmitGraphResult: + """Add a flat graph to the supplied unit of work without ending its transaction.""" + blocks = await uow.blocks.create_many(graph.blocks) + mappings: list[GraphBlockIDMapping] = [] + for form, block in zip(graph.blocks, blocks, strict=True): + if block.id is None: + raise RuntimeError("Inserted Block is missing its database ID") + mappings.append(GraphBlockIDMapping(local_id=form.id, id=block.id)) + persisted_ids = {mapping.local_id: mapping.id for mapping in mappings} + await uow.relations.create_many( + RelationCreateForm( + from_=persisted_ids.get(form.from_, form.from_), + to_=persisted_ids.get(form.to_, form.to_), + content=form.content, + ) + for form in graph.relations + ) + return SubmitGraphResult(blocks=tuple(mappings)) + + +async def submit_graph(graph: GraphForm) -> SubmitGraphResult: + """Commit one complete flat graph before returning its Block identity mapping.""" + async with graph_uow() as uow: + result = await persist_graph(graph, uow) + return result + + +async def get_related_block( + block_id: int, *, content: str, outgoing: bool = True +) -> BlockModel | None: + async with graph_uow() as uow: + return await uow.blocks.get_related(block_id, content=content, outgoing=outgoing) + + +async def persist_stars(graph: StarsGraphForm, uow: GraphUnitOfWork) -> BlockModel: + """Reconcile exact resolver identities and relations within one transaction.""" + from .resolver import ResolverManager + + candidate = BlockModel.model_validate(graph.block) + existing = await ResolverManager.get(candidate).get_existing_async(uow.blocks) + block = existing if existing is not None else await uow.blocks.create(graph.block) + if block.id is None: + raise RuntimeError("Persisted star Block is missing its database ID") + for arc in graph.out_arcs: + target = await persist_stars(arc.to_graph, uow) + if target.id is None: + raise RuntimeError("Persisted star Block is missing its database ID") + await uow.relations.fetchsert( + RelationModel(from_=block.id, to_=target.id, content=arc.relation.content) + ) + for arc in graph.in_arcs: + source = await persist_stars(arc.from_graph, uow) + if source.id is None: + raise RuntimeError("Persisted star Block is missing its database ID") + await uow.relations.fetchsert( + RelationModel(from_=source.id, to_=block.id, content=arc.relation.content) + ) + return block + + +async def submit_stars(graph: StarsGraphForm) -> BlockModel: + async with graph_uow() as uow: + result = await persist_stars(graph, uow) + return result diff --git a/inkcre_core/business/info_base/main.py b/inkcre_core/business/info_base/main.py new file mode 100644 index 00000000..b4099c6d --- /dev/null +++ b/inkcre_core/business/info_base/main.py @@ -0,0 +1,123 @@ +"""Info-base graph command coordination and cross-retrieval use cases.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import typing + +from inkcre_core.business.info_base.services import BlockService, get_entity_records +from inkcre_core.schemas.lexical_retrieval import LexicalRetrievalResult +from inkcre_core.schemas.semantic_retrieval import ( + SemanticRetrievalResult, + VectorRetrievalOptions, +) +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel + +from inkcre_core.schemas.info_base.main import ( + GraphBlockForm, + GraphForm, + GraphRelationForm, + StarsGraphForm, +) + + +@dataclass(frozen=True) +class RetrievalBranch: + result: LexicalRetrievalResult | SemanticRetrievalResult | None = None + error: BaseException | None = None + + +class InfoBaseManager: + """Own graph commands and use cases that span info-base read capabilities.""" + + @classmethod + async def retrieve( + cls, + query: str, + mode: typing.Literal["lexical", "semantic", "hybrid"] = "hybrid", + limit: int = 20, + ) -> dict[str, RetrievalBranch]: + """Run selected retrieval owners independently without merging their ranking.""" + from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager + from inkcre_core.business.semantic_retrieval import SemanticRetrievalManager + + operations: dict[str, typing.Awaitable] = {} + if mode in {"lexical", "hybrid"}: + operations["lexical"] = LexicalRetrievalManager.retrieve_local(query, limit) + if mode in {"semantic", "hybrid"}: + operations["semantic"] = SemanticRetrievalManager.retrieve_local( + query, options=VectorRetrievalOptions(limit=limit) + ) + outcomes = await asyncio.gather(*operations.values(), return_exceptions=True) + return { + name: RetrievalBranch( + error=outcome if isinstance(outcome, BaseException) else None, + result=None if isinstance(outcome, BaseException) else outcome, + ) + for name, outcome in zip(operations, outcomes, strict=True) + } + + @classmethod + async def get_entities( + cls, + entities: typing.Collection[tuple[typing.Literal["block", "relation"], int]], + *, + random_count: int = 1, + ) -> tuple[BlockModel | RelationModel | None, ...]: + """Read ordered persisted entities, or random Blocks when selection is empty.""" + if not entities: + return typing.cast( + tuple[BlockModel | RelationModel | None, ...], + await BlockService.get_random_many(random_count), + ) + blocks, relations = await get_entity_records( + tuple(identity for kind, identity in entities if kind == "block"), + tuple(identity for kind, identity in entities if kind == "relation"), + ) + return tuple( + blocks.get(identity) if kind == "block" else relations.get(identity) + for kind, identity in entities + ) + + @classmethod + def normalize_graph( + cls, + stars: StarsGraphForm, + id_start: int = -1, + ) -> GraphForm: + """Flatten recursive authoring while allocating deterministic negative IDs.""" + if id_start >= 0: + raise ValueError("id_start must be negative") + + next_id = id_start + blocks: list[GraphBlockForm] = [] + relations: list[GraphRelationForm] = [] + + def visit(star: StarsGraphForm) -> int: + nonlocal next_id + block_id = next_id + next_id -= 1 + blocks.append(GraphBlockForm.model_validate(star.block, update={"id": block_id})) + + for arc in star.out_arcs: + to_id = visit(arc.to_graph) + relations.append( + GraphRelationForm.model_validate( + arc.relation, + update={"from_": block_id, "to_": to_id}, + ) + ) + for arc in star.in_arcs: + from_id = visit(arc.from_graph) + relations.append( + GraphRelationForm.model_validate( + arc.relation, + update={"from_": from_id, "to_": block_id}, + ) + ) + return block_id + + visit(stars) + return GraphForm(blocks=tuple(blocks), relations=tuple(relations)) diff --git a/app/business/info_base/resolver/AGENTS.md b/inkcre_core/business/info_base/resolver/AGENTS.md similarity index 97% rename from app/business/info_base/resolver/AGENTS.md rename to inkcre_core/business/info_base/resolver/AGENTS.md index 690a6e17..339e99c4 100644 --- a/app/business/info_base/resolver/AGENTS.md +++ b/inkcre_core/business/info_base/resolver/AGENTS.md @@ -1,6 +1,6 @@ # resolver/ Local Guide -本文件只描述 `app/business/info_base/resolver/` 的局部事实与高风险边界。上层 contract 看 +本文件只描述 `inkcre_core/business/info_base/resolver/` 的局部事实与高风险边界。上层 contract 看 [info_base guide](../AGENTS.md) 与 [business-pipeline-and-authority.md](../../../../docs/30-unit-tdd/business-pipeline-and-authority.md)。 diff --git a/inkcre_core/business/info_base/resolver/__init__.py b/inkcre_core/business/info_base/resolver/__init__.py new file mode 100644 index 00000000..edf45ec6 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/__init__.py @@ -0,0 +1,60 @@ +from .bootstrap import register_core_resolvers +from .contracts import ( + CORE_RESOLVER_IDS, + CoreResolverID, + DuplicateResolverRegistrationError, + ResolverContentError, + ResolverContractError, + TextProjectionContext, + UnknownDraftResolverError, + UnknownResolverError, + UnsupportedResolverCapability, + ResolverMethodInputError, + UnknownResolverMethodError, +) +from .main import ( + Resolver, + ResolverDraftCapability, + ResolverManager, + ResolverMethodContract, +) + +__all__ = [ + "ResolverManager", + "Resolver", + "register_core_resolvers", + "CORE_RESOLVER_IDS", + "CoreResolverID", + "ResolverContractError", + "TextProjectionContext", + "UnknownResolverError", + "UnknownDraftResolverError", + "DuplicateResolverRegistrationError", + "ResolverContentError", + "UnsupportedResolverCapability", + "ResolverDraftCapability", + "ResolverMethodContract", + "ResolverMethodInputError", + "UnknownResolverMethodError", + "AudioResolver", + "EPUBResolver", + "FileResolver", + "ImageResolver", + "PDFResolver", + "VideoResolver", + "TextResolver", + "MarkdownResolver", + "HTMLResolver", + "ZIPResolver", +] + +from .audio import AudioResolver +from .epub import EPUBResolver +from .file import FileResolver +from .image import ImageResolver +from .pdf import PDFResolver +from .video import VideoResolver +from .text import TextResolver +from .markdown import MarkdownResolver +from .html import HTMLResolver +from .zip import ZIPResolver diff --git a/inkcre_core/business/info_base/resolver/audio.py b/inkcre_core/business/info_base/resolver/audio.py new file mode 100644 index 00000000..76efc17e --- /dev/null +++ b/inkcre_core/business/info_base/resolver/audio.py @@ -0,0 +1,173 @@ +"""Exact audio resolver using metadata-only PyAV inspection.""" + +import asyncio +from dataclasses import dataclass +from io import BytesIO +import typing + +import av +from av.stream import Disposition +import pydantic + +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.schemas.ai import AudioContentPart + +from .contracts import ( + ResolverContentError, + TextProjectionContext, + UnsupportedResolverCapability, +) +from .inspection import ( + ByteContentFacts, + detect_media_type, + format_lexical_facts, + require_bytes, +) +from .main import Resolver +from .materialization import try_materialize_model_text + + +AUDIO_RESOLVER_CONFIG_KEY = "core.resolver.audio" +AUDIO_RESOLVER_CONFIG_SCHEMA = "core.resolver.audio.config.v1" + + +class AudioResolverConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + transcript_model: int + + +DeploymentConfigManager.register_schema( + AUDIO_RESOLVER_CONFIG_SCHEMA, AudioResolverConfig, keys=(AUDIO_RESOLVER_CONFIG_KEY,) +) + + +@dataclass(frozen=True, slots=True) +class AudioSolvedContent(ByteContentFacts): + container: str | None + codec: str | None + duration_ms: int | None + channels: int | None + sample_rate_hz: int | None + bitrate_bps: int | None + + +def _container_name(format_name: str | None) -> str | None: + if not format_name: + return None + names = format_name.split(",") + return "mp4" if "mp4" in names else names[0] + + +def _duration_ms(container, stream) -> int | None: + if stream.duration is not None and stream.time_base is not None: + return round(float(stream.duration * stream.time_base) * 1000) + if container.duration is not None: + return round(container.duration / 1000) + return None + + +def _primary_stream(streams): + default = [stream for stream in streams if bool(stream.disposition & Disposition.default)] + ordinary = [ + stream for stream in streams if not bool(stream.disposition & Disposition.attached_pic) + ] + candidates = default or ordinary or list(streams) + return candidates[0] if candidates else None + + +def _inspect_audio(content: bytes) -> AudioSolvedContent: + try: + with av.open(BytesIO(content), mode="r") as container: + stream = _primary_stream(container.streams.audio) + if stream is None: + raise ResolverContentError("core.audio.v1", "no audio stream") + codec = stream.codec_context + facts = AudioSolvedContent( + content=content, + byte_size=len(content), + detected_media_type=detect_media_type(content), + container=_container_name(container.format.name), + codec=codec.name, + duration_ms=_duration_ms(container, stream), + channels=codec.channels, + sample_rate_hz=codec.sample_rate, + bitrate_bps=stream.bit_rate or container.bit_rate, + ) + except ResolverContentError: + raise + except (av.FFmpegError, OSError, ValueError) as error: + raise ResolverContentError("core.audio.v1", "invalid audio") from error + return facts + + +class AudioResolver( + Resolver[AudioSolvedContent, bytes], + rso_type="core.audio.v1", +): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> AudioSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_audio, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + if materialize_missing: + config = typing.cast( + AudioResolverConfig | None, + await DeploymentConfigService.get(AUDIO_RESOLVER_CONFIG_KEY), + ) + if config is not None: + media_type = solved.detected_media_type or ( + f"audio/{solved.container}" if solved.container is not None else None + ) + if media_type is not None: + await try_materialize_model_text( + block_id=self.block_id, + role="transcript", + model=config.transcript_model, + instruction=( + "Transcribe only speech and authored spoken language in the audio. " + "Return plain text with no summary or explanation." + ), + media=AudioContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=await self.get_transfer_url(), + ), + ) + return format_lexical_facts( + "audio", + ( + ("media type", solved.detected_media_type), + ("container", solved.container), + ("codec", solved.codec), + ("duration ms", solved.duration_ms), + ("channels", solved.channels), + ("sample rate Hz", solved.sample_rate_hz), + ("bitrate bps", solved.bitrate_bps), + ), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "audio" diff --git a/inkcre_core/business/info_base/resolver/bootstrap.py b/inkcre_core/business/info_base/resolver/bootstrap.py new file mode 100644 index 00000000..862a2379 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/bootstrap.py @@ -0,0 +1,31 @@ +"""Explicit registration of resolver decoders owned by impl-py.""" + +from .main import ResolverManager + + +def register_core_resolvers() -> None: + """Register built-in decoders independently of extension loading.""" + from .audio import AudioResolver + from .epub import EPUBResolver + from .file import FileResolver + from .html import HTMLResolver + from .image import ImageResolver + from .pdf import PDFResolver + from .text import TextResolver + from .markdown import MarkdownResolver + from .video import VideoResolver + from .zip import ZIPResolver + + for resolver_class in ( + TextResolver, + MarkdownResolver, + HTMLResolver, + ImageResolver, + AudioResolver, + VideoResolver, + PDFResolver, + EPUBResolver, + ZIPResolver, + FileResolver, + ): + ResolverManager.register_resolver(resolver_class) diff --git a/inkcre_core/business/info_base/resolver/contracts.py b/inkcre_core/business/info_base/resolver/contracts.py new file mode 100644 index 00000000..fbcfa89b --- /dev/null +++ b/inkcre_core/business/info_base/resolver/contracts.py @@ -0,0 +1,95 @@ +"""Stable resolver IDs, projection controls, and typed contract failures.""" + +from typing import Literal, TypeAlias + +import pydantic + + +TextProjectionContext: TypeAlias = Literal["default", "lexical"] + + +CoreResolverID: TypeAlias = Literal[ + "core.text.v1", + "core.markdown.v1", + "core.html.v1", + "core.image.v1", + "core.audio.v1", + "core.video.v1", + "core.pdf.v1", + "core.epub.v1", + "core.zip.v1", + "core.file.v1", +] + +CORE_RESOLVER_IDS: tuple[CoreResolverID, ...] = ( + "core.text.v1", + "core.markdown.v1", + "core.html.v1", + "core.image.v1", + "core.audio.v1", + "core.video.v1", + "core.pdf.v1", + "core.epub.v1", + "core.zip.v1", + "core.file.v1", +) + + +class ResolverContractError(RuntimeError): + """Base failure for resolver selection and capability contracts.""" + + +class ResolverMethodInputError(pydantic.ValidationError): + """Native input details, distinguishable from errors inside a content method.""" + + +class UnknownResolverMethodError(ResolverContractError, ValueError): + """An exact Resolver has no projected read method with this name.""" + + +class UnknownResolverError(ResolverContractError): + """No exact resolver decoder is registered for a persisted resolver ID.""" + + def __init__(self, resolver_id: str): + self.resolver_id = resolver_id + super().__init__(f"Unknown resolver ID: {resolver_id}") + + +class UnknownDraftResolverError(ResolverContractError): + """The selected exact Resolver has no graph-drafting capability.""" + + def __init__(self, resolver_id: str): + self.resolver_id = resolver_id + super().__init__(f"Resolver is not available for graph drafting: {resolver_id}") + + +class DuplicateResolverRegistrationError(ResolverContractError): + """Two different decoder classes claimed the same exact resolver ID.""" + + def __init__(self, resolver_id: str, existing: type, attempted: type): + self.resolver_id = resolver_id + self.existing = existing + self.attempted = attempted + super().__init__( + f"Resolver ID {resolver_id} is already registered by " + f"{existing.__module__}.{existing.__qualname__}; " + f"cannot register {attempted.__module__}.{attempted.__qualname__}" + ) + + +class UnsupportedResolverCapability(ResolverContractError): + """The exact resolver contract does not provide one requested projection.""" + + def __init__(self, resolver_id: str, capability: str): + self.resolver_id = resolver_id + self.capability = capability + super().__init__(f"Resolver {resolver_id} does not support {capability}") + + +class ResolverContentError(ResolverContractError, ValueError): + """Hydrated content does not satisfy its exact resolver contract.""" + + def __init__(self, resolver_id: str, reason: str): + self.resolver_id = resolver_id + self.reason = reason + super().__init__(f"Resolver {resolver_id} rejected content: {reason}") diff --git a/inkcre_core/business/info_base/resolver/epub.py b/inkcre_core/business/info_base/resolver/epub.py new file mode 100644 index 00000000..a403579e --- /dev/null +++ b/inkcre_core/business/info_base/resolver/epub.py @@ -0,0 +1,219 @@ +"""Exact EPUB resolver using bounded package-document inspection.""" + +import asyncio +import datetime +from dataclasses import dataclass +from io import BytesIO +import zipfile + +from lxml import etree + +from .contracts import ( + ResolverContentError, + TextProjectionContext, + UnsupportedResolverCapability, +) +from .inspection import ByteContentFacts, format_lexical_facts, require_bytes +from .label import format_label +from .main import Resolver + + +_MAX_EPUB_MEMBERS = 10_000 +_MAX_PACKAGE_BYTES = 2 * 1024 * 1024 +_XML_PARSER = etree.XMLParser( + resolve_entities=False, + no_network=True, + recover=False, + huge_tree=False, +) + + +@dataclass(frozen=True, slots=True) +class EPUBSolvedContent(ByteContentFacts): + epub_version: str | None + title: str | None + creators: tuple[str, ...] + languages: tuple[str, ...] + modified_at: datetime.datetime | None + manifest_count: int + spine_count: int + has_navigation: bool + + +def _local_name(element: etree._Element) -> str: + return etree.QName(element).localname + + +def _read_bounded( + archive: zipfile.ZipFile, + member_name: str, + *, + limit: int = _MAX_PACKAGE_BYTES, +) -> bytes: + try: + member = archive.getinfo(member_name) + except KeyError as error: + raise ResolverContentError( + "core.epub.v1", + f"missing required EPUB member {member_name}", + ) from error + if member.file_size > limit: + raise ResolverContentError( + "core.epub.v1", + f"EPUB member {member_name} exceeds inspection limit", + ) + with archive.open(member) as stream: + result = stream.read(limit + 1) + if len(result) > limit: + raise ResolverContentError( + "core.epub.v1", + f"EPUB member {member_name} exceeds inspection limit", + ) + return result + + +def _parse_xml(content: bytes, label: str) -> etree._Element: + try: + return etree.fromstring(content, parser=_XML_PARSER) + except etree.XMLSyntaxError as error: + raise ResolverContentError("core.epub.v1", f"invalid {label}") from error + + +def _first_text(elements: list[etree._Element]) -> str | None: + for element in elements: + if element.text and (value := element.text.strip()): + return value + return None + + +def _all_text(elements: list[etree._Element]) -> tuple[str, ...]: + return tuple( + value for element in elements if element.text and (value := element.text.strip()) + ) + + +def _parse_modified(value: str | None) -> datetime.datetime | None: + if value is None: + return None + try: + parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ResolverContentError( + "core.epub.v1", + "invalid dcterms:modified timestamp", + ) from error + return parsed + + +def _inspect_epub(content: bytes) -> EPUBSolvedContent: + try: + with zipfile.ZipFile(BytesIO(content)) as archive: + if len(archive.infolist()) > _MAX_EPUB_MEMBERS: + raise ResolverContentError("core.epub.v1", "too many EPUB members") + if _read_bounded(archive, "mimetype", limit=128) != b"application/epub+zip": + raise ResolverContentError("core.epub.v1", "invalid EPUB mimetype member") + + container = _parse_xml( + _read_bounded(archive, "META-INF/container.xml"), + "EPUB container document", + ) + rootfiles = [ + element for element in container.iter() if _local_name(element) == "rootfile" + ] + package_path = next( + (element.get("full-path") for element in rootfiles if element.get("full-path")), + None, + ) + if package_path is None: + raise ResolverContentError("core.epub.v1", "missing EPUB package path") + package = _parse_xml( + _read_bounded(archive, package_path), + "EPUB package document", + ) + except ResolverContentError: + raise + except (zipfile.BadZipFile, OSError, ValueError) as error: + raise ResolverContentError("core.epub.v1", "invalid EPUB container") from error + + elements = list(package.iter()) + titles = [element for element in elements if _local_name(element) == "title"] + creators = [element for element in elements if _local_name(element) == "creator"] + languages = [element for element in elements if _local_name(element) == "language"] + manifest_items = [element for element in elements if _local_name(element) == "item"] + spine_items = [element for element in elements if _local_name(element) == "itemref"] + modified = next( + ( + element.text.strip() + for element in elements + if _local_name(element) == "meta" + and element.get("property") == "dcterms:modified" + and element.text + and element.text.strip() + ), + None, + ) + + return EPUBSolvedContent( + content=content, + byte_size=len(content), + detected_media_type="application/epub+zip", + epub_version=package.get("version"), + title=_first_text(titles), + creators=_all_text(creators), + languages=_all_text(languages), + modified_at=_parse_modified(modified), + manifest_count=len(manifest_items), + spine_count=len(spine_items), + has_navigation=any( + "nav" in (element.get("properties") or "").split() for element in manifest_items + ), + ) + + +class EPUBResolver( + Resolver[EPUBSolvedContent, bytes], + rso_type="core.epub.v1", +): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> EPUBSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_epub, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + del materialize_missing + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_lexical_facts( + "EPUB", + ( + ("media type", solved.detected_media_type), + ("title", solved.title), + ("creators", ", ".join(solved.creators) or None), + ("languages", ", ".join(solved.languages) or None), + ("EPUB version", solved.epub_version), + ("manifest items", solved.manifest_count), + ("spine items", solved.spine_count), + ), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + solved = await self.get_solved_content( + refresh=refresh, + materialize_missing=False, + ) + return format_label("EPUB", solved.title) diff --git a/inkcre_core/business/info_base/resolver/file.py b/inkcre_core/business/info_base/resolver/file.py new file mode 100644 index 00000000..b95167f7 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/file.py @@ -0,0 +1,64 @@ +"""Exact generic-file resolver for bytes without stronger semantics.""" + +import asyncio +from dataclasses import dataclass + +from .contracts import TextProjectionContext, UnsupportedResolverCapability +from .inspection import ( + ByteContentFacts, + detect_media_type, + format_lexical_facts, + require_bytes, +) +from .main import Resolver + + +@dataclass(frozen=True, slots=True) +class FileSolvedContent(ByteContentFacts): + pass + + +def _inspect_file(content: bytes) -> FileSolvedContent: + return FileSolvedContent( + content=content, + byte_size=len(content), + detected_media_type=detect_media_type(content), + ) + + +class FileResolver( + Resolver[FileSolvedContent, bytes], + rso_type="core.file.v1", +): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> FileSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_file, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + del materialize_missing + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_lexical_facts( + "file", + (("media type", solved.detected_media_type), ("bytes", solved.byte_size)), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "file" diff --git a/inkcre_core/business/info_base/resolver/html.py b/inkcre_core/business/info_base/resolver/html.py new file mode 100644 index 00000000..d5b8ba9f --- /dev/null +++ b/inkcre_core/business/info_base/resolver/html.py @@ -0,0 +1,73 @@ +"""Exact HTML-source resolver with a derived text projection.""" + +import asyncio + +import html2text +from lxml import html as lxml_html + +from .inspection import decode_html_bytes +from .label import format_label +from .main import Resolver, TextProjectionContext + + +class HTMLResolver(Resolver[str, str | bytes], rso_type="core.html.v1"): + @classmethod + def create_graph(cls, url: str): + from inkcre_core.schemas.info_base.block import BlockForm + from inkcre_core.schemas.info_base.main import StarsGraphForm + + return StarsGraphForm( + block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1) + ) + + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del materialize_missing + content = await self.get_raw_content(refresh=refresh) + return ( + content if isinstance(content, str) else decode_html_bytes(content, self.__rsotype__) + ) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context + source = await self.get_solved_content( + refresh=refresh, + materialize_missing=materialize_missing, + ) + if any(f"<{tag}" in source.lower() for tag in ("video", "audio", "track")): + document = lxml_html.document_fromstring(source) + for resource in document.xpath("//video | //audio | //source | //track"): + for attribute in ("src", "poster"): + if address := resource.get(attribute): + link = lxml_html.Element("a", href=address) + link.text = resource.get("label") or address + resource.addnext(link) + source = lxml_html.tostring(document, encoding="unicode") + return await asyncio.to_thread(html2text.HTML2Text().handle, source) + + async def get_label(self, *, refresh: bool = False) -> str: + source = await self.get_solved_content( + refresh=refresh, + materialize_missing=False, + ) + identifier = None + try: + document = lxml_html.fromstring(source) + values = document.xpath("//title/text()") or document.xpath( + "//h1//text() | //h2//text() | //h3//text() | //h4//text() | " + "//h5//text() | //h6//text()" + ) + identifier = next((str(value) for value in values if str(value).strip()), None) + except (TypeError, ValueError): + pass + return format_label("html", identifier) diff --git a/inkcre_core/business/info_base/resolver/image.py b/inkcre_core/business/info_base/resolver/image.py new file mode 100644 index 00000000..ffde4a74 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/image.py @@ -0,0 +1,158 @@ +"""Exact image resolver using bounded Pillow header inspection.""" + +import asyncio +from dataclasses import dataclass +from io import BytesIO +import typing + +from PIL import Image, UnidentifiedImageError +import pydantic + +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.schemas.ai import ImageContentPart +from inkcre_core.schemas.info_base.block import BlockForm +from inkcre_core.schemas.info_base.main import OutArcForm, StarsGraphForm +from inkcre_core.schemas.info_base.relation import RelationForm + +from .contracts import ( + ResolverContentError, + TextProjectionContext, + UnsupportedResolverCapability, +) +from .inspection import ( + ByteContentFacts, + detect_media_type, + format_lexical_facts, + require_bytes, +) +from .main import Resolver +from .materialization import try_materialize_model_text + + +IMAGE_RESOLVER_CONFIG_KEY = "core.resolver.image" +IMAGE_RESOLVER_CONFIG_SCHEMA = "core.resolver.image.config.v1" + + +class ImageResolverConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + text_model: int + + +DeploymentConfigManager.register_schema( + IMAGE_RESOLVER_CONFIG_SCHEMA, ImageResolverConfig, keys=(IMAGE_RESOLVER_CONFIG_KEY,) +) + + +@dataclass(frozen=True, slots=True) +class ImageSolvedContent(ByteContentFacts): + format: str | None + width: int | None + height: int | None + frame_count: int | None + + +def _inspect_image(content: bytes) -> ImageSolvedContent: + try: + with Image.open(BytesIO(content)) as image: + image_format = image.format.lower() if image.format else None + width, height = image.size + frame_count = getattr(image, "n_frames", None) + image.verify() + except (UnidentifiedImageError, OSError, SyntaxError, ValueError) as error: + raise ResolverContentError("core.image.v1", "invalid image") from error + return ImageSolvedContent( + content=content, + byte_size=len(content), + detected_media_type=detect_media_type(content), + format=image_format, + width=width, + height=height, + frame_count=frame_count, + ) + + +class ImageResolver( + Resolver[ImageSolvedContent, bytes], + rso_type="core.image.v1", +): + @classmethod + def create_graph(cls, url: str, alt_text: str | None = None) -> StarsGraphForm: + from .text import TextResolver + + return StarsGraphForm( + block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1), + out_arcs=( + OutArcForm( + relation=RelationForm(content="alt:text"), + to_graph=TextResolver.create_graph(alt_text), + ), + ) + if alt_text + else (), + ) + + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> ImageSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_image, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + if materialize_missing: + config = typing.cast( + ImageResolverConfig | None, + await DeploymentConfigService.get(IMAGE_RESOLVER_CONFIG_KEY), + ) + if config is not None: + media_type = solved.detected_media_type or ( + f"image/{solved.format}" if solved.format is not None else None + ) + if media_type is not None: + await try_materialize_model_text( + block_id=self.block_id, + role="text", + model=config.text_model, + instruction=( + "Transcribe only written text visibly present in the image. " + "Return plain text with no explanation; return no content when none exists." + ), + media=ImageContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=await self.get_transfer_url(), + ), + ) + return format_lexical_facts( + "image", + ( + ("media type", solved.detected_media_type), + ("format", solved.format), + ("width", solved.width), + ("height", solved.height), + ("frames", solved.frame_count), + ), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "image" diff --git a/inkcre_core/business/info_base/resolver/inspection.py b/inkcre_core/business/info_base/resolver/inspection.py new file mode 100644 index 00000000..4ac77f4f --- /dev/null +++ b/inkcre_core/business/info_base/resolver/inspection.py @@ -0,0 +1,104 @@ +"""Bounded implementation helpers shared by exact core content resolvers.""" + +from dataclasses import dataclass +from collections.abc import Iterable +import codecs +import re + +import puremagic + +from .contracts import ResolverContentError + + +@dataclass(frozen=True, slots=True) +class ByteContentFacts: + """Peer-local actual bytes plus common byte-derived facts.""" + + content: bytes + byte_size: int + detected_media_type: str | None + + +def detect_media_type(content: bytes) -> str | None: + """Return bounded signature detection without inventing an unknown value.""" + try: + detected = puremagic.from_string(content, mime=True) + except puremagic.PureError: + return None + return detected if isinstance(detected, str) else None + + +def decode_unicode_bytes(content: bytes, resolver_id: str) -> str: + """Decode a Unicode BOM when present, otherwise require strict UTF-8.""" + decoders = ( + (codecs.BOM_UTF32_LE, "utf-32"), + (codecs.BOM_UTF32_BE, "utf-32"), + (codecs.BOM_UTF8, "utf-8-sig"), + (codecs.BOM_UTF16_LE, "utf-16"), + (codecs.BOM_UTF16_BE, "utf-16"), + ) + encoding = next( + (candidate for bom, candidate in decoders if content.startswith(bom)), + "utf-8", + ) + try: + return content.decode(encoding, errors="strict") + except UnicodeError as error: + raise ResolverContentError(resolver_id, f"invalid {encoding} text") from error + + +_HTML_CHARSET_SEARCH_BYTES = 4096 +_HTML_CHARSET_PATTERNS = ( + re.compile(rb"]*charset\s*=\s*['\"]?\s*([a-zA-Z0-9._-]+)", re.I), + re.compile(rb"]*content\s*=\s*['\"][^'\"]*charset=([a-zA-Z0-9._-]+)", re.I), +) + + +def decode_html_bytes(content: bytes, resolver_id: str) -> str: + """Decode HTML using a Unicode BOM, bounded meta declaration, or strict UTF-8.""" + for bom in ( + codecs.BOM_UTF32_LE, + codecs.BOM_UTF32_BE, + codecs.BOM_UTF8, + codecs.BOM_UTF16_LE, + codecs.BOM_UTF16_BE, + ): + if content.startswith(bom): + return decode_unicode_bytes(content, resolver_id) + + prefix = content[:_HTML_CHARSET_SEARCH_BYTES] + declared_encoding = next( + ( + match.group(1).decode("ascii") + for pattern in _HTML_CHARSET_PATTERNS + if (match := pattern.search(prefix)) is not None + ), + "utf-8", + ) + try: + encoding = codecs.lookup(declared_encoding).name + except LookupError as error: + raise ResolverContentError( + resolver_id, + f"unknown declared HTML charset {declared_encoding}", + ) from error + try: + return content.decode(encoding, errors="strict") + except UnicodeError as error: + raise ResolverContentError(resolver_id, f"invalid {encoding} HTML") from error + + +def require_bytes(content: object, resolver_id: str) -> bytes: + if not isinstance(content, bytes): + raise ResolverContentError(resolver_id, "byte-oriented content must be storage-backed") + return content + + +def format_lexical_facts( + kind: str, + facts: Iterable[tuple[str, object | None]], +) -> str: + """Format bounded Resolver-owned metadata without serializing arbitrary objects.""" + lines = [kind] + lines.extend(f"{label}: {value}" for label, value in facts if value is not None) + return "\n".join(lines) diff --git a/inkcre_core/business/info_base/resolver/label.py b/inkcre_core/business/info_base/resolver/label.py new file mode 100644 index 00000000..a271cb2d --- /dev/null +++ b/inkcre_core/business/info_base/resolver/label.py @@ -0,0 +1,31 @@ +"""Shared mechanics for concise Resolver-owned Block labels.""" + +LABEL_IDENTIFIER_LIMIT = 96 + + +def normalize_label_identifier( + value: str | None, + *, + first_line: bool = False, +) -> str | None: + """Normalize whitespace and bound one optional human-readable identifier.""" + if value is None: + return None + candidates = value.splitlines() if first_line else (value,) + normalized = next((" ".join(item.split()) for item in candidates if item.strip()), "") + if not normalized: + return None + if len(normalized) > LABEL_IDENTIFIER_LIMIT: + return f"{normalized[:LABEL_IDENTIFIER_LIMIT]}…" + return normalized + + +def format_label( + kind: str, + identifier: str | None = None, + *, + first_line: bool = False, +) -> str: + """Format one stable resolver-qualified label without exposing exact IDs.""" + normalized = normalize_label_identifier(identifier, first_line=first_line) + return kind if normalized is None else f"{kind} <{normalized}>" diff --git a/inkcre_core/business/info_base/resolver/main.py b/inkcre_core/business/info_base/resolver/main.py new file mode 100644 index 00000000..c3a59cc2 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/main.py @@ -0,0 +1,434 @@ +import abc +from collections.abc import Collection +from dataclasses import dataclass +import inspect +import typing +from typing import Optional as Opt + +import pydantic + +from inkcre_core.business.info_base.services import RelationService +from inkcre_core.schemas.info_base.main import StarsGraphForm +from inkcre_core.schemas.info_base.block import BlockForm, BlockID, ResolverType, BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageID + +from inkcre_core.persistence.info_base.repository import BlockRepository + +from .contracts import ( + DuplicateResolverRegistrationError, + TextProjectionContext, + UnknownDraftResolverError, + UnknownResolverError, + UnknownResolverMethodError, + ResolverMethodInputError, +) + + +@dataclass(frozen=True) +class ResolverDraftCapability: + """One code-owned Resolver graph-drafting contract.""" + + resolver: ResolverType + description: str + input_model: type[pydantic.BaseModel] + resolver_cls: type["Resolver"] + + +@dataclass(frozen=True) +class ResolverMethodContract: + """One typed public read method on a registered Resolver.""" + + name: str + description: str + input_model: type[pydantic.BaseModel] + + @property + def input_schema(self) -> dict[str, typing.Any]: + return self.input_model.model_json_schema() + + +class ResolverManager: + RESOLVER_CLS: dict[ResolverType, type["Resolver"]] = {} + """Global resolver registry. + + Map ResolverType to Resolver class + """ + + @classmethod + def register_resolver(cls, resolver_cls: type["Resolver"]) -> None: + existing = cls.RESOLVER_CLS.get(resolver_cls.__rsotype__) + if existing is resolver_cls: + return + if existing is not None: + raise DuplicateResolverRegistrationError( + resolver_cls.__rsotype__, + existing, + resolver_cls, + ) + cls.RESOLVER_CLS[resolver_cls.__rsotype__] = resolver_cls + + @classmethod + def get(cls, block: BlockModel) -> "Resolver": + """Create resolver instance from block.""" + try: + resolver_cls = cls.RESOLVER_CLS[block.resolver] + except KeyError as error: + raise UnknownResolverError(block.resolver) from error + return resolver_cls(block) + + @classmethod + def get_draft_capabilities(cls) -> tuple[ResolverDraftCapability, ...]: + """Snapshot currently registered Resolvers that can draft rooted graphs.""" + capabilities = ( + ResolverDraftCapability( + resolver=resolver_id, + description=resolver_cls.draft_description, + input_model=resolver_cls.draft_input_model, + resolver_cls=resolver_cls, + ) + for resolver_id, resolver_cls in cls.RESOLVER_CLS.items() + if resolver_cls.draft_description is not None + and resolver_cls.draft_input_model is not None + ) + return tuple(sorted(capabilities, key=lambda capability: capability.resolver)) + + @classmethod + def get_draft_capability( + cls, + resolver: ResolverType, + ) -> ResolverDraftCapability: + """Select one exact graph-drafting contract without fallback.""" + for capability in cls.get_draft_capabilities(): + if capability.resolver == resolver: + return capability + raise UnknownDraftResolverError(resolver) + + @classmethod + def get_method_contracts( + cls, + resolver: ResolverType, + ) -> tuple[ResolverMethodContract, ...]: + """Discover typed public read methods on one registered Resolver.""" + resolver_cls = cls.RESOLVER_CLS.get(resolver) + if resolver_cls is None: + return () + return cls._method_contracts(resolver_cls) + + @classmethod + def get_common_method_contracts(cls) -> tuple[ResolverMethodContract, ...]: + """Common Resolver reads, available without per-type discovery.""" + return cls._method_contracts(Resolver) + + @staticmethod + def _method_contracts( + resolver_cls: type["Resolver"], + ) -> tuple[ResolverMethodContract, ...]: + contracts: list[ResolverMethodContract] = [] + for name, function in inspect.getmembers(resolver_cls, predicate=inspect.isfunction): + if name.startswith("_") or not name.startswith(("get_", "read_")): + continue + try: + signature = inspect.signature(function, eval_str=True) + fields: dict[str, tuple[typing.Any, typing.Any]] = {} + for parameter in signature.parameters.values(): + if parameter.name == "self": + continue + if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): + raise TypeError("Variadic Resolver methods are not projectable") + if parameter.annotation is inspect.Parameter.empty: + raise TypeError("Resolver method parameters must be typed") + default = ( + ... if parameter.default is inspect.Parameter.empty else parameter.default + ) + annotation = parameter.annotation + if typing.get_origin(annotation) is Collection: + item_type = typing.get_args(annotation)[0] + annotation = tuple[item_type, ...] + fields[parameter.name] = (annotation, default) + input_model = typing.cast(typing.Any, pydantic.create_model)( + f"{resolver_cls.__name__}_{name}_Arguments", + __config__=pydantic.ConfigDict(extra="forbid"), + **fields, + ) + input_model.model_json_schema() + except (NameError, TypeError, pydantic.PydanticSchemaGenerationError): + continue + contracts.append( + ResolverMethodContract( + name=name, + description=inspect.getdoc(function) or name.replace("_", " "), + input_model=input_model, + ) + ) + return tuple(contracts) + + @classmethod + def get_method_contract( + cls, + resolver: ResolverType, + name: str, + ) -> ResolverMethodContract | None: + return next( + ( + contract for contract in cls.get_method_contracts(resolver) if contract.name == name + ), + None, + ) + + @classmethod + async def invoke_method( + cls, + block: BlockModel, + name: str, + arguments: dict[str, typing.Any], + ) -> typing.Any: + """Validate and invoke one projected read method on an exact Block Resolver.""" + resolver = cls.get(block) + contract = cls.get_method_contract(block.resolver, name) + if contract is None: + raise UnknownResolverMethodError( + f"Resolver {block.resolver!r} has no method {name!r}" + ) + try: + validated = contract.input_model.model_validate(arguments) + except pydantic.ValidationError as error: + raise ResolverMethodInputError.from_exception_data( + error.title, typing.cast(typing.Any, error.errors(include_url=False)) + ) from error + # Keep nested Python values typed when calling the annotated domain method; + # model_dump would convert nested input models back into dictionaries. + value = getattr(resolver, name)( + **{field: getattr(validated, field) for field in contract.input_model.model_fields} + ) + return await value if inspect.isawaitable(value) else value + + @classmethod + def match_media_type(cls, media_type: str | None) -> ResolverType | None: + """Map one specific media type to an installed exact core resolver ID. + + Evidence precedence and generic/file fallback remain extension-owned. + """ + if media_type is None: + return None + normalized = media_type.partition(";")[0].strip().lower() + if not normalized or normalized in { + "application/octet-stream", + "binary/octet-stream", + "application/binary", + }: + return None + + exact = { + "text/plain": "core.text.v1", + "text/markdown": "core.markdown.v1", + "text/html": "core.html.v1", + "application/xhtml+xml": "core.html.v1", + "application/pdf": "core.pdf.v1", + "application/epub+zip": "core.epub.v1", + "application/zip": "core.zip.v1", + "application/x-zip-compressed": "core.zip.v1", + } + resolver_id = exact.get(normalized) + if resolver_id is None: + family = normalized.partition("/")[0] + resolver_id = { + "image": "core.image.v1", + "audio": "core.audio.v1", + "video": "core.video.v1", + }.get(family) + return resolver_id if resolver_id in cls.RESOLVER_CLS else None + + +SolvedContentTV = typing.TypeVar("SolvedContentTV") +RawContentTV = typing.TypeVar("RawContentTV") +_UNSET = object() + + +class Resolver(abc.ABC, typing.Generic[SolvedContentTV, RawContentTV]): + """Resolver resolves a star graph (a block and its direct relations) + + :tparam SolvedContentTV: The type of the solved content + :tparam RawContentTV: The type of the raw content + """ + + __rsotype__: ResolverType + """Resolver type + + Extension resolvers should be namespaced and versioned, e.g., + `extensions.twitter.tweet.v1`. + """ + + draft_description: typing.ClassVar[str | None] = None + draft_input_model: typing.ClassVar[type[pydantic.BaseModel] | None] = None + + def __init_subclass__(cls, rso_type: str, **kwargs) -> None: + cls.__rsotype__ = rso_type + ResolverManager.register_resolver(cls) + return super().__init_subclass__(**kwargs) + + def __init__(self, block: BlockModel, relations: Opt[tuple[RelationModel, ...]] = None): + """Should never override __init__ in subclasses, use __post_init__ instead. + + :param block: Block to resolve. + :param relations: Relations of the block. + """ + self._block = block + self.__relations: dict[tuple[bool, bool], tuple[RelationModel, ...]] = {} + if relations is not None: + self.__relations[(True, True)] = relations + self.__solved_content: SolvedContentTV | object = _UNSET + """Solved content is the content the resolver really works with, + commonly from raw content. + """ + inline_content = ( + typing.cast(RawContentTV, self._block.content) + if self._block.storage is None + else None + ) + self.__post_init__(inline_content) + + def __post_init__(self, raw_content: Opt[RawContentTV] = None) -> None: + """Subclass post-initialization hook. + + It's suggest to set __solved_content here if possible: + ```python + async def __post_init__(self, raw_content): + if raw_content is not None: + ... # anyhow from raw_content + self.set_solved_content(solved_content) + ``` + """ + ... + + @property + def block_id(self) -> BlockID: + """Get the block ID.""" + return typing.cast(BlockID, self._block.id) + + async def get_raw_content( + self, + *, + refresh: typing.Annotated[ + bool, pydantic.Field(description="Reread current content.") + ] = False, + ) -> RawContentTV: + """Read hydrated content: text or bytes, not a storage pointer.""" + return typing.cast( + RawContentTV, + await self._block.get_hydrated_content(refresh=refresh), + ) + + async def get_transfer_url(self) -> str | None: + """Get a content transfer URL when available.""" + if self._block.storage is None: + return None + from inkcre_core.business.info_base.storage import StorageManager + + storage = await StorageManager.get_storage_async(self._block.storage) + return storage.get_transfer_url(self._block.content) + + async def get_solved_content( + self, + *, + refresh: typing.Annotated[ + bool, pydantic.Field(description="Reread current content.") + ] = False, + materialize_missing: typing.Annotated[ + bool, pydantic.Field(description="Allow creation of missing derived information.") + ] = True, + ) -> SolvedContentTV: + """Read the Resolver's typed interpretation of content.""" + if refresh or self.__solved_content is _UNSET: + self.__solved_content = await self._get_solved_content( + refresh=refresh, + materialize_missing=materialize_missing, + ) + return typing.cast(SolvedContentTV, self.__solved_content) + + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> SolvedContentTV: + """Get the solved content (non-cache). + + Description: + When you say "will be resolved from relations", + it means you acquire the resolver of the relation's other side block, + and use its solved content. + """ + del materialize_missing + return typing.cast( + SolvedContentTV, + await self.get_raw_content(refresh=refresh), + ) + + def set_solved_content(self, content: SolvedContentTV) -> None: + self.__solved_content = content + + async def get_relations( + self, + *, + include_in: typing.Annotated[ + bool, pydantic.Field(description="Include relations pointing to this Block.") + ] = True, + include_out: typing.Annotated[ + bool, pydantic.Field(description="Include relations pointing from this Block.") + ] = True, + refresh: bool = False, + ) -> tuple[RelationModel, ...]: + """Read direct relations of this Block.""" + key = (include_in, include_out) + if refresh or key not in self.__relations: + all_relations = None if refresh else self.__relations.get((True, True)) + if all_relations is not None: + self.__relations[key] = tuple( + relation + for relation in all_relations + if (include_in and relation.to_ == self.block_id) + or (include_out and relation.from_ == self.block_id) + ) + else: + self.__relations[key] = await RelationService.get( + block_id=self.block_id, + include_in=include_in, + include_out=include_out, + ) + return self.__relations[key] + + @classmethod + # @abc.abstractmethod TODO + def create_block(cls, content, storage: Opt[StorageID] = None) -> BlockForm: ... + + @classmethod + # @abc.abstractmethod TODO + def create_graph(cls, *args, **kwargs) -> StarsGraphForm: ... + + @abc.abstractmethod + async def get_text( + self, + *, + context: typing.Annotated[ + TextProjectionContext, + pydantic.Field(description="Lexical projection is Block-local and non-recursive."), + ] = "default", + refresh: typing.Annotated[ + bool, pydantic.Field(description="Reread current content.") + ] = False, + materialize_missing: typing.Annotated[ + bool, pydantic.Field(description="Allow creation of missing derived information.") + ] = True, + ) -> str | None: + """Read a text projection; unsupported, absent and empty are distinct.""" + ... + + @abc.abstractmethod + async def get_label(self, *, refresh: bool = False) -> str: + """Read a concise label for this Block.""" + ... + + async def get_existing_async(self, blocks: BlockRepository) -> Opt[BlockModel]: + """Reconcile the exact resolver/content identity in the caller's transaction.""" + return await blocks.find_content(self._block.resolver, self._block.content) diff --git a/inkcre_core/business/info_base/resolver/markdown.py b/inkcre_core/business/info_base/resolver/markdown.py new file mode 100644 index 00000000..e381cde1 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/markdown.py @@ -0,0 +1,35 @@ +"""Markdown source remains the exact text projection; rendering is a reader concern.""" + +from .inspection import decode_unicode_bytes +from .label import format_label +from .main import Resolver, TextProjectionContext + + +class MarkdownResolver(Resolver[str, str | bytes], rso_type="core.markdown.v1"): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> str: + content = await self.get_raw_content(refresh=refresh) + return ( + content + if isinstance(content, str) + else decode_unicode_bytes(content, self.__rsotype__) + ) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + return await self.get_solved_content( + refresh=refresh, materialize_missing=materialize_missing + ) + + async def get_label(self, *, refresh: bool = False) -> str: + return format_label( + "markdown", + await self.get_text(refresh=refresh, materialize_missing=False), + first_line=True, + ) diff --git a/inkcre_core/business/info_base/resolver/materialization.py b/inkcre_core/business/info_base/resolver/materialization.py new file mode 100644 index 00000000..2744a8e9 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/materialization.py @@ -0,0 +1,80 @@ +"""Shared mechanics for Resolver-owned faithful text derivations.""" + +import logging + +from inkcre_core.business.ai import AIManager +from inkcre_core.schemas.ai import SystemMessage, UserContentPart, UserMessage + + +logger = logging.getLogger(__name__) + + +async def generate_faithful_text( + *, + model: int, + instruction: str, + media: UserContentPart, +) -> str | None: + """Ask one exact configured Model for source-faithful text, without graph access.""" + response = await AIManager.chat( + model, + ( + SystemMessage(content=instruction), + UserMessage(content=(media,)), + ), + ) + text = (response.content or "").strip() + return text or None + + +async def materialize_text_child(block_id: int, role: str, text: str) -> None: + """Best-effort idempotent graph write for one exact information role.""" + from inkcre_core.schemas.info_base.main import ( + GraphBlockForm, + GraphForm, + GraphRelationForm, + ) + + from inkcre_core.business.info_base.commands import persist_graph + from inkcre_core.persistence.info_base.uow import graph_uow + + # Recheck after provider work in the same short scope as the graph insertion. + async with graph_uow() as uow: + if await uow.blocks.get_related(block_id, content=role) is not None: + return + await persist_graph( + GraphForm( + blocks=(GraphBlockForm(id=-1, resolver="core.text.v1", content=text),), + relations=(GraphRelationForm(from_=block_id, to_=-1, content=role),), + ), + uow, + ) + + +async def try_materialize_model_text( + *, + block_id: int, + role: str, + model: int, + instruction: str, + media: UserContentPart, +) -> None: + """Keep one unavailable derivation shallow while preserving diagnostic evidence.""" + from inkcre_core.business.info_base.commands import get_related_block + + if await get_related_block(block_id, content=role) is not None: + return + try: + text = await generate_faithful_text( + model=model, + instruction=instruction, + media=media, + ) + if text is not None: + await materialize_text_child(block_id, role, text) + except Exception: + logger.warning( + "Resolver text materialization unavailable", + exc_info=True, + extra={"block": block_id, "role": role, "model": model}, + ) diff --git a/inkcre_core/business/info_base/resolver/pdf.py b/inkcre_core/business/info_base/resolver/pdf.py new file mode 100644 index 00000000..9c0ea924 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/pdf.py @@ -0,0 +1,131 @@ +"""Exact PDF resolver using bounded structural inspection.""" + +import asyncio +from dataclasses import dataclass +from io import BytesIO + +from pypdf import PdfReader +from pypdf.errors import PdfReadError + +from .contracts import ResolverContentError, TextProjectionContext +from .inspection import ( + ByteContentFacts, + detect_media_type, + format_lexical_facts, + require_bytes, +) +from .label import format_label +from .main import Resolver + + +@dataclass(frozen=True, slots=True) +class PDFSolvedContent(ByteContentFacts): + pdf_version: str | None + page_count: int | None + is_encrypted: bool + title: str | None + author: str | None + + +def _metadata_text(metadata: object, attribute: str) -> str | None: + value = getattr(metadata, attribute, None) + return value if isinstance(value, str) else None + + +def _inspect_pdf(content: bytes) -> PDFSolvedContent: + try: + reader = PdfReader(BytesIO(content), strict=True) + encrypted = reader.is_encrypted + metadata = None if encrypted else reader.metadata + page_count = None if encrypted else len(reader.pages) + except (PdfReadError, OSError, ValueError, TypeError) as error: + raise ResolverContentError("core.pdf.v1", "invalid PDF") from error + + header = reader.pdf_header + version = header.removeprefix("%PDF-") if header.startswith("%PDF-") else None + return PDFSolvedContent( + content=content, + byte_size=len(content), + detected_media_type=detect_media_type(content), + pdf_version=version, + page_count=page_count, + is_encrypted=encrypted, + title=_metadata_text(metadata, "title"), + author=_metadata_text(metadata, "author"), + ) + + +def _extract_pdf_text(content: bytes) -> str | None: + """Extract a bounded text-layer projection without treating metadata as body.""" + try: + reader = PdfReader(BytesIO(content), strict=True) + if reader.is_encrypted: + return None + parts: list[str] = [] + remaining = 1_000_000 + for page in reader.pages[:2_000]: + text = page.extract_text() or "" + if text.strip(): + selected = text[:remaining] + parts.append(selected) + remaining -= len(selected) + if remaining <= 0: + break + except (PdfReadError, OSError, ValueError, TypeError) as error: + raise ResolverContentError("core.pdf.v1", "invalid PDF text layer") from error + joined = "\n\n".join(parts).strip() + return joined or None + + +class PDFResolver( + Resolver[PDFSolvedContent, bytes], + rso_type="core.pdf.v1", +): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> PDFSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_pdf, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str | None: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + body = await asyncio.to_thread(_extract_pdf_text, content) + if context == "default": + return body + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + metadata = format_lexical_facts( + "PDF", + ( + ("media type", solved.detected_media_type), + ("title", solved.title), + ("author", solved.author), + ("pages", solved.page_count), + ("PDF version", solved.pdf_version), + ("encrypted", solved.is_encrypted), + ), + ) + return f"{metadata}\n\n{body}" if body else metadata + + async def get_label(self, *, refresh: bool = False) -> str: + solved = await self.get_solved_content( + refresh=refresh, + materialize_missing=False, + ) + return format_label("PDF", solved.title) diff --git a/inkcre_core/business/info_base/resolver/text.py b/inkcre_core/business/info_base/resolver/text.py new file mode 100644 index 00000000..92feae77 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/text.py @@ -0,0 +1,62 @@ +"""Exact plain-text resolver.""" + +import pydantic + +from .inspection import decode_unicode_bytes +from .label import format_label +from .main import Resolver, TextProjectionContext + + +class TextDraftInput(pydantic.BaseModel): + """Resolver-native input for drafting one plain-text Block.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + text: str + + +class TextResolver(Resolver[str, str | bytes], rso_type="core.text.v1"): + draft_description = "Create one ordinary plain-text semantic content Block." + draft_input_model = TextDraftInput + + @classmethod + def create_graph(cls, input: str | TextDraftInput): + from inkcre_core.schemas.info_base.block import BlockForm + from inkcre_core.schemas.info_base.main import StarsGraphForm + + text = input.text if isinstance(input, TextDraftInput) else input + return StarsGraphForm(block=BlockForm(resolver=cls.__rsotype__, content=text)) + + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del materialize_missing + content = await self.get_raw_content(refresh=refresh) + return ( + content + if isinstance(content, str) + else decode_unicode_bytes(content, self.__rsotype__) + ) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context + return await self.get_solved_content( + refresh=refresh, + materialize_missing=materialize_missing, + ) + + async def get_label(self, *, refresh: bool = False) -> str: + return format_label( + "text", + await self.get_text(refresh=refresh, materialize_missing=False), + first_line=True, + ) diff --git a/inkcre_core/business/info_base/resolver/tools.py b/inkcre_core/business/info_base/resolver/tools.py new file mode 100644 index 00000000..84c2fb96 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/tools.py @@ -0,0 +1,277 @@ +"""Agent Tool controller for typed Resolver method discovery and invocation.""" + +import typing + +import pydantic + +from inkcre_core.business.agent import AgentManager +from inkcre_core.business.agent.projection import project_json +from inkcre_core.business.info_base.services import BlockService +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.info_base.block import BlockID, ResolverType +from inkcre_core.libs.obsrv.telemetry import emit_event + +from .main import ResolverManager + + +RESOLVER_TOOL = "resolver" + + +class ResolverMethodCall(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block_id: BlockID + method: str + arguments: dict[str, JSONValue] = pydantic.Field(default_factory=dict) + + +class ResolverDescribeInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["describe"] + resolver_types: tuple[ResolverType, ...] = () + block_ids: tuple[BlockID, ...] = () + calls: tuple[ResolverMethodCall, ...] = pydantic.Field(default=(), max_length=0) + + +class ResolverInvokeInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["invoke"] + resolver_types: tuple[ResolverType, ...] = pydantic.Field(default=(), max_length=0) + block_ids: tuple[BlockID, ...] = pydantic.Field(default=(), max_length=0) + calls: tuple[ResolverMethodCall, ...] = pydantic.Field(min_length=1, max_length=20) + + +class ResolverMetaToolInput( + pydantic.RootModel[ + typing.Annotated[ + ResolverDescribeInput | ResolverInvokeInput, + pydantic.Field(discriminator="action"), + ] + ] +): + pass + + +def _resolver_input_model() -> type[pydantic.BaseModel]: + common = ResolverManager.get_common_method_contracts() + variants: list[type[pydantic.BaseModel]] = [] + contracts = list(common) + common_names = {item.name for item in common} + for resolver_type in ResolverManager.RESOLVER_CLS: + contracts.extend( + contract + for contract in ResolverManager.get_method_contracts(resolver_type) + if contract.name in common_names + ) + seen: set[tuple] = set() + for contract in contracts: + signature = ( + contract.name, + tuple( + (name, repr(field.annotation), repr(field.default), repr(field.metadata)) + for name, field in contract.input_model.model_fields.items() + ), + ) + if signature in seen: + continue + seen.add(signature) + variants.append( + pydantic.create_model( + f"{contract.name}_Call_{len(variants)}", + __config__=pydantic.ConfigDict(extra="forbid"), + block_id=(int, ...), + method=( + typing.cast(typing.Any, typing.Literal)[contract.name], + pydantic.Field(description=contract.description), + ), + arguments=( + contract.input_model, + ... + if any( + field.is_required() for field in contract.input_model.model_fields.values() + ) + else pydantic.Field(default_factory=contract.input_model), + ), + ) + ) + + variants.append( + pydantic.create_model( + "ExtraMethodCall", + __base__=ResolverMethodCall, + method=( + str, + pydantic.Field( + json_schema_extra={"not": {"enum": [contract.name for contract in common]}} + ), + ), + ) + ) + call_type = typing.cast(typing.Any, typing.Union)[tuple(variants)] + invoke = pydantic.create_model( + "BoundResolverInvokeInput", + __base__=ResolverInvokeInput, + calls=(tuple[call_type, ...], pydantic.Field(min_length=1, max_length=20)), + ) + provider_envelope = pydantic.create_model( + "ResolverEnvelope", + __base__=ResolverDescribeInput, + action=(typing.Literal["describe", "invoke"], ...), + calls=(tuple[call_type, ...], pydantic.Field(default=(), max_length=20)), + ) + method_contract = pydantic.RootModel[ + typing.Annotated[ResolverDescribeInput | invoke, pydantic.Field(discriminator="action")] + ] + + # Runtime validates the dispatch envelope, then the selected Resolver validates + # each call's arguments. One invalid method argument must not reject the batch. + # The richer method schema below guides the model without changing that boundary. + class BoundResolverInput(ResolverMetaToolInput): + @classmethod + def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: + method_schema = method_contract.model_json_schema(*args, **kwargs) + # Some providers infer parameter types only from top-level properties. + # Expose the wider envelope there while retaining the union's oneOf, + # discriminator and $defs so describe/invoke keep their distinct contracts. + provider_schema = provider_envelope.model_json_schema(*args, **kwargs) + method_schema.update(type="object", properties=provider_schema["properties"]) + method_schema.setdefault("$defs", {}).update(provider_schema.get("$defs", {})) + return method_schema + + return BoundResolverInput + + +@AgentManager.tool( + RESOLVER_TOOL, + input_model_factory=_resolver_input_model, + description="Describe or invoke public typed read methods on exact Block Resolvers.", +) +async def resolver(input: ResolverMetaToolInput) -> JSONValue: + request = input.root + if request.action == "describe": + found = await BlockService.get_many(request.block_ids) + resolver_ids = set(request.resolver_types) + resolver_ids.update(block.resolver for block in found) + if not request.block_ids and not request.resolver_types: + resolver_ids.update(ResolverManager.RESOLVER_CLS) + return typing.cast( + JSONValue, + { + "results": [ + { + "resolver": resolver_id, + "methods": [ + { + "name": contract.name, + "description": contract.description, + "input_schema": contract.input_schema, + } + for contract in ResolverManager.get_method_contracts(resolver_id) + ], + } + for resolver_id in sorted(resolver_ids) + if resolver_id in ResolverManager.RESOLVER_CLS + ], + "missing_blocks": sorted(set(request.block_ids) - {block.id for block in found}), + "missing_resolvers": sorted( + resolver_id + for resolver_id in resolver_ids + if resolver_id not in ResolverManager.RESOLVER_CLS + ), + }, + ) + + results: list[JSONValue] = [] + for index, call in enumerate(request.calls): + block = await BlockService.get(call.block_id) + if block is None: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "not_found", + } + ) + continue + contract = ResolverManager.get_method_contract(block.resolver, call.method) + if block.resolver not in ResolverManager.RESOLVER_CLS: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "resolver_unavailable", + "message": f"Resolver {block.resolver!r} is not registered.", + } + ) + continue + if contract is None: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "method_unavailable", + "message": "Method does not exist; use describe for available method contracts.", + "available_methods": [ + item.name for item in ResolverManager.get_method_contracts(block.resolver) + ], + } + ) + continue + try: + value = await ResolverManager.invoke_method( + block, + call.method, + call.arguments.model_dump() + if isinstance(call.arguments, pydantic.BaseModel) + else typing.cast(dict[str, typing.Any], call.arguments), + ) + projected = project_json(value) + except pydantic.ValidationError as error: + results.append( + typing.cast( + JSONValue, + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "invalid_arguments", + "fields": error.errors( + include_url=False, include_context=False, include_input=False + ), + "input_schema": contract.input_schema, + }, + ) + ) + except Exception as error: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": type(error).__name__, + "message": str(error), + } + ) + else: + emit_event( + "inkcre.entity.read", + { + "inkcre.entity.read.kind": "resolved", + "inkcre.entity.block_ids": (call.block_id,), + "inkcre.entity.read.count": 1, + }, + ) + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "result": projected, + } + ) + return typing.cast(JSONValue, {"results": results}) diff --git a/inkcre_core/business/info_base/resolver/video.py b/inkcre_core/business/info_base/resolver/video.py new file mode 100644 index 00000000..5a684c37 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/video.py @@ -0,0 +1,204 @@ +"""Exact video resolver using metadata-only PyAV inspection.""" + +import asyncio +from dataclasses import dataclass +from fractions import Fraction +from io import BytesIO +import typing + +import av +import pydantic + +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.schemas.ai import VideoContentPart +from inkcre_core.schemas.info_base.block import BlockForm +from inkcre_core.schemas.info_base.main import StarsGraphForm + +from .audio import _container_name, _duration_ms, _primary_stream +from .contracts import ( + ResolverContentError, + TextProjectionContext, + UnsupportedResolverCapability, +) +from .inspection import ( + ByteContentFacts, + detect_media_type, + format_lexical_facts, + require_bytes, +) +from .main import Resolver +from .materialization import materialize_text_child, try_materialize_model_text + + +VIDEO_RESOLVER_CONFIG_KEY = "core.resolver.video" +VIDEO_RESOLVER_CONFIG_SCHEMA = "core.resolver.video.config.v1" + + +class VideoResolverConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + text_model: int + transcript_model: int + + +DeploymentConfigManager.register_schema( + VIDEO_RESOLVER_CONFIG_SCHEMA, VideoResolverConfig, keys=(VIDEO_RESOLVER_CONFIG_KEY,) +) + + +@dataclass(frozen=True, slots=True) +class VideoSolvedContent(ByteContentFacts): + container: str | None + video_codec: str | None + duration_ms: int | None + width: int | None + height: int | None + frame_rate: float | None + subtitles: tuple[str, ...] + + +def _frame_rate(rate: Fraction | None) -> float | None: + return float(rate) if rate is not None else None + + +def _subtitles(container: typing.Any) -> tuple[str, ...]: + values: list[str] = [] + streams = tuple(container.streams.subtitles) + if not streams: + return () + for packet in container.demux(*streams): + try: + decoded = packet.decode() + except (av.FFmpegError, OSError, ValueError): + continue + for subtitle in decoded: + raw = getattr(subtitle, "dialogue", None) or getattr(subtitle, "text", None) + if isinstance(raw, bytes): + text = raw.decode("utf-8", errors="replace").strip() + elif isinstance(raw, str): + text = raw.strip() + else: + text = "" + if text and (not values or values[-1] != text): + values.append(text) + return tuple(values) + + +def _inspect_video(content: bytes) -> VideoSolvedContent: + try: + with av.open(BytesIO(content), mode="r") as container: + stream = _primary_stream(container.streams.video) + if stream is None: + raise ResolverContentError("core.video.v1", "no video stream") + codec = stream.codec_context + facts = VideoSolvedContent( + content=content, + byte_size=len(content), + detected_media_type=detect_media_type(content), + container=_container_name(container.format.name), + video_codec=codec.name, + duration_ms=_duration_ms(container, stream), + width=codec.width, + height=codec.height, + frame_rate=_frame_rate(stream.average_rate), + subtitles=_subtitles(container), + ) + except ResolverContentError: + raise + except (av.FFmpegError, OSError, ValueError) as error: + raise ResolverContentError("core.video.v1", "invalid video") from error + return facts + + +class VideoResolver( + Resolver[VideoSolvedContent, bytes], + rso_type="core.video.v1", +): + @classmethod + def create_graph(cls, url: str) -> StarsGraphForm: + return StarsGraphForm( + block=BlockForm(resolver=cls.__rsotype__, content=url, storage=-1) + ) + + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> VideoSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_video, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + if materialize_missing: + if solved.subtitles: + await materialize_text_child( + self.block_id, "subtitle", "\n\n".join(solved.subtitles) + ) + config = typing.cast( + VideoResolverConfig | None, + await DeploymentConfigService.get(VIDEO_RESOLVER_CONFIG_KEY), + ) + if config is not None: + media_type = solved.detected_media_type or ( + f"video/{solved.container}" if solved.container is not None else None + ) + if media_type is not None: + media = VideoContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=await self.get_transfer_url(), + ) + await try_materialize_model_text( + block_id=self.block_id, + role="transcript", + model=config.transcript_model, + instruction=( + "Transcribe only speech and authored spoken language in the video. " + "Return plain text with no summary or visual description." + ), + media=media, + ) + await try_materialize_model_text( + block_id=self.block_id, + role="text", + model=config.text_model, + instruction=( + "Transcribe only written text visibly present in the video. " + "Return plain text with no summary or scene description." + ), + media=media, + ) + return format_lexical_facts( + "video", + ( + ("media type", solved.detected_media_type), + ("container", solved.container), + ("codec", solved.video_codec), + ("duration ms", solved.duration_ms), + ("width", solved.width), + ("height", solved.height), + ("frame rate", solved.frame_rate), + ("subtitle cues", len(solved.subtitles) or None), + ), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "video" diff --git a/inkcre_core/business/info_base/resolver/zip.py b/inkcre_core/business/info_base/resolver/zip.py new file mode 100644 index 00000000..0a0a0f35 --- /dev/null +++ b/inkcre_core/business/info_base/resolver/zip.py @@ -0,0 +1,86 @@ +"""Exact ZIP resolver using central-directory metadata only.""" + +import asyncio +from dataclasses import dataclass +from io import BytesIO +import zipfile + +from .contracts import ( + ResolverContentError, + TextProjectionContext, + UnsupportedResolverCapability, +) +from .inspection import ByteContentFacts, format_lexical_facts, require_bytes +from .main import Resolver + + +@dataclass(frozen=True, slots=True) +class ZIPSolvedContent(ByteContentFacts): + member_count: int + total_compressed_bytes: int + total_uncompressed_bytes: int + compression_methods: tuple[int, ...] + encrypted_member_count: int + + +def _inspect_zip(content: bytes) -> ZIPSolvedContent: + try: + with zipfile.ZipFile(BytesIO(content)) as archive: + members = archive.infolist() + except (zipfile.BadZipFile, OSError, ValueError) as error: + raise ResolverContentError("core.zip.v1", "invalid ZIP archive") from error + + return ZIPSolvedContent( + content=content, + byte_size=len(content), + detected_media_type="application/zip", + member_count=len(members), + total_compressed_bytes=sum(member.compress_size for member in members), + total_uncompressed_bytes=sum(member.file_size for member in members), + compression_methods=tuple(sorted({member.compress_type for member in members})), + encrypted_member_count=sum(bool(member.flag_bits & 0x1) for member in members), + ) + + +class ZIPResolver( + Resolver[ZIPSolvedContent, bytes], + rso_type="core.zip.v1", +): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> ZIPSolvedContent: + del materialize_missing + content = require_bytes( + await self.get_raw_content(refresh=refresh), + self.__rsotype__, + ) + return await asyncio.to_thread(_inspect_zip, content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + if context == "default": + raise UnsupportedResolverCapability(self.__rsotype__, "text") + del materialize_missing + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_lexical_facts( + "ZIP", + ( + ("media type", solved.detected_media_type), + ("members", solved.member_count), + ("compressed bytes", solved.total_compressed_bytes), + ("uncompressed bytes", solved.total_uncompressed_bytes), + ("encrypted members", solved.encrypted_member_count), + ), + ) + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "ZIP" diff --git a/inkcre_core/business/info_base/services.py b/inkcre_core/business/info_base/services.py new file mode 100644 index 00000000..5facb4bc --- /dev/null +++ b/inkcre_core/business/info_base/services.py @@ -0,0 +1,183 @@ +"""Independent info-base use cases; composed writes use GraphUnitOfWork directly.""" + +import typing +from typing import Optional as Opt +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel, BlockID, ResolverType +from inkcre_core.schemas.info_base.relation import RelationModel, RelationID +from inkcre_core.schemas.info_base.storage import StorageID +from inkcre_core.utils.types_ import Undefined, _undefined +from inkcre_core.persistence.info_base.uow import graph_uow + + +class BlockService: + @staticmethod + async def get_many(block_ids: typing.Collection[BlockID]) -> tuple[BlockModel, ...]: + async with graph_uow() as uow: + result = await uow.blocks.get_many(block_ids) + return result + + @staticmethod + async def get_random_many(count: int) -> tuple[BlockModel, ...]: + async with graph_uow() as uow: + result = await uow.blocks.get_random_many(count) + return result + + @staticmethod + async def get_random() -> Opt[BlockModel]: + async with graph_uow() as uow: + result = await uow.blocks.get_random() + return result + + @staticmethod + async def get(block_id: BlockID) -> Opt[BlockModel]: + async with graph_uow() as uow: + result = await uow.blocks.get(block_id) + return result + + @staticmethod + async def edit_block( + block_id: BlockID, + content: Opt[str] = None, + resolver: Opt[ResolverType] = None, + storage: Opt[StorageID] | Undefined = _undefined, + ) -> BlockModel: + async with graph_uow() as uow: + result = await uow.blocks.edit_block(block_id, content, resolver, storage) + return result + + @staticmethod + async def delete(block_id: BlockID) -> bool: + async with graph_uow() as uow: + result = await uow.blocks.delete(block_id) + return result + + @staticmethod + async def get_recent( + num: int = 10, resolver: Opt[ResolverType] = None + ) -> tuple[BlockModel, ...]: + """获取最新的块 + + 按创建时间倒序排序。 + + :param num: 获取的块数量 + :param resolver: 限定解析器类型, None则不限定 + """ + async with graph_uow() as uow: + result = await uow.blocks.get_recent(num, resolver) + return result + + @staticmethod + async def create(form: BlockForm) -> BlockModel: + async with graph_uow() as uow: + result = await uow.blocks.create(form) + return result + + +class RelationService: + @staticmethod + async def get_text( + relation: RelationModel, + *, + refresh: bool = False, + ) -> str | None: + """Project one directed dynamic property through Block-local endpoint labels.""" + if not relation.content.strip(): + return None + async with graph_uow() as uow: + endpoints = { + block.id: block + for block in await uow.blocks.get_many((relation.from_, relation.to_)) + } + from_block = endpoints.get(relation.from_) + to_block = endpoints.get(relation.to_) + if from_block is None or to_block is None: + return None + + # Local imports avoid reversing Resolver -> RelationService ownership. + from inkcre_core.business.info_base.resolver import ResolverManager + + subject = await ResolverManager.get(from_block).get_label(refresh=refresh) + value = await ResolverManager.get(to_block).get_label(refresh=refresh) + if not subject.strip() or not value.strip(): + return None + return f"subject:\n{subject}\nproperty:\n{relation.content}\nvalue:\n{value}" + + @staticmethod + async def get_many( + relation_ids: typing.Collection[RelationID], + ) -> tuple[RelationModel, ...]: + async with graph_uow() as uow: + result = await uow.relations.get_many(relation_ids) + return result + + @staticmethod + async def get_by_id(relation_id: RelationID) -> RelationModel | None: + async with graph_uow() as uow: + result = await uow.relations.get_by_id(relation_id) + return result + + @staticmethod + async def get_endpoint_page( + block_ids: typing.Collection[BlockID], + *, + endpoint: typing.Literal["from", "to"], + contents: typing.Collection[str] = (), + cursor: RelationID | None = None, + limit: int = 21, + ) -> tuple[RelationModel, ...]: + async with graph_uow() as uow: + result = await uow.relations.get_endpoint_page( + block_ids, endpoint=endpoint, contents=contents, cursor=cursor, limit=limit + ) + return result + + @staticmethod + async def get( + block_id: BlockID, + include_in: bool = True, + include_out: bool = True, + content: Opt[str] = None, + ) -> tuple[RelationModel, ...]: + async with graph_uow() as uow: + result = await uow.relations.get(block_id, include_in, include_out, content) + return result + + @staticmethod + async def update( + relation_id: RelationID, + *, + from_: BlockID | Undefined = _undefined, + to_: BlockID | Undefined = _undefined, + content: str | Undefined = _undefined, + ) -> RelationModel: + async with graph_uow() as uow: + result = await uow.relations.update( + relation_id, from_=from_, to_=to_, content=content + ) + return result + + @staticmethod + async def delete(relation_id: RelationID) -> bool: + async with graph_uow() as uow: + result = await uow.relations.delete(relation_id) + return result + + @staticmethod + async def create(from_: BlockID, to_: BlockID, content: str) -> RelationModel: + async with graph_uow() as uow: + result = await uow.relations.create(from_, to_, content) + return result + + +async def get_entity_records( + block_ids: typing.Collection[BlockID], relation_ids: typing.Collection[RelationID] +) -> tuple[dict[BlockID, BlockModel], dict[RelationID, RelationModel]]: + """Read both entity kinds in one short scope before any content hydration.""" + async with graph_uow() as uow: + blocks = await uow.blocks.get_many(block_ids) + relations = await uow.relations.get_many(relation_ids) + # Persisted rows always have primary keys; the shared model also permits new rows. + return ( + {typing.cast(BlockID, block.id): block for block in blocks}, + {typing.cast(RelationID, row.id): row for row in relations}, + ) diff --git a/app/business/info_base/storage/AGENTS.md b/inkcre_core/business/info_base/storage/AGENTS.md similarity index 97% rename from app/business/info_base/storage/AGENTS.md rename to inkcre_core/business/info_base/storage/AGENTS.md index 8e26a194..41edc0ce 100644 --- a/app/business/info_base/storage/AGENTS.md +++ b/inkcre_core/business/info_base/storage/AGENTS.md @@ -1,6 +1,6 @@ # storage/ Local Guide -本文件只描述 `app/business/info_base/storage/` 的局部事实与风险边界。上层 contract 看 +本文件只描述 `inkcre_core/business/info_base/storage/` 的局部事实与风险边界。上层 contract 看 [info_base guide](../AGENTS.md)。 ## 何时阅读 diff --git a/inkcre_core/business/info_base/storage/__init__.py b/inkcre_core/business/info_base/storage/__init__.py new file mode 100644 index 00000000..2f318982 --- /dev/null +++ b/inkcre_core/business/info_base/storage/__init__.py @@ -0,0 +1,15 @@ +from .main import Storage, StorageManager, WritableStorage + +__all__ = [ + "Storage", + "StorageManager", + "WritableStorage", + "HTTPStorage", + "HTTPStorageConfig", + "StorageContentTooLargeError", + "PostgreSQLBinaryStorage", +] + +# Import storage implementations to ensure they're registered +from .http import HTTPStorage, HTTPStorageConfig, StorageContentTooLargeError +from .postgresql import PostgreSQLBinaryStorage diff --git a/inkcre_core/business/info_base/storage/http.py b/inkcre_core/business/info_base/storage/http.py new file mode 100644 index 00000000..61b35592 --- /dev/null +++ b/inkcre_core/business/info_base/storage/http.py @@ -0,0 +1,86 @@ +"""Mechanics-only HTTP storage returning bounded response bytes.""" + +from __future__ import annotations + +import aiohttp +import pydantic +import sqlmodel + +from inkcre_core.utils.base import AIOHTTP_CONNECTOR_GETTER + +from .main import Storage + + +__all__ = [ + "HTTPStorageConfig", + "HTTPStorage", + "StorageContentTooLargeError", +] + + +DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 +READ_CHUNK_BYTES = 64 * 1024 + + +class HTTPStorageConfig(sqlmodel.SQLModel): + """Configuration for bounded HTTP byte retrieval.""" + + timeout: int = pydantic.Field(default=30, gt=0) + follow_redirects: bool = True + max_response_bytes: int = pydantic.Field( + default=DEFAULT_MAX_RESPONSE_BYTES, + gt=0, + ) + + +class StorageContentTooLargeError(ValueError): + """The configured byte boundary was exceeded while hydrating content.""" + + +class HTTPStorage( + Storage[HTTPStorageConfig, bytes], + stg_type="http", + config_cls=HTTPStorageConfig, +): + """Fetch opaque bytes over HTTP(S) without assigning content semantics.""" + + @staticmethod + def _url(block_content: str) -> str: + url = block_content.strip() + if not url.lower().startswith(("http://", "https://")): + raise ValueError("HTTP storage pointer must use http:// or https://") + return url + + def get_transfer_url(self, block_content: str) -> str: + return self._url(block_content) + + async def get_raw_content(self, block_content: str) -> bytes: + url = self._url(block_content) + + timeout = aiohttp.ClientTimeout(total=self._config.timeout) + async with aiohttp.ClientSession( + connector=AIOHTTP_CONNECTOR_GETTER(), + timeout=timeout, + ) as session: + async with session.get( + url, + allow_redirects=self._config.follow_redirects, + ) as response: + response.raise_for_status() + content_length = response.content_length + if content_length is not None and content_length > self._config.max_response_bytes: + raise StorageContentTooLargeError( + f"HTTP response declares {content_length} bytes; " + f"limit is {self._config.max_response_bytes}" + ) + + chunks: list[bytes] = [] + received = 0 + async for chunk in response.content.iter_chunked(READ_CHUNK_BYTES): + received += len(chunk) + if received > self._config.max_response_bytes: + raise StorageContentTooLargeError( + f"HTTP response exceeded {self._config.max_response_bytes} bytes" + ) + chunks.append(chunk) + return b"".join(chunks) diff --git a/inkcre_core/business/info_base/storage/main.py b/inkcre_core/business/info_base/storage/main.py new file mode 100644 index 00000000..95f84227 --- /dev/null +++ b/inkcre_core/business/info_base/storage/main.py @@ -0,0 +1,186 @@ +__all__ = [ + "StorageManager", + "Storage", +] + +import abc +import importlib +import sqlmodel +import typing +from inkcre_core.persistence.info_base.storage import StorageRepository +from inkcre_core.database_contract.profile import ( + BUILTIN_STORAGES, + BUILTIN_STORAGE_TYPES_BY_ID, +) +from inkcre_core.schemas.info_base.storage import ( + StorageID, + StorageTypeID, + StorageModel, +) + + +ConfigTV = typing.TypeVar("ConfigTV", bound=sqlmodel.SQLModel) +ContentTV = typing.TypeVar("ContentTV") + + +class _EmptyConfig(sqlmodel.SQLModel): + """Default empty config class for storages without configuration.""" + + +class StorageManager: + _STORAGE_CLASSES: dict[StorageTypeID, type["Storage"]] = {} + """Storage type registry: type ID -> Storage class""" + + @classmethod + def register_storage(cls, storage_cls: type["Storage"]): + """Register a storage class in memory without external side effects.""" + cls._STORAGE_CLASSES[storage_cls.__stgtype__] = storage_cls + + @classmethod + async def get_storage_async(cls, storage_id: StorageID) -> "Storage": + from inkcre_core.persistence.info_base.uow import graph_uow + + async with graph_uow() as uow: + record = await uow.storage.get(storage_id) + return cls.from_record(record) + + @classmethod + def from_record(cls, record: StorageModel) -> "Storage": + storage_class = cls._STORAGE_CLASSES.get(record.type) + if storage_class is None: + module_path, class_name = record.type.rsplit(".", 1) + storage_class = getattr(importlib.import_module(module_path), class_name) + return storage_class(record) + + @classmethod + async def setup_builtin_storages_async(cls) -> None: + from inkcre_core.persistence.info_base.uow import graph_uow + + records = [] + for storage_cls in cls._STORAGE_CLASSES.values(): + builtin = BUILTIN_STORAGE_TYPES_BY_ID.get(storage_cls.__stgtype__) + records.append( + { + "id": storage_cls.__stgtype__, + "description": builtin.description + if builtin + else storage_cls.__doc__ or "No description.", + "config_schema": builtin.config_schema + if builtin + else storage_cls.__configschema__, + "writable": issubclass(storage_cls, WritableStorage), + } + ) + async with graph_uow() as uow: + await uow.storage.sync_types(records) + await uow.storage.sync_builtins( + [ + {"id": row.id, "type": row.type, "nickname": row.nickname, "config": row.config} + for row in BUILTIN_STORAGES + ] + ) + + +class Storage(abc.ABC, typing.Generic[ConfigTV, ContentTV]): + """Storage base. + Storage retrieves the raw ("real") content from block record. + + Generic parameters: + - ConfigTV: Configuration type variable + - ContentTV: Content type variable + """ + + __configschema__: dict + """Storage configuration JSON schema""" + __configcls__: type[ConfigTV] + __stgtype__: StorageTypeID + """Storage type identifier""" + + def __init_subclass__( + cls, + stg_type: StorageTypeID | None = None, + config_cls: type[ConfigTV] = _EmptyConfig, + **kwargs, + ) -> None: + """ + :param stg_type: Unique storage type string + :param config_cls: Configuration class for the storage + """ + # ConfigTV is bound by the concrete storage subclass. + if stg_type is not None: + cls.__configcls__ = config_cls # pyrefly: ignore[no-access] + cls.__configschema__ = config_cls.model_json_schema() + cls.__stgtype__ = stg_type + StorageManager.register_storage(cls) + return super().__init_subclass__(**kwargs) + + def __init__(self, storage_record: StorageModel): + if storage_record.id is None: + raise ValueError("Storage must be persisted before use") + self._id = storage_record.id + self._config = self.__configcls__.model_validate(storage_record.config) + + self.__post_init__() + + @property + def storage_id(self) -> StorageID: + """Return the persisted Storage reference represented by this instance.""" + return self._id + + def __post_init__(self): + """Post-initialization hook for subclasses.""" + pass + + async def get_raw_content(self, block_content: str) -> ContentTV: + """Get the raw content of the block.""" + raise NotImplementedError( + f"{self.__class__.__name__}.get_raw_content() must be implemented by subclasses." + ) + + def get_transfer_url(self, block_content: str) -> str | None: + """Return an optional external byte-transfer hint for AI/provider use. + + The URL is neither content authority nor a promise that every external + provider can fetch it. Callers must retain hydrated content as the primary + value and treat this result as an optimization hint. + """ + del block_content + return None + + +class WritableStorage(Storage[ConfigTV, ContentTV], abc.ABC): + """Storage capability for raw content owned by the current deployment.""" + + async def get_raw_content(self, block_content: str) -> ContentTV: + from inkcre_core.persistence.info_base.uow import graph_uow + + async with graph_uow() as uow: + return await self.read_content(block_content, uow.storage) + + async def create_content(self, content: ContentTV, storage: StorageRepository) -> str: + return self.serialize_pointer(await self.write_content(content, storage)) + + @abc.abstractmethod + async def read_content( + self, block_content: str, storage: StorageRepository + ) -> ContentTV: ... + + @abc.abstractmethod + async def write_content( + self, content: ContentTV, storage: StorageRepository + ) -> typing.Any: ... + + @abc.abstractmethod + async def update_content( + self, block_content: str, content: ContentTV, storage: StorageRepository + ) -> bool: ... + + @abc.abstractmethod + async def delete_content( + self, block_content: str, storage: StorageRepository + ) -> bool: ... + + @abc.abstractmethod + def serialize_pointer(self, pointer: typing.Any) -> str: + """Encode a storage-owned key as one opaque block pointer string.""" + ... diff --git a/inkcre_core/business/info_base/storage/postgresql.py b/inkcre_core/business/info_base/storage/postgresql.py new file mode 100644 index 00000000..0c11bb00 --- /dev/null +++ b/inkcre_core/business/info_base/storage/postgresql.py @@ -0,0 +1,59 @@ +"""Writable PostgreSQL binary storage.""" + +import uuid + +import pydantic +import sqlmodel + +from .main import WritableStorage +from inkcre_core.persistence.info_base.storage import StorageRepository + + +class PostgreSQLBinaryStorageConfig(sqlmodel.SQLModel): + pass + + +class PostgreSQLBlobPointer(pydantic.BaseModel): + """Minimum pointer fields accepted from storage-backed block content.""" + + model_config = pydantic.ConfigDict(extra="ignore") + + blob_id: uuid.UUID + + +class StorageBlobNotFoundError(LookupError): + pass + + +class PostgreSQLBinaryStorage( + WritableStorage[PostgreSQLBinaryStorageConfig, bytes], + stg_type="postgresql_binary", + config_cls=PostgreSQLBinaryStorageConfig, +): + """Store raw bytes in the protocol database and return an opaque UUID pointer.""" + + def serialize_pointer(self, pointer: object) -> str: + blob_id = pydantic.TypeAdapter(uuid.UUID).validate_python(pointer) + return PostgreSQLBlobPointer(blob_id=blob_id).model_dump_json() + + async def read_content( + self, block_content: str, storage: StorageRepository, *, lock: bool = False + ) -> bytes: + pointer = PostgreSQLBlobPointer.model_validate_json(block_content) + content = await storage.read_blob(pointer.blob_id, lock=lock) + if content is None: + raise StorageBlobNotFoundError(f"Storage blob {pointer.blob_id} not found") + return content + + async def write_content(self, content: bytes, storage: StorageRepository) -> uuid.UUID: + return await storage.create_blob(content) + + async def update_content( + self, block_content: str, content: bytes, storage: StorageRepository + ) -> bool: + pointer = PostgreSQLBlobPointer.model_validate_json(block_content) + return await storage.update_blob(pointer.blob_id, content) + + async def delete_content(self, block_content: str, storage: StorageRepository) -> bool: + pointer = PostgreSQLBlobPointer.model_validate_json(block_content) + return await storage.delete_blob(pointer.blob_id) diff --git a/inkcre_core/business/info_base/tools.py b/inkcre_core/business/info_base/tools.py new file mode 100644 index 00000000..08fe5d14 --- /dev/null +++ b/inkcre_core/business/info_base/tools.py @@ -0,0 +1,152 @@ +"""Agent Tool controllers for info-base retrieval and entity reads.""" + +import typing + +import pydantic + +from inkcre_core.business.agent import AgentManager +from inkcre_core.business.agent.projection import project_json +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.lexical_retrieval import LexicalRetrievalResult +from inkcre_core.libs.obsrv.telemetry import emit_event + +from .main import InfoBaseManager + + +RETRIEVE_TOOL = "retrieve" +GET_ENTITIES_TOOL = "get_entities" +RetrievalMode: typing.TypeAlias = typing.Literal["lexical", "semantic", "hybrid"] + + +class RetrieveInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: str + mode: RetrievalMode = "hybrid" + limit: int = pydantic.Field(default=20, ge=1, le=20) + + @pydantic.field_validator("query") + @classmethod + def non_empty_query(cls, value: str) -> str: + if not value.strip(): + raise ValueError("query must not be empty") + return value + + +class EntityReference(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["block", "relation"] + id: int + + +class GetEntitiesInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entities: tuple[EntityReference, ...] = pydantic.Field(default=(), max_length=20) + random_count: int = pydantic.Field(default=1, ge=1, le=20) + + @pydantic.model_validator(mode="after") + def validate_selection(self) -> typing.Self: + if self.entities and self.random_count != 1: + raise ValueError("random_count only applies when entities is empty") + return self + + +@AgentManager.tool( + RETRIEVE_TOOL, + description="Retrieve lexical, semantic, or separate hybrid results for one query.", +) +async def retrieve(input: RetrieveInput) -> JSONValue: + branches = await InfoBaseManager.retrieve(input.query, input.mode, input.limit) + payload: dict[str, JSONValue] = {} + for name, branch in branches.items(): + if branch.error is not None: + payload[name] = { + "error": type(branch.error).__name__, + "message": str(branch.error), + } + continue + result = branch.result + assert result is not None + if isinstance(result, LexicalRetrievalResult): + block_ids = tuple( + match.block.id for match in result.matches if match.block.id is not None + ) + relation_ids: tuple[int, ...] = () + else: + block_ids = tuple( + match.entity.id + for match in result.matches + if match.type == "block" and match.entity.id is not None + ) + relation_ids = tuple( + match.entity.id + for match in result.matches + if match.type == "relation" and match.entity.id is not None + ) + emit_event( + "inkcre.retrieval.candidates", + { + "inkcre.retrieval.method": "lexical" if name == "lexical" else "semantic", + "inkcre.entity.block_ids": block_ids, + "inkcre.entity.relation_ids": relation_ids, + "inkcre.retrieval.candidate_count": len(result.matches), + }, + ) + if name == "lexical": + payload[name] = { + "matches": [ + { + "entity": {"entity_type": "block", "entity_id": match.block.id}, + **match.model_dump(mode="json", exclude={"block"}), + } + for match in typing.cast(typing.Any, result).matches + ] + } + else: + payload[name] = { + **result.model_dump(mode="json", exclude={"matches"}), + "matches": [ + { + "entity": {"entity_type": match.type, "entity_id": match.entity.id}, + "score": match.score, + } + for match in typing.cast(typing.Any, result).matches + ], + } + return payload + + +@AgentManager.tool( + GET_ENTITIES_TOOL, + description=( + "Read persisted Blocks or Relations without resolving content. " + "Null may indicate an incorrect entity type." + ), +) +async def get_entities(input: GetEntitiesInput) -> JSONValue: + result = await InfoBaseManager.get_entities( + tuple((reference.type, reference.id) for reference in input.entities), + random_count=input.random_count, + ) + emit_event( + "inkcre.entity.read", + { + "inkcre.entity.read.kind": "persisted", + "inkcre.entity.block_ids": tuple( + entity.id + for entity in result + if isinstance(entity, BlockModel) and entity.id is not None + ), + "inkcre.entity.relation_ids": tuple( + entity.id + for entity in result + if isinstance(entity, RelationModel) and entity.id is not None + ), + "inkcre.entity.read.count": sum(entity is not None for entity in result), + }, + ) + return project_json(result) diff --git a/inkcre_core/business/job.py b/inkcre_core/business/job.py new file mode 100644 index 00000000..2459a0ce --- /dev/null +++ b/inkcre_core/business/job.py @@ -0,0 +1,422 @@ +"""Deep runtime for typed durable background Jobs.""" + +import abc +import asyncio +import dataclasses +import typing + +import jsonschema # pyrefly: ignore[untyped-import] +import pydantic + +from inkcre_core.database_contract.profile import BUILTIN_JOB_TYPES_BY_ID +from inkcre_core.persistence.job.uow import JobUnitOfWork, job_uow +from inkcre_core.context import current_core +from inkcre_core.libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID +from inkcre_core.schemas.job import JobID, JobModel, JobStatus, JobTypeID, JobTypeModel +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.libs.obsrv.telemetry import emit_event, operation +from inkcre_core.libs.obsrv.propagation import submission_context, submission_links +from opentelemetry.context import Context + + +LOGGER = get_logger().getChild(__name__) +ParametersTV = typing.TypeVar("ParametersTV", bound=pydantic.BaseModel) + + +class UnknownJobTypeError(ValueError): + """A submitted exact Job type is absent from the deployment catalog.""" + + +@dataclasses.dataclass +class _Execution: + task: asyncio.Task + cancellation_requested: bool = False + closing: bool = False + + def cancel(self) -> None: + # Do not interrupt cleanup already started by timeout or an earlier abort. + if not self.cancellation_requested and not self.closing: + self.cancellation_requested = True + if not self.task.cancelling(): + self.task.cancel() + + +class JobHandler(abc.ABC, typing.Generic[ParametersTV]): + """One exact executable Job contract registered by its owning module.""" + + type: typing.ClassVar[JobTypeID] + description: typing.ClassVar[str] + parameters_model: typing.ClassVar[type[pydantic.BaseModel]] + default_timeout_seconds: typing.ClassVar[int] + + def __init_subclass__( + cls, + *, + job_type: JobTypeID, + description: str, + parameters_model: type[ParametersTV], + default_timeout_seconds: int, + **kwargs, + ) -> None: + if default_timeout_seconds <= 0: + raise ValueError("default_timeout_seconds must be positive") + cls.type = job_type + cls.description = description + cls.parameters_model = parameters_model + cls.default_timeout_seconds = default_timeout_seconds + JobManager.register_handler(cls) + super().__init_subclass__(**kwargs) + + @classmethod + def validate_parameters(cls, parameters: dict[str, typing.Any]) -> ParametersTV: + return typing.cast(ParametersTV, cls.parameters_model.model_validate(parameters)) + + @classmethod + async def normalize_parameters( + cls, parameters: dict[str, typing.Any], uow: JobUnitOfWork + ) -> dict[str, typing.Any]: + """Validate a new Job/Cron input; owners may resolve nested catalog contracts. + + Execution restores the persisted model through validate_parameters instead; + it does not repeat submission-only normalization. + """ + del uow + return cls.validate_parameters(parameters).model_dump(mode="json") + + @classmethod + @abc.abstractmethod + async def can_handle(cls, parameters: ParametersTV) -> bool: + """Return whether this runtime can execute these parameters now.""" + ... + + @classmethod + @abc.abstractmethod + async def handle(cls, job: JobModel, parameters: ParametersTV) -> None: + """Execute one already claimed Job.""" + ... + + +class JobManager: + """Own Job Handler registration, typed creation, claim and terminal closure.""" + + _handlers: dict[JobTypeID, type[JobHandler]] = {} + + @classmethod + def register_handler(cls, handler: type[JobHandler]) -> None: + existing = cls._handlers.get(handler.type) + if existing is handler: + return + if existing is not None: + raise ValueError( + f"Job type {handler.type!r} is already handled by " + f"{existing.__module__}.{existing.__qualname__}" + ) + cls._handlers[handler.type] = handler + + @classmethod + async def sync_job_types(cls) -> None: + rows = [] + for handler in cls._handlers.values(): + builtin = BUILTIN_JOB_TYPES_BY_ID.get(handler.type) + rows.append( + dict( + id=handler.type, + description=builtin.description if builtin is not None else handler.description, + parameters_schema=( + builtin.parameters_schema + if builtin is not None + else handler.parameters_model.model_json_schema() + ), + default_timeout_seconds=( + builtin.default_timeout_seconds + if builtin is not None + else handler.default_timeout_seconds + ), + ) + ) + async with job_uow() as uow: + await uow.jobs.sync_types(rows) + + @classmethod + async def normalize_parameters( + cls, + job_type: JobTypeID, + parameters: dict[str, typing.Any], + uow: JobUnitOfWork, + ) -> dict[str, typing.Any]: + handler = cls._handlers.get(job_type) + if handler is not None: + return await handler.normalize_parameters(parameters, uow) + + persisted_type = await uow.jobs.get_type(job_type) + if persisted_type is None: + raise UnknownJobTypeError(f"Unknown Job type: {job_type}") + jsonschema.Draft202012Validator(persisted_type.parameters_schema).validate(parameters) + return parameters + + @classmethod + async def create( + cls, + job_type: JobTypeID, + parameters: dict[str, typing.Any], + timeout_seconds: int | None = None, + ) -> JobModel: + """Validate and persist one independent pending Job.""" + async with job_uow() as uow: + job = await cls.create_in_uow(job_type, parameters, timeout_seconds, uow=uow) + emit_event("job.submitted", {"inkcre.job.id": job.id or 0}) + return job + + @classmethod + async def create_in_uow( + cls, + job_type: JobTypeID, + parameters: dict[str, typing.Any], + timeout_seconds: int | None = None, + *, + uow: JobUnitOfWork, + ) -> JobModel: + normalized = await cls.normalize_parameters(job_type, parameters, uow) + persisted_type = await uow.jobs.get_type(job_type) + if persisted_type is None: + raise UnknownJobTypeError(f"Unknown Job type: {job_type}") + effective_timeout = ( + persisted_type.default_timeout_seconds if timeout_seconds is None else timeout_seconds + ) + if effective_timeout <= 0: + raise ValueError("Job timeout_seconds must be positive") + + with operation("job.submit") as observation: + span = observation.span + carrier = submission_context() + job = JobModel( + type=job_type, + parameters=normalized, + timeout_seconds=effective_timeout, + submission_traceparent=carrier["submission_traceparent"], + submission_tracestate=carrier["submission_tracestate"], + ) + job = await uow.jobs.create(job) + span.set_attribute("inkcre.job.id", job.id or 0) + # The composing use case still owns commit; this span only proves the flush. + span.set_attribute("inkcre.job.write_phase", "flushed") + return job + + @classmethod + async def _prepare( + cls, + job: JobModel, + ) -> tuple[type[JobHandler], pydantic.BaseModel] | None: + handler = cls._handlers.get(job.type) + if handler is None: + return None + parameters = handler.validate_parameters(job.parameters) + return (handler, parameters) if await handler.can_handle(parameters) else None + + @classmethod + async def get(cls, job_id: JobID) -> JobModel | None: + async with job_uow() as uow: + return await uow.jobs.get(job_id) + + @classmethod + async def get_type(cls, type_: JobTypeID) -> JobTypeModel | None: + async with job_uow() as uow: + return await uow.jobs.get_type(type_) + + @classmethod + async def list_types( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[JobTypeModel], str | None]: + async with job_uow() as uow: + return await uow.jobs.list_types(limit=limit, cursor=cursor) + + @classmethod + async def list_jobs( + cls, + *, + limit: int = 20, + cursor: JobID | None = None, + type_: JobTypeID | None = None, + status: JobStatus | None = None, + ) -> tuple[list[JobModel], JobID | None]: + async with job_uow() as uow: + return await uow.jobs.list_jobs( + limit=limit, cursor=cursor, type_=type_, status=status + ) + + @classmethod + async def notify_worker(cls) -> None: + """Best-effort post-commit hint; scan failure cannot undo accepted work.""" + try: + await cls.check() + except Exception: + LOGGER.exception("Job discovery hint failed; periodic discovery remains active") + + @classmethod + async def abort(cls, job_id: JobID) -> JobModel | None: + """Close pending work or request the executing Peer to stop running work.""" + async with job_uow() as uow: + return await uow.jobs.abort(job_id) + + @classmethod + async def check_abort_requests(cls) -> None: + """Read this Peer's active abort requests in one batch.""" + if not current_core().active_jobs: + return + async with job_uow() as uow: + ids = await uow.jobs.abort_requests(tuple(current_core().active_jobs)) + for job_id in ids: + if job_id is None: + continue + execution = current_core().active_jobs.get(job_id) + if execution is not None: + execution.cancel() + + @classmethod + def start(cls) -> None: + current_core().accepting_jobs = True + + @classmethod + async def shutdown(cls) -> None: + """Stop admission and drain handlers before their Extension resources close.""" + current_core().accepting_jobs = False + scheduled = tuple(current_core().pending_jobs.values()) + for task in scheduled: + if not task.cancelling(): + task.cancel() + executions = tuple(current_core().active_jobs.values()) + for execution in executions: + execution.cancel() + await asyncio.gather( + *scheduled, *(item.task for item in executions), return_exceptions=True + ) + current_core().pending_jobs.clear() + + @classmethod + async def _claim(cls, job_id: JobID) -> JobModel | None: + if not current_core().accepting_jobs: + return None + async with job_uow() as uow: + return await uow.jobs.claim(job_id) + + @classmethod + async def _close(cls, job: JobModel, status: JobStatus) -> bool: + if not status.terminal: + raise ValueError("Job may close only to a terminal status") + execution = current_core().active_jobs.get(job.id) if job.id is not None else None + if execution is not None: + execution.closing = True + # Cleanup is independent of handler cancellation and cannot wait indefinitely. + async with asyncio.timeout(10): + async with job_uow() as uow: + closed = await uow.jobs.close(job, status) + if closed: + emit_event( + "job.closed", {"inkcre.job.id": job.id or 0, "inkcre.job.status": status.value} + ) + return closed + + @classmethod + async def run(cls, job_id: JobID) -> bool: + """Check local eligibility, atomically claim, then execute one Job.""" + if not current_core().accepting_jobs: + return False + candidate = await cls.get(job_id) + if candidate is None or candidate.status != JobStatus.PENDING: + return False + try: + prepared = await cls._prepare(candidate) + except pydantic.ValidationError as error: + # A broken persisted command must be visible as failed, not remain pending + # forever. Unknown/unavailable handlers still leave work for another Peer. + LOGGER.exception("Persisted Job parameters are invalid", extra={"job_id": job_id}) + claimed = await cls._claim(job_id) + if claimed is None: + return False + claimed.state = {**claimed.state, "error": str(error)} + await cls._close(claimed, JobStatus.FAILED) + return True + if prepared is None: + return False + + claimed = await cls._claim(job_id) + if claimed is None: + return False + handler, parameters = prepared + task = asyncio.current_task() + assert task is not None + current_core().active_jobs[job_id] = _Execution(task) + with operation( + "job.execute", + attributes={"inkcre.job.id": job_id}, + context=Context(), + links=submission_links(claimed.submission_traceparent, claimed.submission_tracestate), + ) as observation: + span = observation.span + emit_event("job.started", {"inkcre.job.id": job_id}) + try: + async with asyncio.timeout(claimed.timeout_seconds): + await handler.handle(claimed, parameters) + except asyncio.CancelledError: + LOGGER.info("Job execution aborted", extra={"job_id": job_id}) + observation.outcome = "cancelled" + span.set_attribute("inkcre.job.status", "aborted") + await cls._close(claimed, JobStatus.ABORTED) + except TimeoutError: + LOGGER.warning("Job execution timed out", extra={"job_id": job_id}) + observation.outcome = "error" + span.set_attribute("inkcre.job.status", "timed_out") + await cls._close(claimed, JobStatus.TIMED_OUT) + except Exception as error: + LOGGER.exception("Job execution failed", extra={"job_id": job_id}) + observation.outcome = "error" + span.set_attribute("error.type", type(error).__name__) + span.set_attribute("inkcre.job.status", "failed") + claimed.state = {**claimed.state, "error": str(error)} + await cls._close(claimed, JobStatus.FAILED) + else: + span.set_attribute("inkcre.job.status", "finished") + await cls._close(claimed, JobStatus.FINISHED) + finally: + current_core().active_jobs.pop(job_id, None) + return True + + @classmethod + async def expire_overdue(cls) -> int: + """Use database time to close abandoned overdue running Jobs.""" + async with job_uow() as uow: + return await uow.jobs.expire_overdue() + + @classmethod + async def check(cls) -> None: + """Schedule locally eligible pending Jobs and converge running timeouts.""" + if not current_core().accepting_jobs: + return + async with job_uow() as uow: + pending = await uow.jobs.pending() + + for job in pending: + if job.id is None or job.type not in cls._handlers: + continue + core = current_core() + if job.id in core.pending_jobs or job.id in core.active_jobs: + continue + task = asyncio.create_task(cls._run_traced(job.id), name=f"job.{job.id}") + core.pending_jobs[job.id] = task + task.add_done_callback( + lambda finished, job_id=job.id: core.pending_jobs.pop(job_id, None) + ) + await cls.expire_overdue() + + @classmethod + async def _run_traced(cls, job_id: JobID) -> bool: + token = TRACE_ID.set(f"job.{job_id}") + backend_token = ENABLE_LOG_BACKEND.set(True) + try: + return await cls.run(job_id) + except Exception: + LOGGER.exception("Scheduled Job failed", extra={"job_id": job_id}) + return False + finally: + TRACE_ID.reset(token) + ENABLE_LOG_BACKEND.reset(backend_token) diff --git a/inkcre_core/business/lexical_retrieval/__init__.py b/inkcre_core/business/lexical_retrieval/__init__.py new file mode 100644 index 00000000..56144cdf --- /dev/null +++ b/inkcre_core/business/lexical_retrieval/__init__.py @@ -0,0 +1,25 @@ +"""Exact Block-local lexical retrieval capability.""" + +from .main import ( + LEXICAL_RETRIEVAL_CAPABILITY, + LexicalRetrievalDelegationError, + LexicalRetrievalError, + LexicalRetrievalManager, +) +from .job import ( + LEXICAL_MAINTAIN_JOB_TYPE, + LEXICAL_REBUILD_JOB_TYPE, + LexicalMaintainJobHandler, + LexicalRebuildJobHandler, +) + +__all__ = [ + "LEXICAL_RETRIEVAL_CAPABILITY", + "LexicalRetrievalDelegationError", + "LexicalRetrievalError", + "LexicalRetrievalManager", + "LEXICAL_MAINTAIN_JOB_TYPE", + "LEXICAL_REBUILD_JOB_TYPE", + "LexicalMaintainJobHandler", + "LexicalRebuildJobHandler", +] diff --git a/inkcre_core/business/lexical_retrieval/job.py b/inkcre_core/business/lexical_retrieval/job.py new file mode 100644 index 00000000..f5747a85 --- /dev/null +++ b/inkcre_core/business/lexical_retrieval/job.py @@ -0,0 +1,56 @@ +"""Exact lexical projection maintenance commands hosted by Jobs.""" + +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.business.job import JobHandler +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceJobParameters + +from .main import LexicalRetrievalManager + + +LEXICAL_MAINTAIN_JOB_TYPE = "core.feature_retrieval.lexical.maintain.v1" +LEXICAL_REBUILD_JOB_TYPE = "core.feature_retrieval.lexical.rebuild.v1" + + +class LexicalMaintainJobHandler( + JobHandler[LexicalMaintenanceJobParameters], + job_type=LEXICAL_MAINTAIN_JOB_TYPE, + description="Maintain missing or stale Block lexical projection records.", + parameters_model=LexicalMaintenanceJobParameters, + default_timeout_seconds=900, +): + @classmethod + async def can_handle(cls, parameters: LexicalMaintenanceJobParameters) -> bool: + del parameters + return bool(ResolverManager.RESOLVER_CLS) + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: LexicalMaintenanceJobParameters, + ) -> None: + report = await LexicalRetrievalManager.maintain(parameters.options) + job.state = report.model_dump(mode="json") + + +class LexicalRebuildJobHandler( + JobHandler[LexicalMaintenanceJobParameters], + job_type=LEXICAL_REBUILD_JOB_TYPE, + description="Rebuild Block lexical records present before invocation.", + parameters_model=LexicalMaintenanceJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: LexicalMaintenanceJobParameters) -> bool: + del parameters + return bool(ResolverManager.RESOLVER_CLS) + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: LexicalMaintenanceJobParameters, + ) -> None: + report = await LexicalRetrievalManager.rebuild(parameters.options) + job.state = report.model_dump(mode="json") diff --git a/inkcre_core/business/lexical_retrieval/main.py b/inkcre_core/business/lexical_retrieval/main.py new file mode 100644 index 00000000..ce5b8ca8 --- /dev/null +++ b/inkcre_core/business/lexical_retrieval/main.py @@ -0,0 +1,261 @@ +"""Block-local lexical projection maintenance and exact ranked retrieval.""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime +import logging +import typing + +import pydantic + +from inkcre_core.business.info_base.resolver import ( + ResolverManager, + UnknownResolverError, + UnsupportedResolverCapability, +) +from inkcre_core.business.peer import PeerManager +from inkcre_core.persistence.lexical_retrieval.uow import lexical_uow +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.lexical_retrieval import ( + LexicalEvidence, + LexicalMaintenanceDiagnostic, + LexicalMaintenanceOptions, + LexicalMaintenanceReport, + LexicalRetrievalMatch, + LexicalRetrievalRequest, + LexicalRetrievalResult, +) +from inkcre_core.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef + + +logger = logging.getLogger(__name__) + +LEXICAL_RETRIEVAL_CAPABILITY = "core.feature_retrieval.lexical.v1" +_EXCERPT_LIMIT = 280 + + +class LexicalRetrievalError(RuntimeError): + pass + + +class LexicalRetrievalDelegationError(LexicalRetrievalError): + pass + + +class _ProjectionUnavailable(Exception): + pass + + +@dataclass(frozen=True) +class _Projection: + block: int + label: str + text: str | None + + +@dataclass +class _ReportBuilder: + diagnostic_limit: int + indexed: int = 0 + unavailable: int = 0 + failed: int = 0 + diagnostics: list[LexicalMaintenanceDiagnostic] | None = None + + def __post_init__(self) -> None: + self.diagnostics = [] + + def record( + self, + block: int, + outcome: typing.Literal["unavailable", "failed"], + reason: str, + ) -> None: + if outcome == "unavailable": + self.unavailable += 1 + else: + self.failed += 1 + diagnostics = typing.cast(list[LexicalMaintenanceDiagnostic], self.diagnostics) + if len(diagnostics) < self.diagnostic_limit: + diagnostics.append( + LexicalMaintenanceDiagnostic(block=block, outcome=outcome, reason=reason) + ) + + def build(self) -> LexicalMaintenanceReport: + return LexicalMaintenanceReport( + indexed=self.indexed, + unavailable=self.unavailable, + failed=self.failed, + diagnostics=tuple(self.diagnostics or ()), + ) + + +class LexicalRetrievalManager: + """Sole owner of lexical records, maintenance, ranking, and delegation.""" + + @classmethod + async def retrieve( + cls, + query: str, + limit: int = 20, + *, + route_to_peer: PeerRef | None = None, + ) -> LexicalRetrievalResult: + request = LexicalRetrievalRequest(query=query, limit=limit) + if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): + return await cls.retrieve_local(request.query, request.limit) + + payload = PeerProtocolRequest( + body=typing.cast(typing.Any, request.model_dump(mode="json")) + ) + result = await PeerManager.delegate( + LEXICAL_RETRIEVAL_CAPABILITY, + typing.cast(typing.Any, payload.model_dump(mode="json", exclude_unset=True)), + route_to_peer=route_to_peer, + ) + try: + response = PeerProtocolResponse.model_validate(result) + if response.status != 200 or "body" not in response.model_fields_set: + raise LexicalRetrievalDelegationError( + f"Lexical retrieval Peer returned HTTP {response.status}" + ) + return LexicalRetrievalResult.model_validate(response.body) + except pydantic.ValidationError as error: + raise LexicalRetrievalDelegationError( + "Lexical retrieval Peer returned an invalid response" + ) from error + + @classmethod + async def retrieve_local(cls, query: str, limit: int = 20) -> LexicalRetrievalResult: + request = LexicalRetrievalRequest(query=query, limit=limit) + normalized = request.query.strip() + async with lexical_uow() as repository: + rows = await repository.search(normalized, request.limit) + + return LexicalRetrievalResult( + matches=tuple( + LexicalRetrievalMatch( + block=block, + label=record.label, + excerpt=cls._excerpt(record.label, record.text, normalized), + evidence=typing.cast(LexicalEvidence, evidence_value), + rank=float(rank_value), + ) + for block, record, evidence_value, rank_value in rows + ) + ) + + @classmethod + async def maintain( + cls, + options: LexicalMaintenanceOptions | None = None, + ) -> LexicalMaintenanceReport: + return await cls._maintain( + options or LexicalMaintenanceOptions(), + rebuild_cutoff=None, + ) + + @classmethod + async def rebuild( + cls, + options: LexicalMaintenanceOptions | None = None, + ) -> LexicalMaintenanceReport: + async with lexical_uow() as repository: + cutoff = await repository.database_now() + return await cls._maintain( + options or LexicalMaintenanceOptions(), + rebuild_cutoff=cutoff, + ) + + @classmethod + async def _maintain( + cls, + options: LexicalMaintenanceOptions, + *, + rebuild_cutoff: datetime.datetime | None, + ) -> LexicalMaintenanceReport: + report = _ReportBuilder(options.diagnostic_limit) + cursor = 0 + processed = 0 + while processed < options.max_records: + async with lexical_uow() as repository: + blocks, next_cursor, exhausted = await repository.candidate_page( + cursor, + min(options.scan_page_size, options.max_records - processed), + rebuild_cutoff, + ) + cursor = next_cursor + projections: list[_Projection] = [] + for block in blocks: + block_id = typing.cast(int, block.id) + processed += 1 + try: + projections.append(await cls._project(block)) + except _ProjectionUnavailable as error: + report.record(block_id, "unavailable", str(error)) + except Exception as error: + logger.exception("Lexical projection failed", extra={"block": block_id}) + report.record(block_id, "failed", type(error).__name__) + if projections: + try: + async with lexical_uow() as repository: + await repository.upsert( + [ + { + "block": projection.block, + "label": projection.label, + "text": projection.text, + } + for projection in projections + ] + ) + except Exception as error: + logger.exception( + "Lexical projection batch upsert failed", + extra={"batch_size": len(projections)}, + ) + for projection in projections: + report.record(projection.block, "failed", type(error).__name__) + else: + report.indexed += len(projections) + if exhausted: + break + return report.build() + + @classmethod + async def _project(cls, block: BlockModel) -> _Projection: + block_id = block.id + if block_id is None: + raise RuntimeError("Persisted lexical candidate is missing its ID") + try: + resolver = ResolverManager.get(block) + label = await resolver.get_label() + text = await resolver.get_text(context="lexical", materialize_missing=True) + except UnknownResolverError as error: + raise _ProjectionUnavailable("unknown_resolver") from error + except UnsupportedResolverCapability as error: + raise _ProjectionUnavailable("unsupported_lexical_text") from error + normalized_label = label.strip() + normalized_text = text.strip() if text is not None and text.strip() else None + if not normalized_label: + raise _ProjectionUnavailable("empty_label") + return _Projection(block_id, normalized_label, normalized_text) + + @staticmethod + def _excerpt(label: str, text: str | None, query: str) -> str: + source = text or label + folded_source = source.casefold() + needle = query.casefold() + position = folded_source.find(needle) + if position < 0: + positions = [ + candidate + for term in query.split() + if (candidate := folded_source.find(term.casefold())) >= 0 + ] + position = min(positions, default=0) + start = max(0, position - _EXCERPT_LIMIT // 3) + end = min(len(source), start + _EXCERPT_LIMIT) + prefix = "…" if start else "" + suffix = "…" if end < len(source) else "" + return f"{prefix}{source[start:end]}{suffix}" diff --git a/inkcre_core/business/organization/__init__.py b/inkcre_core/business/organization/__init__.py new file mode 100644 index 00000000..2d7c161c --- /dev/null +++ b/inkcre_core/business/organization/__init__.py @@ -0,0 +1,92 @@ +"""Exact, extensible information-organization behaviors.""" + +from .bootstrap import register_core_organization_behaviors +from .contracts import ( + OrganizationAgentNotFoundError, + OrganizationBlockNotFoundError, + OrganizationBudgetExceededError, + OrganizationDelegationError, + OrganizationError, + OrganizationExecutionError, + OrganizationNotConfiguredError, +) +from .duplicate_assertion import ( + DUPLICATES_ASSERTION_RELATION, + DuplicateAssertionBehaviorResolver, +) +from .evidence_stance import ( + CHALLENGES_RELATION, + SUPPORTS_RELATION, + EvidenceStanceBehaviorResolver, +) +from .referent_anchoring import ( + HAS_MENTION_RELATION, + REFERS_TO_RELATION, + ExistingReferentAnchoringBehaviorResolver, +) +from .refinement import REFINES_RELATION, RefinementBehaviorResolver +from .rumination import ( + RUMINATION_CAPABILITY, + RUMINATION_CONFIG_KEY, + RUMINATION_CONFIG_SCHEMA, + RuminationBehaviorResolver, +) +from .supersession import ( + EDITED_RELATION, + SUPERSEDES_RELATION, + SupersessionBehaviorResolver, +) +from .synthesis import SYNTHESIS_RELATION, SynthesisBehaviorResolver +from .tools import ( + ANCHOR_EXISTING_REFERENT_TOOL, + CREATE_SYNTHESIS_TOOL, + DRAFT_GRAPH_TOOL, + GET_DRAFT_GRAPH_SCHEMA_TOOL, + RECORD_DUPLICATE_ASSERTION_TOOL, + RECORD_EVIDENCE_STANCE_TOOL, + RECORD_ORGANIZATION_CANDIDATE_TOOL, + RECORD_REFINEMENT_TOOL, + RECORD_SUPERSESSION_TOOL, + SUBMIT_GRAPH_TOOL, +) + + +__all__ = [ + "ANCHOR_EXISTING_REFERENT_TOOL", + "CHALLENGES_RELATION", + "CREATE_SYNTHESIS_TOOL", + "DRAFT_GRAPH_TOOL", + "DUPLICATES_ASSERTION_RELATION", + "DuplicateAssertionBehaviorResolver", + "EDITED_RELATION", + "EvidenceStanceBehaviorResolver", + "ExistingReferentAnchoringBehaviorResolver", + "GET_DRAFT_GRAPH_SCHEMA_TOOL", + "HAS_MENTION_RELATION", + "OrganizationAgentNotFoundError", + "OrganizationBlockNotFoundError", + "OrganizationBudgetExceededError", + "OrganizationDelegationError", + "OrganizationError", + "OrganizationExecutionError", + "OrganizationNotConfiguredError", + "RECORD_DUPLICATE_ASSERTION_TOOL", + "RECORD_EVIDENCE_STANCE_TOOL", + "RECORD_ORGANIZATION_CANDIDATE_TOOL", + "RECORD_REFINEMENT_TOOL", + "RECORD_SUPERSESSION_TOOL", + "REFERS_TO_RELATION", + "REFINES_RELATION", + "RUMINATION_CAPABILITY", + "RUMINATION_CONFIG_KEY", + "RUMINATION_CONFIG_SCHEMA", + "RuminationBehaviorResolver", + "SUBMIT_GRAPH_TOOL", + "SUPERSEDES_RELATION", + "SUPPORTS_RELATION", + "SYNTHESIS_RELATION", + "SupersessionBehaviorResolver", + "SynthesisBehaviorResolver", + "RefinementBehaviorResolver", + "register_core_organization_behaviors", +] diff --git a/inkcre_core/business/organization/_shared.py b/inkcre_core/business/organization/_shared.py new file mode 100644 index 00000000..6499e80a --- /dev/null +++ b/inkcre_core/business/organization/_shared.py @@ -0,0 +1,365 @@ +"""Small shared mechanics for exact Organization behaviors.""" + +from __future__ import annotations + +from contextlib import contextmanager +import json +import logging +import typing + +import pydantic + +from inkcre_core.business.agent import AgentManager, AgentNotFoundError, TurnTermination +from inkcre_core.business.deployment_config import DeploymentConfigService +from inkcre_core.business.info_base.resolver import ( + Resolver, + ResolverManager, + UnknownResolverError, + UnsupportedResolverCapability, +) +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.schemas.ai import TextContentPart, UserMessage +from inkcre_core.schemas.info_base.block import BlockForm, BlockID, BlockModel, ResolverType +from inkcre_core.schemas.info_base.relation import RelationID, RelationModel +from inkcre_core.schemas.organization_behavior import ( + CandidateWriteResult, + RelationWriteResult, +) + +from .contracts import ( + OrganizationAgentNotFoundError, + OrganizationBlockNotFoundError, + OrganizationBudgetExceededError, + OrganizationExecutionError, + OrganizationNotConfiguredError, +) + + +CANDIDATE_RELATION = "candidate for" +_CONTEXT_RELATION_LIMIT = 20 + + +def behavior_resolver_classes() -> tuple[type[Resolver], ...]: + classes: list[type[Resolver]] = [] + for resolver_cls in ResolverManager.RESOLVER_CLS.values(): + if ( + issubclass(resolver_cls, Resolver) + and isinstance(getattr(resolver_cls, "organization_description", None), str) + and callable(getattr(resolver_cls, "record_candidate", None)) + ): + classes.append(resolver_cls) + return tuple(sorted(classes, key=lambda resolver_cls: resolver_cls.__rsotype__)) + + +def get_behavior_resolver(resolver: ResolverType) -> type[Resolver] | None: + return next( + ( + resolver_cls + for resolver_cls in behavior_resolver_classes() + if resolver_cls.__rsotype__ == resolver + ), + None, + ) + + +async def get_or_create_descriptor( + behavior: type[Resolver], + uow: GraphUnitOfWork, +) -> BlockModel: + form = BlockForm(resolver=behavior.__rsotype__, content="") + existing = await ResolverManager.get(BlockModel.model_validate(form)).get_existing_async( + uow.blocks + ) + return existing if existing is not None else await uow.blocks.create(form) + + +async def record_candidate( + behavior: type[Resolver], + block_id: BlockID, +) -> CandidateWriteResult: + async with graph_uow() as uow: + if (await uow.blocks.get(block_id)) is None: + raise OrganizationBlockNotFoundError(f"Block {block_id} does not exist") + descriptor = await get_or_create_descriptor(behavior, uow) + descriptor_id = _block_id(descriptor) + if block_id == descriptor_id: + raise ValueError("An Organization behavior cannot be its own candidate") + relation, created = await fetchsert_relation( + block_id, + descriptor_id, + CANDIDATE_RELATION, + uow, + ) + return CandidateWriteResult( + descriptor_block_id=descriptor_id, + relation_id=_relation_id(relation), + created=created, + ) + + +async def candidate_seed_ids( + behavior: type[Resolver], + limit: int, +) -> tuple[BlockID, ...]: + if limit <= 0: + return () + async with graph_uow() as uow: + descriptor = await get_or_create_descriptor(behavior, uow) + descriptor_id = _block_id(descriptor) + return await uow.relations.random_incoming_sources( + descriptor_id, CANDIDATE_RELATION, limit + ) + + +async def recent_block_ids(limit: int) -> tuple[BlockID, ...]: + if limit <= 0: + return () + behavior_types = tuple( + resolver_cls.__rsotype__ for resolver_cls in behavior_resolver_classes() + ) + async with graph_uow() as uow: + return await uow.blocks.select_ids( + limit, exclude_resolvers=behavior_types, random_order=False + ) + + +async def random_block_ids(limit: int) -> tuple[BlockID, ...]: + if limit <= 0: + return () + behavior_types = tuple( + resolver_cls.__rsotype__ for resolver_cls in behavior_resolver_classes() + ) + async with graph_uow() as uow: + return await uow.blocks.select_ids( + limit, exclude_resolvers=behavior_types, random_order=True + ) + + +async def recent_relation_endpoint_ids( + limit: int, + *, + contents: typing.Collection[str] = (), +) -> tuple[BlockID, ...]: + if limit <= 0: + return () + async with graph_uow() as uow: + relations = await uow.relations.recent(limit, contents=contents) + return tuple( + dict.fromkeys( + endpoint for relation in relations for endpoint in (relation.from_, relation.to_) + ) + ) + + +def merge_seed_categories( + max_seeds: int, + *categories: typing.Collection[BlockID], +) -> tuple[BlockID, ...]: + """Reserve one position per non-empty category, then fill in priority order.""" + result: list[BlockID] = [] + normalized = [tuple(dict.fromkeys(category)) for category in categories] + for category in normalized: + if category and category[0] not in result: + result.append(category[0]) + if len(result) == max_seeds: + return tuple(result) + for category in normalized: + for block_id in category[1:]: + if block_id not in result: + result.append(block_id) + if len(result) == max_seeds: + return tuple(result) + return tuple(result) + + +async def configured_agent_available( + config_key: str, + config_type: type[pydantic.BaseModel], +) -> bool: + config = await DeploymentConfigService.get(config_key) + if config is None: + return False + if not isinstance(config, config_type): + raise TypeError(f"Organization config {config_key!r} returned the wrong model") + return await AgentManager.can_execute(typing.cast(typing.Any, config).agent, "text") + + +@contextmanager +def continue_after_seed_failure( + logger: logging.Logger, + behavior: ResolverType, + seed_block_id: BlockID, +) -> typing.Generator[None, None, None]: + """Recover only candidate-local failures in an automatic attempt.""" + try: + yield + except (OrganizationBlockNotFoundError, OrganizationBudgetExceededError) as error: + logger.warning( + "organization.seed.considered", + extra={ + "behavior": behavior, + "seed_block_ids": (seed_block_id,), + "outcome": "recoverable_failure", + "reason": ( + "seed_missing" + if isinstance(error, OrganizationBlockNotFoundError) + else "model_call_limit" + ), + }, + ) + + +async def run_configured_agent( + config_key: str, + config_type: type[pydantic.BaseModel], + message: UserMessage, +) -> None: + config = await DeploymentConfigService.get(config_key) + if config is None: + raise OrganizationNotConfiguredError( + f"Organization behavior {config_key!r} is not configured" + ) + if not isinstance(config, config_type): + raise TypeError(f"Organization config {config_key!r} returned the wrong model") + agent_id = typing.cast(typing.Any, config).agent + try: + thread = await AgentManager.run(agent_id, message) + except AgentNotFoundError as error: + raise OrganizationAgentNotFoundError( + f"Configured Organization Agent {agent_id} does not exist" + ) from error + turn = thread.current_turn + if turn is None: # pragma: no cover - AgentManager.run invariant + raise OrganizationExecutionError("Organization Agent did not start a Turn") + outcome = await turn + if outcome == TurnTermination.MAX_MODEL_CALLS: + raise OrganizationBudgetExceededError( + "Organization Agent exceeded its per-Turn model-call budget" + ) + + +async def build_seed_message( + request: str, + judgment_contract: tuple[str, ...], + seed_id: BlockID, +) -> UserMessage | None: + """Resolve one bounded seed neighborhood without holding a DB transaction.""" + async with graph_uow() as uow: + block = await uow.blocks.get(seed_id) + if block is None: + raise OrganizationBlockNotFoundError(f"Block {seed_id} does not exist") + relations = tuple( + sorted( + (await uow.relations.get(seed_id)), + key=lambda relation: relation.id or 0, + )[-_CONTEXT_RELATION_LIMIT:] + ) + neighbor_ids = { + relation.to_ if relation.from_ == seed_id else relation.from_ + for relation in relations + } + neighbors = { + neighbor.id: neighbor + for neighbor in (await uow.blocks.get_many(neighbor_ids)) + if neighbor.id is not None + } + + try: + resolver = ResolverManager.get(block) + text = await resolver.get_text(materialize_missing=False) + label = await resolver.get_label() + except (UnknownResolverError, UnsupportedResolverCapability): + return None + if text is None or not text.strip(): + return None + + relation_context: list[dict[str, typing.Any]] = [] + for relation in relations: + other_id = relation.to_ if relation.from_ == seed_id else relation.from_ + other = neighbors.get(other_id) + other_label = None + if other is not None: + try: + other_label = await ResolverManager.get(other).get_label() + except (UnknownResolverError, UnsupportedResolverCapability): + pass + relation_context.append( + { + "id": relation.id, + "direction": "outgoing" if relation.from_ == seed_id else "incoming", + "content": relation.content, + "other_block": { + "id": other_id, + "resolver": other.resolver if other is not None else None, + "label": other_label, + }, + } + ) + context = { + "request": request, + "judgment_contract": judgment_contract, + "seed_block": { + "id": seed_id, + "resolver": block.resolver, + "label": label, + "text": text, + }, + "direct_relations": relation_context, + "exploration": ( + "The seed and its direct relations are only a starting point. Use the declared " + "retrieval, Resolver, and graph tools when more evidence is needed. Persist only " + "through the exact behavior tool, or cautiously mark a different behavior candidate." + ), + } + return UserMessage( + content=( + TextContentPart( + text=json.dumps( + context, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ), + ) + ) + + +async def fetchsert_relation( + from_: BlockID, + to_: BlockID, + content: str, + uow: GraphUnitOfWork, +) -> tuple[RelationModel, bool]: + proposed = RelationModel(from_=from_, to_=to_, content=content) + relation = await uow.relations.fetchsert(proposed) + return relation, relation is proposed + + +def relation_result(relation: RelationModel, created: bool) -> RelationWriteResult: + return RelationWriteResult(relation_id=_relation_id(relation), created=created) + + +async def require_distinct_blocks( + left: BlockID, + right: BlockID, + uow: GraphUnitOfWork, +) -> None: + if left == right: + raise ValueError("Organization relation endpoints must be different") + found = {block.id for block in (await uow.blocks.get_many((left, right)))} + missing = tuple(block_id for block_id in (left, right) if block_id not in found) + if missing: + raise OrganizationBlockNotFoundError(f"Blocks do not exist: {missing!r}") + + +def _block_id(block: BlockModel) -> BlockID: + if block.id is None: # pragma: no cover - persisted Block invariant + raise RuntimeError("Persisted Block has no ID") + return block.id + + +def _relation_id(relation: RelationModel) -> RelationID: + if relation.id is None: # pragma: no cover - persisted Relation invariant + raise RuntimeError("Persisted Relation has no ID") + return relation.id diff --git a/inkcre_core/business/organization/bootstrap.py b/inkcre_core/business/organization/bootstrap.py new file mode 100644 index 00000000..d769a4ef --- /dev/null +++ b/inkcre_core/business/organization/bootstrap.py @@ -0,0 +1,24 @@ +"""Explicit loading of core Organization Behavior Resolvers.""" + +from inkcre_core.business.info_base.resolver import ResolverManager + + +def register_core_organization_behaviors() -> None: + from .duplicate_assertion import DuplicateAssertionBehaviorResolver + from .evidence_stance import EvidenceStanceBehaviorResolver + from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver + from .refinement import RefinementBehaviorResolver + from .rumination import RuminationBehaviorResolver + from .supersession import SupersessionBehaviorResolver + from .synthesis import SynthesisBehaviorResolver + + for resolver_class in ( + RuminationBehaviorResolver, + SupersessionBehaviorResolver, + RefinementBehaviorResolver, + EvidenceStanceBehaviorResolver, + SynthesisBehaviorResolver, + ExistingReferentAnchoringBehaviorResolver, + DuplicateAssertionBehaviorResolver, + ): + ResolverManager.register_resolver(resolver_class) diff --git a/inkcre_core/business/organization/contracts.py b/inkcre_core/business/organization/contracts.py new file mode 100644 index 00000000..a395a533 --- /dev/null +++ b/inkcre_core/business/organization/contracts.py @@ -0,0 +1,29 @@ +"""Stable failures at the Organization capability boundary.""" + + +class OrganizationError(RuntimeError): + """Base failure at the Organization capability boundary.""" + + +class OrganizationBlockNotFoundError(OrganizationError): + pass + + +class OrganizationNotConfiguredError(OrganizationError): + pass + + +class OrganizationAgentNotFoundError(OrganizationError): + pass + + +class OrganizationExecutionError(OrganizationError): + pass + + +class OrganizationBudgetExceededError(OrganizationExecutionError): + """One Agent Turn ended at its configured model-call limit.""" + + +class OrganizationDelegationError(OrganizationError): + pass diff --git a/inkcre_core/business/organization/duplicate_assertion.py b/inkcre_core/business/organization/duplicate_assertion.py new file mode 100644 index 00000000..599d1f37 --- /dev/null +++ b/inkcre_core/business/organization/duplicate_assertion.py @@ -0,0 +1,160 @@ +"""Provenance-aware duplicate assertion behavior.""" + +from __future__ import annotations + +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + RelationWriteResult, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + record_candidate, + relation_result, + require_distinct_blocks, + run_configured_agent, +) + + +LOGGER = get_logger().getChild(__name__) + +DUPLICATES_ASSERTION_RELATION = "duplicates assertion" +DUPLICATE_ASSERTION_BEHAVIOR = "core.organization.behavior.duplicate-assertion.v1" +DUPLICATE_ASSERTION_CONFIG_KEY = "core.organization.duplicate_assertion" +DUPLICATE_ASSERTION_CONFIG_SCHEMA = "core.organization.duplicate_assertion.config.v1" + +DeploymentConfigManager.register_schema( + DUPLICATE_ASSERTION_CONFIG_SCHEMA, + BehaviorAgentConfig, + keys=(DUPLICATE_ASSERTION_CONFIG_KEY,), +) + + +class DuplicateAssertionBehaviorResolver( + Resolver[str, str], + rso_type=DUPLICATE_ASSERTION_BEHAVIOR, +): + organization_description = ( + "Relate whole-Block assertions copied from the same provenance occurrence." + ) + judgment_contract = ( + "Both Blocks completely and independently address the compared assertion.", + "Referent, predicate, polarity, force, units, and material qualifiers match.", + "Applicable scope, time, version, environment, and attribution are compatible.", + "Both assertions ultimately derive from the same observable provenance occurrence.", + "Neither Block adds independent evidence, reasoning, or authoritative decision.", + "No material asymmetric information gain is hidden by the relation.", + "The relation prevents evidence multiplication or restores useful provenance paths.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: duplicate assertion" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def record_duplicate_assertion( + cls, + left_block_id: BlockID, + right_block_id: BlockID, + ) -> RelationWriteResult: + async with graph_uow() as uow: + await require_distinct_blocks(left_block_id, right_block_id, uow) + from_, to_ = sorted((left_block_id, right_block_id)) + relation, created = await fetchsert_relation( + from_, + to_, + DUPLICATES_ASSERTION_RELATION, + uow, + ) + return relation_result(relation, created) + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available( + DUPLICATE_ASSERTION_CONFIG_KEY, + BehaviorAgentConfig, + ) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + recent = await recent_block_ids(max_seeds) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, recent, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "recent_count": len(recent), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Record only whole-Block duplicate assertions " + "from the same provenance occurrence.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent( + DUPLICATE_ASSERTION_CONFIG_KEY, + BehaviorAgentConfig, + message, + ) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/evidence_stance.py b/inkcre_core/business/organization/evidence_stance.py new file mode 100644 index 00000000..03e5b555 --- /dev/null +++ b/inkcre_core/business/organization/evidence_stance.py @@ -0,0 +1,169 @@ +"""Provenance-preserving evidence stance relation behavior.""" + +from __future__ import annotations + +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + RelationWriteResult, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + recent_relation_endpoint_ids, + record_candidate, + relation_result, + require_distinct_blocks, + run_configured_agent, +) + + +LOGGER = get_logger().getChild(__name__) + +SUPPORTS_RELATION = "supports" +CHALLENGES_RELATION = "challenges" +EVIDENCE_STANCE_BEHAVIOR = "core.organization.behavior.evidence-stance.v1" +EVIDENCE_STANCE_CONFIG_KEY = "core.organization.evidence_stance" +EVIDENCE_STANCE_CONFIG_SCHEMA = "core.organization.evidence_stance.config.v1" + +DeploymentConfigManager.register_schema( + EVIDENCE_STANCE_CONFIG_SCHEMA, + BehaviorAgentConfig, + keys=(EVIDENCE_STANCE_CONFIG_KEY,), +) + + +class EvidenceStanceBehaviorResolver( + Resolver[str, str], + rso_type=EVIDENCE_STANCE_BEHAVIOR, +): + organization_description = ( + "Relate attributable evidence that supports or challenges an assertion." + ) + judgment_contract = ( + "Evidence and assertion are complete addressable information units.", + "The source is evidence and the target is an evaluable assertion.", + "Their proposition and applicable scope are comparable.", + "The evidence contributes reasons beyond establishing source fidelity; " + "source authority alone is insufficient.", + "Evidence provenance and speaker attribution remain recoverable.", + "The stance is unambiguously support or challenge for the whole assertion.", + "Duplicate, refinement, replacement, or topical proximity alone is insufficient.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: evidence stance" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def record_evidence_stance( + cls, + evidence_block_id: BlockID, + assertion_block_id: BlockID, + stance: typing.Literal["supports", "challenges"], + ) -> RelationWriteResult: + async with graph_uow() as uow: + await require_distinct_blocks(evidence_block_id, assertion_block_id, uow) + opposite = CHALLENGES_RELATION if stance == SUPPORTS_RELATION else SUPPORTS_RELATION + existing_opposite = await uow.relations.get_outgoing_many( + (evidence_block_id,), to_ids=(assertion_block_id,), content=opposite + ) + if existing_opposite: + raise ValueError("The evidence/assertion pair already has the opposite stance") + relation, created = await fetchsert_relation( + evidence_block_id, + assertion_block_id, + stance, + uow, + ) + return relation_result(relation, created) + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available(EVIDENCE_STANCE_CONFIG_KEY, BehaviorAgentConfig) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + strong = merge_seed_categories( + max_seeds, + await recent_relation_endpoint_ids(max_seeds), + await recent_block_ids(max_seeds), + ) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, strong, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "strong_count": len(strong), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Determine only attributable evidence support or challenge relations.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent( + EVIDENCE_STANCE_CONFIG_KEY, + BehaviorAgentConfig, + message, + ) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/jobs.py b/inkcre_core/business/organization/jobs.py new file mode 100644 index 00000000..e57b02f0 --- /dev/null +++ b/inkcre_core/business/organization/jobs.py @@ -0,0 +1,197 @@ +"""Independent automatic Organization routes and explicit rumination Jobs.""" + +from inkcre_core.business.job import JobHandler +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.organization import RuminationRequest +from inkcre_core.schemas.organization_behavior import AutomaticOrganizationJobParameters + +from .duplicate_assertion import DuplicateAssertionBehaviorResolver +from .evidence_stance import EvidenceStanceBehaviorResolver +from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver +from .refinement import RefinementBehaviorResolver +from .rumination import RuminationBehaviorResolver +from .supersession import SupersessionBehaviorResolver +from .synthesis import SynthesisBehaviorResolver + + +RUMINATION_JOB_TYPE = "core.organization.rumination.automatic.v1" +EXPLICIT_RUMINATION_JOB_TYPE = "core.organization.rumination.explicit.v1" +SUPERSESSION_JOB_TYPE = "core.organization.supersession.automatic.v1" +REFINEMENT_JOB_TYPE = "core.organization.refinement.automatic.v1" +EVIDENCE_STANCE_JOB_TYPE = "core.organization.evidence-stance.automatic.v1" +SYNTHESIS_JOB_TYPE = "core.organization.synthesis.automatic.v1" +REFERENT_ANCHORING_JOB_TYPE = "core.organization.existing-referent-anchoring.automatic.v1" +DUPLICATE_ASSERTION_JOB_TYPE = "core.organization.duplicate-assertion.automatic.v1" + + +class ExplicitRuminationJobHandler( + JobHandler[RuminationRequest], + job_type=EXPLICIT_RUMINATION_JOB_TYPE, + description="Reconsider one explicitly selected Block through rumination.", + parameters_model=RuminationRequest, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: RuminationRequest) -> bool: + del parameters + return await RuminationBehaviorResolver.can_run_automatic() + + @classmethod + async def handle(cls, job: JobModel, parameters: RuminationRequest) -> None: + del job + # A claimed Job executes here; it must not delegate another execution. + await RuminationBehaviorResolver.ruminate_local(parameters.block) + + +class RuminationJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=RUMINATION_JOB_TYPE, + description="Automatically reconsider bounded information seeds through rumination.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await RuminationBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await RuminationBehaviorResolver.run_automatic(parameters.max_seeds) + + +class SupersessionJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=SUPERSESSION_JOB_TYPE, + description="Automatically judge bounded scoped-supersession candidates.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await SupersessionBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await SupersessionBehaviorResolver.run_automatic(parameters.max_seeds) + + +class RefinementJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=REFINEMENT_JOB_TYPE, + description="Automatically judge bounded non-dominating refinement candidates.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await RefinementBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await RefinementBehaviorResolver.run_automatic(parameters.max_seeds) + + +class EvidenceStanceJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=EVIDENCE_STANCE_JOB_TYPE, + description="Automatically judge bounded attributable evidence stances.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await EvidenceStanceBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await EvidenceStanceBehaviorResolver.run_automatic(parameters.max_seeds) + + +class SynthesisJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=SYNTHESIS_JOB_TYPE, + description="Automatically create bounded provenance-preserving syntheses.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await SynthesisBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await SynthesisBehaviorResolver.run_automatic(parameters.max_seeds) + + +class ExistingReferentAnchoringJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=REFERENT_ANCHORING_JOB_TYPE, + description="Automatically anchor bounded mentions to existing referents.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await ExistingReferentAnchoringBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await ExistingReferentAnchoringBehaviorResolver.run_automatic(parameters.max_seeds) + + +class DuplicateAssertionJobHandler( + JobHandler[AutomaticOrganizationJobParameters], + job_type=DUPLICATE_ASSERTION_JOB_TYPE, + description="Automatically judge bounded provenance-aware duplicate assertions.", + parameters_model=AutomaticOrganizationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: AutomaticOrganizationJobParameters) -> bool: + del parameters + return await DuplicateAssertionBehaviorResolver.can_run_automatic() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: AutomaticOrganizationJobParameters, + ) -> None: + del job + await DuplicateAssertionBehaviorResolver.run_automatic(parameters.max_seeds) diff --git a/inkcre_core/business/organization/referent_anchoring.py b/inkcre_core/business/organization/referent_anchoring.py new file mode 100644 index 00000000..a3c33a66 --- /dev/null +++ b/inkcre_core/business/organization/referent_anchoring.py @@ -0,0 +1,273 @@ +"""Existing-referent anchoring through occurrence-local selected text.""" + +from __future__ import annotations + +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.info_base.block import BlockForm, BlockID +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + ExistingReferentAnchorProposal, + ExistingReferentAnchorResult, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + record_candidate, + relation_result, + require_distinct_blocks, + run_configured_agent, +) + + +LOGGER = get_logger().getChild(__name__) + +HAS_MENTION_RELATION = "has mention" +REFERS_TO_RELATION = "refers to" +REFERENT_ANCHORING_BEHAVIOR = "core.organization.behavior.existing-referent-anchoring.v1" +REFERENT_ANCHORING_CONFIG_KEY = "core.organization.existing_referent_anchoring" +REFERENT_ANCHORING_CONFIG_SCHEMA = "core.organization.existing_referent_anchoring.config.v1" + +DeploymentConfigManager.register_schema( + REFERENT_ANCHORING_CONFIG_SCHEMA, + BehaviorAgentConfig, + keys=(REFERENT_ANCHORING_CONFIG_KEY,), +) + + +class ExistingReferentAnchoringBehaviorResolver( + Resolver[str, str], + rso_type=REFERENT_ANCHORING_BEHAVIOR, +): + organization_description = ( + "Anchor one source-grounded referring fragment to existing " + "identity-bearing information." + ) + judgment_contract = ( + "The source expression meaningfully denotes a reusable referent.", + "Selected text identifies this mention without unrelated material.", + "The referent Block already exists and was not created as a temporary label.", + "The target contains enough identity to distinguish plausible alternatives.", + "Denotation remains continuous across name, time, environment, and scope.", + "Plausible competing referents have been considered and excluded.", + "The anchor improves cross-source or cross-time use rather than graph density.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: existing referent anchoring" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def anchor_existing_referent( + cls, + source_block_id: BlockID, + selected_text: str, + referent_block_id: BlockID, + ) -> ExistingReferentAnchorResult: + proposal = ExistingReferentAnchorProposal( + source_block_id=source_block_id, + selected_text=selected_text, + referent_block_id=referent_block_id, + ) + async with graph_uow() as uow: + await require_distinct_blocks( + proposal.source_block_id, proposal.referent_block_id, uow + ) + source = await uow.blocks.get(proposal.source_block_id) + if source is None: # pragma: no cover - require_distinct_blocks invariant + raise ValueError("Source Block does not exist") + + existing = await cls._existing_path( + proposal.source_block_id, + proposal.selected_text, + proposal.referent_block_id, + uow, + ) + if existing is not None: + fragment_id, has_mention, refers_to = existing + return ExistingReferentAnchorResult( + fragment_block_id=fragment_id, + fragment_created=False, + has_mention=( + relation_result(has_mention, False) if has_mention is not None else None + ), + refers_to=relation_result(refers_to, False), + ) + + source_is_fragment = ( + source.resolver == "core.text.v1" and source.content == proposal.selected_text + ) + if source_is_fragment: + fragment_id = proposal.source_block_id + fragment_created = False + has_mention_result = None + else: + fragment = await uow.blocks.create( + BlockForm(resolver="core.text.v1", content=proposal.selected_text) + ) + if fragment.id is None: # pragma: no cover - persisted Block invariant + raise RuntimeError("Persisted referring fragment has no ID") + fragment_id = fragment.id + fragment_created = True + has_mention, created = await fetchsert_relation( + proposal.source_block_id, + fragment_id, + HAS_MENTION_RELATION, + uow, + ) + has_mention_result = relation_result(has_mention, created) + + refers_to, created = await fetchsert_relation( + fragment_id, + proposal.referent_block_id, + REFERS_TO_RELATION, + uow, + ) + return ExistingReferentAnchorResult( + fragment_block_id=fragment_id, + fragment_created=fragment_created, + has_mention=has_mention_result, + refers_to=relation_result(refers_to, created), + ) + + @staticmethod + async def _existing_path( + source_block_id: BlockID, + selected_text: str, + referent_block_id: BlockID, + uow: GraphUnitOfWork, + ) -> tuple[BlockID, RelationModel | None, RelationModel] | None: + source = await uow.blocks.get(source_block_id) + if ( + source is not None + and source.resolver == "core.text.v1" + and source.content == selected_text + ): + refers_to = next( + ( + relation + for relation in ( + await uow.relations.get( + source_block_id, include_in=False, content=REFERS_TO_RELATION + ) + ) + if relation.to_ == referent_block_id + ), + None, + ) + if refers_to is not None: + return source_block_id, None, refers_to + + for has_mention in await uow.relations.get( + source_block_id, include_in=False, content=HAS_MENTION_RELATION + ): + fragment = await uow.blocks.get(has_mention.to_) + if ( + fragment is None + or fragment.resolver != "core.text.v1" + or fragment.content != selected_text + ): + continue + refers_to = next( + ( + relation + for relation in ( + await uow.relations.get( + has_mention.to_, include_in=False, content=REFERS_TO_RELATION + ) + ) + if relation.to_ == referent_block_id + ), + None, + ) + if refers_to is not None: + return has_mention.to_, has_mention, refers_to + return None + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available( + REFERENT_ANCHORING_CONFIG_KEY, + BehaviorAgentConfig, + ) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + recent = await recent_block_ids(max_seeds) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, recent, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "recent_count": len(recent), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Anchor only resolved source mentions to existing identity-bearing Blocks.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent( + REFERENT_ANCHORING_CONFIG_KEY, + BehaviorAgentConfig, + message, + ) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/refinement.py b/inkcre_core/business/organization/refinement.py new file mode 100644 index 00000000..5f44c26b --- /dev/null +++ b/inkcre_core/business/organization/refinement.py @@ -0,0 +1,187 @@ +"""Non-dominating refinement relation and automatic semantic judgment.""" + +from __future__ import annotations + +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + RelationWriteResult, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + recent_relation_endpoint_ids, + record_candidate, + relation_result, + require_distinct_blocks, + run_configured_agent, +) +from .supersession import EDITED_RELATION + + +LOGGER = get_logger().getChild(__name__) + +REFINES_RELATION = "refines" +REFINEMENT_BEHAVIOR = "core.organization.behavior.refinement.v1" +REFINEMENT_CONFIG_KEY = "core.organization.refinement" +REFINEMENT_CONFIG_SCHEMA = "core.organization.refinement.config.v1" + +DeploymentConfigManager.register_schema( + REFINEMENT_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(REFINEMENT_CONFIG_KEY,) +) + + +class RefinementBehaviorResolver( + Resolver[str, str], + rso_type=REFINEMENT_BEHAVIOR, +): + organization_description = ( + "Relate useful compatible detail that refines but does not replace information." + ) + judgment_contract = ( + "Both endpoints are complete addressable information units.", + "They continue the same referent and evolvable subject.", + "The refinement scope equals or is visibly contained by the predecessor scope.", + "Their information roles and attribution remain compatible.", + "The refinement adds reusable detail, constraints, explanation, or precision.", + "The predecessor remains independently safe as a coarser description.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: refinement" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def record_refinement( + cls, + refinement_block_id: BlockID, + predecessor_block_id: BlockID, + ) -> RelationWriteResult: + async with graph_uow() as uow: + await require_distinct_blocks(refinement_block_id, predecessor_block_id, uow) + if await cls._has_directed_path( + predecessor_block_id, + refinement_block_id, + uow=uow, + ): + raise ValueError( + f"Cannot refine: an existing refines path runs from predecessor " + f"{predecessor_block_id} to refinement {refinement_block_id}" + ) + relation, created = await fetchsert_relation( + refinement_block_id, + predecessor_block_id, + REFINES_RELATION, + uow, + ) + return relation_result(relation, created) + + @classmethod + async def _has_directed_path( + cls, + start: BlockID, + target: BlockID, + *, + uow: GraphUnitOfWork, + ) -> bool: + frontier = {start} + visited = {start} + while frontier: + rows = tuple( + relation.to_ + for relation in await uow.relations.get_outgoing_many( + frontier, content=REFINES_RELATION + ) + ) + next_frontier = set(rows) - visited + if target in next_frontier: + return True + visited.update(next_frontier) + frontier = next_frontier + return False + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available(REFINEMENT_CONFIG_KEY, BehaviorAgentConfig) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + strong = merge_seed_categories( + max_seeds, + await recent_relation_endpoint_ids(max_seeds, contents=(EDITED_RELATION,)), + await recent_block_ids(max_seeds), + ) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, strong, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "strong_count": len(strong), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Determine only useful non-dominating refinement relations.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent(REFINEMENT_CONFIG_KEY, BehaviorAgentConfig, message) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/rumination.py b/inkcre_core/business/organization/rumination.py new file mode 100644 index 00000000..132ddece --- /dev/null +++ b/inkcre_core/business/organization/rumination.py @@ -0,0 +1,256 @@ +"""Explicit and automatic rumination carried by an exact Behavior Resolver.""" + +from __future__ import annotations + +import json +import typing + +import pydantic + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import ( + Resolver, + ResolverManager, + UnknownResolverError, + UnsupportedResolverCapability, +) +from inkcre_core.business.peer import PeerManager +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.ai import JSONValue, TextContentPart, UserMessage +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.organization import RuminationConfig, RuminationRequest +from inkcre_core.schemas.organization_behavior import CandidateWriteResult +from inkcre_core.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef + +from ._shared import ( + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + merge_seed_categories, + random_block_ids, + recent_block_ids, + record_candidate, + run_configured_agent, +) +from .contracts import ( + OrganizationBlockNotFoundError, + OrganizationDelegationError, + OrganizationExecutionError, +) + + +LOGGER = get_logger().getChild(__name__) + +RUMINATION_CONFIG_KEY = "core.organization.rumination" +RUMINATION_CONFIG_SCHEMA = "core.organization.rumination.config.v1" +RUMINATION_CAPABILITY = "core.organization.rumination.v1" +RUMINATION_BEHAVIOR = "core.organization.behavior.rumination.v1" + +DeploymentConfigManager.register_schema( + RUMINATION_CONFIG_SCHEMA, RuminationConfig, keys=(RUMINATION_CONFIG_KEY,) +) + + +class RuminationBehaviorResolver( + Resolver[str, str], + rso_type=RUMINATION_BEHAVIOR, +): + organization_description = ( + "Open-ended reconsideration of one information Block that may add a useful graph." + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: rumination" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available(RUMINATION_CONFIG_KEY, RuminationConfig) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + recent = await recent_block_ids(max_seeds) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, recent, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "recent_count": len(recent), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + await cls.ruminate_local(seed) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) + + @classmethod + async def ruminate( + cls, + block_id: BlockID, + *, + route_to_peer: PeerRef | None = None, + ) -> None: + """Execute locally unless the caller explicitly selects another Peer.""" + request = RuminationRequest(block=block_id) + if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): + await cls.ruminate_local(request.block) + return + + payload = PeerProtocolRequest( + body=typing.cast(JSONValue, request.model_dump(mode="json")) + ) + result = await PeerManager.delegate( + RUMINATION_CAPABILITY, + typing.cast(JSONValue, payload.model_dump(mode="json", exclude_unset=True)), + route_to_peer=route_to_peer, + ) + try: + response = PeerProtocolResponse.model_validate(result) + except pydantic.ValidationError as error: + raise OrganizationDelegationError( + "Rumination Peer returned an invalid response" + ) from error + if response.status != 204 or "body" in response.model_fields_set: + raise OrganizationDelegationError(f"Rumination Peer returned HTTP {response.status}") + + @classmethod + async def ruminate_local(cls, block_id: BlockID) -> None: + initial_message = await cls._build_initial_message(block_id) + if initial_message is None: + return + await run_configured_agent( + RUMINATION_CONFIG_KEY, + RuminationConfig, + initial_message, + ) + + @classmethod + async def _build_initial_message(cls, block_id: BlockID) -> UserMessage | None: + async with graph_uow() as uow: + block = await uow.blocks.get(block_id) + if block is None: + raise OrganizationBlockNotFoundError(f"Block {block_id} does not exist") + relations = tuple( + sorted( + (await uow.relations.get(block_id)), + key=lambda relation: relation.id or 0, + ) + ) + neighbor_ids = { + relation.to_ if relation.from_ == block_id else relation.from_ + for relation in relations + } + neighbors = { + neighbor.id: neighbor for neighbor in await uow.blocks.get_many(neighbor_ids) + } + + try: + focal_text = await ResolverManager.get(block).get_text() + except (UnknownResolverError, UnsupportedResolverCapability): + return None + except Exception as error: + raise OrganizationExecutionError( + "Rumination could not understand focal Block" + ) from error + if focal_text is None or not focal_text.strip(): + return None + + relation_context: list[dict[str, typing.Any]] = [] + for relation in relations: + neighbor_id, direction = cls._neighbor_and_direction(block_id, relation) + neighbor = neighbors.get(neighbor_id) + label: str | None = None + if neighbor is not None: + try: + label = await ResolverManager.get(neighbor).get_label() + except Exception: + LOGGER.debug( + "Could not project rumination neighbor label", + exc_info=True, + extra={"block": neighbor_id}, + ) + relation_context.append( + { + "id": relation.id, + "direction": direction, + "property": relation.content, + "other_block": { + "id": neighbor_id, + "resolver": neighbor.resolver if neighbor is not None else None, + "label": label, + }, + } + ) + + context = { + "request": "ruminate", + "focal_block": { + "id": block_id, + "resolver": block.resolver, + "text": focal_text, + }, + "direct_relations": relation_context, + "available_draft_resolvers": [ + { + "resolver": capability.resolver, + "description": capability.description, + } + for capability in ResolverManager.get_draft_capabilities() + ], + } + return UserMessage( + content=( + TextContentPart( + text=json.dumps( + context, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ), + ) + ) + + @staticmethod + def _neighbor_and_direction( + block_id: BlockID, + relation: RelationModel, + ) -> tuple[BlockID, typing.Literal["incoming", "outgoing", "self"]]: + if relation.from_ == block_id and relation.to_ == block_id: + return block_id, "self" + if relation.from_ == block_id: + return relation.to_, "outgoing" + return relation.from_, "incoming" diff --git a/inkcre_core/business/organization/supersession.py b/inkcre_core/business/organization/supersession.py new file mode 100644 index 00000000..753c7f3d --- /dev/null +++ b/inkcre_core/business/organization/supersession.py @@ -0,0 +1,305 @@ +"""Scoped supersession relation and automatic semantic judgment.""" + +from __future__ import annotations + +from collections import deque +from graphlib import CycleError, TopologicalSorter +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.graph_navigation_retrieval import GraphModel +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.info_base.relation import RelationID, RelationModel +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + RelationWriteResult, + SupersessionLineage, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + recent_relation_endpoint_ids, + record_candidate, + relation_result, + require_distinct_blocks, + run_configured_agent, +) + + +LOGGER = get_logger().getChild(__name__) + +SUPERSEDES_RELATION = "supersedes" +EDITED_RELATION = "edited" +SUPERSESSION_BEHAVIOR = "core.organization.behavior.supersession.v1" +SUPERSESSION_CONFIG_KEY = "core.organization.supersession" +SUPERSESSION_CONFIG_SCHEMA = "core.organization.supersession.config.v1" + +DeploymentConfigManager.register_schema( + SUPERSESSION_CONFIG_SCHEMA, + BehaviorAgentConfig, + keys=(SUPERSESSION_CONFIG_KEY,), +) + + +class SupersessionBehaviorResolver( + Resolver[str, str], + rso_type=SUPERSESSION_BEHAVIOR, +): + organization_description = ( + "Relate a semantic successor that fully replaces one predecessor in scope." + ) + judgment_contract = ( + "Both endpoints are complete addressable information units.", + "They continue the same referent and evolvable subject.", + "The successor covers the predecessor's complete applicable scope.", + "Semantic order, not collection time, identifies successor and predecessor.", + "The successor has authority for this subject and scope.", + "Continuing to use the predecessor as current would be wrong.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: supersession" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def record_supersession( + cls, + successor_block_id: BlockID, + predecessor_block_id: BlockID, + ) -> RelationWriteResult: + async with graph_uow() as uow: + await require_distinct_blocks(successor_block_id, predecessor_block_id, uow) + if await cls._has_directed_path( + predecessor_block_id, + successor_block_id, + uow=uow, + ): + raise ValueError( + f"Cannot supersede: an existing supersedes path runs from predecessor " + f"{predecessor_block_id} to successor {successor_block_id}" + ) + relation, created = await fetchsert_relation( + successor_block_id, + predecessor_block_id, + SUPERSEDES_RELATION, + uow, + ) + return relation_result(relation, created) + + @classmethod + async def _has_directed_path( + cls, + start: BlockID, + target: BlockID, + *, + uow: GraphUnitOfWork, + ) -> bool: + frontier = {start} + visited = {start} + while frontier: + rows = tuple( + relation.to_ + for relation in await uow.relations.get_outgoing_many( + frontier, content=SUPERSEDES_RELATION + ) + ) + next_frontier = set(rows) - visited + if target in next_frontier: + return True + visited.update(next_frontier) + frontier = next_frontier + return False + + async def read_lineage( + self, + focal_block_id: BlockID, + *, + max_explored_blocks: int = 1000, + max_explored_relations: int = 10000, + ) -> SupersessionLineage: + """Read supersedes history; incomplete or cyclic graphs have no current frontier.""" + if max_explored_blocks < 1 or max_explored_relations < 1: + raise ValueError("exploration bounds must be positive") + return await self._read_lineage( + focal_block_id, + max_explored_blocks=max_explored_blocks, + max_explored_relations=max_explored_relations, + ) + + async def _read_lineage( + self, + focal_block_id: BlockID, + *, + max_explored_blocks: int, + max_explored_relations: int, + ) -> SupersessionLineage: + async with graph_uow() as uow: + if (await uow.blocks.get(focal_block_id)) is None: + raise ValueError("Focal Block does not exist") + visited = {focal_block_id} + frontier = deque((focal_block_id,)) + relations: dict[RelationID, RelationModel] = {} + scanned = 0 + truncated = False + while frontier and not truncated: + current = frontier.popleft() + for endpoint in ("from", "to"): + cursor: RelationID | None = None + while not truncated: + remaining = max_explored_relations - scanned + if remaining == 0: + truncated = True + break + requested = min(200, remaining + 1) + page = await uow.relations.get_endpoint_page( + (current,), + endpoint=typing.cast(typing.Literal["from", "to"], endpoint), + contents=(SUPERSEDES_RELATION,), + cursor=cursor, + limit=requested, + ) + if len(page) > remaining: + page = page[:remaining] + truncated = True + scanned += len(page) + for relation in page: + if relation.id is None: + continue + relations[relation.id] = relation + neighbor = relation.to_ if relation.from_ == current else relation.from_ + if neighbor in visited: + continue + if len(visited) >= max_explored_blocks: + truncated = True + break + visited.add(neighbor) + frontier.append(neighbor) + if truncated or len(page) < requested: + break + cursor = typing.cast(RelationID, page[-1].id) + + cycle_detected = self._cycle_detected(visited, tuple(relations.values())) + incoming = {relation.to_ for relation in relations.values()} + current_block_ids = ( + () + if truncated or cycle_detected + else tuple(sorted(block_id for block_id in visited if block_id not in incoming)) + ) + blocks = await uow.blocks.get_many(visited) + existing = {block.id for block in blocks} + closed_relations = tuple( + relation + for relation in relations.values() + if relation.from_ in existing and relation.to_ in existing + ) + return SupersessionLineage( + graph=GraphModel(blocks=blocks, relations=closed_relations), + current_block_ids=current_block_ids, + truncated=truncated, + cycle_detected=cycle_detected, + ) + + @staticmethod + def _cycle_detected( + blocks: typing.Collection[BlockID], + relations: typing.Collection[RelationModel], + ) -> bool: + # The standard library uses an explicit stack, so long lineages do not + # consume Python call-stack depth. + sorter: TopologicalSorter[BlockID] = TopologicalSorter() + for block_id in blocks: + sorter.add(block_id) + for relation in relations: + sorter.add(relation.to_, relation.from_) + try: + sorter.prepare() + except CycleError: + return True + return False + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available(SUPERSESSION_CONFIG_KEY, BehaviorAgentConfig) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + strong = merge_seed_categories( + max_seeds, + await recent_relation_endpoint_ids(max_seeds, contents=(EDITED_RELATION,)), + await recent_block_ids(max_seeds), + ) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, strong, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "strong_count": len(strong), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Determine only well-supported scoped supersession relations.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent( + SUPERSESSION_CONFIG_KEY, + BehaviorAgentConfig, + message, + ) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/synthesis.py b/inkcre_core/business/organization/synthesis.py new file mode 100644 index 00000000..1a2523f7 --- /dev/null +++ b/inkcre_core/business/organization/synthesis.py @@ -0,0 +1,236 @@ +"""Provenance-preserving n-ary synthesis behavior.""" + +from __future__ import annotations + +import typing + + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.resolver import Resolver +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from inkcre_core.libs.obsrv.main import get_logger +from inkcre_core.schemas.info_base.block import BlockForm, BlockID, BlockModel +from inkcre_core.schemas.organization_behavior import ( + BehaviorAgentConfig, + CandidateWriteResult, + SynthesisProposal, + SynthesisWriteResult, +) + +from ._shared import ( + build_seed_message, + candidate_seed_ids, + configured_agent_available, + continue_after_seed_failure, + fetchsert_relation, + merge_seed_categories, + random_block_ids, + recent_block_ids, + recent_relation_endpoint_ids, + record_candidate, + relation_result, + run_configured_agent, +) +from .supersession import EDITED_RELATION + + +LOGGER = get_logger().getChild(__name__) + +SYNTHESIS_RELATION = "synthesis" +SYNTHESIS_BEHAVIOR = "core.organization.behavior.synthesis.v1" +SYNTHESIS_CONFIG_KEY = "core.organization.synthesis" +SYNTHESIS_CONFIG_SCHEMA = "core.organization.synthesis.config.v1" + +DeploymentConfigManager.register_schema( + SYNTHESIS_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(SYNTHESIS_CONFIG_KEY,) +) + + +class SynthesisBehaviorResolver( + Resolver[str, str], + rso_type=SYNTHESIS_BEHAVIOR, +): + organization_description = ( + "Create reusable multi-source information while preserving exact source basis." + ) + judgment_contract = ( + "The result is independently useful information, not a list of related sources.", + "Every source materially contributes content, attribution, or uncertainty.", + "Source disagreements, uncertainty, and speaker attribution remain visible.", + "Scopes are compatible or their differences are explicitly preserved.", + "Duplicate-connected copies do not multiply independent corroboration.", + "No existing synthesis already provides the same reusable distinction.", + "Observed past use and recurrence make future reuse plausible.", + ) + + async def get_text( + self, + *, + context: typing.Literal["default", "lexical"] = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, refresh, materialize_missing + return self.organization_description + + async def get_label(self, *, refresh: bool = False) -> str: + del refresh + return "organization behavior: synthesis" + + @classmethod + async def record_candidate( + cls, + block_id: BlockID, + ) -> CandidateWriteResult: + return await record_candidate(cls, block_id) + + @classmethod + async def create_synthesis( + cls, + text: str, + source_block_ids: typing.Collection[BlockID], + previous_synthesis_block_id: BlockID | None = None, + ) -> SynthesisWriteResult: + proposal = SynthesisProposal( + text=text, + source_block_ids=tuple(source_block_ids), + previous_synthesis_block_id=previous_synthesis_block_id, + ) + async with graph_uow() as uow: + found = {block.id for block in (await uow.blocks.get_many(proposal.source_block_ids))} + missing = tuple(source for source in proposal.source_block_ids if source not in found) + if missing: + raise ValueError(f"Synthesis source Blocks do not exist: {missing!r}") + if proposal.previous_synthesis_block_id is not None: + previous = await uow.blocks.get(proposal.previous_synthesis_block_id) + if previous is None: + raise ValueError("Previous synthesis Block does not exist") + previous_basis = await cls._source_basis(previous, uow) + if len(set(previous_basis)) < 2: + raise ValueError("Previous synthesis has no valid multi-source basis") + + source_set = set(proposal.source_block_ids) + text_candidates = await uow.blocks.matching_content("core.text.v1", proposal.text) + synthesis = None + for candidate in text_candidates: + if await cls._source_basis(candidate, uow) == source_set: + synthesis = candidate + break + synthesis_created = synthesis is None + if synthesis is None: + synthesis = await uow.blocks.create( + BlockForm(resolver="core.text.v1", content=proposal.text) + ) + if synthesis.id is None: # pragma: no cover - persisted Block invariant + raise RuntimeError("Persisted synthesis Block has no ID") + + basis = [] + for source_block_id in sorted(source_set): + relation, created = await fetchsert_relation( + source_block_id, + synthesis.id, + SYNTHESIS_RELATION, + uow, + ) + basis.append(relation_result(relation, created)) + + edited = None + if ( + proposal.previous_synthesis_block_id is not None + and proposal.previous_synthesis_block_id != synthesis.id + ): + relation, created = await fetchsert_relation( + proposal.previous_synthesis_block_id, + synthesis.id, + EDITED_RELATION, + uow, + ) + edited = relation_result(relation, created) + return SynthesisWriteResult( + synthesis_block_id=synthesis.id, + synthesis_created=synthesis_created, + basis=tuple(basis), + edited=edited, + ) + + @staticmethod + async def _source_basis( + synthesis: BlockModel, + uow: GraphUnitOfWork, + ) -> set[BlockID]: + if synthesis.id is None: + return set() + return { + relation.from_ + for relation in await uow.relations.get( + synthesis.id, include_out=False, content=SYNTHESIS_RELATION + ) + } + + @classmethod + async def _change_signal_ids(cls, limit: int) -> tuple[BlockID, ...]: + endpoints = await recent_relation_endpoint_ids(limit) + if not endpoints: + return () + async with graph_uow() as uow: + affected = tuple( + relation.to_ + for relation in await uow.relations.get_outgoing_many( + endpoints, content=SYNTHESIS_RELATION + ) + ) + return tuple(dict.fromkeys((*endpoints, *affected))) + + @classmethod + async def can_run_automatic(cls) -> bool: + return await configured_agent_available(SYNTHESIS_CONFIG_KEY, BehaviorAgentConfig) + + @classmethod + async def run_automatic(cls, max_seeds: int) -> None: + candidates = await candidate_seed_ids(cls, max_seeds) + strong = merge_seed_categories( + max_seeds, + await cls._change_signal_ids(max_seeds), + await recent_block_ids(max_seeds), + ) + random = await random_block_ids(max_seeds) + seeds = merge_seed_categories(max_seeds, candidates, strong, random) + LOGGER.info( + "organization.seeds.selected", + extra={ + "behavior": cls.__rsotype__, + "max_seeds": max_seeds, + "candidate_count": len(candidates), + "strong_count": len(strong), + "random_count": len(random), + "seed_block_ids": seeds, + }, + ) + for seed in seeds: + with continue_after_seed_failure(LOGGER, cls.__rsotype__, seed): + message = await build_seed_message( + "Create only provenance-preserving, reusable multi-source synthesis.", + cls.judgment_contract, + seed, + ) + if message is None: + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "unresolved", + "reason": "text_unavailable", + }, + ) + continue + await run_configured_agent(SYNTHESIS_CONFIG_KEY, BehaviorAgentConfig, message) + LOGGER.info( + "organization.seed.considered", + extra={ + "behavior": cls.__rsotype__, + "seed_block_ids": (seed,), + "outcome": "considered", + "reason": "agent_completed", + }, + ) diff --git a/inkcre_core/business/organization/tools.py b/inkcre_core/business/organization/tools.py new file mode 100644 index 00000000..ad24740a --- /dev/null +++ b/inkcre_core/business/organization/tools.py @@ -0,0 +1,342 @@ +"""Owner-coherent Agent read tools and exact Organization mutation tools.""" + +from __future__ import annotations + +import typing + +import pydantic + +from inkcre_core.business.agent import AgentManager, ToolExecutionError +from inkcre_core.business.info_base import InfoBaseManager +from inkcre_core.business.info_base.commands import submit_graph as persist_submitted_graph +from inkcre_core.business.info_base.resolver import ( + ResolverDraftCapability, + ResolverManager, +) +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.organization import ( + DraftGraphInput, + GetDraftGraphSchemaInput, + SubmitGraphInput, +) +from inkcre_core.schemas.organization_behavior import ( + DuplicateAssertionProposal, + EvidenceStanceProposal, + ExistingReferentAnchorProposal, + RecordOrganizationCandidateInput, + RefinementProposal, + SupersessionProposal, + SynthesisProposal, +) +from ._shared import behavior_resolver_classes, get_behavior_resolver +from .contracts import OrganizationError +from .duplicate_assertion import ( + DUPLICATES_ASSERTION_RELATION, + DuplicateAssertionBehaviorResolver, +) +from .evidence_stance import EvidenceStanceBehaviorResolver +from .referent_anchoring import ExistingReferentAnchoringBehaviorResolver +from .refinement import REFINES_RELATION, RefinementBehaviorResolver +from .supersession import SUPERSEDES_RELATION, SupersessionBehaviorResolver +from .synthesis import SynthesisBehaviorResolver + + +GET_DRAFT_GRAPH_SCHEMA_TOOL = "get_draft_graph_schema" +DRAFT_GRAPH_TOOL = "draft_graph" +SUBMIT_GRAPH_TOOL = "submit_graph" +RECORD_SUPERSESSION_TOOL = "record_supersession" +RECORD_REFINEMENT_TOOL = "record_refinement" +RECORD_EVIDENCE_STANCE_TOOL = "record_evidence_stance" +CREATE_SYNTHESIS_TOOL = "create_synthesis" +ANCHOR_EXISTING_REFERENT_TOOL = "anchor_existing_referent" +RECORD_DUPLICATE_ASSERTION_TOOL = "record_duplicate_assertion" +RECORD_ORGANIZATION_CANDIDATE_TOOL = "record_organization_candidate" + + +def _draft_capability_snapshot() -> dict[str, ResolverDraftCapability]: + return { + capability.resolver: capability + for capability in ResolverManager.get_draft_capabilities() + } + + +def _schema_discovery_input_model() -> type[pydantic.BaseModel]: + snapshot = _draft_capability_snapshot() + if not snapshot: # pragma: no cover - core.text.v1 is always registered + raise RuntimeError("No Resolver graph-drafting capability is registered") + + def add_exact_ids(schema: dict[str, typing.Any]) -> None: + schema["properties"]["resolver_types"]["items"] = { + "type": "string", + "enum": list(snapshot), + } + + class BoundGetDraftGraphSchemaInput(GetDraftGraphSchemaInput): + model_config = pydantic.ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra=add_exact_ids, + ) + + @pydantic.field_validator("resolver_types") + @classmethod + def exact_resolvers(cls, resolvers: tuple[str, ...]) -> tuple[str, ...]: + unknown = tuple(resolver for resolver in resolvers if resolver not in snapshot) + if unknown: + raise ValueError(f"Unavailable draft Resolver IDs: {unknown!r}") + return resolvers + + return BoundGetDraftGraphSchemaInput + + +def _draft_graph_input_model() -> type[pydantic.BaseModel]: + snapshot = _draft_capability_snapshot() + if not snapshot: # pragma: no cover - core.text.v1 is always registered + raise RuntimeError("No Resolver graph-drafting capability is registered") + + def add_exact_ids(schema: dict[str, typing.Any]) -> None: + schema["properties"]["resolver_type"] = { + "type": "string", + "enum": list(snapshot), + } + + class BoundDraftGraphInput(DraftGraphInput): + model_config = pydantic.ConfigDict( + extra="forbid", + json_schema_extra=add_exact_ids, + ) + + @pydantic.field_validator("resolver_type") + @classmethod + def exact_resolver(cls, resolver: str) -> str: + if resolver not in snapshot: + raise ValueError(f"Unavailable draft Resolver ID: {resolver!r}") + return resolver + + @pydantic.model_validator(mode="after") + def validate_resolver_input(self) -> typing.Self: + capability = snapshot[self.resolver_type] + # Validate at the caller's actual path; an inner resolver_type error must + # not tell the Agent to remove its valid outer selector. + payload_model = pydantic.create_model( + "ResolverDraftPayload", input=(capability.input_model, ...) + ) + payload = payload_model.model_validate({"input": self.input}) + object.__setattr__(self, "_resolver_input", getattr(payload, "input")) + return self + + return BoundDraftGraphInput + + +@AgentManager.tool( + GET_DRAFT_GRAPH_SCHEMA_TOOL, + description="Describe graph-drafting inputs for selected Resolver types.", + input_model_factory=_schema_discovery_input_model, +) +async def get_draft_graph_schema(input: GetDraftGraphSchemaInput) -> JSONValue: + snapshot = _draft_capability_snapshot() + return { + "resolvers": [ + { + "resolver_type": resolver, + "description": snapshot[resolver].description, + "input_schema": typing.cast( + dict[str, JSONValue], + snapshot[resolver].input_model.model_json_schema(), + ), + } + for resolver in input.resolver_types + ] + } + + +@AgentManager.tool( + DRAFT_GRAPH_TOOL, + description="Draft one rooted GraphForm through an exact Resolver without persistence.", + input_model_factory=_draft_graph_input_model, +) +async def draft_graph(input: DraftGraphInput) -> JSONValue: + capability = ResolverManager.get_draft_capability(input.resolver_type) + resolver_input = typing.cast(pydantic.BaseModel, getattr(input, "_resolver_input")) + stars = capability.resolver_cls.create_graph(resolver_input) + graph = InfoBaseManager.normalize_graph(stars, input.local_block_id_start) + return typing.cast(JSONValue, graph.model_dump(mode="json")) + + +@AgentManager.tool( + SUBMIT_GRAPH_TOOL, + description="Persist one complete GraphForm and return local-to-persisted Block IDs.", +) +async def submit_graph(input: SubmitGraphInput) -> JSONValue: + result = await persist_submitted_graph(input.graph) + return typing.cast(JSONValue, result.model_dump(mode="json")) + + +def _candidate_input_model() -> type[pydantic.BaseModel]: + snapshot = {behavior.__rsotype__: behavior for behavior in behavior_resolver_classes()} + if not snapshot: + raise RuntimeError("No Organization Behavior Resolver is registered") + + def add_exact_behaviors(schema: dict[str, typing.Any]) -> None: + schema["properties"]["behavior"] = { + "oneOf": [ + { + "const": behavior_id, + "description": behavior.organization_description, + } + for behavior_id, behavior in snapshot.items() + ] + } + + class BoundRecordOrganizationCandidateInput(RecordOrganizationCandidateInput): + model_config = pydantic.ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra=add_exact_behaviors, + ) + + @pydantic.field_validator("behavior") + @classmethod + def exact_behavior(cls, behavior: str) -> str: + if behavior not in snapshot: + raise ValueError( + f"Unavailable behavior {behavior!r}; available: {', '.join(snapshot)}" + ) + return behavior + + return BoundRecordOrganizationCandidateInput + + +async def _exact_result(operation: typing.Awaitable[pydantic.BaseModel]) -> JSONValue: + try: + result = await operation + except (ValueError, OrganizationError) as error: + raise ToolExecutionError( + {"error": type(error).__name__, "message": str(error)} + ) from error + return typing.cast(JSONValue, result.model_dump(mode="json")) + + +@AgentManager.tool( + RECORD_SUPERSESSION_TOOL, + description=( + f"Record {SUPERSEDES_RELATION!r}: the successor is authorized to replace " + "the predecessor across its entire scope on the same subject." + ), +) +async def record_supersession(input: SupersessionProposal) -> JSONValue: + return await _exact_result( + SupersessionBehaviorResolver.record_supersession( + input.successor_block_id, + input.predecessor_block_id, + ) + ) + + +@AgentManager.tool( + RECORD_REFINEMENT_TOOL, + description=( + f"Record {REFINES_RELATION!r}: new compatible detail at equal or narrower scope " + "on the same subject, not mere extraction or rewording. The predecessor " + "remains independently usable as a coarser description." + ), +) +async def record_refinement(input: RefinementProposal) -> JSONValue: + return await _exact_result( + RefinementBehaviorResolver.record_refinement( + input.refinement_block_id, + input.predecessor_block_id, + ) + ) + + +@AgentManager.tool( + RECORD_EVIDENCE_STANCE_TOOL, + description=( + "Record attributable evidence supporting or challenging a whole " + "assertion in comparable scope, without declaring it true or false. " + "Merely establishing that derived content faithfully restates its source is " + "not evidence stance, regardless of source authority. Shared provenance is " + "allowed when observation or reasoning " + "contributes reasons beyond restatement." + ), +) +async def record_evidence_stance(input: EvidenceStanceProposal) -> JSONValue: + return await _exact_result( + EvidenceStanceBehaviorResolver.record_evidence_stance( + input.evidence_block_id, + input.assertion_block_id, + input.stance, + ) + ) + + +@AgentManager.tool( + CREATE_SYNTHESIS_TOOL, + description=( + "Create reusable multi-source information preserving provenance, " + "disagreement, uncertainty and speaker attribution; copies do not " + "multiply corroboration." + ), +) +async def create_synthesis(input: SynthesisProposal) -> JSONValue: + return await _exact_result( + SynthesisBehaviorResolver.create_synthesis( + input.text, + input.source_block_ids, + input.previous_synthesis_block_id, + ) + ) + + +@AgentManager.tool( + ANCHOR_EXISTING_REFERENT_TOOL, + description=( + "Link a source's referring fragment to an existing identity-bearing " + "referent, without creating a new referent." + ), +) +async def anchor_existing_referent( + input: ExistingReferentAnchorProposal, +) -> JSONValue: + return await _exact_result( + ExistingReferentAnchoringBehaviorResolver.anchor_existing_referent( + input.source_block_id, + input.selected_text, + input.referent_block_id, + ) + ) + + +@AgentManager.tool( + RECORD_DUPLICATE_ASSERTION_TOOL, + description=( + f"Record {DUPLICATES_ASSERTION_RELATION!r}: whole assertions from the same " + "provenance occurrence with no " + "independent evidence, reasoning, decision or material gain; matching " + "words alone are insufficient." + ), +) +async def record_duplicate_assertion(input: DuplicateAssertionProposal) -> JSONValue: + return await _exact_result( + DuplicateAssertionBehaviorResolver.record_duplicate_assertion( + input.left_block_id, + input.right_block_id, + ) + ) + + +@AgentManager.tool( + RECORD_ORGANIZATION_CANDIDATE_TOOL, + description="Mark an organization candidate without executing the behavior.", + input_model_factory=_candidate_input_model, +) +async def record_organization_candidate( + input: RecordOrganizationCandidateInput, +) -> JSONValue: + behavior = get_behavior_resolver(input.behavior) + if behavior is None: + raise ToolExecutionError({"error": "behavior_unavailable"}) + return await _exact_result( + typing.cast(typing.Any, behavior).record_candidate(input.block_id) + ) diff --git a/inkcre_core/business/organization_job.py b/inkcre_core/business/organization_job.py new file mode 100644 index 00000000..404def26 --- /dev/null +++ b/inkcre_core/business/organization_job.py @@ -0,0 +1,33 @@ +"""Exact automatic Organization commands hosted by Jobs.""" + +from inkcre_core.business.job import JobHandler +from inkcre_core.business.organization_media import ( + MEDIA_INTERPRETATION_JOB_TYPE, + can_handle_media_interpretation, + interpret_missing_media, +) +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.organization import MediaInterpretationJobParameters + + +class MediaInterpretationJobHandler( + JobHandler[MediaInterpretationJobParameters], + job_type=MEDIA_INTERPRETATION_JOB_TYPE, + description="Interpret missing image, audio, and video Blocks through configured Agents.", + parameters_model=MediaInterpretationJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: MediaInterpretationJobParameters) -> bool: + del parameters + return await can_handle_media_interpretation() + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: MediaInterpretationJobParameters, + ) -> None: + del parameters + report = await interpret_missing_media() + job.state = report.model_dump(mode="json") diff --git a/inkcre_core/business/organization_media.py b/inkcre_core/business/organization_media.py new file mode 100644 index 00000000..5daac8f1 --- /dev/null +++ b/inkcre_core/business/organization_media.py @@ -0,0 +1,266 @@ +"""System-driven media interpretation approach owned by Organization.""" + +import json +import logging +import typing + + +from inkcre_core.business.agent import AgentManager, TurnTermination +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.business.info_base.commands import get_related_block +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.business.info_base.resolver.audio import AudioSolvedContent +from inkcre_core.business.info_base.resolver.image import ImageSolvedContent +from inkcre_core.business.info_base.resolver.video import VideoSolvedContent +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.schemas.ai import ( + AudioContentPart, + ImageContentPart, + TextContentPart, + UserContentPart, + UserMessage, + VideoContentPart, +) +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.organization import ( + MediaInterpretationConfig, + MediaInterpretationDiagnostic, + MediaInterpretationReport, +) + + +logger = logging.getLogger(__name__) + +MEDIA_INTERPRETATION_CONFIG_KEY = "core.organization.media_interpretation" +MEDIA_INTERPRETATION_CONFIG_SCHEMA = "core.organization.media_interpretation.config.v1" +MEDIA_INTERPRETATION_JOB_TYPE = "core.organization.media_interpretation.v1" +_CANDIDATE_LIMIT = 20 +_DIAGNOSTIC_LIMIT = 20 +_RESOLVER_MODALITIES: dict[str, typing.Literal["image", "audio", "video"]] = { + "core.image.v1": "image", + "core.audio.v1": "audio", + "core.video.v1": "video", +} + + +DeploymentConfigManager.register_schema( + MEDIA_INTERPRETATION_CONFIG_SCHEMA, + MediaInterpretationConfig, + keys=(MEDIA_INTERPRETATION_CONFIG_KEY,), +) + + +async def _config() -> MediaInterpretationConfig | None: + value = await DeploymentConfigService.get(MEDIA_INTERPRETATION_CONFIG_KEY) + if value is None: + return None + if not isinstance(value, MediaInterpretationConfig): + raise TypeError("Media interpretation config registry returned the wrong model") + return value + + +def _agent( + config: MediaInterpretationConfig, + modality: typing.Literal["image", "audio", "video"], +) -> int: + return { + "image": config.image_agent, + "audio": config.audio_agent, + "video": config.video_agent, + }[modality] + + +async def can_handle_media_interpretation() -> bool: + config = await _config() + if config is None: + return False + for modality in typing.cast( + tuple[typing.Literal["image", "audio", "video"], ...], + ("image", "audio", "video"), + ): + if await AgentManager.can_execute(_agent(config, modality), modality): + return True + return False + + +async def _candidates() -> tuple[BlockModel, ...]: + async with graph_uow() as uow: + return await uow.blocks.without_outgoing_relation( + tuple(_RESOLVER_MODALITIES), "interpretation", limit=_CANDIDATE_LIMIT + ) + + +async def _relation_context(block_id: int) -> list[dict[str, typing.Any]]: + async with graph_uow() as uow: + relations = (await uow.relations.get(block_id))[:20] + neighbor_ids = { + relation.to_ if relation.from_ == block_id else relation.from_ + for relation in relations + } + neighbors = {block.id: block for block in await uow.blocks.get_many(neighbor_ids)} + context: list[dict[str, typing.Any]] = [] + for relation in relations: + other_id = relation.to_ if relation.from_ == block_id else relation.from_ + other = neighbors.get(other_id) + label = None + if other is not None: + try: + label = await ResolverManager.get(other).get_label() + except Exception: + logger.debug("Could not project media neighbor label", exc_info=True) + context.append( + { + "direction": "outgoing" if relation.from_ == block_id else "incoming", + "property": relation.content, + "other_block": {"id": other_id, "label": label}, + } + ) + return context + + +async def _message(block: BlockModel) -> UserMessage | None: + resolver = ResolverManager.get(block) + solved = await resolver.get_solved_content(materialize_missing=False) + transfer_url = await resolver.get_transfer_url() + media: UserContentPart + facts: dict[str, typing.Any] + if isinstance(solved, ImageSolvedContent): + media_type = solved.detected_media_type or ( + f"image/{solved.format}" if solved.format is not None else None + ) + if media_type is None: + return None + media = ImageContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=transfer_url, + ) + facts = {"format": solved.format, "width": solved.width, "height": solved.height} + elif isinstance(solved, AudioSolvedContent): + media_type = solved.detected_media_type or ( + f"audio/{solved.container}" if solved.container is not None else None + ) + if media_type is None: + return None + media = AudioContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=transfer_url, + ) + facts = { + "container": solved.container, + "codec": solved.codec, + "duration_ms": solved.duration_ms, + } + elif isinstance(solved, VideoSolvedContent): + media_type = solved.detected_media_type or ( + f"video/{solved.container}" if solved.container is not None else None + ) + if media_type is None: + return None + media = VideoContentPart( + data=solved.content, + mime_type=media_type, + transfer_url=transfer_url, + ) + facts = { + "container": solved.container, + "codec": solved.video_codec, + "duration_ms": solved.duration_ms, + "width": solved.width, + "height": solved.height, + } + else: + return None + + context = { + "request": "interpret media and submit only a useful additive graph", + "focal_block": { + "id": block.id, + "resolver": block.resolver, + "facts": facts, + }, + "direct_relations": await _relation_context(typing.cast(int, block.id)), + } + return UserMessage( + content=( + TextContentPart( + text=json.dumps( + context, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ), + media, + ) + ) + + +async def interpret_missing_media() -> MediaInterpretationReport: + config = await _config() + if config is None: + return MediaInterpretationReport() + selected = interpreted = unavailable = failed = no_output = 0 + diagnostics: list[MediaInterpretationDiagnostic] = [] + + def diagnostic( + block: int, + modality: typing.Literal["image", "audio", "video"], + outcome: typing.Literal["unavailable", "failed", "no_output"], + reason: str, + ) -> None: + if len(diagnostics) < _DIAGNOSTIC_LIMIT: + diagnostics.append( + MediaInterpretationDiagnostic( + block=block, + modality=modality, + outcome=outcome, + reason=reason, + ) + ) + + for block in await _candidates(): + selected += 1 + block_id = typing.cast(int, block.id) + modality = _RESOLVER_MODALITIES[block.resolver] + agent = _agent(config, modality) + if not await AgentManager.can_execute(agent, modality): + unavailable += 1 + diagnostic(block_id, modality, "unavailable", "agent_not_locally_executable") + continue + try: + message = await _message(block) + if message is None: + unavailable += 1 + diagnostic(block_id, modality, "unavailable", "media_input_unavailable") + continue + thread = await AgentManager.run(agent, message) + turn = thread.current_turn + if turn is None: + raise RuntimeError("Media interpretation Agent did not start a Turn") + outcome = await turn + if outcome == TurnTermination.MAX_MODEL_CALLS: + raise RuntimeError("Media interpretation exceeded its model-call budget") + except Exception as error: + failed += 1 + logger.exception("Media interpretation failed", extra={"block": block_id}) + diagnostic(block_id, modality, "failed", type(error).__name__) + continue + if await get_related_block(block_id, content="interpretation") is None: + no_output += 1 + diagnostic(block_id, modality, "no_output", "missing_interpretation_relation") + else: + interpreted += 1 + + return MediaInterpretationReport( + selected=selected, + interpreted=interpreted, + unavailable=unavailable, + failed=failed, + no_output=no_output, + diagnostics=tuple(diagnostics), + ) diff --git a/app/business/peer/AGENTS.md b/inkcre_core/business/peer/AGENTS.md similarity index 100% rename from app/business/peer/AGENTS.md rename to inkcre_core/business/peer/AGENTS.md diff --git a/inkcre_core/business/peer/__init__.py b/inkcre_core/business/peer/__init__.py new file mode 100644 index 00000000..1be40f31 --- /dev/null +++ b/inkcre_core/business/peer/__init__.py @@ -0,0 +1,26 @@ +"""Peer discovery and request-response capability delegation.""" + +from .contracts import ( + CapabilityDelegationUnavailable, + DuplicatePeerRegistrationError, + PeerError, + PeerOutcomeUnknown, + PeerProtocolConfigurationError, + PeerProtocolError, + PeerRequestNotExecuted, +) +from .http import PeerHTTPInbound, PeerHTTPOutbound +from .main import PeerManager + +__all__ = [ + "CapabilityDelegationUnavailable", + "DuplicatePeerRegistrationError", + "PeerError", + "PeerHTTPInbound", + "PeerHTTPOutbound", + "PeerManager", + "PeerOutcomeUnknown", + "PeerProtocolConfigurationError", + "PeerProtocolError", + "PeerRequestNotExecuted", +] diff --git a/inkcre_core/business/peer/contracts.py b/inkcre_core/business/peer/contracts.py new file mode 100644 index 00000000..1f2df364 --- /dev/null +++ b/inkcre_core/business/peer/contracts.py @@ -0,0 +1,68 @@ +"""Peer discovery, routing, and outbound failure contracts.""" + +from collections.abc import Callable +from dataclasses import dataclass +import typing + +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.peer import ( + CapabilityID, + CorePeerConfig, + PeerCapabilityAdvertisement, + PeerModel, + PeerProtocolID, +) + + +class PeerError(RuntimeError): + """Base failure exposed by the Peer domain.""" + + +class DuplicatePeerRegistrationError(PeerError): + pass + + +class CapabilityDelegationUnavailable(PeerError): + pass + + +class PeerProtocolError(PeerError): + pass + + +class PeerProtocolConfigurationError(PeerProtocolError): + """One advertised inbound cannot construct its caller-local outbound.""" + + +class PeerRequestNotExecuted(PeerProtocolError): + """The selected outbound proved capability execution did not begin.""" + + +class PeerOutcomeUnknown(PeerProtocolError): + """Dispatch may have occurred but no execution outcome is known.""" + + +class PeerInbound(typing.Protocol): + @property + def capability(self) -> CapabilityID: ... + + def advertise( + self, + config: CorePeerConfig, + ) -> PeerCapabilityAdvertisement | None: ... + + +class PeerOutbound(typing.Protocol): + async def execute(self, payload: JSONValue) -> JSONValue: ... + + +PeerOutboundFactory: typing.TypeAlias = Callable[ + [PeerModel, dict[str, JSONValue]], + PeerOutbound, +] + + +@dataclass(frozen=True) +class PeerOutboundRegistration: + protocol: PeerProtocolID + factory: PeerOutboundFactory diff --git a/inkcre_core/business/peer/http.py b/inkcre_core/business/peer/http.py new file mode 100644 index 00000000..e1dd8684 --- /dev/null +++ b/inkcre_core/business/peer/http.py @@ -0,0 +1,201 @@ +"""core.peer.protocol.http.v1 inbound advertisement and one-shot outbound.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +import json +import typing + +import httpx +import pydantic + +from inkcre_core.auth import create_peer_jwt +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.peer import ( + PEER_EXECUTION_HEADER, + PEER_HTTP_PROTOCOL, + PEER_NOT_EXECUTED, + CapabilityID, + CorePeerConfig, + PeerCapabilityAdvertisement, + PeerHTTPInboundParameters, + PeerInboundInterface, + PeerModel, + PeerProtocolRequest, + PeerProtocolResponse, +) +from inkcre_core.context import current_core +from inkcre_core.libs.obsrv.propagation import inject_context +from inkcre_core.libs.obsrv.telemetry import operation +from opentelemetry.trace import SpanKind + +from .contracts import ( + PeerOutcomeUnknown, + PeerProtocolConfigurationError, + PeerProtocolError, + PeerRequestNotExecuted, +) + + +_RESERVED_REQUEST_HEADERS = frozenset( + { + "authorization", + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) + + +@dataclass(frozen=True) +class PeerHTTPInbound: + """One fixed Business route advertised through Peer HTTP v1.""" + + capability: CapabilityID + method: str + path: str + + def __post_init__(self) -> None: + if not self.path.startswith("/") or "?" in self.path or "#" in self.path: + raise ValueError("Peer HTTP inbound path must be one absolute path") + + def advertise( + self, + config: CorePeerConfig, + ) -> PeerCapabilityAdvertisement | None: + if config.http_public_base_url is None: + return None + parameters = PeerHTTPInboundParameters( + method=self.method, + url=f"{config.http_public_base_url}{self.path}", + ) + return PeerCapabilityAdvertisement( + id=self.capability, + inbound=PeerInboundInterface( + protocol=PEER_HTTP_PROTOCOL, + parameters=typing.cast( + dict[str, JSONValue], + parameters.model_dump(mode="json"), + ), + ), + ) + + +class PeerHTTPOutbound: + """One-shot authenticated JSON HTTP execution for one advertised inbound.""" + + def __init__( + self, + peer: PeerModel, + parameters: dict[str, JSONValue], + ) -> None: + self.peer = peer + try: + self.parameters = PeerHTTPInboundParameters.model_validate(parameters) + except pydantic.ValidationError as error: + raise PeerProtocolConfigurationError( + f"Peer {peer.id} published invalid HTTP inbound parameters" + ) from error + + async def execute(self, payload: JSONValue) -> JSONValue: + try: + request = PeerProtocolRequest.model_validate(payload) + except pydantic.ValidationError as error: + raise PeerProtocolError("Invalid Peer HTTP request envelope") from error + + reserved = _RESERVED_REQUEST_HEADERS.intersection(request.headers) + if reserved: + rendered = ", ".join(sorted(reserved)) + raise PeerProtocolError(f"Peer HTTP payload attempted reserved headers: {rendered}") + + query = [(name, value) for name, values in request.query.items() for value in values] + headers = [ + (name, value) for name, values in request.headers.items() for value in values + ] + options = current_core().options + token = create_peer_jwt( + options.jwt_secret, lifetime_seconds=options.jwt_default_lifetime_seconds + ) + headers.append(("authorization", f"Bearer {token}")) + kwargs: dict[str, typing.Any] = { + "method": self.parameters.method, + "url": self.parameters.url, + "params": query, + "headers": headers, + } + if "body" in request.model_fields_set: + kwargs["json"] = request.body + + with operation( + "peer.http", + attributes={"inkcre.peer.target_id": str(self.peer.id)}, + kind=SpanKind.CLIENT, + ) as observation: + span = observation.span + propagated = inject_context() + if propagated: + # The transport owns Trace Context; business payload headers cannot duplicate it. + headers[:] = [ + (key, value) + for key, value in headers + if key.lower() not in {"traceparent", "tracestate"} + ] + headers.extend(propagated.items()) + try: + async with httpx.AsyncClient( + timeout=current_core().options.peer_http_timeout_seconds, + ) as client: + response = await client.request(**kwargs) + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as error: + span.set_attribute("inkcre.peer.outcome", "not_executed") + raise PeerRequestNotExecuted( + f"Could not dispatch to Peer {self.peer.id}" + ) from error + except httpx.RequestError as error: + span.set_attribute("inkcre.peer.outcome", "unknown") + raise PeerOutcomeUnknown( + f"Peer {self.peer.id} dispatch outcome is unknown" + ) from error + + if ( + response.headers.get(PEER_EXECUTION_HEADER, "").strip().lower() == PEER_NOT_EXECUTED + ): + span.set_attribute("inkcre.peer.outcome", "not_executed") + raise PeerRequestNotExecuted( + f"Peer {self.peer.id} reported that execution did not begin" + ) + + span.set_attribute("inkcre.peer.outcome", "responded") + span.set_attribute("http.response.status_code", response.status_code) + if response.status_code >= 500: + observation.outcome = "error" + + grouped_headers: defaultdict[str, list[str]] = defaultdict(list) + for name, value in response.headers.multi_items(): + if name.lower() != PEER_EXECUTION_HEADER.lower(): + grouped_headers[name.lower()].append(value) + + response_fields: dict[str, typing.Any] = { + "status": response.status_code, + "headers": grouped_headers, + } + if response.content: + try: + response_fields["body"] = response.json() + except json.JSONDecodeError as error: + raise PeerProtocolError( + f"Peer {self.peer.id} returned a non-JSON HTTP body" + ) from error + normalized = PeerProtocolResponse.model_validate(response_fields) + return typing.cast( + JSONValue, + normalized.model_dump(mode="json", exclude_unset=True), + ) diff --git a/inkcre_core/business/peer/main.py b/inkcre_core/business/peer/main.py new file mode 100644 index 00000000..a3d65d30 --- /dev/null +++ b/inkcre_core/business/peer/main.py @@ -0,0 +1,252 @@ +"""Peer identity, local inbound publication, discovery, and delegation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime +import logging +import random +import typing + +import pydantic + +from inkcre_core.configuration import ConfigContract +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.peer import ( + PEER_HTTP_PROTOCOL, + CapabilityID, + CorePeerConfig, + PeerCapabilityAdvertisement, + PeerModel, + PeerRef, + normalize_capability_snapshot, +) +from inkcre_core.context import current_core + +from .contracts import ( + CapabilityDelegationUnavailable, + DuplicatePeerRegistrationError, + PeerInbound, + PeerOutcomeUnknown, + PeerOutboundFactory, + PeerProtocolConfigurationError, + PeerRequestNotExecuted, +) +from .http import PeerHTTPOutbound +from inkcre_core.persistence.peer.uow import peer_uow + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _Candidate: + peer: PeerModel + advertisement: PeerCapabilityAdvertisement + + +class PeerManager: + """Own Peer facts, local inbound registry, discovery, and one-shot routing.""" + + _random: random.Random = random.SystemRandom() + _config_contract = ConfigContract(CorePeerConfig) + + @classmethod + async def register_self(cls) -> PeerModel: + """Upsert runtime-owned identity/schema without changing owner configuration.""" + async with peer_uow() as peers: + peer = await peers.register( + current_core().options.peer_id, + current_core().options.peer_name, + current_core().options.application_version, + cls._config_contract.json_schema(), + ) + logger.info("Peer registered", extra={"peer": str(peer.id), "name": peer.name}) + return peer + + @classmethod + def get_current_peer_ref(cls) -> PeerRef: + return current_core().options.peer_id + + @classmethod + async def get_current_config_async(cls) -> CorePeerConfig: + peer = await cls.get_async(cls.get_current_peer_ref()) + if peer is None: + raise RuntimeError("Current Peer must be registered before reading config") + try: + return cls._config_contract.validate(peer.config) + except pydantic.ValidationError as error: + raise ValueError("Current Peer config is invalid") from error + + @classmethod + async def get_async(cls, peer: PeerRef) -> PeerModel | None: + async with peer_uow() as peers: + return await peers.get(peer) + + @classmethod + async def get_all(cls) -> tuple[PeerModel, ...]: + async with peer_uow() as peers: + return await peers.get_all() + + @classmethod + async def get_with_lease(cls, peer: PeerRef) -> dict[str, typing.Any] | None: + async with peer_uow() as peers: + rows, _ = await peers.with_leases(peer_id=peer) + return rows[0] if rows else None + + @classmethod + async def list_with_leases( + cls, *, limit: int | None = None, cursor: PeerRef | None = None + ) -> tuple[list[dict[str, typing.Any]], PeerRef | None]: + async with peer_uow() as peers: + return await peers.with_leases(limit=limit, cursor=cursor) + + @classmethod + def register_inbound(cls, inbound: PeerInbound) -> bool: + """Register one inbound and report whether this call added it.""" + existing = current_core().inbounds.get(inbound.capability) + if existing is inbound or existing == inbound: + return False + if existing is not None: + raise DuplicatePeerRegistrationError( + f"Capability inbound {inbound.capability!r} is already registered" + ) + current_core().inbounds[inbound.capability] = inbound + return True + + @classmethod + def unregister_inbound(cls, capability: CapabilityID) -> None: + current_core().inbounds.pop(capability, None) + + @classmethod + def register_outbound( + cls, + protocol: str, + factory: PeerOutboundFactory, + ) -> None: + existing = current_core().outbounds.get(protocol) + if existing is factory: + return + if existing is not None: + raise DuplicatePeerRegistrationError( + f"Peer outbound {protocol!r} is already registered" + ) + current_core().outbounds[protocol] = factory + + @classmethod + def setup_builtin_outbounds(cls) -> None: + cls.register_outbound(PEER_HTTP_PROTOCOL, PeerHTTPOutbound) + + @classmethod + async def publish_self(cls) -> PeerModel: + """Replace this Peer-owned capability snapshot from current config/registry.""" + async with peer_uow() as peers: + peer = await peers.get(current_core().options.peer_id, for_update=True) + if peer is None: + raise RuntimeError("Current Peer must be registered before publication") + try: + config = cls._config_contract.validate(peer.config) + except pydantic.ValidationError as error: + raise ValueError("Current Peer config is invalid") from error + advertisements = normalize_capability_snapshot( + advertisement + for inbound in current_core().inbounds.values() + if (advertisement := inbound.advertise(config)) is not None + ) + normalized = [ + typing.cast(dict[str, JSONValue], advertisement.model_dump(mode="json")) + for advertisement in advertisements + ] + if peer.capabilities != normalized: + peer.capabilities = normalized + await peers.save(peer) + return peer + + @classmethod + async def renew_self_lease(cls, ttl_seconds: int) -> datetime.datetime: + """Renew liveness through the database-time helper.""" + if ttl_seconds <= 0: + raise ValueError("Peer lease TTL must be positive") + async with peer_uow() as peers: + expiry = await peers.renew_lease(current_core().options.peer_id, ttl_seconds) + return expiry + + @classmethod + async def refresh_self(cls, ttl_seconds: int) -> PeerModel: + """Refresh advertisement, then renew its route lease.""" + peer = await cls.publish_self() + peer.lease_expires_at = await cls.renew_self_lease(ttl_seconds) + return peer + + @classmethod + async def clear_self_lease(cls) -> None: + async with peer_uow() as peers: + await peers.clear_lease(current_core().options.peer_id) + + @classmethod + async def delegate( + cls, + capability: CapabilityID, + payload: JSONValue, + *, + route_to_peer: PeerRef | None = None, + ) -> JSONValue: + """Execute through one eligible Peer, failing over only after non-execution.""" + candidates = await cls._candidates(capability, route_to_peer) + attempted = 0 + for candidate in candidates: + factory = current_core().outbounds.get(candidate.advertisement.inbound.protocol) + if factory is None: # candidate eligibility is rechecked defensively + continue + try: + outbound = factory( + candidate.peer, + candidate.advertisement.inbound.parameters, + ) + except PeerProtocolConfigurationError: + continue + attempted += 1 + try: + return await outbound.execute(payload) + except PeerRequestNotExecuted: + if route_to_peer is not None: + break + continue + except PeerOutcomeUnknown: + raise + raise CapabilityDelegationUnavailable( + f"No eligible Peer completed capability {capability!r}; attempted={attempted}" + ) + + @classmethod + async def _candidates( + cls, + capability: CapabilityID, + route_to_peer: PeerRef | None, + ) -> tuple[_Candidate, ...]: + async with peer_uow() as peers_repository: + peers = await peers_repository.candidates( + current_core().options.peer_id, route_to_peer + ) + + candidates: list[_Candidate] = [] + for peer in peers: + try: + snapshots = peer.capability_snapshot() + except (pydantic.ValidationError, ValueError): + logger.warning( + "Skipping malformed Peer capability snapshot", + extra={"peer": str(peer.id)}, + ) + continue + for advertisement in snapshots: + if ( + advertisement.id == capability + and advertisement.inbound.protocol in current_core().outbounds + ): + candidates.append(_Candidate(peer, advertisement)) + break + + if route_to_peer is None: + cls._random.shuffle(candidates) + return tuple(candidates) diff --git a/app/business/semantic_retrieval/AGENTS.md b/inkcre_core/business/semantic_retrieval/AGENTS.md similarity index 100% rename from app/business/semantic_retrieval/AGENTS.md rename to inkcre_core/business/semantic_retrieval/AGENTS.md diff --git a/inkcre_core/business/semantic_retrieval/__init__.py b/inkcre_core/business/semantic_retrieval/__init__.py new file mode 100644 index 00000000..c6a14424 --- /dev/null +++ b/inkcre_core/business/semantic_retrieval/__init__.py @@ -0,0 +1,35 @@ +"""Profile-scoped local semantic retrieval and embedding maintenance.""" + +from .main import ( + EmbeddingProfileNotFoundError, + InvalidSemanticVectorError, + SEMANTIC_RETRIEVAL_CONFIG_KEY, + SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, + SEMANTIC_RETRIEVAL_CAPABILITY, + SemanticRetrievalDelegationError, + SemanticRetrievalError, + SemanticRetrievalManager, + SemanticRetrievalNotConfiguredError, +) +from .job import ( + SEMANTIC_MAINTAIN_JOB_TYPE, + SEMANTIC_REBUILD_JOB_TYPE, + SemanticMaintainJobHandler, + SemanticRebuildJobHandler, +) + +__all__ = [ + "EmbeddingProfileNotFoundError", + "InvalidSemanticVectorError", + "SEMANTIC_RETRIEVAL_CONFIG_KEY", + "SEMANTIC_RETRIEVAL_CONFIG_SCHEMA", + "SEMANTIC_RETRIEVAL_CAPABILITY", + "SemanticRetrievalDelegationError", + "SemanticRetrievalError", + "SemanticRetrievalManager", + "SemanticRetrievalNotConfiguredError", + "SEMANTIC_MAINTAIN_JOB_TYPE", + "SEMANTIC_REBUILD_JOB_TYPE", + "SemanticMaintainJobHandler", + "SemanticRebuildJobHandler", +] diff --git a/inkcre_core/business/semantic_retrieval/job.py b/inkcre_core/business/semantic_retrieval/job.py new file mode 100644 index 00000000..2c297013 --- /dev/null +++ b/inkcre_core/business/semantic_retrieval/job.py @@ -0,0 +1,59 @@ +"""Exact semantic embedding maintenance commands hosted by Jobs.""" + +from inkcre_core.business.job import JobHandler +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.semantic_retrieval import EmbeddingMaintenanceJobParameters + +from .main import SemanticRetrievalManager + + +SEMANTIC_MAINTAIN_JOB_TYPE = "core.semantic_retrieval.maintain.v1" +SEMANTIC_REBUILD_JOB_TYPE = "core.semantic_retrieval.rebuild.v1" + + +class SemanticMaintainJobHandler( + JobHandler[EmbeddingMaintenanceJobParameters], + job_type=SEMANTIC_MAINTAIN_JOB_TYPE, + description="Maintain missing or stale semantic embedding records.", + parameters_model=EmbeddingMaintenanceJobParameters, + default_timeout_seconds=900, +): + @classmethod + async def can_handle(cls, parameters: EmbeddingMaintenanceJobParameters) -> bool: + return await SemanticRetrievalManager.can_maintain(parameters.profile) + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: EmbeddingMaintenanceJobParameters, + ) -> None: + report = await SemanticRetrievalManager.maintain( + parameters.profile, + parameters.options, + ) + job.state = report.model_dump(mode="json") + + +class SemanticRebuildJobHandler( + JobHandler[EmbeddingMaintenanceJobParameters], + job_type=SEMANTIC_REBUILD_JOB_TYPE, + description="Rebuild semantic embedding records present before invocation.", + parameters_model=EmbeddingMaintenanceJobParameters, + default_timeout_seconds=1800, +): + @classmethod + async def can_handle(cls, parameters: EmbeddingMaintenanceJobParameters) -> bool: + return await SemanticRetrievalManager.can_maintain(parameters.profile) + + @classmethod + async def handle( + cls, + job: JobModel, + parameters: EmbeddingMaintenanceJobParameters, + ) -> None: + report = await SemanticRetrievalManager.rebuild( + parameters.profile, + parameters.options, + ) + job.state = report.model_dump(mode="json") diff --git a/inkcre_core/business/semantic_retrieval/main.py b/inkcre_core/business/semantic_retrieval/main.py new file mode 100644 index 00000000..aa3739c0 --- /dev/null +++ b/inkcre_core/business/semantic_retrieval/main.py @@ -0,0 +1,551 @@ +"""Profile-scoped embedding maintenance and exact semantic retrieval.""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime +import logging +import math +import typing + +import pydantic + +from inkcre_core.business.ai import AIExecutionRequirement, AIManager +from inkcre_core.business.deployment_config import ( + DeploymentConfigManager, + DeploymentConfigService, +) +from inkcre_core.business.info_base.services import RelationService +from inkcre_core.business.info_base.resolver import ( + ResolverManager, + UnknownResolverError, + UnsupportedResolverCapability, +) +from inkcre_core.business.peer import PeerManager +from inkcre_core.persistence.semantic_retrieval.uow import semantic_retrieval_uow +from inkcre_core.schemas.ai import ( + BlockEmbeddingModel, + EmbeddingProfileID, + EmbeddingProfileModel, + RelationEmbeddingModel, +) +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef +from inkcre_core.schemas.semantic_retrieval import ( + BlockSemanticRetrievalMatch, + EmbeddingMaintenanceDiagnostic, + EmbeddingMaintenanceOptions, + EmbeddingMaintenanceReport, + RelationSemanticRetrievalMatch, + SemanticEntityType, + SemanticRetrievalConfig, + SemanticRetrievalMatch, + SemanticRetrievalRequest, + SemanticRetrievalResult, + VectorRetrievalOptions, +) + + +logger = logging.getLogger(__name__) + +SEMANTIC_RETRIEVAL_CONFIG_KEY = "semantic_retrieval" +SEMANTIC_RETRIEVAL_CONFIG_SCHEMA = "core.semantic_retrieval.config.v1" +SEMANTIC_RETRIEVAL_CAPABILITY = "core.semantic_retrieval.v1" + + +class SemanticRetrievalError(RuntimeError): + pass + + +class SemanticRetrievalNotConfiguredError(SemanticRetrievalError): + pass + + +class EmbeddingProfileNotFoundError(SemanticRetrievalError): + pass + + +class InvalidSemanticVectorError(SemanticRetrievalError): + pass + + +class SemanticRetrievalDelegationError(SemanticRetrievalError): + pass + + +class _ProjectionUnavailable(Exception): + pass + + +@dataclass(frozen=True) +class _Candidate: + entity_type: SemanticEntityType + entity_id: int + entity: BlockModel | RelationModel + text: str + + +@dataclass(frozen=True) +class _CandidatePage: + entities: tuple[BlockModel | RelationModel, ...] + next_cursor: int + exhausted: bool + + +@dataclass +class _ReportBuilder: + profile: EmbeddingProfileID + diagnostic_limit: int + embedded: int = 0 + unavailable: int = 0 + failed: int = 0 + diagnostics: list[EmbeddingMaintenanceDiagnostic] | None = None + + def __post_init__(self) -> None: + self.diagnostics = [] + + def record( + self, + entity_type: SemanticEntityType, + entity: int, + outcome: typing.Literal["unavailable", "failed"], + reason: str, + ) -> None: + if outcome == "unavailable": + self.unavailable += 1 + else: + self.failed += 1 + diagnostics = typing.cast(list[EmbeddingMaintenanceDiagnostic], self.diagnostics) + if len(diagnostics) < self.diagnostic_limit: + diagnostics.append( + EmbeddingMaintenanceDiagnostic( + entity_type=entity_type, + entity=entity, + outcome=outcome, + reason=reason, + ) + ) + + def build(self) -> EmbeddingMaintenanceReport: + return EmbeddingMaintenanceReport( + profile=self.profile, + embedded=self.embedded, + unavailable=self.unavailable, + failed=self.failed, + diagnostics=tuple(self.diagnostics or ()), + ) + + +DeploymentConfigManager.register_schema( + SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, + SemanticRetrievalConfig, + keys=(SEMANTIC_RETRIEVAL_CONFIG_KEY,), +) + + +class SemanticRetrievalManager: + """Single use-domain owner for projection, records, ranking and defaults.""" + + @classmethod + async def get_profile( + cls, profile_id: EmbeddingProfileID + ) -> EmbeddingProfileModel | None: + async with semantic_retrieval_uow() as repository: + return await repository.get_profile(profile_id) + + @classmethod + async def list_profiles(cls, *, limit: int | None = None, cursor: int | None = None): + async with semantic_retrieval_uow() as repository: + return await repository.list_profiles(limit=limit, cursor=cursor) + + @classmethod + async def _configured_profile_id(cls) -> EmbeddingProfileID: + value = await DeploymentConfigService.get(SEMANTIC_RETRIEVAL_CONFIG_KEY) + if value is None: + raise SemanticRetrievalNotConfiguredError( + "Semantic retrieval default Profile is not configured" + ) + if not isinstance(value, SemanticRetrievalConfig): + raise TypeError("Semantic retrieval config registry returned the wrong model") + if value.default_profile is None: + raise SemanticRetrievalNotConfiguredError( + "Semantic retrieval default Profile is not configured" + ) + return value.default_profile + + @classmethod + async def _load_profile( + cls, + profile: EmbeddingProfileID | None, + ) -> EmbeddingProfileModel: + profile_id = await cls._configured_profile_id() if profile is None else profile + async with semantic_retrieval_uow() as repository: + result = await repository.get_profile(profile_id) + if result is None: + raise EmbeddingProfileNotFoundError(f"Embedding Profile {profile_id} does not exist") + return result + + @classmethod + async def can_maintain(cls, profile: EmbeddingProfileID | None = None) -> bool: + """Return static local Job eligibility without probing the provider.""" + try: + selected = await cls._load_profile(profile) + except (SemanticRetrievalNotConfiguredError, EmbeddingProfileNotFoundError): + return False + return await AIManager.can_execute( + selected.ai_model, + AIExecutionRequirement( + capability="embedding", + input_modalities=frozenset({"text"}), + output_modalities=frozenset({"vector"}), + ), + ) + + @classmethod + async def retrieve( + cls, + query: str, + profile: EmbeddingProfileID | None = None, + options: VectorRetrievalOptions | None = None, + *, + route_to_peer: PeerRef | None = None, + ) -> SemanticRetrievalResult: + """Execute locally unless the caller explicitly selects another Peer.""" + request = SemanticRetrievalRequest( + query=query, + profile=profile, + options=options or VectorRetrievalOptions(), + ) + if route_to_peer is None or route_to_peer == PeerManager.get_current_peer_ref(): + return await cls.retrieve_local( + request.query, + request.profile, + request.options, + ) + + payload = PeerProtocolRequest( + body=typing.cast( + typing.Any, + request.model_dump(mode="json"), + ) + ) + result = await PeerManager.delegate( + SEMANTIC_RETRIEVAL_CAPABILITY, + typing.cast(typing.Any, payload.model_dump(mode="json", exclude_unset=True)), + route_to_peer=route_to_peer, + ) + try: + response = PeerProtocolResponse.model_validate(result) + if response.status != 200 or "body" not in response.model_fields_set: + raise SemanticRetrievalDelegationError( + f"Semantic retrieval Peer returned HTTP {response.status}" + ) + return SemanticRetrievalResult.model_validate(response.body) + except pydantic.ValidationError as error: + raise SemanticRetrievalDelegationError( + "Semantic retrieval Peer returned an invalid response" + ) from error + + @classmethod + async def retrieve_local( + cls, + query: str, + profile: EmbeddingProfileID | None = None, + options: VectorRetrievalOptions | None = None, + ) -> SemanticRetrievalResult: + """Execute exact local retrieval without entering Peer delegation.""" + if not query.strip(): + raise ValueError("query must not be empty") + retrieval_options = options or VectorRetrievalOptions() + selected = await cls._load_profile(profile) + profile_id = cls._profile_id(selected) + query_vector = ( + await AIManager.embed( + selected.ai_model, + (query,), + selected.dimensions, + ) + )[0] + cls._require_non_zero_vector(query_vector) + + matches: list[SemanticRetrievalMatch] = [] + async with semantic_retrieval_uow() as repository: + if "block" in retrieval_options.entity_types: + rows = await repository.retrieve_blocks(selected, query_vector, retrieval_options) + matches.extend( + BlockSemanticRetrievalMatch(entity=block, score=cls._score(distance)) + for block, distance in rows + ) + if "relation" in retrieval_options.entity_types: + rows = await repository.retrieve_relations( + selected, query_vector, retrieval_options + ) + matches.extend( + RelationSemanticRetrievalMatch(entity=relation, score=cls._score(distance)) + for relation, distance in rows + ) + + matches.sort( + key=lambda match: ( + -match.score, + match.type, + typing.cast(int, match.entity.id), + ) + ) + return SemanticRetrievalResult( + profile=profile_id, + matches=tuple(matches[: retrieval_options.limit]), + ) + + @classmethod + async def maintain( + cls, + profile: EmbeddingProfileID | None = None, + options: EmbeddingMaintenanceOptions | None = None, + ) -> EmbeddingMaintenanceReport: + """Embed only missing/stale records and resume through durable successes.""" + selected = await cls._load_profile(profile) + return await cls._maintain( + selected, + options or EmbeddingMaintenanceOptions(), + rebuild_cutoff=None, + ) + + @classmethod + async def rebuild( + cls, + profile: EmbeddingProfileID | None = None, + options: EmbeddingMaintenanceOptions | None = None, + ) -> EmbeddingMaintenanceReport: + """Re-embed records that were present before this invocation began.""" + selected = await cls._load_profile(profile) + async with semantic_retrieval_uow() as repository: + cutoff = await repository.database_now() + return await cls._maintain( + selected, + options or EmbeddingMaintenanceOptions(), + rebuild_cutoff=cutoff, + ) + + @classmethod + async def maintain_default( + cls, + options: EmbeddingMaintenanceOptions, + ) -> EmbeddingMaintenanceReport | None: + """Scheduled no-op when this deployment has no selected default Profile.""" + try: + return await cls.maintain(options=options) + except SemanticRetrievalNotConfiguredError: + return None + + @classmethod + async def _maintain( + cls, + profile: EmbeddingProfileModel, + options: EmbeddingMaintenanceOptions, + *, + rebuild_cutoff: datetime.datetime | None, + ) -> EmbeddingMaintenanceReport: + profile_id = cls._profile_id(profile) + report = _ReportBuilder(profile_id, options.diagnostic_limit) + batch: list[_Candidate] = [] + + async def offer(entity_type: SemanticEntityType, entity) -> bool: + entity_id = typing.cast(int | None, entity.id) + if entity_id is None: + raise RuntimeError("Persisted semantic candidate is missing its ID") + try: + text = await cls._project(entity_type, entity) + except _ProjectionUnavailable as error: + report.record(entity_type, entity_id, "unavailable", str(error)) + return True + except Exception as error: + logger.exception( + "Semantic projection failed", + extra={"entity_type": entity_type, "entity": entity_id}, + ) + report.record(entity_type, entity_id, "failed", type(error).__name__) + return True + batch.append(_Candidate(entity_type, entity_id, entity, text)) + if ( + len(batch) >= options.batch_size + or report.embedded + len(batch) >= options.max_embeddings + ): + return await flush() + return True + + async def flush() -> bool: + if not batch: + return True + current = tuple(batch) + batch.clear() + try: + await cls._embed_and_upsert(profile, current) + except Exception as error: + logger.exception( + "Semantic embedding batch failed", + extra={"profile": profile_id, "batch_size": len(current)}, + ) + for candidate in current: + report.record( + candidate.entity_type, + candidate.entity_id, + "failed", + type(error).__name__, + ) + return False + report.embedded += len(current) + return report.embedded < options.max_embeddings + + for entity_type in typing.cast(tuple[SemanticEntityType, ...], ("block", "relation")): + cursor = 0 + while report.embedded + len(batch) < options.max_embeddings: + page = await cls._candidate_page( + profile, + entity_type, + cursor, + options.scan_page_size, + rebuild_cutoff, + ) + cursor = page.next_cursor + for entity in page.entities: + if not await offer(entity_type, entity): + return report.build() + if report.embedded >= options.max_embeddings: + return report.build() + if page.exhausted: + break + await flush() + return report.build() + + @classmethod + async def _project( + cls, + entity_type: SemanticEntityType, + entity: BlockModel | RelationModel, + ) -> str: + try: + if entity_type == "block": + text = await ResolverManager.get(typing.cast(BlockModel, entity)).get_text() + else: + text = await RelationService.get_text(typing.cast(RelationModel, entity)) + except UnknownResolverError as error: + raise _ProjectionUnavailable("unknown_resolver") from error + except UnsupportedResolverCapability as error: + raise _ProjectionUnavailable("unsupported_text") from error + if text is None or not text.strip(): + raise _ProjectionUnavailable("empty_projection") + return text + + @classmethod + async def _embed_and_upsert( + cls, + profile: EmbeddingProfileModel, + candidates: tuple[_Candidate, ...], + ) -> None: + vectors = await AIManager.embed( + profile.ai_model, + tuple(candidate.text for candidate in candidates), + profile.dimensions, + ) + for vector in vectors: + cls._require_non_zero_vector(vector) + + profile_id = cls._profile_id(profile) + blocks = [] + relations = [] + for candidate, vector in zip(candidates, vectors, strict=True): + row = { + "profile": profile_id, + candidate.entity_type: candidate.entity_id, + "embedding": vector, + } + (blocks if candidate.entity_type == "block" else relations).append(row) + async with semantic_retrieval_uow() as repository: + await repository.upsert(blocks, relations) + + @classmethod + async def _candidate_page( + cls, + profile: EmbeddingProfileModel, + entity_type: SemanticEntityType, + cursor: int, + page_size: int, + rebuild_cutoff: datetime.datetime | None, + ) -> _CandidatePage: + profile_id = cls._profile_id(profile) + async with semantic_retrieval_uow() as repository: + if entity_type == "block": + rows = await repository.candidate_blocks(profile_id, cursor, page_size) + entities = tuple( + block + for block, record in rows + if cls._block_requires_embedding(profile, block, record, rebuild_cutoff) + ) + else: + rows = await repository.candidate_relations(profile_id, cursor, page_size) + entities = tuple( + relation + for relation, record, from_updated, to_updated in rows + if cls._relation_requires_embedding( + profile, relation, record, (from_updated, to_updated), rebuild_cutoff + ) + ) + next_cursor = typing.cast(int, rows[-1][0].id) if rows else cursor + return _CandidatePage(entities, next_cursor, len(rows) < page_size) + + @staticmethod + def _block_requires_embedding( + profile: EmbeddingProfileModel, + block: BlockModel, + record: BlockEmbeddingModel | None, + rebuild_cutoff: datetime.datetime | None, + ) -> bool: + if record is None: + return True + if rebuild_cutoff is not None: + return record.updated_at < rebuild_cutoff + return ( + len(record.embedding) != profile.dimensions + or record.updated_at < profile.updated_at + or record.updated_at < block.updated_at + ) + + @staticmethod + def _relation_requires_embedding( + profile: EmbeddingProfileModel, + relation: RelationModel, + record: RelationEmbeddingModel | None, + endpoint_updated_at: tuple[datetime.datetime, datetime.datetime], + rebuild_cutoff: datetime.datetime | None, + ) -> bool: + if record is None: + return True + if rebuild_cutoff is not None: + return record.updated_at < rebuild_cutoff + return ( + len(record.embedding) != profile.dimensions + or record.updated_at < profile.updated_at + or record.updated_at < relation.updated_at + or record.updated_at < endpoint_updated_at[0] + or record.updated_at < endpoint_updated_at[1] + ) + + @staticmethod + def _profile_id(profile: EmbeddingProfileModel) -> EmbeddingProfileID: + if profile.id is None: + raise RuntimeError("Persisted Embedding Profile is missing its ID") + return profile.id + + @staticmethod + def _require_non_zero_vector(vector) -> None: + if not any(value != 0 for value in vector): + raise InvalidSemanticVectorError("Semantic vectors must be non-zero") + + @staticmethod + def _score(distance: float) -> float: + score = 1.0 - distance + if not math.isfinite(score): + raise InvalidSemanticVectorError("Cosine score must be finite") + return min(1.0, max(-1.0, score)) diff --git a/app/business/source/AGENTS.md b/inkcre_core/business/source/AGENTS.md similarity index 97% rename from app/business/source/AGENTS.md rename to inkcre_core/business/source/AGENTS.md index 40e6b608..361c5147 100644 --- a/app/business/source/AGENTS.md +++ b/inkcre_core/business/source/AGENTS.md @@ -1,6 +1,6 @@ # source/ Local Guide -本文件只描述 `app/business/source/` 的局部事实与变更风险。跨 subtree contract 看 +本文件只描述 `inkcre_core/business/source/` 的局部事实与变更风险。跨 subtree contract 看 [business-pipeline-and-authority.md](../../../docs/30-unit-tdd/business-pipeline-and-authority.md)。 ## 何时阅读 diff --git a/inkcre_core/business/source/__init__.py b/inkcre_core/business/source/__init__.py new file mode 100644 index 00000000..20b0d0c3 --- /dev/null +++ b/inkcre_core/business/source/__init__.py @@ -0,0 +1,24 @@ +from .main import SourceBase, SourceManager +from .config import ( + SOURCE_CONFIG_KEY, + SOURCE_CONFIG_SCHEMA_ID, + SourceDeploymentConfig, +) +from .job import ( + SOURCE_BACKFILL_JOB_TYPE, + SOURCE_COLLECT_JOB_TYPE, + SourceBackfillJobHandler, + SourceCollectJobHandler, +) + +__all__ = [ + "SourceBase", + "SourceManager", + "SOURCE_COLLECT_JOB_TYPE", + "SOURCE_BACKFILL_JOB_TYPE", + "SourceCollectJobHandler", + "SourceBackfillJobHandler", + "SOURCE_CONFIG_KEY", + "SOURCE_CONFIG_SCHEMA_ID", + "SourceDeploymentConfig", +] diff --git a/inkcre_core/business/source/config.py b/inkcre_core/business/source/config.py new file mode 100644 index 00000000..e5f8070b --- /dev/null +++ b/inkcre_core/business/source/config.py @@ -0,0 +1,46 @@ +"""Deployment-wide policy used by Source-owned content materialization.""" + +import typing + +import pydantic + +from inkcre_core.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.info_base.storage import StorageManager, WritableStorage +from inkcre_core.schemas.info_base.storage import StorageID +from inkcre_core.schemas.source import SourceModel +from inkcre_core.persistence.source.uow import SourceUnitOfWork + + +SOURCE_CONFIG_KEY = "core.source" +SOURCE_CONFIG_SCHEMA_ID = "core.source.config.v1" +POSTGRESQL_BINARY_STORAGE_ID = -4 + + +class SourceDeploymentConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + default_storage: StorageID = POSTGRESQL_BINARY_STORAGE_ID + + +DeploymentConfigManager.register_schema( + SOURCE_CONFIG_SCHEMA_ID, SourceDeploymentConfig, keys=(SOURCE_CONFIG_KEY,) +) + + +async def resolve_writable_storage_async( + source: SourceModel, uow: SourceUnitOfWork +) -> WritableStorage: + """Resolve storage using the caller's Source/graph/config transaction.""" + storage_id = source.storage + if storage_id is None: + record = await uow.configuration.get(SOURCE_CONFIG_KEY) + persisted = None if record is None else DeploymentConfigManager._restore_record(record) + storage_id = ( + POSTGRESQL_BINARY_STORAGE_ID + if persisted is None + else typing.cast(SourceDeploymentConfig, persisted).default_storage + ) + storage = StorageManager.from_record(await uow.graph.storage.get(storage_id)) + if not isinstance(storage, WritableStorage): + raise ValueError(f"Storage {storage_id} is not writable") + return storage diff --git a/inkcre_core/business/source/job.py b/inkcre_core/business/source/job.py new file mode 100644 index 00000000..7b1bae06 --- /dev/null +++ b/inkcre_core/business/source/job.py @@ -0,0 +1,90 @@ +"""Exact Source command handlers hosted by the global Job runtime.""" + +from inkcre_core.business.job import JobHandler +from inkcre_core.persistence.job.uow import JobUnitOfWork +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.source import ( + SourceBackfillParameters, + SourceCollectParameters, +) + +from inkcre_core.validation import input_path + +from .main import SourceManager, SourceNotFoundError + + +SOURCE_COLLECT_JOB_TYPE = "core.source.collect.v1" +SOURCE_BACKFILL_JOB_TYPE = "core.source.backfill.v1" + + +async def _source_type(parameters: SourceCollectParameters) -> str | None: + source = await SourceManager.get(parameters.source) + return None if source is None else source.type + + +class SourceCollectJobHandler( + JobHandler[SourceCollectParameters], + job_type=SOURCE_COLLECT_JOB_TYPE, + description="Run one ordinary collection command for a configured Source.", + parameters_model=SourceCollectParameters, + default_timeout_seconds=300, +): + @classmethod + async def normalize_parameters(cls, parameters: dict, uow: JobUnitOfWork) -> dict: + normalized = await super().normalize_parameters(parameters, uow) + source = await uow.sources.get(normalized["source"]) + if source is None: + raise SourceNotFoundError(f"Source {normalized['source']} does not exist") + with input_path("config"): + normalized["config"] = SourceManager.normalize_config( + source.type, + normalized["config"], + await uow.sources.get_type(source.type), + command="collect", + ) + return normalized + + @classmethod + async def can_handle(cls, parameters: SourceCollectParameters) -> bool: + source_type = await _source_type(parameters) + return source_type is not None and SourceManager.has_source_type(source_type) + + @classmethod + async def handle(cls, job: JobModel, parameters: SourceCollectParameters) -> None: + source = await SourceManager.get_source_ins(parameters.source) + config = source.validate_collect_config(parameters.config) + await source.collect(job, config) + + +class SourceBackfillJobHandler( + JobHandler[SourceBackfillParameters], + job_type=SOURCE_BACKFILL_JOB_TYPE, + description="Run one exact historical collection command for a configured Source.", + parameters_model=SourceBackfillParameters, + default_timeout_seconds=1800, +): + @classmethod + async def normalize_parameters(cls, parameters: dict, uow: JobUnitOfWork) -> dict: + normalized = await super().normalize_parameters(parameters, uow) + source = await uow.sources.get(normalized["source"]) + if source is None: + raise SourceNotFoundError(f"Source {normalized['source']} does not exist") + with input_path("config"): + normalized["config"] = SourceManager.normalize_config( + source.type, + normalized["config"], + await uow.sources.get_type(source.type), + command="backfill", + ) + return normalized + + @classmethod + async def can_handle(cls, parameters: SourceBackfillParameters) -> bool: + source_type = await _source_type(parameters) + return source_type is not None and SourceManager.supports_backfill(source_type) + + @classmethod + async def handle(cls, job: JobModel, parameters: SourceBackfillParameters) -> None: + source = await SourceManager.get_source_ins(parameters.source) + config = source.validate_backfill_config(parameters.config) + await source.backfill(job, config) diff --git a/inkcre_core/business/source/main.py b/inkcre_core/business/source/main.py new file mode 100644 index 00000000..0b67949b --- /dev/null +++ b/inkcre_core/business/source/main.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +from inkcre_core.context import current_core + +import abc +import jsonschema # pyrefly: ignore[untyped-import] +import pydantic +import typing +from typing import Optional as Opt + +from inkcre_core.persistence.source.uow import SourceUnitOfWork, source_uow +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.source import ( + SourceModel, + SourceID, + SourceTypesModel, + SourceUpdateForm, +) +from inkcre_core.validation import input_path +from .resolver import SOURCE_RESOLVER_ID, SourceContent + +ConfigTV = typing.TypeVar("ConfigTV", bound=pydantic.BaseModel) + + +class SourceNotFoundError(LookupError): + """A Source instance or persisted type does not exist.""" + + +class UnsupportedSourceCommandError(ValueError): + """The catalog explicitly does not support the requested collection mode.""" + + +class EmptySourceCommandConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + +class SourceBase(abc.ABC, typing.Generic[ConfigTV]): + """InkCre Source Base class. + + Configuration is loaded from the database. Instantiate a source when it is + used instead of caching configuration separately. + """ + + __configschema__: dict + """Source configuration JSON schema""" + __configcls__: type[ConfigTV] + __collectconfigcls__: type[pydantic.BaseModel] + __backfillconfigcls__: type[pydantic.BaseModel] | None + + def __init_subclass__( + cls, + config_cls: type[ConfigTV], + collect_config_cls: type[pydantic.BaseModel] = EmptySourceCommandConfig, + backfill_config_cls: type[pydantic.BaseModel] | None = None, + **kwargs, + ) -> None: + # ConfigTV is bound by the concrete source subclass. + cls.__configcls__ = config_cls # pyrefly: ignore[no-access] + cls.__configschema__ = config_cls.model_json_schema() + cls.__collectconfigcls__ = collect_config_cls # pyrefly: ignore[no-access] + cls.__backfillconfigcls__ = backfill_config_cls # pyrefly: ignore[no-access] + SourceManager.add_source_type(cls) + return super().__init_subclass__(**kwargs) + + def __init__(self, _id: SourceID) -> None: + self._id = _id + + @abc.abstractmethod + async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: + """Collect new data from the source. + + :param job: The collect job containing config and state. + + Notes: + - Should not surpress exceptions, raise it. + """ + + async def backfill(self, job: JobModel, config: pydantic.BaseModel) -> None: + del job, config + raise NotImplementedError(f"{self.__class__.__name__} does not support backfill") + + async def record(self, data: typing.Any) -> None: + """Record data passively (e.g., from webhook). + + :param data: The data to record from external source (e.g., webhook payload). + + Notes: + - Should not suppress exceptions, raise it. + - Used for passive collection methods like webhooks. + """ + raise NotImplementedError(f"{self.__class__.__name__} does not support passive record") + + async def get_config(self) -> ConfigTV: + """Read and validate current Source configuration in a short scope.""" + async with source_uow() as uow: + source = await uow.sources.get(self._id) + if source is None: + raise SourceNotFoundError(f"Source {self._id} does not exist") + return self.__configcls__.model_validate(source.config) + + def validate_collect_config(self, config: dict) -> pydantic.BaseModel: + return self.__collectconfigcls__.model_validate(config) + + def validate_backfill_config(self, config: dict) -> pydantic.BaseModel: + if self.__backfillconfigcls__ is None: + raise NotImplementedError(f"{self.__class__.__name__} does not support backfill") + return self.__backfillconfigcls__.model_validate(config) + + async def get_state(self) -> dict: + async with source_uow() as uow: + source = await uow.sources.get(self._id) + if source is None: + raise SourceNotFoundError(f"Source {self._id} does not exist") + return source.state or {} + + async def set_state(self, state: dict) -> None: + async with source_uow() as uow: + source = await uow.sources.get(self._id, lock=True) + if source is None: + raise SourceNotFoundError(f"Source {self._id} does not exist") + source.state = state + await uow.sources.save(source) + + +class SourceManager: + """ + + - Run collect method of all configured sources + - Add, remove and configure source instances + - Add, remove sources + """ + + _SOURCE_CLASSES: dict[str, type[SourceBase]] = {} + + @classmethod + def add_source_type(cls, source_cls: type[SourceBase]) -> None: + """Register a source type in memory without external side effects.""" + source_type = source_cls.__module__ + "." + source_cls.__qualname__ + cls._SOURCE_CLASSES[source_type] = source_cls + + @classmethod + async def sync_source_types_async( + cls, source_classes: dict[str, type[SourceBase]] | None = None + ) -> None: + registered = cls._SOURCE_CLASSES if source_classes is None else source_classes + rows = [ + dict( + id=source_type, + description=source_cls.__doc__ or "No description.", + config_schema=source_cls.__configschema__, + collect_config_schema=source_cls.__collectconfigcls__.model_json_schema(), + backfill_config_schema=( + None + if source_cls.__backfillconfigcls__ is None + else source_cls.__backfillconfigcls__.model_json_schema() + ), + ) + for source_type, source_cls in registered.items() + ] + async with source_uow() as uow: + await uow.sources.sync_types(rows) + + @classmethod + def has_source_type(cls, source_type: str) -> bool: + return source_type in cls._SOURCE_CLASSES + + @classmethod + def supports_backfill(cls, source_type: str) -> bool: + source_cls = cls._SOURCE_CLASSES.get(source_type) + return source_cls is not None and source_cls.__backfillconfigcls__ is not None + + @classmethod + async def get_source_ins(cls, source_id: SourceID) -> SourceBase: + ins = current_core().sources.get(source_id) + if ins is not None: + return ins + source = await cls.get(source_id) + if source is None: + raise SourceNotFoundError(f"Source {source_id} does not exist") + source_class = cls._SOURCE_CLASSES.get(source.type) + if source_class is None: + raise ValueError(f"Source class {source.type} not registered.") + ins = source_class(_id=source_id) + current_core().sources[source_id] = ins + return ins + + @classmethod + async def create( + cls, + type_: str, + nickname: Opt[str] = None, + config: dict | None = None, + storage: int | None = None, + ) -> SourceModel: + async with source_uow() as uow: + with input_path("config"): + normalized = cls.normalize_config( + type_, config or {}, await uow.sources.get_type(type_) + ) + source = SourceModel( + type=type_, nickname=nickname, config=normalized, storage=storage + ) + await uow.sources.save(source) + return source + + @classmethod + def normalize_config( + cls, + type_: str, + config: dict, + catalog: SourceTypesModel | None, + *, + command: typing.Literal["collect", "backfill"] | None = None, + ) -> dict: + """Accept catalog-only input without requiring local execution capability.""" + if catalog is None: + raise SourceNotFoundError(f"Source type {type_!r} does not exist") + source_class = cls._SOURCE_CLASSES.get(type_) + if command == "backfill": + schema = catalog.backfill_config_schema + model = source_class.__backfillconfigcls__ if source_class else None + elif command == "collect": + schema = catalog.collect_config_schema + model = source_class.__collectconfigcls__ if source_class else None + else: + schema = catalog.config_schema + model = source_class.__configcls__ if source_class else None # pyrefly: ignore[missing-attribute] + if schema is None: + raise UnsupportedSourceCommandError( + f"Source type {type_!r} does not support {command}" + ) + if model is not None: + return model.model_validate(config).model_dump(mode="json") + jsonschema.Draft202012Validator(schema).validate(config) + return config + + @classmethod + async def get(cls, source_id: SourceID) -> SourceModel | None: + async with source_uow() as uow: + return await uow.sources.get(source_id) + + @classmethod + async def get_type(cls, type_: str) -> SourceTypesModel | None: + async with source_uow() as uow: + return await uow.sources.get_type(type_) + + @classmethod + async def list_sources( + cls, *, limit: int | None = None, cursor: SourceID | None = None + ) -> tuple[list[SourceModel], SourceID | None]: + async with source_uow() as uow: + return await uow.sources.list_sources(limit=limit, cursor=cursor) + + @classmethod + async def list_types( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[SourceTypesModel], str | None]: + async with source_uow() as uow: + return await uow.sources.list_types(limit=limit, cursor=cursor) + + @classmethod + async def update(cls, source_id: SourceID, form: SourceUpdateForm) -> SourceModel: + async with source_uow() as uow: + source = await uow.sources.get(source_id, lock=True) + if source is None: + raise SourceNotFoundError(f"Source {source_id} does not exist") + changes = form.model_dump(exclude_unset=True) + if "config" in changes: + with input_path("config"): + changes["config"] = cls.normalize_config( + source.type, changes["config"], await uow.sources.get_type(source.type) + ) + for field, value in changes.items(): + setattr(source, field, value) + await uow.sources.save(source) + if "nickname" in changes and source.block is not None: + await cls.ensure_block_async(source, uow) + return source + + @classmethod + async def delete(cls, source_id: SourceID) -> bool: + """Delete configuration only; collected graph and scheduled commands remain.""" + async with source_uow() as uow: + source = await uow.sources.get(source_id) + if source is None: + return False + await uow.sources.delete(source) + current_core().sources.pop(source_id, None) + return True + + @classmethod + async def ensure_block_async( + cls, source: SourceModel, uow: SourceUnitOfWork + ) -> BlockModel: + """Create/reuse a Source anchor in the caller's graph/state transaction.""" + if source.id is None: + raise ValueError("Source must be persisted before creating its anchor") + locked = await uow.sources.get(source.id, lock=True) + if locked is None: + raise SourceNotFoundError(f"Source {source.id} does not exist") + content = SourceContent( + id=source.id, type=locked.type, nickname=locked.nickname + ).model_dump_json() + if locked.block is None: + block = await uow.graph.blocks.create( + BlockForm(resolver=SOURCE_RESOLVER_ID, content=content) + ) + locked.block = block.id + await uow.sources.save(locked) + return block + block = await uow.graph.blocks.get(locked.block) + if block is None: + raise RuntimeError("Source anchor reference does not resolve") + if block.resolver != SOURCE_RESOLVER_ID or block.content != content: + block.resolver = SOURCE_RESOLVER_ID + block.storage = None + block.content = content + await uow.graph.blocks.save(block) + return block diff --git a/inkcre_core/business/source/resolver.py b/inkcre_core/business/source/resolver.py new file mode 100644 index 00000000..ec04cb62 --- /dev/null +++ b/inkcre_core/business/source/resolver.py @@ -0,0 +1,47 @@ +"""Use-time projection for lazy Source anchor Blocks.""" + +import pydantic + +from inkcre_core.business.info_base.resolver.label import format_label +from inkcre_core.business.info_base.resolver.main import Resolver, TextProjectionContext + + +SOURCE_RESOLVER_ID = "core.source.v1" + + +class SourceContent(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + id: int + type: str + nickname: str | None = None + + +class SourceResolver(Resolver[SourceContent, str], rso_type=SOURCE_RESOLVER_ID): + async def _get_solved_content( + self, + *, + refresh: bool = False, + materialize_missing: bool = True, + ) -> SourceContent: + del materialize_missing + return SourceContent.model_validate_json(await self.get_raw_content(refresh=refresh)) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context + source = await self.get_solved_content( + refresh=refresh, + materialize_missing=materialize_missing, + ) + name = f" named {source.nickname}" if source.nickname else "" + return f"Source {source.id}{name} uses {source.type}." + + async def get_label(self, *, refresh: bool = False) -> str: + source = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_label("source", source.nickname or source.type) diff --git a/inkcre_core/business/source/webpage.py b/inkcre_core/business/source/webpage.py new file mode 100644 index 00000000..74814835 --- /dev/null +++ b/inkcre_core/business/source/webpage.py @@ -0,0 +1 @@ +"""Webpage Source""" diff --git a/inkcre_core/configuration.py b/inkcre_core/configuration.py new file mode 100644 index 00000000..c09431af --- /dev/null +++ b/inkcre_core/configuration.py @@ -0,0 +1,59 @@ +"""Persistence-neutral mechanics for model-owned configuration values.""" + +from collections.abc import Mapping +import typing + +import pydantic + + +ConfigModelT = typing.TypeVar("ConfigModelT", bound=pydantic.BaseModel) + + +class ConfigContract(typing.Generic[ConfigModelT]): + """Apply one Pydantic model as a complete configuration contract. + + This abstraction deliberately has no persistence, registry, key, or live-update + behavior. Those consequences remain with the domain that owns the config. + """ + + def __init__(self, model: type[ConfigModelT]) -> None: + self.model = model + + def validate( + self, + value: ConfigModelT | Mapping[str, typing.Any], + ) -> ConfigModelT: + """Validate new input; pass an already constructed target model through. + + Passing a model back through Pydantic can rerun its model validators. + Typed callers have already crossed that boundary; persistence consumers may + still use one native model construction to restore nested Python types. + """ + if isinstance(value, self.model): + return value + return self.model.model_validate(value) + + def normalize( + self, + value: ConfigModelT | Mapping[str, typing.Any], + ) -> dict[str, typing.Any]: + """Return the complete value in JSON-compatible canonical form.""" + return self.validate(value).model_dump(mode="json") + + def prepare_patch( + self, + current: ConfigModelT | Mapping[str, typing.Any], + patch: Mapping[str, typing.Any], + ) -> ConfigModelT: + """Shallow-merge a patch and validate the resulting complete value.""" + candidate = ( + current.model_dump(mode="json") + if isinstance(current, pydantic.BaseModel) + else dict(current) + ) + candidate.update(patch) + return self.validate(candidate) + + def json_schema(self) -> dict[str, typing.Any]: + """Project the model's JSON Schema for UI or protocol discovery.""" + return self.model.model_json_schema() diff --git a/inkcre_core/context.py b/inkcre_core/context.py new file mode 100644 index 00000000..5013eb6a --- /dev/null +++ b/inkcre_core/context.py @@ -0,0 +1,103 @@ +"""Explicit database and execution scope for the SDK's domain operations.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 +import asyncio + +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine + +if TYPE_CHECKING: + from .business.job import _Execution + from .business.peer.contracts import PeerInbound, PeerOutboundFactory + from .business.source.main import SourceBase + + +@dataclass(frozen=True) +class CoreOptions: + """Runtime values supplied by the consumer, independent of dotenv or HTTP servers.""" + + peer_id: UUID = field(default_factory=uuid4) + peer_name: str = "InKCre Python" + application_version: str = "development" + jwt_secret: str = "" + jwt_default_lifetime_seconds: int = 60 + peer_http_timeout_seconds: float = 30 + agent_debug: bool = False + + +_CURRENT: ContextVar[Core] = ContextVar("inkcre_core") + + +def current_core() -> Core: + """Require an explicit binding; there is no default deployment or global engine.""" + core = _CURRENT.get(None) + if core is None: + raise RuntimeError("Use 'with core.bind():' around InKCre operations") + if core._closed: + raise RuntimeError("This InKCre Core has been closed") + return core + + +class Core: + """Own SDK resources, or borrow an engine without taking its disposal authority. + + Binding is task-local and inherited by child tasks. Each operation still creates + its own short-lived Unit of Work; sessions are never stored in the context. + """ + + def __init__( + self, + database: str | AsyncEngine, + *, + options: CoreOptions | None = None, + pool_pre_ping: bool = False, + ) -> None: + self.options = options or CoreOptions() + self._owns_engine = isinstance(database, str) + self.engine = ( + create_async_engine(database, pool_pre_ping=pool_pre_ping) + if isinstance(database, str) + else database + ) + self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) + self.sources: dict[int, SourceBase] = {} + self.inbounds: dict[str, PeerInbound] = {} + self.outbounds: dict[str, PeerOutboundFactory] = {} + self.active_jobs: dict[int, _Execution] = {} + self.pending_jobs: dict[int, asyncio.Task[bool]] = {} + self.accepting_jobs = False + self._closed = False + + @contextmanager + def bind(self) -> Generator[Core, None, None]: + """Bind this instance for an operation; nested bindings restore their caller.""" + if self._closed: + raise RuntimeError("This InKCre Core has been closed") + token = _CURRENT.set(self) + try: + yield self + finally: + _CURRENT.reset(token) + + async def aclose(self) -> None: + """Drain this instance's workers and dispose only an engine it created.""" + if self._closed: + return + with self.bind(): + if self.pending_jobs or self.active_jobs: + from .business.job import JobManager + + await JobManager.shutdown() + self.accepting_jobs = False + self.sources.clear() + self.inbounds.clear() + self.outbounds.clear() + if self._owns_engine: + await self.engine.dispose() + self._closed = True diff --git a/inkcre_core/database_contract/__init__.py b/inkcre_core/database_contract/__init__.py new file mode 100644 index 00000000..8943ac4e --- /dev/null +++ b/inkcre_core/database_contract/__init__.py @@ -0,0 +1,29 @@ +"""Executable peer database runtime contract.""" + +from .constants import ( + AUTHENTICATED_ROLE, + AUTHENTICATOR_ROLE, + CONTRACT_REVISION, + CORE_RUNTIME_ROLE, + DEVELOPMENT_PEER_ID, + INTERNAL_SCHEMA, + JWT_ALGORITHM, + JWT_AUDIENCE, + JWT_ISSUER, + JWT_MAX_LIFETIME_SECONDS, + PROTOCOL_SCHEMA, +) + +__all__ = [ + "AUTHENTICATED_ROLE", + "AUTHENTICATOR_ROLE", + "CONTRACT_REVISION", + "CORE_RUNTIME_ROLE", + "DEVELOPMENT_PEER_ID", + "INTERNAL_SCHEMA", + "JWT_ALGORITHM", + "JWT_AUDIENCE", + "JWT_ISSUER", + "JWT_MAX_LIFETIME_SECONDS", + "PROTOCOL_SCHEMA", +] diff --git a/inkcre_core/database_contract/constants.py b/inkcre_core/database_contract/constants.py new file mode 100644 index 00000000..c5dd790f --- /dev/null +++ b/inkcre_core/database_contract/constants.py @@ -0,0 +1,57 @@ +"""Stable names and values shared by migrations, runtimes, and consumers.""" + +import uuid + + +CONTRACT_FORMAT = 1 +CONTRACT_REVISION = "extension-distribution-open-v1" + +PROTOCOL_SCHEMA = "inkcre" +INTERNAL_SCHEMA = "inkcre_internal" + +AUTHENTICATED_ROLE = "authenticated" +AUTHENTICATOR_ROLE = "authenticator" +ANONYMOUS_ROLE = "anonymous" +CORE_RUNTIME_ROLE = "inkcre_core" + +JWT_ALGORITHM = "HS256" +JWT_ROLE = AUTHENTICATED_ROLE +JWT_ISSUER = "inkcre-peer" +JWT_AUDIENCE = "inkcre-api" +# Frozen input for admitted historical migrations; live admission has no lifetime cap. +JWT_MAX_LIFETIME_SECONDS = 24 * 60 * 60 +JWT_MINIMUM_SECRET_BYTES = 32 + +DEVELOPMENT_PEER_ID = uuid.UUID("00000000-0000-4000-8000-000000000001") +DEVELOPMENT_PEER_NAME = "client-web-development" + +DATABASE_ENVIRONMENTS = frozenset({"runtime", "development", "preview", "production"}) +RESET_CONFIRMATION = "reset-development-data" + +APPLICATION_TABLES = ( + "agents", + "ai_dialects", + "ai_models", + "ai_providers", + "block_embeddings", + "block_lexical_records", + "blocks", + "peers", + "configs", + "extensions", + "extension_peer_participations", + "embedding_profiles", + "crons", + "jobs", + "job_types", + "logs", + "relation_embeddings", + "relations", + "sink_types", + "sinks", + "sources", + "sources_types", + "storage_types", + "storage_blobs", + "storages", +) diff --git a/inkcre_core/database_contract/profile.py b/inkcre_core/database_contract/profile.py new file mode 100644 index 00000000..bfb56505 --- /dev/null +++ b/inkcre_core/database_contract/profile.py @@ -0,0 +1,386 @@ +"""Checked-in built-in catalog profile. + +The profile is data only. Reconciliation can consume it without importing FastAPI, +application settings, extension runtimes, or the scheduler. +""" + +from dataclasses import dataclass +from typing import Any + + +JsonObject = dict[str, Any] + + +@dataclass(frozen=True) +class TypeProfile: + id: str + description: str + config_schema: JsonObject + + +@dataclass(frozen=True) +class StorageTypeProfile: + id: str + description: str + config_schema: JsonObject + writable: bool + + +@dataclass(frozen=True) +class JobTypeProfile: + id: str + description: str + parameters_schema: JsonObject + default_timeout_seconds: int + + +@dataclass(frozen=True) +class StorageProfile: + id: int + type: str + nickname: str + config: JsonObject + + +def _object_schema( + title: str, + description: str, + properties: JsonObject, +) -> JsonObject: + return { + "description": description, + "properties": properties, + "title": title, + "type": "object", + } + + +def _positive_integer(default: int) -> JsonObject: + return {"default": default, "exclusiveMinimum": 0, "type": "integer"} + + +def _boolean(default: bool) -> JsonObject: + return {"default": default, "type": "boolean"} + + +HTTP_STORAGE_SCHEMA = _object_schema( + "HTTPStorageConfig", + "Configuration for HTTP storage.", + { + "follow_redirects": _boolean(True), + "max_response_bytes": _positive_integer(64 * 1024 * 1024), + "timeout": _positive_integer(30), + }, +) + +OPENAI_COMPATIBLE_DIALECT_SCHEMA = { + "additionalProperties": False, + "description": "Connection values for one OpenAI-compatible provider instance.", + "properties": { + "api_key": { + "minLength": 1, + "title": "Api Key", + "type": "string", + }, + "base_url": { + "anyOf": [ + {"minLength": 1, "type": "string"}, + {"type": "null"}, + ], + "default": None, + "title": "Base Url", + }, + }, + "required": ["api_key"], + "title": "OpenAICompatibleConfig", + "type": "object", +} + +BUILTIN_AI_DIALECTS = ( + TypeProfile( + "core.openai-compatible.v1", + "OpenAI-compatible embedding and chat protocol.", + OPENAI_COMPATIBLE_DIALECT_SCHEMA, + ), + TypeProfile( + "core.alibaba-model-studio.v1", + "Alibaba Model Studio OpenAI-compatible multimodal chat protocol.", + OPENAI_COMPATIBLE_DIALECT_SCHEMA, + ), +) + +BUILTIN_STORAGE_TYPES = ( + StorageTypeProfile( + "http", + "HTTP storage for bounded retrieval of opaque response bytes.", + HTTP_STORAGE_SCHEMA, + False, + ), +) + +BUILTIN_STORAGE_TYPES += ( + StorageTypeProfile( + "postgresql_binary", + "PostgreSQL storage for deployment-owned binary content.", + _object_schema( + "PostgreSQLBinaryStorageConfig", + "Configuration for PostgreSQL binary storage.", + {}, + ), + True, + ), +) + + +SOURCE_COLLECT_PARAMETERS_SCHEMA = { + "additionalProperties": False, + "description": "Parameters of the exact ordinary Source Job type.", + "properties": { + "config": { + "additionalProperties": True, + "title": "Config", + "type": "object", + }, + "source": {"title": "Source", "type": "integer"}, + }, + "required": ["source"], + "title": "SourceCollectParameters", + "type": "object", +} + +SOURCE_BACKFILL_PARAMETERS_SCHEMA = { + **SOURCE_COLLECT_PARAMETERS_SCHEMA, + "description": "Parameters of the exact historical Source Job type.", + "title": "SourceBackfillParameters", +} + +LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA = { + "$defs": { + "LexicalMaintenanceOptions": { + "additionalProperties": False, + "properties": { + "diagnostic_limit": { + "default": 20, + "minimum": 0, + "title": "Diagnostic Limit", + "type": "integer", + }, + "max_records": { + "default": 100, + "minimum": 1, + "title": "Max Records", + "type": "integer", + }, + "scan_page_size": { + "default": 100, + "minimum": 1, + "title": "Scan Page Size", + "type": "integer", + }, + }, + "title": "LexicalMaintenanceOptions", + "type": "object", + } + }, + "additionalProperties": False, + "properties": { + "options": {"$ref": "#/$defs/LexicalMaintenanceOptions"}, + }, + "title": "LexicalMaintenanceJobParameters", + "type": "object", +} + +SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA = { + "$defs": { + "EmbeddingMaintenanceOptions": { + "additionalProperties": False, + "description": "Peer-local bounds for one resumable maintenance invocation.", + "properties": { + "batch_size": { + "default": 20, + "minimum": 1, + "title": "Batch Size", + "type": "integer", + }, + "diagnostic_limit": { + "default": 20, + "minimum": 0, + "title": "Diagnostic Limit", + "type": "integer", + }, + "max_embeddings": { + "default": 100, + "minimum": 1, + "title": "Max Embeddings", + "type": "integer", + }, + "scan_page_size": { + "default": 100, + "minimum": 1, + "title": "Scan Page Size", + "type": "integer", + }, + }, + "title": "EmbeddingMaintenanceOptions", + "type": "object", + } + }, + "additionalProperties": False, + "properties": { + "options": {"$ref": "#/$defs/EmbeddingMaintenanceOptions"}, + "profile": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "title": "Profile", + }, + }, + "title": "EmbeddingMaintenanceJobParameters", + "type": "object", +} + +MEDIA_INTERPRETATION_PARAMETERS_SCHEMA = { + "additionalProperties": False, + "properties": {}, + "title": "MediaInterpretationJobParameters", + "type": "object", +} + +AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA = { + "additionalProperties": False, + "properties": { + "max_seeds": { + "default": 10, + "maximum": 100, + "minimum": 3, + "title": "Max Seeds", + "type": "integer", + } + }, + "title": "AutomaticOrganizationJobParameters", + "type": "object", +} + +BUILTIN_JOB_TYPES = ( + JobTypeProfile( + "core.source.collect.v1", + "Run one ordinary collection command for a configured Source.", + SOURCE_COLLECT_PARAMETERS_SCHEMA, + 300, + ), + JobTypeProfile( + "core.source.backfill.v1", + "Run one exact historical collection command for a configured Source.", + SOURCE_BACKFILL_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.feature_retrieval.lexical.maintain.v1", + "Maintain missing or stale Block lexical projection records.", + LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA, + 900, + ), + JobTypeProfile( + "core.feature_retrieval.lexical.rebuild.v1", + "Rebuild Block lexical records present before invocation.", + LEXICAL_MAINTENANCE_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.semantic_retrieval.maintain.v1", + "Maintain missing or stale semantic embedding records.", + SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA, + 900, + ), + JobTypeProfile( + "core.semantic_retrieval.rebuild.v1", + "Rebuild semantic embedding records present before invocation.", + SEMANTIC_MAINTENANCE_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.media_interpretation.v1", + "Interpret missing image, audio, and video Blocks through configured Agents.", + MEDIA_INTERPRETATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.rumination.explicit.v1", + "Reconsider one explicitly selected Block through rumination.", + { + "additionalProperties": False, + "description": "Explicit request to reconsider one focal Block.", + "properties": {"block": {"title": "Block", "type": "integer"}}, + "required": ["block"], + "title": "RuminationRequest", + "type": "object", + }, + 1800, + ), + JobTypeProfile( + "core.organization.rumination.automatic.v1", + "Automatically reconsider bounded information seeds through rumination.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.supersession.automatic.v1", + "Automatically judge bounded scoped-supersession candidates.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.refinement.automatic.v1", + "Automatically judge bounded non-dominating refinement candidates.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.evidence-stance.automatic.v1", + "Automatically judge bounded attributable evidence stances.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.synthesis.automatic.v1", + "Automatically create bounded provenance-preserving syntheses.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.existing-referent-anchoring.automatic.v1", + "Automatically anchor bounded mentions to existing referents.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.organization.duplicate-assertion.automatic.v1", + "Automatically judge bounded provenance-aware duplicate assertions.", + AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, + 1800, + ), + JobTypeProfile( + "core.sink.agent-query.v1", + "Answer one information need through an enabled Agent Query Sink.", + { + "additionalProperties": False, + "properties": { + "query": {"minLength": 1, "title": "Query", "type": "string"}, + "sink": {"title": "Sink", "type": "integer"}, + }, + "required": ["sink", "query"], + "title": "AgentQueryJobParameters", + "type": "object", + }, + 300, + ), +) + + +BUILTIN_STORAGES = ( + StorageProfile(-1, "http", "HTTP", {}), + StorageProfile(-4, "postgresql_binary", "PostgreSQL Binary", {}), +) + +BUILTIN_AI_DIALECTS_BY_ID = {item.id: item for item in BUILTIN_AI_DIALECTS} +BUILTIN_STORAGE_TYPES_BY_ID = {item.id: item for item in BUILTIN_STORAGE_TYPES} +BUILTIN_JOB_TYPES_BY_ID = {item.id: item for item in BUILTIN_JOB_TYPES} diff --git a/libs/AGENTS.md b/inkcre_core/libs/AGENTS.md similarity index 67% rename from libs/AGENTS.md rename to inkcre_core/libs/AGENTS.md index 23d94cef..d93f4319 100644 --- a/libs/AGENTS.md +++ b/inkcre_core/libs/AGENTS.md @@ -4,5 +4,5 @@ - Libraries must not acquire business-domain authority or import `app/business` policy. - Observability code may transport and format telemetry; it must not become the authority for application state. -- AI contracts belong in `app/schemas/ai/` and execution in `app/business/ai/`; do not reintroduce a process-global AI client under `libs/`. +- AI contracts belong in `inkcre_core/schemas/ai/` and execution in `inkcre_core/business/ai/`; do not reintroduce a process-global AI client under `libs/`. - Required check: run the affected library tests and import-boundary checks. diff --git a/inkcre_core/libs/obsrv/__init__.py b/inkcre_core/libs/obsrv/__init__.py new file mode 100644 index 00000000..39a4f499 --- /dev/null +++ b/inkcre_core/libs/obsrv/__init__.py @@ -0,0 +1,5 @@ +"""Observability package.""" + +__all__ = ["get_logger", "setup_obsrv"] + +from .main import get_logger, setup_obsrv diff --git a/inkcre_core/libs/obsrv/log_handler_logtail.py b/inkcre_core/libs/obsrv/log_handler_logtail.py new file mode 100644 index 00000000..61a93592 --- /dev/null +++ b/inkcre_core/libs/obsrv/log_handler_logtail.py @@ -0,0 +1,29 @@ +"""Logtail logging handler.""" + +import logging +from typing import Optional + +from logtail import LogtailHandler as RealLogtailHandler + + +class LogtailHandler: + """Wrapper for Logtail handler.""" + + def __init__(self, source_token: Optional[str] = None, host: Optional[str] = None): + self.source_token = source_token + self.host = host + self.handler = None + + def get_handler(self) -> Optional[logging.Handler]: + """Get the logtail handler if available.""" + if not self.source_token: + return None + if self.handler is None: + try: + self.handler = RealLogtailHandler(source_token=self.source_token) + if self.host: + self.handler.host = self.host + self.handler.setLevel(logging.INFO) + except Exception: + return None + return self.handler diff --git a/inkcre_core/libs/obsrv/log_handler_postgresql.py b/inkcre_core/libs/obsrv/log_handler_postgresql.py new file mode 100644 index 00000000..5b8d0d55 --- /dev/null +++ b/inkcre_core/libs/obsrv/log_handler_postgresql.py @@ -0,0 +1,75 @@ +"""Bounded bridge from synchronous logging calls to async PostgreSQL writes.""" + +import asyncio +import logging +from queue import Empty, Full, Queue +import sys + +from inkcre_core.libs.obsrv.log_record import ENABLE_LOG_BACKEND, LogModel, adapt_log_record + + +class PostgreSQLHandler(logging.Handler): + """Capture caller context immediately; persist only while lifespan is running.""" + + def __init__(self, level: int): + super().__init__(level) + self._queue: Queue[LogModel] = Queue(maxsize=1024) + self._task: asyncio.Task[None] | None = None + self._accepting = False + self.dropped = 0 + + def start(self) -> None: + if self._task is not None: + raise RuntimeError("PostgreSQL log writer is already running") + self._accepting = True + self._task = asyncio.create_task(self._write(), name="postgresql-log-writer") + + def emit(self, record: logging.LogRecord) -> None: + if not self._accepting or not ENABLE_LOG_BACKEND.get(): + return + try: + self._queue.put_nowait(adapt_log_record(record)) + except Full: + self.dropped += 1 + if self.dropped == 1: + print("PostgreSQL log queue full; dropping backend records", file=sys.stderr) + except Exception: + self.handleError(record) + + async def _write(self) -> None: + from inkcre_core.persistence.observability.uow import write_logs + + while self._accepting or not self._queue.empty(): + batch: list[LogModel] = [] + for _ in range(100): + try: + batch.append(self._queue.get_nowait()) + except Empty: + break + if not batch: + await asyncio.sleep(0.1) + continue + try: + await write_logs(batch) + except Exception as error: + # Report through stderr, never recursively through the failed backend. + print(f"PostgreSQL log batch failed: {type(error).__name__}", file=sys.stderr) + + async def aclose(self) -> None: + # Handler.handle() holds this lock even when emit originates on a worker thread. + self.acquire() + try: + self._accepting = False + finally: + self.release() + if self._task is not None: + try: + async with asyncio.timeout(5): + await self._task + except TimeoutError: + print("PostgreSQL log drain timed out; pending records lost", file=sys.stderr) + finally: + self._task = None + while not self._queue.empty(): + self._queue.get_nowait() + super().close() diff --git a/inkcre_core/libs/obsrv/log_record.py b/inkcre_core/libs/obsrv/log_record.py new file mode 100644 index 00000000..f0a1e869 --- /dev/null +++ b/inkcre_core/libs/obsrv/log_record.py @@ -0,0 +1,119 @@ +__all__ = ["LogModel", "adapt_log_record", "TRACE_ID", "SPAN_ID", "ENABLE_LOG_BACKEND"] + +from contextvars import ContextVar +import logging +import datetime +from typing import Mapping, Any, Dict, Optional as Opt +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +import sqlmodel + +from inkcre_core.database_contract.constants import PROTOCOL_SCHEMA + + +TRACE_ID: ContextVar[str | None] = ContextVar("TRACE_ID", default=None) +SPAN_ID: ContextVar[str | None] = ContextVar("SPAN_ID", default=None) +ENABLE_LOG_BACKEND: ContextVar[bool] = ContextVar("ENABLE_LOG_BACKEND", default=False) +"""Whether the log backend emit the logs or not""" + + +LOGGING_TO_OTEL: Mapping[int, int] = { + 10: 5, # DEBUG + 20: 9, # INFO + 30: 13, # WARNING + 40: 17, # ERROR + 50: 21, # CRITICAL +} + + +def logging_level_to_otel(levelno: int) -> int: + return LOGGING_TO_OTEL.get(levelno, 9) + + +class LogModel(sqlmodel.SQLModel, table=True): + """Log model""" + + __tablename__ = "logs" # type: ignore + __table_args__ = {"schema": PROTOCOL_SCHEMA} + + id: Opt[int] = sqlmodel.Field( + default=None, + sa_column=sa.Column(sa.BigInteger, primary_key=True, autoincrement=True), + ) + timestamp: datetime.datetime = sqlmodel.Field( + default_factory=datetime.datetime.now, + sa_column=sa.Column( + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + ) + severity_number: int = sqlmodel.Field(sa_type=sa.SmallInteger) + severity_text: str = sqlmodel.Field(sa_column=sa.Column(sa.Text, nullable=False)) + body: str = sqlmodel.Field(sa_column=sa.Column(sa.Text, nullable=False)) + trace_id: Opt[str] = sqlmodel.Field( + default=None, + sa_column=sa.Column(sa.Text, nullable=True), + ) + span_id: Opt[str] = sqlmodel.Field( + default=None, + sa_column=sa.Column(sa.Text, nullable=True), + ) + attributes: Dict[str, Any] = sqlmodel.Field( + sa_type=JSONB, + default_factory=dict, + sa_column_kwargs={"server_default": sa.text("'{}'::jsonb")}, + ) + + +def adapt_log_record( + record: logging.LogRecord, + # *, + # service_name: str, + # service_version: str | None = None, + # environment: str | None = None, +) -> LogModel: + attributes = { + k: v + for k, v in record.__dict__.items() + if k + not in { + "name", + "msg", + "message", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "taskName", + "asctime", + } + and not k.startswith("_") + } + + return LogModel( + timestamp=datetime.datetime.fromtimestamp(record.created), + severity_number=logging_level_to_otel(record.levelno), + severity_text=record.levelname, + body=record.getMessage(), + trace_id=TRACE_ID.get(), + span_id=SPAN_ID.get(), + attributes=attributes, + # service_name=service_name, + # service_version=service_version, + # environment=environment, + ) diff --git a/inkcre_core/libs/obsrv/main.py b/inkcre_core/libs/obsrv/main.py new file mode 100644 index 00000000..72f5b0c3 --- /dev/null +++ b/inkcre_core/libs/obsrv/main.py @@ -0,0 +1,85 @@ +"""Main observability setup.""" + +import logging +import sys +from .setting import ObsrvSetting + +__all__ = ["get_logger"] + +LOGGER = logging.getLogger("inkcre") + + +def setup_obsrv(config: ObsrvSetting) -> logging.Logger: + """Setup observability components.""" + + # Create logger + global LOGGER + LOGGER = logging.getLogger("inkcre") + LOGGER.setLevel(logging.DEBUG) + + # Clear any existing handlers + LOGGER.handlers.clear() + + # Console handler (always enabled) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + console_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + console_handler.setFormatter(console_formatter) + LOGGER.addHandler(console_handler) + + # Add backend-specific handlers + backend = config.logging_backend + if backend == "logtail": + from .log_handler_logtail import LogtailHandler + + logtail_wrapper = LogtailHandler( + source_token=config.logtail_source_token, + host=config.logtail_host, + ) + handler = logtail_wrapper.get_handler() + if handler: + LOGGER.addHandler(handler) + LOGGER.info("Logtail logging enabled") + else: + LOGGER.warning("Logtail logging configured but not available") + elif backend == "postgresql": + from .log_handler_postgresql import PostgreSQLHandler + + pg_handler = PostgreSQLHandler(level=config.logging_backend_level) + LOGGER.addHandler(pg_handler) + LOGGER.info("PostgreSQL logging enabled") + elif backend not in (None, "", "none"): + LOGGER.warning(f"Unknown logging backend: {backend}") + + return LOGGER + + +def get_logger() -> logging.Logger: + """Get the application logger instance. + + Returns: + Logger instance for the application + """ + global LOGGER + return LOGGER + + +def start_obsrv() -> None: + from .log_handler_postgresql import PostgreSQLHandler + + for handler in LOGGER.handlers: + if isinstance(handler, PostgreSQLHandler): + handler.start() + + +async def close_obsrv() -> None: + from .log_handler_postgresql import PostgreSQLHandler + from .telemetry import close_telemetry + + await close_telemetry() + + for handler in LOGGER.handlers: + if isinstance(handler, PostgreSQLHandler): + await handler.aclose() diff --git a/inkcre_core/libs/obsrv/propagation.py b/inkcre_core/libs/obsrv/propagation.py new file mode 100644 index 00000000..5db0c9c3 --- /dev/null +++ b/inkcre_core/libs/obsrv/propagation.py @@ -0,0 +1,52 @@ +"""Standard trace propagation independent of application initialization.""" + +from collections.abc import Mapping +from opentelemetry.context import Context +from opentelemetry.trace import Link +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from opentelemetry import trace +from .telemetry import emit_event, is_enabled + +PROPAGATOR = TraceContextTextMapPropagator() + + +def inject_context() -> dict[str, str]: + """Inject only standard Trace Context when this runtime explicitly enabled telemetry.""" + carrier: dict[str, str] = {} + if is_enabled(): + PROPAGATOR.inject(carrier) + return carrier + + +def submission_context() -> dict[str, str | None]: + carrier = inject_context() + state = carrier.get("tracestate") + if state is not None and len(state.encode("utf-8")) > 512: + state = None + emit_event("telemetry.carrier.dropped", {"reason": "tracestate_capacity"}) + return { + "submission_traceparent": carrier.get("traceparent"), + "submission_tracestate": state, + } + + +def extract_context(carrier: Mapping[str, str]) -> Context: + """Use the SDK parser within the application's bounded, two-header carrier.""" + if not is_enabled(): + return Context() + bounded = { + key: value + for key in ("traceparent", "tracestate") + if (value := carrier.get(key)) is not None and len(value.encode("utf-8")) <= 512 + } + return PROPAGATOR.extract(bounded, context=Context()) + + +def submission_links(parent: str | None, state: str | None) -> tuple[Link, ...]: + carrier = {} + if parent is not None: + carrier["traceparent"] = parent + if state is not None: + carrier["tracestate"] = state + context = trace.get_current_span(extract_context(carrier)).get_span_context() + return (Link(context),) if context.is_valid else () diff --git a/inkcre_core/libs/obsrv/setting.py b/inkcre_core/libs/obsrv/setting.py new file mode 100644 index 00000000..8e3a3346 --- /dev/null +++ b/inkcre_core/libs/obsrv/setting.py @@ -0,0 +1,35 @@ +"""Observability settings.""" + +from typing import Optional + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class ObsrvSetting(BaseSettings): + """Observability settings.""" + + telemetry_enabled: bool = Field( + default=False, + description="Enable optional metadata-only OpenTelemetry export for this Peer.", + ) + + agent_debug: bool = Field( + default=False, + description="Record Agent inputs, tool contracts and execution events for development.", + ) + + logging_backend: Optional[str] = Field( + default="postgresql", + description="Logging backend to use (e.g., 'logtail', 'postgresql')", + ) + logging_backend_level: int = Field( + default=20, + description="Minimum log level for sending logs to the backend", + ) + logtail_source_token: Optional[str] = Field( + default=None, description="Better Stack (Logtail) source token for remote logging" + ) + logtail_host: Optional[str] = Field( + default=None, description="Better Stack (Logtail) host URL" + ) diff --git a/inkcre_core/libs/obsrv/telemetry.py b/inkcre_core/libs/obsrv/telemetry.py new file mode 100644 index 00000000..6343e9d9 --- /dev/null +++ b/inkcre_core/libs/obsrv/telemetry.py @@ -0,0 +1,488 @@ +"""Opt-in OTLP metadata transport; callers own allowed field names and value sources.""" + +import asyncio +from collections.abc import Callable, Generator, Mapping, Sequence +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass, field +from functools import partial +import logging +import math +import os +import sys +import time +from typing import TYPE_CHECKING, Literal +from urllib.parse import urlsplit +import uuid + +from opentelemetry.context import Context +from opentelemetry.trace import INVALID_SPAN, Link, Span, SpanKind, StatusCode, use_span +from opentelemetry.util.types import AttributeValue +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +if TYPE_CHECKING: + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.metrics import Counter, Histogram + from opentelemetry.trace import Tracer + from opentelemetry._logs import Logger + + +class _ExportSettings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + otel_exporter_otlp_traces_endpoint: str = "" + otel_exporter_otlp_logs_endpoint: str = "" + otel_exporter_otlp_metrics_endpoint: str = "" + otel_exporter_otlp_headers: SecretStr = SecretStr("") + otel_exporter_otlp_traces_headers: SecretStr = SecretStr("") + otel_exporter_otlp_logs_headers: SecretStr = SecretStr("") + otel_exporter_otlp_metrics_headers: SecretStr = SecretStr("") + # Validate each signal separately, so a bad timeout does not disable siblings. + otel_exporter_otlp_timeout: str = "10" + otel_exporter_otlp_traces_timeout: str = "" + otel_exporter_otlp_logs_timeout: str = "" + otel_exporter_otlp_metrics_timeout: str = "" + + +@dataclass +class _Runtime: + connections: dict[str, tuple[str, dict[str, str], float]] = field(default_factory=dict) + traces: "TracerProvider | None" = None + logs: "LoggerProvider | None" = None + metrics: "MeterProvider | None" = None + tracer: "Tracer | None" = None + logger: "Logger | None" = None + operations: "Counter | None" = None + duration: "Histogram | None" = None + tokens: "Counter | None" = None + missing_usage: "Counter | None" = None + + +_runtime: _Runtime | None = None + + +def _diagnostic(reason: str) -> None: + # This sink never receives endpoints, headers, exceptions, or arbitrary log messages. + try: + print(f"OpenTelemetry: {reason}", file=sys.stderr) + except Exception: + # Diagnostics cannot make a broken stderr stream a business failure. + pass + + +class _SDKDiagnostics(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + # SDK errors may contain response bodies, URLs or malformed secret headers. + _diagnostic("SDK warning or export failure; check the configured OTLP receiver") + + +def _configure_diagnostics() -> None: + logger = logging.getLogger("opentelemetry") + if not any(isinstance(handler, _SDKDiagnostics) for handler in logger.handlers): + handler = _SDKDiagnostics(level=logging.WARNING) + logger.addHandler(handler) + logger.propagate = False + + +def _endpoint(value: str) -> str: + parsed = urlsplit(value) + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or not parsed.path + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or any(character.isspace() for character in value) + ): + raise ValueError("invalid OTLP URL") + # Accessing port performs urllib's range and syntax validation. + parsed.port + return value + + +def is_enabled() -> bool: + """Whether at least one metadata signal initialized for this runtime.""" + return _runtime is not None + + +def get_export_connection(signal: str) -> tuple[str, dict[str, str], float] | None: + """Return private endpoint, copied headers and timeout seconds for server forwarding.""" + connection = _runtime.connections.get(signal) if _runtime is not None else None + return ( + (connection[0], dict(connection[1]), connection[2]) if connection is not None else None + ) + + +def start_telemetry( + *, + enabled: bool, + service_version: str, + resource_attributes: Mapping[str, AttributeValue] | None = None, + endpoints: Mapping[str, str] | None = None, +) -> None: + """Initialize only configured signals; configuration failures never gate readiness.""" + global _runtime + if not enabled or _runtime is not None: + return + + _configure_diagnostics() + try: + config = _ExportSettings(_env_file=os.getenv("INKCRE_ENV_FILE", ".env") or None) + except Exception: + _diagnostic("invalid runtime export configuration; telemetry is disabled") + return + + from opentelemetry.exporter.otlp.proto.http import Compression + from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.metrics import NoOpMeterProvider + from opentelemetry.sdk._logs import LoggerProvider, LogRecordLimits + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk.metrics import MeterProvider, SimpleFixedSizeExemplarReservoir + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import SpanLimits, TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.sdk.trace.sampling import ALWAYS_ON + from opentelemetry.util.re import parse_env_headers + from requests.utils import check_header_validity # type: ignore[untyped-import] + + # Resource.create invokes environment detectors; direct construction is deliberate. + resource = Resource( + { + **(resource_attributes or {}), + "service.name": "core-py", + "service.version": service_version, + "service.instance.id": str(uuid.uuid4()), + } + ) + runtime = _Runtime() + no_metrics = NoOpMeterProvider() + for signal in ("metrics", "traces", "logs"): + endpoint = getattr(config, f"otel_exporter_otlp_{signal}_endpoint") or ( + endpoints or {} + ).get(signal, "") + if not endpoint: + continue + cleanup: Callable[[], None] | None = None + internal_metrics = runtime.metrics or no_metrics + try: + endpoint = _endpoint(endpoint) + timeout = float( + getattr(config, f"otel_exporter_otlp_{signal}_timeout") + or config.otel_exporter_otlp_timeout + or "10" + ) + if not math.isfinite(timeout) or not 0 < timeout <= 30: + raise ValueError("OTLP timeout must be finite, positive and at most 30 seconds") + raw_headers = ( + getattr(config, f"otel_exporter_otlp_{signal}_headers").get_secret_value() + or config.otel_exporter_otlp_headers.get_secret_value() + ) + headers = parse_env_headers(raw_headers, liberal=True) + # The SDK parser drops malformed entries. Do not silently accept partial auth. + if len(headers) != len([part for part in raw_headers.split(",") if part.strip()]): + raise ValueError("invalid OTLP headers") + for header in headers.items(): + check_header_validity(header) + + if signal == "traces": + exporter = OTLPSpanExporter( + endpoint=endpoint, + headers=headers, + timeout=timeout, + compression=Compression.NoCompression, + meter_provider=internal_metrics, + ) + cleanup = exporter.shutdown + traces = TracerProvider( + resource=resource, + sampler=ALWAYS_ON, + shutdown_on_exit=False, + span_limits=SpanLimits( + max_attributes=64, + max_events=16, + max_links=16, + max_attribute_length=256, + max_span_attributes=64, + max_event_attributes=64, + max_link_attributes=64, + max_span_attribute_length=256, + ), + meter_provider=internal_metrics, + ) + processor = BatchSpanProcessor( + exporter, + max_queue_size=256, + max_export_batch_size=256, + schedule_delay_millis=5000, + export_timeout_millis=timeout * 1000, + meter_provider=internal_metrics, + ) + cleanup = processor.shutdown + traces.add_span_processor(processor) + cleanup = traces.shutdown + tracer = traces.get_tracer("inkcre.metadata") + runtime.traces, runtime.tracer = traces, tracer + elif signal == "logs": + log_exporter = OTLPLogExporter( + endpoint=endpoint, + headers=headers, + timeout=timeout, + compression=Compression.NoCompression, + meter_provider=internal_metrics, + ) + cleanup = log_exporter.shutdown + logs = LoggerProvider( + resource=resource, + shutdown_on_exit=False, + meter_provider=internal_metrics, + log_record_limits=LogRecordLimits(max_attributes=64, max_attribute_length=256), + ) + log_processor = BatchLogRecordProcessor( + log_exporter, + max_queue_size=256, + max_export_batch_size=256, + schedule_delay_millis=5000, + export_timeout_millis=timeout * 1000, + meter_provider=internal_metrics, + ) + cleanup = log_processor.shutdown + logs.add_log_record_processor(log_processor) + cleanup = logs.shutdown + logger = logs.get_logger("inkcre.metadata") + runtime.logs, runtime.logger = logs, logger + else: + metric_exporter = OTLPMetricExporter( + endpoint=endpoint, + headers=headers, + timeout=timeout, + compression=Compression.NoCompression, + meter_provider=no_metrics, + ) + cleanup = metric_exporter.shutdown + reader = PeriodicExportingMetricReader( + metric_exporter, + export_interval_millis=30000, + export_timeout_millis=timeout * 1000, + ) + cleanup = partial(reader.shutdown, timeout_millis=(timeout + 2) * 1000) + metrics = MeterProvider( + resource=resource, + metric_readers=[reader], + shutdown_on_exit=False, + views=[ + # SDK defaults are too coarse for a duration measured in seconds. + View( + instrument_name="inkcre.operation.duration", + aggregation=ExplicitBucketHistogramAggregation( + boundaries=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1, + 2.5, + 5, + 10, + 30, + 60, + 300, + ) + ), + ), + View( + meter_name="opentelemetry-sdk", + attribute_keys={ + "otel.component.type", + "error.type", + "http.response.status_code", + }, + # An exemplar can otherwise retain attributes excluded by the View. + exemplar_reservoir_factory=lambda _: ( + lambda _config=None, **_kwargs: SimpleFixedSizeExemplarReservoir(size=0) + ), + ), + ], + ) + cleanup = partial(metrics.shutdown, timeout_millis=(timeout + 2) * 1000) + meter = metrics.get_meter("inkcre.metadata") + operations = meter.create_counter("inkcre.operation.count", unit="{operation}") + duration = meter.create_histogram("inkcre.operation.duration", unit="s") + tokens = meter.create_counter("inkcre.ai.token.usage", unit="{token}") + missing_usage = meter.create_counter("inkcre.ai.usage.missing", unit="{field}") + runtime.metrics = metrics + runtime.operations, runtime.duration = operations, duration + runtime.tokens, runtime.missing_usage = tokens, missing_usage + except Exception: + if cleanup is not None: + _shutdown(cleanup) + _diagnostic(f"{signal} configuration or initialization failed; signal is disabled") + else: + runtime.connections[signal] = (endpoint, dict(headers), timeout) + + if any((runtime.traces, runtime.logs, runtime.metrics)): + _runtime = runtime + else: + _diagnostic("no usable per-signal endpoint; telemetry is disabled") + + +def _shutdown(shutdown: Callable[[], None]) -> None: + try: + shutdown() + except Exception: + _diagnostic("signal shutdown failed; remaining telemetry may be lost") + + +async def close_telemetry() -> None: + """Stop new records and concurrently drain SDK queues outside the event loop. + + SDK trace/log shutdown waits up to 30 seconds for each worker, then metrics gets + its configured request timeout plus two seconds. These worker wait budgets are + not transport-wide hard deadlines; freezing or termination can lose records. + """ + global _runtime + runtime, _runtime = _runtime, None + if runtime is None: + return + shutdowns: list[Callable[[], None]] = [] + for provider in (runtime.traces, runtime.logs): + if provider is not None: + shutdowns.append(provider.shutdown) + # SDK 1.45 force_flush ignores its timeout; shutdown owns the bounded queue drain. + await asyncio.gather(*(asyncio.to_thread(_shutdown, shutdown) for shutdown in shutdowns)) + if runtime.metrics is not None: + # Collect final native processor/exporter metrics after their workers have drained. + await asyncio.to_thread( + _shutdown, + partial( + runtime.metrics.shutdown, + timeout_millis=(runtime.connections["metrics"][2] + 2) * 1000, + ), + ) + + +def _record(action: Callable[[], object]) -> None: + """Isolate one owned telemetry call; never wrap the caller's business block.""" + try: + action() + except Exception: + _diagnostic("recording failed; optional telemetry may be lost") + + +@dataclass +class Operation: + """One execution scope's result, independent of whether its Span is recorded. + + Business boundaries set outcome when they return a failure or handle cancellation. + Span methods remain available explicitly through span; Span status is not a metric. + Those standard SDK methods require the caller's admitted primitive metadata values; + the facade isolates its own SDK calls, not arbitrary caller code inside the scope. + """ + + span: Span + outcome: Literal["success", "error", "cancelled"] = "success" + + +@contextmanager +def operation( + name: str, + *, + attributes: Mapping[str, AttributeValue] | None = None, + context: Context | None = None, + links: Sequence[Link] | None = None, + kind: SpanKind = SpanKind.INTERNAL, +) -> Generator[Operation, None, None]: + """Measure a fixed operation name; callers supply only admitted metadata values. + + Exceptions and cancellation propagate unchanged. Their messages, stack traces and + status descriptions are never recorded by this context manager. + """ + runtime = _runtime + if runtime is None: + yield Operation(INVALID_SPAN) + return + + span = INVALID_SPAN + if runtime.tracer is not None: + try: + span = runtime.tracer.start_span( + name, + attributes=attributes, + context=context, + links=links, + kind=kind, + record_exception=False, + set_status_on_exception=False, + ) + except Exception: + _diagnostic("span creation failed; operation metrics remain available") + observation = Operation(span) + started = time.perf_counter() + activation = ExitStack() + _record( + lambda: activation.enter_context( + use_span(span, record_exception=False, set_status_on_exception=False) + ) + ) + try: + yield observation + except BaseException as error: + observation.outcome = ( + "cancelled" if isinstance(error, asyncio.CancelledError) else "error" + ) + _record(partial(span.set_attribute, "error.type", type(error).__name__)) + raise + finally: + # Exit the SDK context with no exception and end separately. This prevents SDK + # context cleanup or span processors from masking the caller's original error. + if observation.outcome != "success": + _record(partial(span.set_status, StatusCode.ERROR)) + _record(partial(span.set_attribute, "inkcre.outcome", observation.outcome)) + labels = {"operation": name, "outcome": observation.outcome} + if runtime.operations is not None: + _record(partial(runtime.operations.add, 1, labels)) + if runtime.duration is not None: + _record(partial(runtime.duration.record, time.perf_counter() - started, labels)) + _record(activation.close) + _record(span.end) + + +def emit_event(name: str, attributes: Mapping[str, AttributeValue]) -> None: + """Emit a fixed event name and admitted metadata without a Python logging bridge.""" + runtime = _runtime + if runtime is not None and runtime.logger is not None: + from opentelemetry._logs import SeverityNumber + + _record( + partial( + runtime.logger.emit, + body=name, + severity_number=SeverityNumber.INFO, + attributes=attributes, + ) + ) + + +def record_ai_usage( + operation: str, input_tokens: int | None, output_tokens: int | None +) -> None: + """Count known nonnegative provider usage and count missing fields separately.""" + runtime = _runtime + if runtime is None or runtime.tokens is None or runtime.missing_usage is None: + return + for kind, value in (("input", input_tokens), ("output", output_tokens)): + labels = {"operation": operation, "token.type": kind} + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + _record(partial(runtime.tokens.add, value, labels)) + else: + _record(partial(runtime.missing_usage.add, 1, labels)) diff --git a/inkcre_core/persistence/__init__.py b/inkcre_core/persistence/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/agent/__init__.py b/inkcre_core/persistence/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/agent/repository.py b/inkcre_core/persistence/agent/repository.py new file mode 100644 index 00000000..2ac48f47 --- /dev/null +++ b/inkcre_core/persistence/agent/repository.py @@ -0,0 +1,49 @@ +"""Persisted Agent definitions; Thread runtime state has a separate owner.""" + +import sqlmodel +from sqlalchemy.ext.asyncio import AsyncSession +from inkcre_core.schemas.agent import AgentDefinitionModel, AgentForm + + +class AgentRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get( + self, agent_id: int, *, for_update: bool = False + ) -> AgentDefinitionModel | None: + statement = sqlmodel.select(AgentDefinitionModel).where( + AgentDefinitionModel.id == agent_id + ) + if for_update: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def list( + self, *, limit: int | None, cursor: int | None + ) -> tuple[list[AgentDefinitionModel], int | None]: + statement = sqlmodel.select(AgentDefinitionModel).order_by( + sqlmodel.col(AgentDefinitionModel.id) + ) + if cursor is not None: + statement = statement.where(sqlmodel.col(AgentDefinitionModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def create(self, form: AgentForm) -> AgentDefinitionModel: + record = AgentDefinitionModel(**form.model_dump()) + self._session.add(record) + await self._session.flush() + return record + + async def save(self, record: AgentDefinitionModel) -> None: + self._session.add(record) + await self._session.flush() + await self._session.refresh(record) + + async def delete(self, record: AgentDefinitionModel) -> None: + await self._session.delete(record) diff --git a/inkcre_core/persistence/agent/uow.py b/inkcre_core/persistence/agent/uow.py new file mode 100644 index 00000000..a79040ec --- /dev/null +++ b/inkcre_core/persistence/agent/uow.py @@ -0,0 +1,13 @@ +"""Short transaction for persisted Agent definitions.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import AgentRepository + + +@asynccontextmanager +async def agent_uow() -> AsyncGenerator[AgentRepository, None]: + async with current_core().sessions.begin() as session: + yield AgentRepository(session) diff --git a/inkcre_core/persistence/ai/__init__.py b/inkcre_core/persistence/ai/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/ai/repository.py b/inkcre_core/persistence/ai/repository.py new file mode 100644 index 00000000..f1b0bfed --- /dev/null +++ b/inkcre_core/persistence/ai/repository.py @@ -0,0 +1,47 @@ +"""AI registry facts inside the caller's transaction.""" + +from collections.abc import Sequence +from typing import Any + +import sqlmodel +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.ai import AIDialectModel, AIModelModel, AIProviderModel + + +class AIRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_model(self, model_id: int) -> AIModelModel | None: + return await self._session.get(AIModelModel, model_id) + + async def get_provider(self, provider_id: int) -> AIProviderModel | None: + return await self._session.get(AIProviderModel, provider_id) + + async def list_models( + self, *, limit: int | None, cursor: int | None + ) -> tuple[list[AIModelModel], int | None]: + statement = sqlmodel.select(AIModelModel).order_by(sqlmodel.col(AIModelModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(AIModelModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def sync_dialects(self, records: Sequence[dict[str, Any]]) -> None: + if not records: + return + statement = insert(AIDialectModel).values(records) + statement = statement.on_conflict_do_update( + index_elements=[AIDialectModel.id], + set_={ + "description": statement.excluded.description, + "config_schema": statement.excluded.config_schema, + }, + ) + await self._session.execute(statement) diff --git a/inkcre_core/persistence/ai/uow.py b/inkcre_core/persistence/ai/uow.py new file mode 100644 index 00000000..82b147c9 --- /dev/null +++ b/inkcre_core/persistence/ai/uow.py @@ -0,0 +1,13 @@ +"""Short database scope for AI facts; provider calls outlive this scope.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import AIRepository + + +@asynccontextmanager +async def ai_uow() -> AsyncGenerator[AIRepository, None]: + async with current_core().sessions.begin() as session: + yield AIRepository(session) diff --git a/inkcre_core/persistence/cron/__init__.py b/inkcre_core/persistence/cron/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/cron/repository.py b/inkcre_core/persistence/cron/repository.py new file mode 100644 index 00000000..e60ebfb5 --- /dev/null +++ b/inkcre_core/persistence/cron/repository.py @@ -0,0 +1,50 @@ +"""Locked Cron occurrences and database-clock observations.""" + +import datetime + +import sqlmodel +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.cron import CronModel + + +class CronRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get(self, cron_id: int, *, lock: bool = False, skip_locked: bool = False): + statement = sqlmodel.select(CronModel).where(CronModel.id == cron_id) + if lock: + statement = statement.with_for_update(skip_locked=skip_locked) + return (await self._session.scalars(statement)).one_or_none() + + async def database_now(self) -> datetime.datetime: + return ( + await self._session.execute(sqlmodel.select(sqlmodel.func.statement_timestamp())) + ).scalar_one() + + async def enabled_ids(self): + return tuple( + await self._session.scalars( + sqlmodel.select(CronModel.id).where(sqlmodel.col(CronModel.enabled).is_(True)) + ) + ) + + async def list(self, *, limit: int | None, cursor: int | None): + statement = sqlmodel.select(CronModel).order_by(sqlmodel.col(CronModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(CronModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def save(self, cron: CronModel) -> None: + self._session.add(cron) + await self._session.flush() + await self._session.refresh(cron) + + async def delete(self, cron: CronModel) -> None: + await self._session.delete(cron) diff --git a/inkcre_core/persistence/cron/uow.py b/inkcre_core/persistence/cron/uow.py new file mode 100644 index 00000000..abfb41c3 --- /dev/null +++ b/inkcre_core/persistence/cron/uow.py @@ -0,0 +1,24 @@ +"""A Cron occurrence and its admitted Job commit together.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from inkcre_core.context import current_core +from inkcre_core.persistence.job.repository import JobRepository +from inkcre_core.persistence.job.uow import JobUnitOfWork +from inkcre_core.persistence.source.repository import SourceRepository +from .repository import CronRepository + + +@dataclass(frozen=True) +class CronUnitOfWork(JobUnitOfWork): + crons: CronRepository + + +@asynccontextmanager +async def cron_uow() -> AsyncGenerator[CronUnitOfWork, None]: + async with current_core().sessions.begin() as session: + yield CronUnitOfWork( + JobRepository(session), SourceRepository(session), CronRepository(session) + ) diff --git a/inkcre_core/persistence/deployment_config/__init__.py b/inkcre_core/persistence/deployment_config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/deployment_config/repository.py b/inkcre_core/persistence/deployment_config/repository.py new file mode 100644 index 00000000..36048196 --- /dev/null +++ b/inkcre_core/persistence/deployment_config/repository.py @@ -0,0 +1,68 @@ +"""Deployment config persistence and transaction composition.""" + +from typing import Any + +import sqlmodel +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.deployment_config import DeploymentConfigModel + + +class DeploymentConfigRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get( + self, key: str, *, for_update: bool = False + ) -> DeploymentConfigModel | None: + statement = sqlmodel.select(DeploymentConfigModel).where( + DeploymentConfigModel.key == key + ) + if for_update: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def list( + self, *, limit: int | None, cursor: str | None + ) -> tuple[list[DeploymentConfigModel], str | None]: + statement = sqlmodel.select(DeploymentConfigModel).order_by(DeploymentConfigModel.key) + if cursor is not None: + statement = statement.where(DeploymentConfigModel.key > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].key if more else None + + async def replace( + self, key: str, schema_id: str, value: dict[str, Any] + ) -> tuple[DeploymentConfigModel, bool]: + statement = insert(DeploymentConfigModel).values( + key=key, schema_id=schema_id, value=value + ) + statement = statement.on_conflict_do_nothing( + index_elements=[DeploymentConfigModel.key] + ).returning(DeploymentConfigModel) + record = (await self._session.scalars(statement)).one_or_none() + if record is not None: + return record, True + # The insert conflict, rather than a preceding read, determines creation status. + statement = ( + sqlmodel.update(DeploymentConfigModel) + .where(sqlmodel.col(DeploymentConfigModel.key) == key) + .values(schema_id=schema_id, value=value) + .returning(DeploymentConfigModel) + ) + record = (await self._session.scalars(statement)).one() + return record, False + + async def save(self, record: DeploymentConfigModel) -> None: + self._session.add(record) + await self._session.flush() + # UPDATE timestamps are managed by database triggers, not Python defaults. + await self._session.refresh(record) + + async def delete(self, record: DeploymentConfigModel) -> None: + await self._session.delete(record) diff --git a/inkcre_core/persistence/deployment_config/uow.py b/inkcre_core/persistence/deployment_config/uow.py new file mode 100644 index 00000000..3bfc3252 --- /dev/null +++ b/inkcre_core/persistence/deployment_config/uow.py @@ -0,0 +1,12 @@ +"""Deployment configuration transaction composition.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from inkcre_core.context import current_core +from .repository import DeploymentConfigRepository + + +@asynccontextmanager +async def configuration_transaction() -> AsyncGenerator[DeploymentConfigRepository, None]: + async with current_core().sessions.begin() as session: + yield DeploymentConfigRepository(session) diff --git a/inkcre_core/persistence/extension/__init__.py b/inkcre_core/persistence/extension/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/extension/repository.py b/inkcre_core/persistence/extension/repository.py new file mode 100644 index 00000000..91c72176 --- /dev/null +++ b/inkcre_core/persistence/extension/repository.py @@ -0,0 +1,72 @@ +"""Session-bound access to canonical Extension rows and the enabled RPC.""" + +import uuid + +import sqlalchemy +import sqlmodel +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel + + +class ExtensionRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list(self): + rows = await self._session.scalars( + sqlmodel.select(ExtensionModel).order_by(ExtensionModel.name) + ) + return tuple(rows) + + async def get(self, name: str, *, lock: bool = False): + statement = sqlmodel.select(ExtensionModel).where(ExtensionModel.name == name) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def try_install_lock(self, name: str) -> bool: + result = await self._session.execute( + sqlalchemy.text("SELECT pg_try_advisory_xact_lock(hashtextextended(:name, 0))"), + {"name": name}, + ) + return bool(result.scalar_one()) + + async def save(self, row: ExtensionModel) -> None: + self._session.add(row) + await self._session.flush() + + async def delete(self, row: ExtensionModel) -> None: + await self._session.delete(row) + + async def list_participations(self, peer_id: uuid.UUID): + rows = await self._session.scalars( + sqlmodel.select(ExtensionPeerParticipationModel) + .where(ExtensionPeerParticipationModel.peer_id == peer_id) + .order_by(ExtensionPeerParticipationModel.name) + ) + return tuple(rows) + + async def list_participations_for_extension(self, name: str): + rows = await self._session.scalars( + sqlmodel.select(ExtensionPeerParticipationModel).where( + ExtensionPeerParticipationModel.name == name + ) + ) + return tuple(rows) + + async def get_participation(self, name: str, peer_id: uuid.UUID, *, lock: bool = False): + statement = sqlmodel.select(ExtensionPeerParticipationModel).where( + ExtensionPeerParticipationModel.name == name, + ExtensionPeerParticipationModel.peer_id == peer_id, + ) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def save_participation(self, row: ExtensionPeerParticipationModel) -> None: + self._session.add(row) + await self._session.flush() + + async def delete_participation(self, row: ExtensionPeerParticipationModel) -> None: + await self._session.delete(row) diff --git a/inkcre_core/persistence/extension/uow.py b/inkcre_core/persistence/extension/uow.py new file mode 100644 index 00000000..196c6c22 --- /dev/null +++ b/inkcre_core/persistence/extension/uow.py @@ -0,0 +1,13 @@ +"""Short Extension transaction; publication and acquisition remain outside.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import ExtensionRepository + + +@asynccontextmanager +async def extension_uow() -> AsyncGenerator[ExtensionRepository, None]: + async with current_core().sessions.begin() as session: + yield ExtensionRepository(session) diff --git a/inkcre_core/persistence/info_base/__init__.py b/inkcre_core/persistence/info_base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/info_base/repository.py b/inkcre_core/persistence/info_base/repository.py new file mode 100644 index 00000000..a2f7a302 --- /dev/null +++ b/inkcre_core/persistence/info_base/repository.py @@ -0,0 +1,501 @@ +"""Graph persistence within a caller-owned asynchronous transaction.""" + +from collections.abc import Iterable +import random +import typing +from typing import Optional as Opt +import sqlalchemy +import sqlmodel +from inkcre_core.utils.types_ import Undefined, _undefined +from inkcre_core.schemas.info_base.block import BlockID, ResolverType +from inkcre_core.schemas.info_base.relation import RelationID +from inkcre_core.schemas.info_base.storage import StorageID + +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.relation import RelationCreateForm, RelationModel + + +class BlockRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_many(self, forms: Iterable[BlockForm]) -> tuple[BlockModel, ...]: + """Insert producer fields and load database identities in one flush.""" + blocks = tuple( + BlockModel.model_validate(form.model_dump(include=set(BlockForm.model_fields))) + for form in forms + ) + if blocks: + self._session.add_all(blocks) + await self._session.flush() + return blocks + + async def get_many(self, block_ids: typing.Collection[BlockID]) -> tuple[BlockModel, ...]: + """Return the existing Blocks from a bounded identity set.""" + if not block_ids: + return () + return tuple( + ( + await self._session.scalars( + sqlmodel.select(BlockModel).where( + sqlmodel.col(BlockModel.id).in_(tuple(block_ids)) + ) + ) + ).all() + ) + + async def get_by_resolvers( + self, resolvers: typing.Collection[str], *, lock: bool = False + ) -> tuple[BlockModel, ...]: + if not resolvers: + return () + statement = sqlmodel.select(BlockModel).where( + sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)) + ) + if lock: + statement = statement.order_by(sqlmodel.col(BlockModel.id)).with_for_update() + return tuple(await self._session.scalars(statement)) + + async def save_many(self, blocks: Iterable[BlockModel]) -> None: + self._session.add_all(blocks) + await self._session.flush() + + async def get_random_many(self, count: int) -> tuple[BlockModel, ...]: + """Return up to count distinct random Blocks.""" + if count <= 0: + return () + return tuple( + ( + await self._session.scalars( + sqlmodel.select(BlockModel).order_by(sqlmodel.func.random()).limit(count) + ) + ).all() + ) + + async def get_random(self) -> Opt[BlockModel]: + """Return one existing Block without transferring all identities.""" + block_id_column = sqlmodel.col(BlockModel.id) + count = ( + await self._session.scalars(sqlmodel.select(sqlmodel.func.count(block_id_column))) + ).one() + if count == 0: + return None + offset = random.SystemRandom().randrange(count) + return ( + await self._session.scalars( + sqlmodel.select(BlockModel).order_by(block_id_column).offset(offset).limit(1) + ) + ).one() + + async def get(self, block_id: BlockID, *, lock: bool = False) -> Opt[BlockModel]: + statement = sqlmodel.select(BlockModel).where(BlockModel.id == block_id) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def save(self, block: BlockModel) -> None: + self._session.add(block) + await self._session.flush() + + async def edit_block( + self, + block_id: BlockID, + content: Opt[str] = None, + resolver: Opt[ResolverType] = None, + storage: Opt[StorageID] | Undefined = _undefined, + ) -> BlockModel: + """编辑块""" + block = await self.get(block_id) + if block is None: + raise ValueError("Block not found") + if content is not None: + block.content = content + if resolver is not None: + block.resolver = resolver + if storage is not _undefined: + block.storage = typing.cast(StorageID | None, storage) + self._session.add(block) + await self._session.flush() + await self._session.refresh(block) + return block + + async def delete(self, block_id: BlockID) -> bool: + """Delete a block, within the surrounding transaction.""" + block = await self.get(block_id) + if block is None: + return False + await self._session.delete(block) + await self._session.flush() + return True + + async def get_recent( + self, num: int = 10, resolver: Opt[ResolverType] = None + ) -> tuple[BlockModel, ...]: + statement = ( + sqlmodel.select(BlockModel).order_by(sqlmodel.desc(BlockModel.created_at)).limit(num) + ) + if resolver: + statement = statement.where(BlockModel.resolver == resolver) + return tuple((await self._session.scalars(statement)).all()) + + async def create(self, form: BlockForm) -> BlockModel: + return (await self.create_many((form,)))[0] + + async def find_content(self, resolver: str, content: str) -> BlockModel | None: + return ( + await self._session.scalars( + sqlmodel.select(BlockModel).where( + BlockModel.resolver == resolver, + BlockModel.content == content, + ) + ) + ).one_or_none() + + async def find_json_field( + self, + resolver: str, + field: str, + value: str, + *, + block_ids: typing.Collection[BlockID] | None = None, + ) -> tuple[BlockModel, ...]: + from inkcre_core.utils.sql import find_by_json_field + + statement = sqlmodel.select(BlockModel).where( + BlockModel.resolver == resolver, + find_by_json_field(BlockModel.content, field, value), + ) + if block_ids is not None: + statement = statement.where(sqlmodel.col(BlockModel.id).in_(tuple(block_ids))) + return tuple(await self._session.scalars(statement)) + + async def find_json_field_many( + self, + resolvers: typing.Collection[str], + field: str, + values: typing.Collection[str], + ) -> tuple[BlockModel, ...]: + """Find inline JSON records in a bounded native-identity set.""" + from sqlalchemy.dialects.postgresql import JSONB + + if not resolvers or not values: + return () + return tuple( + await self._session.scalars( + sqlmodel.select(BlockModel) + .where( + sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)), + sqlalchemy.case( + ( + sqlalchemy.func.pg_input_is_valid(BlockModel.content, "jsonb"), + sqlalchemy.cast(BlockModel.content, JSONB)[field].astext, + ) + ).in_(tuple(values)), + sqlmodel.col(BlockModel.storage).is_(None), + ) + .order_by(sqlmodel.col(BlockModel.id)) + ) + ) + + async def acquire_transaction_lock(self, namespace: str, *, shared: bool = False) -> None: + """Serialize an owning use case until its surrounding transaction ends.""" + function = ( + sqlmodel.func.pg_advisory_xact_lock_shared + if shared + else sqlmodel.func.pg_advisory_xact_lock + ) + await self._session.execute( + sqlmodel.select(function(sqlmodel.func.hashtextextended(namespace, 0))) + ) + + async def get_related( + self, block_id: BlockID, *, content: str, outgoing: bool = True + ) -> BlockModel | None: + endpoint = RelationModel.from_ if outgoing else RelationModel.to_ + other = RelationModel.to_ if outgoing else RelationModel.from_ + statement = ( + sqlmodel.select(BlockModel) + .join(RelationModel, sqlmodel.col(BlockModel.id) == other) + .where( + endpoint == block_id, + RelationModel.content == content, + ) + .limit(1) + ) + return (await self._session.scalars(statement)).first() + + async def select_ids( + self, + limit: int, + *, + exclude_resolvers: typing.Collection[str], + random_order: bool = False, + ) -> tuple[BlockID, ...]: + statement = sqlmodel.select(BlockModel.id).where( + sqlmodel.col(BlockModel.resolver).not_in(tuple(exclude_resolvers)) + ) + statement = ( + statement.order_by(sqlalchemy.func.random()) + if random_order + else statement.order_by( + sqlmodel.desc(BlockModel.updated_at), sqlmodel.desc(BlockModel.id) + ) + ) + return tuple( + typing.cast(BlockID, value) + for value in await self._session.scalars(statement.limit(limit)) + ) + + async def matching_content(self, resolver: str, content: str) -> tuple[BlockModel, ...]: + return tuple( + await self._session.scalars( + sqlmodel.select(BlockModel).where( + BlockModel.resolver == resolver, BlockModel.content == content + ) + ) + ) + + async def without_outgoing_relation( + self, resolvers: typing.Collection[str], content: str, *, limit: int + ) -> tuple[BlockModel, ...]: + exists = sqlalchemy.exists( + sqlmodel.select(RelationModel.id).where( + RelationModel.from_ == BlockModel.id, RelationModel.content == content + ) + ) + return tuple( + await self._session.scalars( + sqlmodel.select(BlockModel) + .where(sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)), ~exists) + .order_by(sqlmodel.col(BlockModel.id)) + .limit(limit) + ) + ) + + +class RelationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_many( + self, forms: Iterable[RelationCreateForm] + ) -> tuple[RelationModel, ...]: + """Insert directed relations without committing the surrounding operation.""" + relations = tuple(RelationModel.model_validate(form) for form in forms) + if relations: + self._session.add_all(relations) + await self._session.flush() + return relations + + async def get_many( + self, relation_ids: typing.Collection[RelationID] + ) -> tuple[RelationModel, ...]: + """Return the existing Relations from a bounded identity set.""" + if not relation_ids: + return () + return tuple( + ( + await self._session.scalars( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.id).in_(tuple(relation_ids)) + ) + ) + ).all() + ) + + async def get_for_endpoints( + self, block_ids: typing.Collection[BlockID], *, contents: typing.Collection[str] + ) -> tuple[RelationModel, ...]: + if not block_ids: + return () + return tuple( + await self._session.scalars( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.content).in_(tuple(contents)), + sqlalchemy.or_( + sqlmodel.col(RelationModel.from_).in_(tuple(block_ids)), + sqlmodel.col(RelationModel.to_).in_(tuple(block_ids)), + ), + ) + ) + ) + + async def delete_many(self, relation_ids: typing.Collection[RelationID]) -> None: + if relation_ids: + await self._session.execute( + sqlalchemy.delete(RelationModel).where( + sqlmodel.col(RelationModel.id).in_(tuple(relation_ids)) + ) + ) + + async def get_outgoing_many( + self, + block_ids: typing.Collection[BlockID], + *, + to_ids: typing.Collection[BlockID] | None = None, + content: str | None = None, + lock: bool = False, + ) -> tuple[RelationModel, ...]: + if not block_ids: + return () + statement = sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.from_).in_(tuple(block_ids)) + ) + if to_ids is not None: + statement = statement.where(sqlmodel.col(RelationModel.to_).in_(tuple(to_ids))) + if content is not None: + statement = statement.where(RelationModel.content == content) + if lock: + statement = statement.order_by(sqlmodel.col(RelationModel.id)).with_for_update() + return tuple(await self._session.scalars(statement)) + + async def save(self, relation: RelationModel) -> None: + self._session.add(relation) + await self._session.flush() + + async def remove(self, relation: RelationModel) -> None: + await self._session.delete(relation) + + async def get_by_id(self, relation_id: RelationID) -> RelationModel | None: + return await self._session.get(RelationModel, relation_id) + + async def get_endpoint_page( + self, + block_ids: typing.Collection[BlockID], + *, + endpoint: typing.Literal["from", "to"], + contents: typing.Collection[str] = (), + cursor: RelationID | None = None, + limit: int = 21, + ) -> tuple[RelationModel, ...]: + """Read a bounded Relation page for one persisted endpoint direction.""" + if not block_ids or limit <= 0: + return () + endpoint_column = RelationModel.from_ if endpoint == "from" else RelationModel.to_ + statement = sqlmodel.select(RelationModel).where( + sqlmodel.col(endpoint_column).in_(tuple(block_ids)) + ) + if contents: + statement = statement.where(sqlmodel.col(RelationModel.content).in_(tuple(contents))) + relation_id_column = sqlmodel.col(RelationModel.id) + if cursor is not None: + statement = statement.where(relation_id_column < cursor) + statement = statement.order_by(sqlmodel.desc(relation_id_column)).limit(limit) + return tuple((await self._session.scalars(statement)).all()) + + async def fetchsert(self, relation: RelationModel) -> RelationModel: + """Create if not exists, else return the existing one. + + Will NOT commit the session. + """ + existing = ( + await self._session.scalars( + sqlmodel.select(RelationModel).where( + RelationModel.content == relation.content, + RelationModel.from_ == relation.from_, + RelationModel.to_ == relation.to_, + ) + ) + ).one_or_none() + if existing is not None: + return existing + self._session.add(relation) + await self._session.flush() + return relation + + async def get( + self, + block_id: BlockID, + include_in: bool = True, + include_out: bool = True, + content: Opt[str] = None, + ) -> tuple[RelationModel, ...]: + """Get relations from/to a block + + :param include_in: Include the relations where the block is the target + :param include_out: Include the relations where the block is the source + :param content: If specified, filter relations by content (eq) + """ + if not include_in and (not include_out): + return () + directions: list[typing.Any] = [] + if include_in: + directions.append(RelationModel.to_ == block_id) + if include_out: + directions.append(RelationModel.from_ == block_id) + statement = sqlmodel.select(RelationModel).where(sqlalchemy.or_(*directions)) + if content is not None: + statement = statement.where(RelationModel.content == content) + res = (await self._session.scalars(statement)).all() + return tuple(res) + + async def update( + self, + relation_id: RelationID, + *, + from_: BlockID | Undefined = _undefined, + to_: BlockID | Undefined = _undefined, + content: str | Undefined = _undefined, + ) -> RelationModel: + """Update selected relation facts in the surrounding transaction.""" + relation = ( + await self._session.scalars( + sqlmodel.select(RelationModel).where(RelationModel.id == relation_id) + ) + ).one_or_none() + if relation is None: + raise ValueError("Relation not found") + if from_ is not _undefined: + relation.from_ = typing.cast(BlockID, from_) + if to_ is not _undefined: + relation.to_ = typing.cast(BlockID, to_) + if content is not _undefined: + relation.content = typing.cast(str, content) + self._session.add(relation) + await self._session.flush() + await self._session.refresh(relation) + return relation + + async def delete(self, relation_id: RelationID) -> bool: + """Delete a relation, within the surrounding transaction.""" + relation = ( + await self._session.scalars( + sqlmodel.select(RelationModel).where(RelationModel.id == relation_id) + ) + ).one_or_none() + if relation is None: + return False + await self._session.delete(relation) + await self._session.flush() + return True + + async def create(self, from_: BlockID, to_: BlockID, content: str) -> RelationModel: + return ( + await self.create_many((RelationCreateForm(from_=from_, to_=to_, content=content),)) + )[0] + + async def random_incoming_sources( + self, block_id: BlockID, content: str, limit: int + ) -> tuple[BlockID, ...]: + return tuple( + await self._session.scalars( + sqlmodel.select(RelationModel.from_) + .where(RelationModel.to_ == block_id, RelationModel.content == content) + .order_by(sqlalchemy.func.random()) + .limit(limit) + ) + ) + + async def recent( + self, limit: int, *, contents: typing.Collection[str] = () + ) -> tuple[RelationModel, ...]: + statement = ( + sqlmodel.select(RelationModel) + .order_by(sqlmodel.desc(RelationModel.updated_at), sqlmodel.desc(RelationModel.id)) + .limit(limit) + ) + if contents: + statement = statement.where(sqlmodel.col(RelationModel.content).in_(tuple(contents))) + return tuple(await self._session.scalars(statement)) diff --git a/inkcre_core/persistence/info_base/storage.py b/inkcre_core/persistence/info_base/storage.py new file mode 100644 index 00000000..33067ab0 --- /dev/null +++ b/inkcre_core/persistence/info_base/storage.py @@ -0,0 +1,96 @@ +"""Storage catalog and byte persistence bound to the enclosing graph transaction.""" + +from collections.abc import Sequence +from typing import Any +from uuid import UUID + +import sqlalchemy +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +import sqlmodel + +from inkcre_core.schemas.info_base.storage import ( + StorageBlobModel, + StorageModel, + StorageTypesModel, +) + + +class StorageRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get(self, storage_id: int) -> StorageModel: + return ( + await self._session.scalars( + sqlmodel.select(StorageModel).where(StorageModel.id == storage_id) + ) + ).one() + + async def sync_types(self, values: Sequence[dict[str, Any]]) -> None: + if not values: + return + statement = insert(StorageTypesModel).values(values) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=[StorageTypesModel.id], + set_={ + "description": statement.excluded.description, + "config_schema": statement.excluded.config_schema, + "writable": statement.excluded.writable, + }, + ) + ) + + async def sync_builtins(self, values: Sequence[dict[str, Any]]) -> None: + if not values: + return + statement = insert(StorageModel).values(values) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=["id"], + set_={ + "type": statement.excluded.type, + "nickname": statement.excluded.nickname, + "config": statement.excluded.config, + }, + ) + ) + + async def create(self, storage: StorageModel) -> StorageModel: + self._session.add(storage) + await self._session.flush() + return storage + + async def read_blob(self, blob_id: UUID, *, lock: bool = False) -> bytes | None: + statement = sqlmodel.select(StorageBlobModel.data).where(StorageBlobModel.id == blob_id) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def create_blob(self, content: bytes) -> UUID: + blob = StorageBlobModel(data=content) + self._session.add(blob) + await self._session.flush() + return blob.id + + async def update_blob(self, blob_id: UUID, content: bytes) -> bool: + result = await self._session.scalars( + sqlalchemy.update(StorageBlobModel) + .where( + sqlmodel.col(StorageBlobModel.id) == blob_id, + ) + .values(data=content) + .returning(sqlmodel.col(StorageBlobModel.id)) + ) + return result.one_or_none() is not None + + async def delete_blob(self, blob_id: UUID) -> bool: + result = await self._session.scalars( + sqlalchemy.delete(StorageBlobModel) + .where( + sqlmodel.col(StorageBlobModel.id) == blob_id, + ) + .returning(sqlmodel.col(StorageBlobModel.id)) + ) + return result.one_or_none() is not None diff --git a/inkcre_core/persistence/info_base/uow.py b/inkcre_core/persistence/info_base/uow.py new file mode 100644 index 00000000..ae4f95eb --- /dev/null +++ b/inkcre_core/persistence/info_base/uow.py @@ -0,0 +1,30 @@ +"""Graph transaction composition; repositories share one private session.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from inkcre_core.context import current_core + +from .repository import BlockRepository, RelationRepository +from .storage import StorageRepository + + +@dataclass(frozen=True) +class GraphUnitOfWork: + blocks: BlockRepository + relations: RelationRepository + storage: StorageRepository + + +@asynccontextmanager +async def graph_uow( + *, repeatable_read: bool = False +) -> AsyncGenerator[GraphUnitOfWork, None]: + """Commit on success; roll back on failure and always close the session.""" + async with current_core().sessions.begin() as session: + if repeatable_read: + await session.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + yield GraphUnitOfWork( + BlockRepository(session), RelationRepository(session), StorageRepository(session) + ) diff --git a/inkcre_core/persistence/job/__init__.py b/inkcre_core/persistence/job/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/job/repository.py b/inkcre_core/persistence/job/repository.py new file mode 100644 index 00000000..803dc841 --- /dev/null +++ b/inkcre_core/persistence/job/repository.py @@ -0,0 +1,140 @@ +"""Session-bound durable Job catalog, claim and terminal writes.""" + +import typing + +import sqlalchemy +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +import sqlmodel + +from inkcre_core.schemas.job import JobModel, JobStatus, JobTypeModel + + +class JobRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def sync_types(self, rows: list[dict]) -> None: + if not rows: + return + statement = insert(JobTypeModel).values(rows) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=[JobTypeModel.id], + set_={ + key: getattr(statement.excluded, key) + for key in ("description", "parameters_schema", "default_timeout_seconds") + }, + ) + ) + + async def get(self, job_id: int) -> JobModel | None: + return await self._session.get(JobModel, job_id) + + async def get_type(self, type_: str) -> JobTypeModel | None: + return await self._session.get(JobTypeModel, type_) + + async def create(self, job: JobModel) -> JobModel: + self._session.add(job) + await self._session.flush() + await self._session.refresh(job) + return job + + async def list_types(self, *, limit: int | None, cursor: str | None): + statement = sqlmodel.select(JobTypeModel).order_by(JobTypeModel.id) + if cursor is not None: + statement = statement.where(JobTypeModel.id > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def list_jobs( + self, *, limit: int, cursor: int | None, type_: str | None, status: JobStatus | None + ): + statement = sqlmodel.select(JobModel).order_by(sqlmodel.col(JobModel.id).desc()) + if cursor is not None: + statement = statement.where(sqlmodel.col(JobModel.id) < cursor) + if type_ is not None: + statement = statement.where(JobModel.type == type_) + if status is not None: + statement = statement.where(JobModel.status == status) + rows = list((await self._session.scalars(statement.limit(limit + 1))).all()) + more = len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def abort(self, job_id: int) -> JobModel | None: + table = typing.cast(typing.Any, getattr(JobModel, "__table__")) + await self._session.execute( + sqlalchemy.update(table) + .where( + table.c.id == job_id, + table.c.status.in_((JobStatus.PENDING, JobStatus.RUNNING)), + ) + .values( + abort_requested=True, + status=sqlalchemy.case( + ( + table.c.status == JobStatus.PENDING, + sqlalchemy.cast(JobStatus.ABORTED.value, table.c.status.type), + ), + else_=table.c.status, + ), + ) + ) + return await self.get(job_id) + + async def abort_requests(self, active_ids: tuple[int, ...]): + return tuple( + await self._session.scalars( + sqlmodel.select(JobModel.id).where( + sqlmodel.col(JobModel.id).in_(active_ids), + sqlmodel.col(JobModel.abort_requested).is_(True), + ) + ) + ) + + async def claim(self, job_id: int) -> JobModel | None: + result = await self._session.scalars( + sqlalchemy.update(JobModel) + .where( + sqlmodel.col(JobModel.id) == job_id, + sqlmodel.col(JobModel.status) == JobStatus.PENDING, + ) + .values(status=JobStatus.RUNNING) + .returning(JobModel) + ) + return result.one_or_none() + + async def close(self, job: JobModel, status: JobStatus) -> bool: + table = typing.cast(typing.Any, getattr(JobModel, "__table__")) + result = await self._session.execute( + sqlalchemy.update(table) + .where(table.c.id == job.id, table.c.status == JobStatus.RUNNING) + .values(status=status, state=job.state) + ) + return bool(typing.cast(typing.Any, result).rowcount) + + async def expire_overdue(self) -> int: + table = typing.cast(typing.Any, getattr(JobModel, "__table__")) + result = await self._session.execute( + sqlalchemy.update(table) + .where( + table.c.status == JobStatus.RUNNING, + sqlalchemy.text( + "started_at + timeout_seconds * interval '1 second' <= statement_timestamp()" + ), + ) + .values(status=JobStatus.TIMED_OUT) + ) + return typing.cast(typing.Any, result).rowcount or 0 + + async def pending(self): + return tuple( + await self._session.scalars( + sqlmodel.select(JobModel).where(JobModel.status == JobStatus.PENDING) + ) + ) diff --git a/inkcre_core/persistence/job/uow.py b/inkcre_core/persistence/job/uow.py new file mode 100644 index 00000000..75530fad --- /dev/null +++ b/inkcre_core/persistence/job/uow.py @@ -0,0 +1,21 @@ +"""Job admission also reads Source contracts within its transaction.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from inkcre_core.context import current_core +from inkcre_core.persistence.source.repository import SourceRepository +from .repository import JobRepository + + +@dataclass(frozen=True) +class JobUnitOfWork: + jobs: JobRepository + sources: SourceRepository + + +@asynccontextmanager +async def job_uow() -> AsyncGenerator[JobUnitOfWork, None]: + async with current_core().sessions.begin() as session: + yield JobUnitOfWork(JobRepository(session), SourceRepository(session)) diff --git a/inkcre_core/persistence/lexical_retrieval/__init__.py b/inkcre_core/persistence/lexical_retrieval/__init__.py new file mode 100644 index 00000000..05dca4e7 --- /dev/null +++ b/inkcre_core/persistence/lexical_retrieval/__init__.py @@ -0,0 +1 @@ +"""Lexical projection persistence.""" diff --git a/inkcre_core/persistence/lexical_retrieval/repository.py b/inkcre_core/persistence/lexical_retrieval/repository.py new file mode 100644 index 00000000..1322b007 --- /dev/null +++ b/inkcre_core/persistence/lexical_retrieval/repository.py @@ -0,0 +1,145 @@ +"""Session-bound lexical ranking, candidate scans and projection batch writes.""" + +import datetime +import typing + +import sqlalchemy +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +import sqlmodel + +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.lexical_retrieval import BlockLexicalRecordModel + + +class LexicalRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def search(self, normalized: str, limit: int): + literal_pattern = f"%{self._escape_like(normalized)}%" + + block_columns = typing.cast( + typing.Any, + BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + BlockLexicalRecordModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + query_terms = sqlalchemy.func.plainto_tsquery("simple", normalized) + label_exact = sqlalchemy.func.lower(record_columns.label) == normalized.lower() + label_substring = record_columns.label.ilike(literal_pattern, escape="\\") + text_substring = record_columns.text.ilike(literal_pattern, escape="\\") + term_match = record_columns.search_vector.op("@@")(query_terms) + evidence = sqlalchemy.case( + (label_exact, "label_exact"), + (label_substring, "label_substring"), + (text_substring, "text_substring"), + else_="terms", + ).label("evidence") + evidence_class = sqlalchemy.case( + (label_exact, 4.0), + (label_substring, 3.0), + (text_substring, 2.0), + else_=1.0, + ) + term_rank = sqlalchemy.func.ts_rank_cd(record_columns.search_vector, query_terms) + rank = (evidence_class + term_rank).label("rank") + + statement = ( + sqlmodel.select(BlockModel, BlockLexicalRecordModel, evidence, rank) + .join( + BlockLexicalRecordModel, + record_columns.block == block_columns.id, + ) + .where( + record_columns.updated_at >= block_columns.updated_at, + sqlalchemy.or_( + label_exact, + label_substring, + text_substring, + term_match, + ), + ) + .order_by( + sqlalchemy.desc(evidence_class), + sqlalchemy.desc(term_rank), + block_columns.id, + ) + .limit(limit) + ) + return (await self._session.execute(statement)).all() + + async def candidate_page( + self, + cursor: int, + page_size: int, + rebuild_cutoff: datetime.datetime | None, + ) -> tuple[tuple[BlockModel, ...], int, bool]: + block_columns = typing.cast( + typing.Any, + BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + BlockLexicalRecordModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + needs_projection = sqlalchemy.or_( + record_columns.block.is_(None), + record_columns.updated_at < block_columns.updated_at, + ) + if rebuild_cutoff is not None: + needs_projection = sqlalchemy.or_( + needs_projection, + record_columns.updated_at < rebuild_cutoff, + ) + statement = ( + sqlmodel.select(BlockModel) + .outerjoin( + BlockLexicalRecordModel, + record_columns.block == block_columns.id, + ) + .where(block_columns.id > cursor, needs_projection) + .order_by(block_columns.id) + .limit(page_size) + ) + blocks = tuple(await self._session.scalars(statement)) + next_cursor = typing.cast(int, blocks[-1].id) if blocks else cursor + return blocks, next_cursor, len(blocks) < page_size + + async def database_now(self) -> datetime.datetime: + return ( + await self._session.execute(sqlmodel.select(sqlalchemy.func.current_timestamp())) + ).scalar_one() + + async def upsert(self, projections: list[dict[str, typing.Any]]) -> None: + if not projections: + return + rows = [] + for projection in projections: + label_vector = sqlalchemy.func.setweight( + sqlalchemy.func.to_tsvector("simple", projection["label"]), + sqlalchemy.literal_column("'A'::\"char\""), + ) + text_vector = sqlalchemy.func.setweight( + sqlalchemy.func.to_tsvector("simple", projection["text"] or ""), + sqlalchemy.literal_column("'D'::\"char\""), + ) + rows.append({**projection, "search_vector": label_vector.op("||")(text_vector)}) + statement = insert(BlockLexicalRecordModel).values(rows) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=["block"], + set_={ + "label": statement.excluded.label, + "text": statement.excluded.text, + "search_vector": statement.excluded.search_vector, + "updated_at": sqlalchemy.func.current_timestamp(), + }, + ) + ) + + @staticmethod + def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/inkcre_core/persistence/lexical_retrieval/uow.py b/inkcre_core/persistence/lexical_retrieval/uow.py new file mode 100644 index 00000000..818a22c8 --- /dev/null +++ b/inkcre_core/persistence/lexical_retrieval/uow.py @@ -0,0 +1,13 @@ +"""Short lexical database scope, separate from Resolver projection.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import LexicalRepository + + +@asynccontextmanager +async def lexical_uow() -> AsyncGenerator[LexicalRepository, None]: + async with current_core().sessions.begin() as session: + yield LexicalRepository(session) diff --git a/inkcre_core/persistence/observability/__init__.py b/inkcre_core/persistence/observability/__init__.py new file mode 100644 index 00000000..302d1365 --- /dev/null +++ b/inkcre_core/persistence/observability/__init__.py @@ -0,0 +1 @@ +"""Isolated asynchronous telemetry persistence.""" diff --git a/inkcre_core/persistence/observability/uow.py b/inkcre_core/persistence/observability/uow.py new file mode 100644 index 00000000..e442cde0 --- /dev/null +++ b/inkcre_core/persistence/observability/uow.py @@ -0,0 +1,11 @@ +"""Log batches commit independently of application transactions.""" + +from collections.abc import Sequence + +from inkcre_core.context import current_core +from inkcre_core.libs.obsrv.log_record import LogModel + + +async def write_logs(entries: Sequence[LogModel]) -> None: + async with current_core().sessions.begin() as session: + session.add_all(entries) diff --git a/inkcre_core/persistence/peer/__init__.py b/inkcre_core/persistence/peer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/peer/repository.py b/inkcre_core/persistence/peer/repository.py new file mode 100644 index 00000000..0b9b0daf --- /dev/null +++ b/inkcre_core/persistence/peer/repository.py @@ -0,0 +1,106 @@ +"""Peer persistence; all lease comparisons use the database clock.""" + +import datetime +from typing import Any + +import sqlalchemy +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +import sqlmodel + +from inkcre_core.database_contract import PROTOCOL_SCHEMA +from inkcre_core.schemas.peer import PeerModel, PeerRef + + +class PeerRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def register( + self, + peer_id: PeerRef, + name: str, + application_version: str, + schema: dict, + ) -> PeerModel: + statement = insert(PeerModel).values( + id=peer_id, + name=name, + application_version=application_version, + labels=[], + config={}, + config_schema=schema, + capabilities=[], + ) + statement = statement.on_conflict_do_update( + index_elements=["id"], + set_={ + "application_version": statement.excluded.application_version, + "config_schema": statement.excluded.config_schema, + }, + ).returning(PeerModel) + return (await self._session.scalars(statement)).one() + + async def get(self, peer_id: PeerRef, *, for_update: bool = False) -> PeerModel | None: + statement = sqlmodel.select(PeerModel).where(PeerModel.id == peer_id) + if for_update: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def get_all(self) -> tuple[PeerModel, ...]: + return tuple((await self._session.scalars(sqlmodel.select(PeerModel))).all()) + + async def with_leases( + self, + *, + peer_id: PeerRef | None = None, + limit: int | None = None, + cursor: PeerRef | None = None, + ) -> tuple[list[dict[str, Any]], PeerRef | None]: + statement = sqlmodel.select( + PeerModel, + sqlalchemy.func.coalesce( + PeerModel.lease_expires_at > sqlalchemy.func.statement_timestamp(), False + ), + ).order_by(sqlmodel.col(PeerModel.id)) + if peer_id is not None: + statement = statement.where(PeerModel.id == peer_id) + if cursor is not None: + statement = statement.where(PeerModel.id > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.execute(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return [ + {**row.model_dump(mode="json"), "lease_active": active} for row, active in rows + ], rows[-1][0].id if more else None + + async def save(self, peer: PeerModel) -> None: + self._session.add(peer) + await self._session.flush() + await self._session.refresh(peer) + + async def renew_lease(self, peer_id: PeerRef, ttl_seconds: int) -> datetime.datetime: + statement = sqlalchemy.select( + getattr(sqlalchemy.func, PROTOCOL_SCHEMA).renew_peer_lease(peer_id, ttl_seconds) + ) + return (await self._session.execute(statement)).scalar_one() + + async def clear_lease(self, peer_id: PeerRef) -> None: + await self._session.execute( + sqlalchemy.update(PeerModel) + .where(sqlmodel.col(PeerModel.id) == peer_id) + .values(lease_expires_at=None) + ) + + async def candidates( + self, self_id: PeerRef, route_to_peer: PeerRef | None + ) -> tuple[PeerModel, ...]: + statement = sqlmodel.select(PeerModel).where( + PeerModel.id != self_id, + PeerModel.lease_expires_at > sqlalchemy.func.statement_timestamp(), + ) + if route_to_peer is not None: + statement = statement.where(PeerModel.id == route_to_peer) + return tuple((await self._session.scalars(statement)).all()) diff --git a/inkcre_core/persistence/peer/uow.py b/inkcre_core/persistence/peer/uow.py new file mode 100644 index 00000000..a2b4df11 --- /dev/null +++ b/inkcre_core/persistence/peer/uow.py @@ -0,0 +1,13 @@ +"""Transaction scopes for Peer facts and database-owned leases.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import PeerRepository + + +@asynccontextmanager +async def peer_uow() -> AsyncGenerator[PeerRepository, None]: + async with current_core().sessions.begin() as session: + yield PeerRepository(session) diff --git a/inkcre_core/persistence/semantic_retrieval/__init__.py b/inkcre_core/persistence/semantic_retrieval/__init__.py new file mode 100644 index 00000000..d396cf8b --- /dev/null +++ b/inkcre_core/persistence/semantic_retrieval/__init__.py @@ -0,0 +1 @@ +"""Semantic retrieval persistence.""" diff --git a/inkcre_core/persistence/semantic_retrieval/repository.py b/inkcre_core/persistence/semantic_retrieval/repository.py new file mode 100644 index 00000000..c27c8513 --- /dev/null +++ b/inkcre_core/persistence/semantic_retrieval/repository.py @@ -0,0 +1,203 @@ +"""Session-bound embedding profiles and derived retrieval records.""" + +import datetime +import typing + +import sqlalchemy +import sqlalchemy.orm +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession +import sqlmodel + +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.semantic_retrieval import VectorRetrievalOptions + +from inkcre_core.schemas.ai import ( + EmbeddingProfileID, + EmbeddingProfileModel, + BlockEmbeddingModel, + RelationEmbeddingModel, +) + + +class SemanticRetrievalRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_profile( + self, profile_id: EmbeddingProfileID + ) -> EmbeddingProfileModel | None: + return await self._session.get(EmbeddingProfileModel, profile_id) + + async def list_profiles(self, *, limit: int | None, cursor: int | None): + statement = sqlmodel.select(EmbeddingProfileModel).order_by( + sqlmodel.col(EmbeddingProfileModel.id) + ) + if cursor is not None: + statement = statement.where(sqlmodel.col(EmbeddingProfileModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list(await self._session.scalars(statement)) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def database_now(self) -> datetime.datetime: + return ( + await self._session.execute(sqlmodel.select(sqlalchemy.func.current_timestamp())) + ).scalar_one() + + async def candidate_blocks(self, profile_id: int, cursor: int, page_size: int): + block_columns = typing.cast( + typing.Any, + BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + BlockEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + statement = ( + sqlmodel.select(BlockModel, BlockEmbeddingModel) + .outerjoin( + BlockEmbeddingModel, + sqlalchemy.and_( + record_columns.profile == profile_id, + record_columns.block == block_columns.id, + ), + ) + .where(block_columns.id > cursor) + .order_by(block_columns.id) + .limit(page_size) + ) + return (await self._session.execute(statement)).all() + + async def candidate_relations(self, profile_id: int, cursor: int, page_size: int): + from_block = sqlalchemy.orm.aliased(BlockModel) + to_block = sqlalchemy.orm.aliased(BlockModel) + from_columns = typing.cast(typing.Any, from_block) + to_columns = typing.cast(typing.Any, to_block) + relation_columns = typing.cast( + typing.Any, + RelationModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + RelationEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + statement = ( + sqlmodel.select( + RelationModel, + RelationEmbeddingModel, + from_block.updated_at, + to_block.updated_at, + ) + .outerjoin( + RelationEmbeddingModel, + sqlalchemy.and_( + record_columns.profile == profile_id, + record_columns.relation == relation_columns.id, + ), + ) + .join(from_block, from_columns.id == relation_columns.from_) + .join(to_block, to_columns.id == relation_columns.to_) + .where(relation_columns.id > cursor) + .order_by(relation_columns.id) + .limit(page_size) + ) + return (await self._session.execute(statement)).all() + + async def retrieve_blocks( + self, + profile: EmbeddingProfileModel, + query_vector, + options: VectorRetrievalOptions, + ): + profile_id = profile.id + block_columns = typing.cast( + typing.Any, + BlockModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + BlockEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + distance = record_columns.embedding.cosine_distance(query_vector).label("distance") + statement = ( + sqlmodel.select(BlockModel, distance) + .join(BlockEmbeddingModel, record_columns.block == block_columns.id) + .where( + record_columns.profile == profile_id, + record_columns.updated_at >= profile.updated_at, + record_columns.updated_at >= block_columns.updated_at, + sqlalchemy.func.vector_dims(record_columns.embedding) == profile.dimensions, + sqlalchemy.func.vector_norm(record_columns.embedding) > 0, + ) + .order_by(distance, block_columns.id) + .limit(options.limit) + ) + if options.min_score is not None: + statement = statement.where(distance <= 1 - options.min_score) + return (await self._session.execute(statement)).all() + + async def retrieve_relations( + self, + profile: EmbeddingProfileModel, + query_vector, + options: VectorRetrievalOptions, + ): + profile_id = profile.id + from_block = sqlalchemy.orm.aliased(BlockModel) + to_block = sqlalchemy.orm.aliased(BlockModel) + from_columns = typing.cast(typing.Any, from_block) + to_columns = typing.cast(typing.Any, to_block) + relation_columns = typing.cast( + typing.Any, + RelationModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + record_columns = typing.cast( + typing.Any, + RelationEmbeddingModel.__table__.c, # pyrefly: ignore[missing-attribute] + ) + distance = record_columns.embedding.cosine_distance(query_vector).label("distance") + statement = ( + sqlmodel.select(RelationModel, distance) + .join( + RelationEmbeddingModel, + record_columns.relation == relation_columns.id, + ) + .join(from_block, from_columns.id == relation_columns.from_) + .join(to_block, to_columns.id == relation_columns.to_) + .where( + record_columns.profile == profile_id, + record_columns.updated_at >= profile.updated_at, + record_columns.updated_at >= relation_columns.updated_at, + record_columns.updated_at >= from_columns.updated_at, + record_columns.updated_at >= to_columns.updated_at, + sqlalchemy.func.vector_dims(record_columns.embedding) == profile.dimensions, + sqlalchemy.func.vector_norm(record_columns.embedding) > 0, + ) + .order_by(distance, relation_columns.id) + .limit(options.limit) + ) + if options.min_score is not None: + statement = statement.where(distance <= 1 - options.min_score) + return (await self._session.execute(statement)).all() + + async def upsert(self, blocks: list[dict], relations: list[dict]) -> None: + for model, key, rows in ( + (BlockEmbeddingModel, "block", blocks), + (RelationEmbeddingModel, "relation", relations), + ): + if not rows: + continue + statement = insert(model).values(rows) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=["profile", key], + set_={ + "embedding": statement.excluded.embedding, + "updated_at": sqlalchemy.func.current_timestamp(), + }, + ) + ) diff --git a/inkcre_core/persistence/semantic_retrieval/uow.py b/inkcre_core/persistence/semantic_retrieval/uow.py new file mode 100644 index 00000000..41a0a587 --- /dev/null +++ b/inkcre_core/persistence/semantic_retrieval/uow.py @@ -0,0 +1,13 @@ +"""Short persistence scope; provider calls run after it closes.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import SemanticRetrievalRepository + + +@asynccontextmanager +async def semantic_retrieval_uow() -> AsyncGenerator[SemanticRetrievalRepository, None]: + async with current_core().sessions.begin() as session: + yield SemanticRetrievalRepository(session) diff --git a/inkcre_core/persistence/sink/__init__.py b/inkcre_core/persistence/sink/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/sink/repository.py b/inkcre_core/persistence/sink/repository.py new file mode 100644 index 00000000..c90304c1 --- /dev/null +++ b/inkcre_core/persistence/sink/repository.py @@ -0,0 +1,55 @@ +"""Session-bound Sink catalog and instance records.""" + +import sqlmodel +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.sink import SinkModel, SinkTypeModel + + +class SinkRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def sync_types(self, rows: list[dict]) -> None: + if not rows: + return + statement = insert(SinkTypeModel).values(rows) + await self._session.execute( + statement.on_conflict_do_update( + index_elements=["id"], + set_={ + "description": statement.excluded.description, + "config_schema": statement.excluded.config_schema, + }, + ) + ) + + async def list_types(self): + return tuple( + await self._session.scalars(sqlmodel.select(SinkTypeModel).order_by(SinkTypeModel.id)) + ) + + async def get_type(self, sink_type: str) -> SinkTypeModel | None: + return await self._session.get(SinkTypeModel, sink_type) + + async def list(self): + return tuple( + await self._session.scalars( + sqlmodel.select(SinkModel).order_by(sqlmodel.col(SinkModel.id)) + ) + ) + + async def get(self, sink_id: int, *, lock: bool = False) -> SinkModel | None: + statement = sqlmodel.select(SinkModel).where(SinkModel.id == sink_id) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def save(self, sink: SinkModel) -> None: + self._session.add(sink) + await self._session.flush() + await self._session.refresh(sink) + + async def delete(self, sink: SinkModel) -> None: + await self._session.delete(sink) diff --git a/inkcre_core/persistence/sink/uow.py b/inkcre_core/persistence/sink/uow.py new file mode 100644 index 00000000..0cf49554 --- /dev/null +++ b/inkcre_core/persistence/sink/uow.py @@ -0,0 +1,13 @@ +"""Short Sink persistence scope; runtime start/close happen after commit.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from inkcre_core.context import current_core +from .repository import SinkRepository + + +@asynccontextmanager +async def sink_uow() -> AsyncGenerator[SinkRepository, None]: + async with current_core().sessions.begin() as session: + yield SinkRepository(session) diff --git a/inkcre_core/persistence/source/__init__.py b/inkcre_core/persistence/source/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/persistence/source/repository.py b/inkcre_core/persistence/source/repository.py new file mode 100644 index 00000000..eb767123 --- /dev/null +++ b/inkcre_core/persistence/source/repository.py @@ -0,0 +1,76 @@ +"""Persistence for Source catalog and instances.""" + +import sqlmodel +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from inkcre_core.schemas.source import SourceModel, SourceTypesModel + + +class SourceRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def sync_types(self, rows: list[dict]) -> None: + if not rows: + return + statement = insert(SourceTypesModel).values(rows) + statement = statement.on_conflict_do_update( + index_elements=[SourceTypesModel.id], + set_={ + key: getattr(statement.excluded, key) + for key in ( + "description", + "config_schema", + "collect_config_schema", + "backfill_config_schema", + ) + }, + ) + await self._session.execute(statement) + + async def get(self, source_id: int, *, lock: bool = False): + statement = sqlmodel.select(SourceModel).where(SourceModel.id == source_id) + if lock: + statement = statement.with_for_update() + return (await self._session.scalars(statement)).one_or_none() + + async def get_by_block(self, block_id: int) -> SourceModel | None: + return ( + await self._session.scalars( + sqlmodel.select(SourceModel).where(SourceModel.block == block_id) + ) + ).one_or_none() + + async def save(self, source: SourceModel) -> None: + self._session.add(source) + await self._session.flush() + await self._session.refresh(source) + + async def get_type(self, type_: str) -> SourceTypesModel | None: + return await self._session.get(SourceTypesModel, type_) + + async def list_sources(self, *, limit: int | None, cursor: int | None): + statement = sqlmodel.select(SourceModel).order_by(sqlmodel.col(SourceModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(SourceModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def list_types(self, *, limit: int | None, cursor: str | None): + statement = sqlmodel.select(SourceTypesModel).order_by(SourceTypesModel.id) + if cursor is not None: + statement = statement.where(SourceTypesModel.id > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + rows = list((await self._session.scalars(statement)).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + async def delete(self, source: SourceModel) -> None: + await self._session.delete(source) diff --git a/inkcre_core/persistence/source/uow.py b/inkcre_core/persistence/source/uow.py new file mode 100644 index 00000000..d2215124 --- /dev/null +++ b/inkcre_core/persistence/source/uow.py @@ -0,0 +1,31 @@ +"""Source state, graph and storage participate in one short transaction.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from inkcre_core.context import current_core +from inkcre_core.persistence.info_base.uow import GraphUnitOfWork +from inkcre_core.persistence.info_base.repository import BlockRepository, RelationRepository +from inkcre_core.persistence.info_base.storage import StorageRepository +from inkcre_core.persistence.deployment_config.repository import DeploymentConfigRepository +from .repository import SourceRepository + + +@dataclass(frozen=True) +class SourceUnitOfWork: + sources: SourceRepository + graph: GraphUnitOfWork + configuration: DeploymentConfigRepository + + +@asynccontextmanager +async def source_uow() -> AsyncGenerator[SourceUnitOfWork, None]: + async with current_core().sessions.begin() as session: + yield SourceUnitOfWork( + SourceRepository(session), + GraphUnitOfWork( + BlockRepository(session), RelationRepository(session), StorageRepository(session) + ), + DeploymentConfigRepository(session), + ) diff --git a/inkcre_core/py.typed b/inkcre_core/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/app/schemas/AGENTS.md b/inkcre_core/schemas/AGENTS.md similarity index 76% rename from app/schemas/AGENTS.md rename to inkcre_core/schemas/AGENTS.md index 658aa47f..effb9057 100644 --- a/app/schemas/AGENTS.md +++ b/inkcre_core/schemas/AGENTS.md @@ -1,4 +1,4 @@ -- Import your schema in `app/schemas/__init__.py` so Alembic discovers it while generating migrations. +- Import your schema in `inkcre_core/schemas/__init__.py` so Alembic discovers it while generating migrations. - Follow the [Python input/restoration boundary](../../docs/30-unit-tdd/business-pipeline-and-authority.md#cross-subtree-constraints) when changing Pydantic forms or persistence codecs. A typed input should not be dumped and revalidated merely to cross an internal call boundary; necessary restoration of nested persisted types is a different operation. diff --git a/inkcre_core/schemas/__init__.py b/inkcre_core/schemas/__init__.py new file mode 100644 index 00000000..c7eb0f62 --- /dev/null +++ b/inkcre_core/schemas/__init__.py @@ -0,0 +1,56 @@ +__all__ = [ + "Base", + "BlockModel", + "StorageModel", + "StorageBlobModel", + "StorageTypesModel", + "RelationModel", + "SourceModel", + "JobTypeModel", + "JobModel", + "CronModel", + "ExtensionModel", + "ExtensionPeerParticipationModel", + "AIDialectModel", + "AIProviderModel", + "AIModelModel", + "EmbeddingProfileModel", + "RelationEmbeddingModel", + "BlockEmbeddingModel", + "BlockLexicalRecordModel", + "AgentDefinitionModel", + "PeerModel", + "DeploymentConfigModel", + "SinkModel", + "SinkTypeModel", +] + +import sqlalchemy.orm +import sqlmodel + +from inkcre_core.database_contract import PROTOCOL_SCHEMA + + +Base = sqlalchemy.orm.declarative_base(metadata=sqlalchemy.MetaData(schema=PROTOCOL_SCHEMA)) +sqlmodel.SQLModel.metadata = Base.metadata + +from .info_base.block import BlockModel +from .info_base.storage import StorageBlobModel, StorageModel, StorageTypesModel +from .info_base.relation import RelationModel +from .source import SourceModel +from .job import JobModel, JobTypeModel +from .cron import CronModel +from .extension.main import ExtensionModel, ExtensionPeerParticipationModel +from .ai import ( + AIDialectModel, + AIModelModel, + AIProviderModel, + BlockEmbeddingModel, + EmbeddingProfileModel, + RelationEmbeddingModel, +) +from .agent import AgentDefinitionModel +from .peer import PeerModel +from .deployment_config import DeploymentConfigModel +from .lexical_retrieval import BlockLexicalRecordModel +from .sink import SinkModel, SinkTypeModel diff --git a/inkcre_core/schemas/agent.py b/inkcre_core/schemas/agent.py new file mode 100644 index 00000000..aaefd3b8 --- /dev/null +++ b/inkcre_core/schemas/agent.py @@ -0,0 +1,173 @@ +"""Reusable Agent definitions persisted as shared deployment facts.""" + +import datetime +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + +from inkcre_core.schemas.ai import AIModelID, ToolChoice + + +AgentID: typing.TypeAlias = int + +_TOOL_CHOICE_ADAPTER = pydantic.TypeAdapter(ToolChoice | None) + + +def normalize_agent_tools(value: typing.Iterable[str]) -> tuple[str, ...]: + """Validate and canonicalize Agent Tool IDs with set semantics.""" + tools = tuple(value) + if any(not tool for tool in tools): + raise ValueError("Agent Tool IDs must not be empty") + if len(tools) != len(set(tools)): + raise ValueError("Agent Tool IDs must be unique") + return tuple(sorted(tools)) + + +class AgentToolsType(sqlalchemy.TypeDecorator): + """Round-trip canonical Agent Tool ID sets through PostgreSQL text arrays.""" + + impl = sqlalchemy.dialects.postgresql.ARRAY(sqlalchemy.Text) + cache_ok = True + + def process_bind_param(self, value, dialect): + del dialect + return list(normalize_agent_tools(value or ())) + + def process_result_value(self, value, dialect): + del dialect + return tuple(value or ()) + + +class ToolChoiceType(sqlalchemy.TypeDecorator): + """Round-trip the canonical nullable ToolChoice union through JSONB.""" + + impl = sqlalchemy.dialects.postgresql.JSONB + cache_ok = True + + def load_dialect_impl(self, dialect): + return dialect.type_descriptor(sqlalchemy.dialects.postgresql.JSONB(none_as_null=True)) + + def process_bind_param(self, value, dialect): + del dialect + choice = _TOOL_CHOICE_ADAPTER.validate_python(value) + if isinstance(choice, pydantic.BaseModel): + return choice.model_dump(mode="json") + return choice + + def process_result_value(self, value, dialect): + del dialect + return _TOOL_CHOICE_ADAPTER.validate_python(value) + + +class AgentForm(pydantic.BaseModel): + """Editable Agent definition, without execution or database-managed fields.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + name: str + system_prompt: str + model: AIModelID + tools: tuple[str, ...] = () + tool_choice: ToolChoice | None = None + max_model_calls_per_turn: int = pydantic.Field(gt=0) + + @pydantic.field_validator("tools") + @classmethod + def canonical_tools(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return normalize_agent_tools(value) + + +class AgentUpdateForm(pydantic.BaseModel): + """PATCH omission preserves a field; only tool_choice accepts explicit null.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + name: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + system_prompt: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + model: AIModelID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + tools: tuple[str, ...] = () + tool_choice: ToolChoice | None = None + max_model_calls_per_turn: int = pydantic.Field(default=None, gt=0) # pyrefly: ignore[bad-assignment] + + +class AgentDefinitionModel(sqlmodel.SQLModel, table=True): + """One reusable system-prompt, model, Tool and turn-budget composition.""" + + __tablename__ = "agents" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + "max_model_calls_per_turn > 0", + name="ck_agents_max_model_calls_per_turn_positive", + ), + ) + + id: AgentID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) + system_prompt: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + tools: tuple[str, ...] = sqlmodel.Field( + default=(), + sa_column=sqlalchemy.Column( + AgentToolsType(), + nullable=False, + server_default=sqlalchemy.text("'{}'::text[]"), + ), + ) + tool_choice: ToolChoice | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(ToolChoiceType(), nullable=True), + ) + model: AIModelID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("ai_models.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + max_model_calls_per_turn: int = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + @pydantic.field_validator("tools", mode="before") + @classmethod + def canonical_tools(cls, value: typing.Iterable[str]) -> tuple[str, ...]: + return normalize_agent_tools(value) + + @pydantic.field_validator("tool_choice", mode="before") + @classmethod + def canonical_tool_choice(cls, value: typing.Any) -> ToolChoice | None: + return _TOOL_CHOICE_ADAPTER.validate_python(value) + + @pydantic.field_validator("max_model_calls_per_turn") + @classmethod + def positive_model_call_budget(cls, value: int) -> int: + if value <= 0: + raise ValueError("max_model_calls_per_turn must be positive") + return value diff --git a/inkcre_core/schemas/ai/__init__.py b/inkcre_core/schemas/ai/__init__.py new file mode 100644 index 00000000..e226e18e --- /dev/null +++ b/inkcre_core/schemas/ai/__init__.py @@ -0,0 +1,80 @@ +"""Canonical AI contracts and shared persistence models.""" + +from .capability import ( + AICapability, + AICapabilityType, + AIFeature, + AIModality, + AIModelCapability, + ChatCapability, + EmbeddingCapability, + normalize_capabilities, +) +from .chat import ( + AudioContentPart, + AssistantMessage, + FunctionTool, + ImageContentPart, + JSONValue, + Message, + NamedToolChoice, + SystemMessage, + TextContentPart, + ToolCall, + ToolChoice, + ToolResult, + ToolResultMessage, + UserContentPart, + UserMessage, + VideoContentPart, + validate_message_history, +) +from .embedding import BlockEmbeddingModel, RelationEmbeddingModel +from .main import ( + AIDialectID, + AIDialectModel, + AIModelID, + AIModelModel, + AIProviderID, + AIProviderModel, + EmbeddingProfileID, + EmbeddingProfileModel, +) + +__all__ = [ + "AICapability", + "AICapabilityType", + "AIDialectID", + "AIDialectModel", + "AIFeature", + "AIModality", + "AIModelCapability", + "AIModelID", + "AIModelModel", + "AIProviderID", + "AIProviderModel", + "AudioContentPart", + "AssistantMessage", + "BlockEmbeddingModel", + "ChatCapability", + "EmbeddingCapability", + "EmbeddingProfileID", + "EmbeddingProfileModel", + "FunctionTool", + "ImageContentPart", + "JSONValue", + "Message", + "NamedToolChoice", + "RelationEmbeddingModel", + "SystemMessage", + "TextContentPart", + "ToolCall", + "ToolChoice", + "ToolResult", + "ToolResultMessage", + "UserContentPart", + "UserMessage", + "VideoContentPart", + "normalize_capabilities", + "validate_message_history", +] diff --git a/inkcre_core/schemas/ai/capability.py b/inkcre_core/schemas/ai/capability.py new file mode 100644 index 00000000..4a52afcf --- /dev/null +++ b/inkcre_core/schemas/ai/capability.py @@ -0,0 +1,67 @@ +"""Provider-neutral AI model capability declarations.""" + +import typing + +import pydantic + + +AICapabilityType: typing.TypeAlias = typing.Literal["embedding", "chat"] +AIModality: typing.TypeAlias = str +AIFeature: typing.TypeAlias = str + + +def _canonical_string_set(value: typing.Any) -> tuple[str, ...]: + if not isinstance(value, (list, tuple, set, frozenset)): + raise ValueError("value must be an array of strings") + items = tuple(value) + if not all(isinstance(item, str) and item for item in items): + raise ValueError("values must be non-empty strings") + if len(items) != len(set(items)): + raise ValueError("duplicate values are not allowed") + return tuple(sorted(items)) + + +class AICapability(pydantic.BaseModel): + """Fields shared by every typed capability declaration.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: AICapabilityType + input_modalities: tuple[AIModality, ...] + output_modalities: tuple[AIModality, ...] + features: tuple[AIFeature, ...] = () + + _normalize_input_modalities = pydantic.field_validator("input_modalities", mode="before")( + _canonical_string_set + ) + _normalize_output_modalities = pydantic.field_validator( + "output_modalities", mode="before" + )(_canonical_string_set) + _normalize_features = pydantic.field_validator("features", mode="before")( + _canonical_string_set + ) + + +class EmbeddingCapability(AICapability): + type: typing.Literal["embedding"] = "embedding" + + +class ChatCapability(AICapability): + type: typing.Literal["chat"] = "chat" + + +AIModelCapability: typing.TypeAlias = typing.Annotated[ + EmbeddingCapability | ChatCapability, + pydantic.Field(discriminator="type"), +] + +CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[AIModelCapability, ...]) + + +def normalize_capabilities(value: typing.Any) -> tuple[AIModelCapability, ...]: + """Validate and canonically order one declaration per capability type.""" + capabilities = CAPABILITIES_ADAPTER.validate_python(value) + types = tuple(capability.type for capability in capabilities) + if len(types) != len(set(types)): + raise ValueError("duplicate AI capability types are not allowed") + return tuple(sorted(capabilities, key=lambda capability: capability.type)) diff --git a/inkcre_core/schemas/ai/chat.py b/inkcre_core/schemas/ai/chat.py new file mode 100644 index 00000000..013ef9ad --- /dev/null +++ b/inkcre_core/schemas/ai/chat.py @@ -0,0 +1,214 @@ +"""Canonical AI chat, Tool and ToolResult contracts.""" + +import typing +from urllib.parse import urlsplit + +import pydantic + + +JSONValue: typing.TypeAlias = pydantic.JsonValue + + +class FunctionTool(pydantic.BaseModel): + """One caller-owned function Tool exposed to an AI model.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + id: str = pydantic.Field(min_length=1) + description: str + input_schema: dict[str, JSONValue] + + +class ToolCall(pydantic.BaseModel): + """One model-authored request to invoke a caller-owned Tool.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + id: str = pydantic.Field(min_length=1) + tool: str = pydantic.Field(min_length=1) + arguments: dict[str, JSONValue] + + +class ToolResult(pydantic.BaseModel): + """One caller-side result correlated to a ToolCall.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + tool_call_id: str = pydantic.Field(min_length=1) + content: JSONValue + is_error: bool = False + + +class NamedToolChoice(pydantic.BaseModel): + """Require one exact caller-owned Tool.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["function"] = "function" + tool: str = pydantic.Field(min_length=1) + + +ToolChoice: typing.TypeAlias = typing.Literal["none", "auto", "required"] | NamedToolChoice + + +class SystemMessage(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["system"] = "system" + content: str + + +class TextContentPart(pydantic.BaseModel): + """One authored text item in an ordered multimodal User message.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["text"] = "text" + text: str = pydantic.Field(min_length=1) + + +class _MediaContentPart(pydantic.BaseModel): + """AI-owned media transfer value, independent of Block and Storage identity.""" + + model_config = pydantic.ConfigDict( + extra="forbid", + frozen=True, + ser_json_bytes="base64", + val_json_bytes="base64", + ) + + data: bytes = pydantic.Field(min_length=1) + mime_type: str = pydantic.Field(min_length=1) + transfer_url: str | None = None + + @pydantic.field_validator("mime_type") + @classmethod + def normalized_mime_type(cls, value: str) -> str: + normalized = value.partition(";")[0].strip().lower() + if "/" not in normalized or any(character.isspace() for character in normalized): + raise ValueError("mime_type must be one concrete media type") + return normalized + + @pydantic.field_validator("transfer_url") + @classmethod + def http_transfer_url(cls, value: str | None) -> str | None: + if value is None: + return None + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("transfer_url must be one absolute HTTP(S) URL") + if parsed.username is not None or parsed.password is not None: + raise ValueError("transfer_url must not contain user-info credentials") + return value + + +class ImageContentPart(_MediaContentPart): + type: typing.Literal["image"] = "image" + + @pydantic.field_validator("mime_type") + @classmethod + def image_mime_type(cls, value: str) -> str: + if not value.startswith("image/"): + raise ValueError("ImageContentPart requires an image MIME type") + return value + + +class AudioContentPart(_MediaContentPart): + type: typing.Literal["audio"] = "audio" + + @pydantic.field_validator("mime_type") + @classmethod + def audio_mime_type(cls, value: str) -> str: + if not value.startswith("audio/"): + raise ValueError("AudioContentPart requires an audio MIME type") + return value + + +class VideoContentPart(_MediaContentPart): + type: typing.Literal["video"] = "video" + + @pydantic.field_validator("mime_type") + @classmethod + def video_mime_type(cls, value: str) -> str: + if not value.startswith("video/"): + raise ValueError("VideoContentPart requires a video MIME type") + return value + + +UserContentPart: typing.TypeAlias = typing.Annotated[ + TextContentPart | ImageContentPart | AudioContentPart | VideoContentPart, + pydantic.Field(discriminator="type"), +] + + +class UserMessage(pydantic.BaseModel): + model_config = pydantic.ConfigDict( + extra="forbid", + frozen=True, + ser_json_bytes="base64", + val_json_bytes="base64", + ) + + type: typing.Literal["user"] = "user" + content: tuple[UserContentPart, ...] = pydantic.Field(min_length=1) + + +class AssistantMessage(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["assistant"] = "assistant" + content: str | None = None + tool_calls: tuple[ToolCall, ...] = () + + @pydantic.model_validator(mode="after") + def unique_tool_call_ids(self) -> "AssistantMessage": + call_ids = tuple(call.id for call in self.tool_calls) + if len(call_ids) != len(set(call_ids)): + raise ValueError("AssistantMessage ToolCall IDs must be unique") + return self + + +class ToolResultMessage(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["tool_result"] = "tool_result" + results: tuple[ToolResult, ...] = pydantic.Field(min_length=1) + + @pydantic.model_validator(mode="after") + def unique_tool_call_ids(self) -> "ToolResultMessage": + call_ids = tuple(result.tool_call_id for result in self.results) + if len(call_ids) != len(set(call_ids)): + raise ValueError("ToolResultMessage ToolCall IDs must be unique") + return self + + +Message: typing.TypeAlias = typing.Annotated[ + SystemMessage | UserMessage | AssistantMessage | ToolResultMessage, + pydantic.Field(discriminator="type"), +] + +_MESSAGES_ADAPTER = pydantic.TypeAdapter(tuple[Message, ...]) + + +def validate_message_history(value: typing.Any) -> tuple[Message, ...]: + """Validate canonical types and every closed Assistant/ToolResult pair.""" + messages = _MESSAGES_ADAPTER.validate_python(value) + index = 0 + while index < len(messages): + message = messages[index] + if isinstance(message, ToolResultMessage): + raise ValueError("ToolResultMessage must follow an AssistantMessage with ToolCalls") + if isinstance(message, AssistantMessage) and message.tool_calls: + if index + 1 >= len(messages) or not isinstance( + messages[index + 1], ToolResultMessage + ): + raise ValueError("AssistantMessage ToolCalls require an adjacent ToolResultMessage") + result_message = typing.cast(ToolResultMessage, messages[index + 1]) + call_ids = {call.id for call in message.tool_calls} + result_ids = {result.tool_call_id for result in result_message.results} + if result_ids != call_ids: + raise ValueError("ToolResultMessage must exactly cover preceding ToolCall IDs") + index += 2 + continue + index += 1 + return messages diff --git a/inkcre_core/schemas/ai/embedding.py b/inkcre_core/schemas/ai/embedding.py new file mode 100644 index 00000000..72e2aafc --- /dev/null +++ b/inkcre_core/schemas/ai/embedding.py @@ -0,0 +1,91 @@ +"""Profile-scoped Block and Relation embedding records.""" + +import datetime + +import pgvector.sqlalchemy +import sqlalchemy +import sqlmodel + +from inkcre_core.schemas.info_base.main import Vector + +from .main import EmbeddingProfileID + + +class BlockEmbeddingModel(sqlmodel.SQLModel, table=True): + __tablename__ = "block_embeddings" # type: ignore + + profile: EmbeddingProfileID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey( + "embedding_profiles.id", onupdate="CASCADE", ondelete="RESTRICT" + ), + primary_key=True, + ) + ) + block: int = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + ) + embedding: Vector = sqlmodel.Field( + sa_column=sqlalchemy.Column(pgvector.sqlalchemy.VECTOR(), nullable=False) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + +class RelationEmbeddingModel(sqlmodel.SQLModel, table=True): + __tablename__ = "relation_embeddings" # type: ignore + + profile: EmbeddingProfileID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey( + "embedding_profiles.id", onupdate="CASCADE", ondelete="RESTRICT" + ), + primary_key=True, + ) + ) + relation: int = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("relations.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + ) + embedding: Vector = sqlmodel.Field( + sa_column=sqlalchemy.Column(pgvector.sqlalchemy.VECTOR(), nullable=False) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) diff --git a/inkcre_core/schemas/ai/main.py b/inkcre_core/schemas/ai/main.py new file mode 100644 index 00000000..f189d7eb --- /dev/null +++ b/inkcre_core/schemas/ai/main.py @@ -0,0 +1,227 @@ +"""Shared AI dialect, provider, model and embedding-profile facts.""" + +import datetime +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + +from .capability import CAPABILITIES_ADAPTER, AIModelCapability, normalize_capabilities + + +AIDialectID: typing.TypeAlias = str +AIProviderID: typing.TypeAlias = int +AIModelID: typing.TypeAlias = int +EmbeddingProfileID: typing.TypeAlias = int + + +class AICapabilitiesType(sqlalchemy.TypeDecorator): + """Round-trip typed, canonical capability declarations through JSONB.""" + + impl = sqlalchemy.dialects.postgresql.JSONB + cache_ok = True + + def process_bind_param(self, value, dialect): + del dialect + return [ + capability.model_dump(mode="json") for capability in normalize_capabilities(value) + ] + + def process_result_value(self, value, dialect): + del dialect + return CAPABILITIES_ADAPTER.validate_python(value or ()) + + +class AIDialectModel(sqlmodel.SQLModel, table=True): + """One exact shared AI wire/config dialect contract.""" + + __tablename__ = "ai_dialects" # type: ignore + + id: AIDialectID = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + description: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + config_schema: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + + +class AIProviderModel(sqlmodel.SQLModel, table=True): + """One deployment-scoped configuration of an AI dialect.""" + + __tablename__ = "ai_providers" # type: ignore + + id: AIProviderID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) + dialect: AIDialectID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("ai_dialects.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + config: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + enabled: bool = sqlmodel.Field( + default=True, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, + nullable=False, + server_default=sqlalchemy.true(), + ), + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + +class AIModelModel(sqlmodel.SQLModel, table=True): + """One provider-bound native model offering and its effective capabilities.""" + + __tablename__ = "ai_models" # type: ignore + __table_args__ = ( + sqlalchemy.UniqueConstraint( + "provider", "native_model_id", name="uq_ai_models_provider_native_model" + ), + ) + + id: AIModelID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + provider: AIProviderID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("ai_providers.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + native_model_id: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + name: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + capabilities: tuple[AIModelCapability, ...] = sqlmodel.Field( + default=(), + sa_column=sqlalchemy.Column(AICapabilitiesType(), nullable=False), + ) + enabled: bool = sqlmodel.Field( + default=True, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, + nullable=False, + server_default=sqlalchemy.true(), + ), + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + @pydantic.field_validator("capabilities", mode="before") + @classmethod + def canonical_capabilities(cls, value: typing.Any) -> tuple[AIModelCapability, ...]: + return normalize_capabilities(value) + + +class EmbeddingProfileModel(sqlmodel.SQLModel, table=True): + """One durable vector-space compatibility contract.""" + + __tablename__ = "embedding_profiles" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + "dimensions > 0", name="ck_embedding_profiles_dimensions_positive" + ), + ) + + id: EmbeddingProfileID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + name: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + ai_model: AIModelID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("ai_models.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + dimensions: int = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) diff --git a/inkcre_core/schemas/cron.py b/inkcre_core/schemas/cron.py new file mode 100644 index 00000000..bf452e15 --- /dev/null +++ b/inkcre_core/schemas/cron.py @@ -0,0 +1,120 @@ +"""Deployment-wide recurring Job-template schemas.""" + +import datetime +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + +from .job import JobID, JobTypeID + + +CronID: typing.TypeAlias = int + + +class CronModel(sqlmodel.SQLModel, table=True): + """One five-field UNIX schedule that materializes typed Jobs.""" + + __tablename__ = "crons" # type: ignore + __table_args__ = ( + sqlalchemy.Index("crons_enabled_idx", "enabled"), + sqlalchemy.CheckConstraint( + "job_timeout_seconds IS NULL OR job_timeout_seconds > 0", + name="crons_job_timeout_seconds_positive", + ), + ) + + id: CronID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + schedule: str = sqlmodel.Field( + min_length=1, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False), + ) + enabled: bool = sqlmodel.Field( + default=True, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, + nullable=False, + server_default=sqlalchemy.true(), + ), + ) + job_type: JobTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("job_types.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + job_parameters: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + job_timeout_seconds: int | None = sqlmodel.Field( + default=None, + gt=0, + sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=True), + ) + last_job: JobID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("jobs.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=True, + ), + ) + last_scheduled_for: datetime.datetime | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + +class CronForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + schedule: str = pydantic.Field(min_length=1) + enabled: bool = True + job_type: JobTypeID + job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) + + +class CronUpdateForm(pydantic.BaseModel): + """Optional submitted fields; null only clears the timeout override.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + # Omission is not null input: exclude_unset removes these defaults, whereas + # an explicitly submitted null still fails the non-null field type. + schedule: str = pydantic.Field(default=None, min_length=1) # pyrefly: ignore[bad-assignment] + enabled: bool = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + job_type: JobTypeID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) diff --git a/inkcre_core/schemas/deployment_config.py b/inkcre_core/schemas/deployment_config.py new file mode 100644 index 00000000..0c4e4c61 --- /dev/null +++ b/inkcre_core/schemas/deployment_config.py @@ -0,0 +1,69 @@ +"""Deployment-scoped shared configuration persistence and API models.""" + +import datetime +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + + +DeploymentConfigKey: typing.TypeAlias = str +DeploymentConfigSchemaID: typing.TypeAlias = str + + +class DeploymentConfigModel(sqlmodel.SQLModel, table=True): + """One deployment-owned config value under an exact schema contract.""" + + __tablename__ = "configs" # type: ignore + + key: DeploymentConfigKey = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + schema_id: DeploymentConfigSchemaID = sqlmodel.Field( + sa_column=sqlalchemy.Column("schema", sqlalchemy.Text, nullable=False) + ) + value: dict[str, typing.Any] = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + ) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + +class DeploymentConfigReplaceForm(pydantic.BaseModel): + """Complete PUT body; schema changes are allowed only through this form.""" + + model_config = pydantic.ConfigDict(populate_by_name=True) + + schema_id: DeploymentConfigSchemaID = pydantic.Field(alias="schema") + value: dict[str, typing.Any] + + +class DeploymentConfigView(pydantic.BaseModel): + """Stored deployment config and metadata; reading does not revalidate value.""" + + model_config = pydantic.ConfigDict(populate_by_name=True) + + key: DeploymentConfigKey + schema_id: DeploymentConfigSchemaID = pydantic.Field(alias="schema") + value: dict[str, typing.Any] + created_at: datetime.datetime + updated_at: datetime.datetime diff --git a/inkcre_core/schemas/extension/__init__.py b/inkcre_core/schemas/extension/__init__.py new file mode 100644 index 00000000..4a6fac60 --- /dev/null +++ b/inkcre_core/schemas/extension/__init__.py @@ -0,0 +1,37 @@ +from .main import ( + DisableExtensionCommand, + EnableExtensionCommand, + ExtensionPeerStatus, + ExtensionPeerStatusRow, + EXTENSION_NAME_PATTERN, + EXTENSION_SEMVER_PATTERN, + ExtensionManagementCommand, + ExtensionModel, + ExtensionPeerParticipationModel, + ExtensionName, + InstallExtensionCommand, + PatchExtensionConfigCommand, + SelectExtensionInput, + SelectExtensionCommand, + ExtensionStatusCommand, + UnselectExtensionCommand, +) + +__all__ = [ + "DisableExtensionCommand", + "EnableExtensionCommand", + "ExtensionPeerStatus", + "ExtensionPeerStatusRow", + "EXTENSION_NAME_PATTERN", + "EXTENSION_SEMVER_PATTERN", + "ExtensionManagementCommand", + "ExtensionModel", + "ExtensionPeerParticipationModel", + "ExtensionName", + "InstallExtensionCommand", + "PatchExtensionConfigCommand", + "SelectExtensionInput", + "SelectExtensionCommand", + "ExtensionStatusCommand", + "UnselectExtensionCommand", +] diff --git a/inkcre_core/schemas/extension/main.py b/inkcre_core/schemas/extension/main.py new file mode 100644 index 00000000..0d6322ef --- /dev/null +++ b/inkcre_core/schemas/extension/main.py @@ -0,0 +1,215 @@ +import typing +import uuid + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + + +ExtensionName: typing.TypeAlias = str +NativeDistribution = typing.Annotated[str, pydantic.Field(min_length=1)] +EXTENSION_NAME_PATTERN = ( + r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?/" + r"[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$" +) +EXTENSION_SEMVER_PATTERN = ( + r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)" + r"(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"([.](0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$" +) + + +class InstallExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["install"] + extension: ExtensionName + version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) + + +class SelectExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["select"] + extension: ExtensionName + distribution: NativeDistribution + version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) + + +class ExtensionStatusCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["status"] + extension: ExtensionName | None = None + + +class UnselectExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["unselect"] + extension: ExtensionName + + +class EnableExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["enable"] + extension: ExtensionName + + +class DisableExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["disable"] + extension: ExtensionName + + +class PatchExtensionConfigCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["patch_config"] + extension: ExtensionName + patch: dict[str, typing.Any] + + +class ExtensionPeerStatusRow(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + name: ExtensionName + peer_id: uuid.UUID + distribution: NativeDistribution | None + version: str | None + enabled: bool + config_schema: dict[str, typing.Any] | None + supported_distributions: tuple[NativeDistribution, ...] + loaded_version: str | None + running: bool + restart_required: bool + operation: str | None + error: str | None + + +class ExtensionPeerStatus(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + peer_id: uuid.UUID + supported_distributions: tuple[NativeDistribution, ...] + extensions: tuple[ExtensionPeerStatusRow, ...] + + +class SelectExtensionInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + distribution: NativeDistribution + version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) + + +ExtensionManagementCommand: typing.TypeAlias = typing.Annotated[ + SelectExtensionCommand + | ExtensionStatusCommand + | UnselectExtensionCommand + | EnableExtensionCommand + | DisableExtensionCommand + | PatchExtensionConfigCommand, + pydantic.Field(discriminator="action"), +] + + +class ExtensionModel(sqlmodel.SQLModel, table=True): + """Shared Extension identity, configuration, and producer-owned state.""" + + __tablename__: str = "extensions" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + f"name ~ '{EXTENSION_NAME_PATTERN}'", + name="extensions_name_canonical", + ), + sqlalchemy.CheckConstraint( + "jsonb_typeof(state) = 'object'", + name="extensions_state_object", + ), + ) + + name: ExtensionName = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + nickname: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + config: dict = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + ) + state: dict = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + ) + + +class ExtensionPeerParticipationModel(sqlmodel.SQLModel, table=True): + """One Peer’s exact native distribution selection and enable intent.""" + + __tablename__: str = "extension_peer_participations" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + f"name ~ '{EXTENSION_NAME_PATTERN}'", + name="extension_peer_participations_name_canonical", + ), + sqlalchemy.CheckConstraint( + f"version ~ '{EXTENSION_SEMVER_PATTERN}'", + name="extension_peer_participations_version_canonical", + ), + sqlalchemy.CheckConstraint( + "config_schema IS NULL OR jsonb_typeof(config_schema) = 'object'", + name="extension_peer_participations_config_schema_object", + ), + sqlalchemy.CheckConstraint( + "distribution IS NOT NULL OR config_schema IS NULL", + name="extension_peer_participations_unresolved_schema_null", + ), + sqlalchemy.ForeignKeyConstraint( + ["name"], ["inkcre.extensions.name"], ondelete="CASCADE" + ), + sqlalchemy.ForeignKeyConstraint(["peer_id"], ["inkcre.peers.id"], ondelete="CASCADE"), + sqlalchemy.PrimaryKeyConstraint( + "name", "peer_id", name="extension_peer_participations_pkey" + ), + ) + + name: ExtensionName = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + peer_id: uuid.UUID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.UUID(as_uuid=True), nullable=False + ) + ) + distribution: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + version: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + enabled: bool = sqlmodel.Field( + default=False, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false() + ), + ) + config_schema: dict | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB(none_as_null=True), nullable=True + ), + ) diff --git a/inkcre_core/schemas/graph_navigation_retrieval.py b/inkcre_core/schemas/graph_navigation_retrieval.py new file mode 100644 index 00000000..3555052f --- /dev/null +++ b/inkcre_core/schemas/graph_navigation_retrieval.py @@ -0,0 +1,90 @@ +"""Presentation-neutral graph-navigation retrieval contracts.""" + +import typing + +import pydantic + +from inkcre_core.schemas.info_base.block import BlockID, BlockModel +from inkcre_core.schemas.info_base.relation import RelationID, RelationModel + + +GraphDirection: typing.TypeAlias = typing.Literal["in", "out", "both"] + +DEFAULT_NEIGHBORHOOD_LIMIT = 20 +MAX_NEIGHBORHOOD_LIMIT = 100 +DEFAULT_MAX_HOPS = 4 +MAX_MAX_HOPS = 8 +DEFAULT_MAX_EXPLORED_BLOCKS = 1000 +MAX_MAX_EXPLORED_BLOCKS = 10000 +DEFAULT_MAX_EXPLORED_RELATIONS = 10000 + + +class GraphModel(pydantic.BaseModel): + """Endpoint-closed persisted graph read model.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + blocks: tuple[BlockModel, ...] + relations: tuple[RelationModel, ...] + + +class BlockNeighborhood(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + focal_block: BlockID + graph: GraphModel + next_cursor: RelationID | None = None + + +class RelationNeighborhood(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + focal_relation: RelationID + graph: GraphModel + + +class ConnectedSeedComponent(pydantic.BaseModel): + """One observed undirected component containing one or more input seeds.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + seed_block_ids: tuple[BlockID, ...] + member_block_ids: tuple[BlockID, ...] + + +class ConnectedComponentsResult(pydantic.BaseModel): + """Bounded seed partition plus an endpoint-closed spanning proof.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + components: tuple[ConnectedSeedComponent, ...] + proof_graph: GraphModel + missing_seed_block_ids: tuple[BlockID, ...] + truncated: bool + + +class PathFound(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + status: typing.Literal["found"] = "found" + graph: GraphModel + block_path: tuple[BlockID, ...] + relation_path: tuple[RelationID, ...] + + +class PathNotFound(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + status: typing.Literal["not_found"] = "not_found" + + +class PathLimitReached(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + status: typing.Literal["limit_reached"] = "limit_reached" + + +PathResult: typing.TypeAlias = typing.Annotated[ + PathFound | PathNotFound | PathLimitReached, + pydantic.Field(discriminator="status"), +] diff --git a/inkcre_core/schemas/info_base/__init__.py b/inkcre_core/schemas/info_base/__init__.py new file mode 100644 index 00000000..abcd96c5 --- /dev/null +++ b/inkcre_core/schemas/info_base/__init__.py @@ -0,0 +1,36 @@ +from .block import BlockForm, BlockModel, BlockID, ResolverType +from .relation import RelationCreateForm, RelationForm, RelationModel, RelationID +from .storage import StorageBlobModel, StorageModel, StorageID, StorageTypesModel +from .main import ( + GraphBlockForm, + GraphForm, + GraphRelationForm, + InArcForm, + OutArcForm, + StarsGraphForm, + SubmitGraphResult, + Vector, +) + +__all__ = [ + "BlockModel", + "BlockForm", + "BlockID", + "ResolverType", + "RelationModel", + "RelationForm", + "RelationCreateForm", + "RelationID", + "StorageModel", + "StorageBlobModel", + "StorageID", + "StorageTypesModel", + "StarsGraphForm", + "OutArcForm", + "InArcForm", + "GraphBlockForm", + "GraphRelationForm", + "GraphForm", + "SubmitGraphResult", + "Vector", +] diff --git a/inkcre_core/schemas/info_base/block.py b/inkcre_core/schemas/info_base/block.py new file mode 100644 index 00000000..83195023 --- /dev/null +++ b/inkcre_core/schemas/info_base/block.py @@ -0,0 +1,111 @@ +import datetime +import typing +import sqlalchemy +import sqlmodel +from pydantic import PrivateAttr +from typing import Optional as Opt +from .storage import StorageID + +ResolverType: typing.TypeAlias = str +"""Type of resolver. + +Extension resolvers should follows `extensions.{extension_id}.{resolver_name}` pattern. +""" +BlockID: typing.TypeAlias = int +HydratedContent: typing.TypeAlias = str | bytes +ResolverTV = typing.TypeVar("ResolverTV", bound="ResolverType") +"""Resolver type variable""" + + +class BlockForm(sqlmodel.SQLModel): + """Producer-owned values for creating one Block.""" + + model_config = {"extra": "forbid"} + + storage: Opt[StorageID] = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("storages.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=True, + ), + ) + resolver: ResolverType = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + content: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + """Content of the block. + + Block content stored as text in database. + For runtime usage, use storage to get the raw content. + """ + + +class BlockModel(BlockForm, table=True): + __tablename__ = "blocks" # type: ignore + + id: Opt[BlockID] = sqlmodel.Field( + sa_column=sqlmodel.Column(sqlmodel.Integer, primary_key=True, autoincrement=True), + default=None, + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + _hydrated_content: HydratedContent | None = PrivateAttr(default=None) + _hydrated_content_source: tuple[StorageID | None, str] | None = PrivateAttr(default=None) + + async def get_hydrated_content(self, *, refresh: bool = False) -> HydratedContent: + """Return inline content or bytes loaded through the configured storage. + + The cache belongs only to this block instance. It is keyed by the persisted + storage pointer fields and is replaced when ``refresh`` is requested. + """ + source = (self.storage, self.content) + private_state = self.__pydantic_private__ + if private_state is None: + private_state = {} + object.__setattr__(self, "__pydantic_private__", private_state) + if ( + not refresh + and private_state.get("_hydrated_content_source") == source + and private_state.get("_hydrated_content") is not None + ): + return typing.cast(HydratedContent, private_state["_hydrated_content"]) + + if self.storage is None: + hydrated_content: HydratedContent = self.content + else: + from inkcre_core.business.info_base.storage import StorageManager + + storage = await StorageManager.get_storage_async(self.storage) + stored_content = await storage.get_raw_content(self.content) + if not isinstance(stored_content, bytes): + raise TypeError( + f"Storage {storage.__class__.__name__} returned " + f"{type(stored_content).__name__}; configured storage must return bytes" + ) + hydrated_content = stored_content + + private_state["_hydrated_content"] = hydrated_content + private_state["_hydrated_content_source"] = source + return hydrated_content + + async def get_context_as_text(self) -> str | None: + from inkcre_core.business.info_base.resolver import ResolverManager + + resolver = ResolverManager.get(self) + return await resolver.get_text() diff --git a/inkcre_core/schemas/info_base/main.py b/inkcre_core/schemas/info_base/main.py new file mode 100644 index 00000000..81e9bfb3 --- /dev/null +++ b/inkcre_core/schemas/info_base/main.py @@ -0,0 +1,116 @@ +"""Producer graph forms and graph-command results.""" + +__all__ = [ + "Vector", + "OutArcForm", + "InArcForm", + "StarsGraphForm", + "GraphBlockForm", + "GraphRelationForm", + "GraphForm", + "GraphBlockIDMapping", + "SubmitGraphResult", +] + +import typing + +import pydantic +import sqlmodel + +from inkcre_core.schemas.info_base.block import BlockForm, BlockID +from inkcre_core.schemas.info_base.relation import RelationForm + + +Vector: typing.TypeAlias = tuple[float, ...] +NegativeBlockID: typing.TypeAlias = typing.Annotated[int, pydantic.Field(lt=0)] +NonZeroBlockID: typing.TypeAlias = int + + +class OutArcForm(sqlmodel.SQLModel): + """One outgoing relation from the enclosing star to a recursive star.""" + + model_config = {"extra": "forbid"} + + relation: RelationForm + to_graph: "StarsGraphForm" + + +class InArcForm(sqlmodel.SQLModel): + """One incoming relation from a recursive star to the enclosing star.""" + + model_config = {"extra": "forbid"} + + relation: RelationForm + from_graph: "StarsGraphForm" + + +class StarsGraphForm(sqlmodel.SQLModel): + """Recursive star-centered authoring form used by Resolvers and extensions.""" + + model_config = {"extra": "forbid"} + + block: BlockForm + out_arcs: tuple[OutArcForm, ...] = () + in_arcs: tuple[InArcForm, ...] = () + + +class GraphBlockForm(BlockForm): + """A new Block declaration under one GraphForm-local negative ID.""" + + id: NegativeBlockID + + +class GraphRelationForm(RelationForm): + """A Relation declaration over the GraphForm signed Block-ID namespace.""" + + from_: NonZeroBlockID + to_: NonZeroBlockID + + @pydantic.field_validator("from_", "to_") + @classmethod + def require_non_zero_endpoint(cls, value: int) -> int: + if value == 0: + raise ValueError("GraphForm Relation endpoints must be non-zero") + return value + + +class GraphForm(sqlmodel.SQLModel): + """Flat command for adding arbitrarily connected Blocks and Relations.""" + + model_config = {"extra": "forbid"} + + blocks: tuple[GraphBlockForm, ...] = () + relations: tuple[GraphRelationForm, ...] = () + + @pydantic.model_validator(mode="after") + def validate_local_block_references(self) -> "GraphForm": + local_ids = [block.id for block in self.blocks] + if len(local_ids) != len(set(local_ids)): + raise ValueError("GraphForm new Block IDs must be unique") + + unresolved = { + endpoint + for relation in self.relations + for endpoint in (relation.from_, relation.to_) + if endpoint < 0 and endpoint not in local_ids + } + if unresolved: + rendered = ", ".join(str(value) for value in sorted(unresolved)) + raise ValueError(f"GraphForm contains unresolved local Block IDs: {rendered}") + return self + + +class GraphBlockIDMapping(sqlmodel.SQLModel): + """Map one command-local Block identity to its persisted identity.""" + + local_id: NegativeBlockID + id: BlockID + + +class SubmitGraphResult(sqlmodel.SQLModel): + """Minimal successful graph-command result used by producers and Agent Tools.""" + + blocks: tuple[GraphBlockIDMapping, ...] + + +StarsGraphForm.model_rebuild() diff --git a/inkcre_core/schemas/info_base/relation.py b/inkcre_core/schemas/info_base/relation.py new file mode 100644 index 00000000..506cffa2 --- /dev/null +++ b/inkcre_core/schemas/info_base/relation.py @@ -0,0 +1,68 @@ +import typing +from typing import Optional as Opt +import datetime +import sqlalchemy +import sqlmodel + +from inkcre_core.schemas.info_base.block import BlockID + +RelationID: typing.TypeAlias = int + + +class RelationForm(sqlmodel.SQLModel): + """Producer-owned values for creating one Relation.""" + + model_config = {"extra": "forbid"} + + content: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + + +class RelationCreateForm(RelationForm): + """Standalone Relation creation command with persisted Block endpoints.""" + + from_: BlockID + to_: BlockID + + +class RelationModel(RelationForm, table=True): + __tablename__ = "relations" # type: ignore + __table_args__ = ( + sqlalchemy.Index( + "relations_from_id_desc_idx", + "from_", + sqlalchemy.desc("id"), + ), + sqlalchemy.Index( + "relations_to_id_desc_idx", + "to_", + sqlalchemy.desc("id"), + ), + ) + + id: Opt[RelationID] = sqlmodel.Field( + sa_column=sqlmodel.Column(sqlmodel.Integer, primary_key=True, autoincrement=True), + default=None, + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + from_: int = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("blocks.id", ondelete="CASCADE", onupdate="CASCADE"), + ), + default=0, + ) + to_: int = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("blocks.id", ondelete="CASCADE", onupdate="CASCADE"), + ), + default=0, + ) diff --git a/inkcre_core/schemas/info_base/rest.py b/inkcre_core/schemas/info_base/rest.py new file mode 100644 index 00000000..4fb48a2e --- /dev/null +++ b/inkcre_core/schemas/info_base/rest.py @@ -0,0 +1,60 @@ +"""Ordinary REST input forms; persisted entities and Peer wire formats stay separate.""" + +import typing + +import pydantic + +from inkcre_core.schemas.graph_navigation_retrieval import ( + DEFAULT_MAX_HOPS, + MAX_MAX_HOPS, + DEFAULT_MAX_EXPLORED_BLOCKS, + MAX_MAX_EXPLORED_BLOCKS, + DEFAULT_MAX_EXPLORED_RELATIONS, + GraphDirection, +) + + +class EntityReference(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + type: typing.Literal["block", "relation"] + id: int + + +class EntitiesGetForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + entities: list[EntityReference] + content: typing.Literal["none", "raw", "hydrated"] = "raw" + + +class BlockUpdateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + resolver: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + storage: int | None = None + + +class RelationUpdateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + from_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + to_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + + +class GraphPathForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + from_block_id: int + to_block_id: int + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + max_hops: int = pydantic.Field(DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) + max_explored_blocks: int = pydantic.Field( + DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS + ) + + +class GraphComponentsForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + seed_block_ids: tuple[int, ...] + contents: tuple[str, ...] = pydantic.Field(min_length=1) + max_explored_blocks: int = pydantic.Field(DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) + max_explored_relations: int = pydantic.Field(DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) diff --git a/inkcre_core/schemas/info_base/storage.py b/inkcre_core/schemas/info_base/storage.py new file mode 100644 index 00000000..4aeb5cc4 --- /dev/null +++ b/inkcre_core/schemas/info_base/storage.py @@ -0,0 +1,94 @@ +import typing +import uuid +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel +from typing import Optional as Opt + + +StorageTypeID: typing.TypeAlias = str +StorageID: typing.TypeAlias = int + + +class StorageBlobModel(sqlmodel.SQLModel, table=True): + """Raw PostgreSQL-owned bytes addressed by an opaque generated pointer.""" + + __tablename__: str = "storage_blobs" # type: ignore + + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sqlalchemy.text("gen_random_uuid()"), + ), + ) + data: bytes = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.LargeBinary, nullable=False) + ) + + +class StorageTypesModel(sqlmodel.SQLModel, table=True): + __tablename__: str = "storage_types" # type: ignore + + id: StorageTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + """Type of storage. + + A custom string to identify the storage type. + For extension storages, must follow `extensions.{extension_id}.{type}` format. + """ + description: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + """Description of this storage type.""" + config_schema: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default=dict, + ) + writable: bool = sqlmodel.Field( + default=False, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, + nullable=False, + server_default=sqlalchemy.false(), + ), + ) + + +class StorageModel(sqlmodel.SQLModel, table=True): + __tablename__: str = "storages" # type: ignore + + id: Opt[StorageID] = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True), + default=None, + ) + type: str = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("storage_types.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + ) + """Type of storage. + + An absolute import path to the module where storage class at. + When delete a storage type, all storages of this type will be deleted too. + """ + nickname: Opt[str] = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + config: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default=dict, + ) diff --git a/inkcre_core/schemas/job.py b/inkcre_core/schemas/job.py new file mode 100644 index 00000000..740c0a88 --- /dev/null +++ b/inkcre_core/schemas/job.py @@ -0,0 +1,173 @@ +"""Durable background-command schemas.""" + +import datetime +import enum +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + + +JobTypeID: typing.TypeAlias = str +JobID: typing.TypeAlias = int + + +class JobStatus(enum.StrEnum): + PENDING = "pending" + RUNNING = "running" + FINISHED = "finished" + FAILED = "failed" + TIMED_OUT = "timed_out" + ABORTED = "aborted" + + @property + def terminal(self) -> bool: + return self not in {self.PENDING, self.RUNNING} + + +class JobTypeModel(sqlmodel.SQLModel, table=True): + """Database projection of one exact runtime Job Handler contract.""" + + __tablename__ = "job_types" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + "default_timeout_seconds > 0", + name="job_types_default_timeout_seconds_positive", + ), + ) + + id: JobTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + description: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + parameters_schema: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + default_timeout_seconds: int = sqlmodel.Field( + gt=0, + sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False), + ) + + +class JobModel(sqlmodel.SQLModel, table=True): + """One durable execution opportunity for an exact registered command.""" + + __tablename__ = "jobs" # type: ignore + __table_args__ = ( + sqlalchemy.Index("jobs_status_idx", "status"), + sqlalchemy.CheckConstraint( + "octet_length(submission_traceparent) <= 512", + name="jobs_submission_traceparent_capacity", + ), + sqlalchemy.CheckConstraint( + "octet_length(submission_tracestate) <= 512", + name="jobs_submission_tracestate_capacity", + ), + sqlalchemy.CheckConstraint( + "timeout_seconds > 0", + name="jobs_timeout_seconds_positive", + ), + sqlalchemy.CheckConstraint( + "(status = 'pending' AND started_at IS NULL AND closed_at IS NULL) OR " + "(status = 'running' AND started_at IS NOT NULL AND closed_at IS NULL) OR " + "(status IN ('finished', 'failed', 'timed_out', 'aborted') " + "AND closed_at IS NOT NULL)", + name="jobs_lifecycle_timestamps_valid", + ), + ) + + id: JobID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + type: JobTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("job_types.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + parameters: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + submission_traceparent: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + submission_tracestate: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + state: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + abort_requested: bool = sqlmodel.Field( + default=False, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false() + ), + ) + timeout_seconds: int = sqlmodel.Field( + gt=0, + sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False), + ) + status: JobStatus = sqlmodel.Field( + default=JobStatus.PENDING, + sa_column=sqlalchemy.Column( + sqlalchemy.Enum( + JobStatus, + name="jobstatus", + values_callable=lambda values: [value.value for value in values], + inherit_schema=True, + ), + nullable=False, + server_default=JobStatus.PENDING.value, + ), + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + started_at: datetime.datetime | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), + ) + closed_at: datetime.datetime | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.TIMESTAMP(timezone=True), nullable=True), + ) + + +class JobCreateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + type: JobTypeID + parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + timeout_seconds: int | None = pydantic.Field(default=None, gt=0) diff --git a/inkcre_core/schemas/lexical_retrieval.py b/inkcre_core/schemas/lexical_retrieval.py new file mode 100644 index 00000000..15d85e67 --- /dev/null +++ b/inkcre_core/schemas/lexical_retrieval.py @@ -0,0 +1,150 @@ +"""Block-local lexical projection, maintenance, and ranked result contracts.""" + +import datetime +import math +import typing + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + +from inkcre_core.schemas.info_base.block import BlockModel + + +LexicalEvidence: typing.TypeAlias = typing.Literal[ + "label_exact", + "label_substring", + "text_substring", + "terms", +] + + +class BlockLexicalRecordModel(sqlmodel.SQLModel, table=True): + """Rebuildable Resolver projection owned only by lexical retrieval.""" + + __tablename__ = "block_lexical_records" # type: ignore + __table_args__ = ( + sqlalchemy.Index( + "block_lexical_records_search_vector_idx", + "search_vector", + postgresql_using="gin", + ), + sqlalchemy.Index( + "block_lexical_records_label_trgm_idx", + "label", + postgresql_using="gin", + postgresql_ops={"label": "gin_trgm_ops"}, + ), + sqlalchemy.Index( + "block_lexical_records_text_trgm_idx", + "text", + postgresql_using="gin", + postgresql_ops={"text": "gin_trgm_ops"}, + ), + ) + + block: int = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + ) + label: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) + text: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + search_vector: str = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.TSVECTOR, + nullable=False, + ) + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + +class LexicalRetrievalRequest(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: str + limit: int = pydantic.Field(default=20, ge=1, le=20) + + @pydantic.field_validator("query") + @classmethod + def non_empty_query(cls, value: str) -> str: + if not value.strip(): + raise ValueError("query must not be empty") + return value + + +class LexicalRetrievalMatch(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block: BlockModel + label: str + excerpt: str + evidence: LexicalEvidence + rank: float = pydantic.Field(ge=0) + + @pydantic.field_validator("rank") + @classmethod + def finite_rank(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("rank must be finite") + return value + + +class LexicalRetrievalResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + matches: tuple[LexicalRetrievalMatch, ...] + + +class LexicalMaintenanceOptions(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + max_records: int = pydantic.Field(default=100, ge=1) + scan_page_size: int = pydantic.Field(default=100, ge=1) + diagnostic_limit: int = pydantic.Field(default=20, ge=0) + + +class LexicalMaintenanceDiagnostic(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block: int + outcome: typing.Literal["unavailable", "failed"] + reason: str + + +class LexicalMaintenanceReport(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + indexed: int = 0 + unavailable: int = 0 + failed: int = 0 + diagnostics: tuple[LexicalMaintenanceDiagnostic, ...] = () + + +class LexicalMaintenanceJobParameters(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + options: LexicalMaintenanceOptions = pydantic.Field( + default_factory=LexicalMaintenanceOptions + ) diff --git a/inkcre_core/schemas/observability.py b/inkcre_core/schemas/observability.py new file mode 100644 index 00000000..b8e588ad --- /dev/null +++ b/inkcre_core/schemas/observability.py @@ -0,0 +1,37 @@ +"""Non-secret deployment telemetry configuration shared by admitted Peers.""" + +import uuid +import pydantic + +CONFIG_KEY = "inkcre.observability" +CONFIG_SCHEMA = "inkcre.observability.v1" + + +def _credential_free_url(value: pydantic.AnyHttpUrl | None): + if value is not None and ( + value.username or value.password or value.query or value.fragment + ): + raise ValueError("observability URLs cannot contain credentials, queries or fragments") + return value + + +class TelemetryEndpoints(pydantic.BaseModel): + """Complete OTLP/HTTP destinations; authentication stays in local runtime config.""" + + model_config = pydantic.ConfigDict(extra="forbid") + traces: pydantic.AnyHttpUrl | None = None + logs: pydantic.AnyHttpUrl | None = None + metrics: pydantic.AnyHttpUrl | None = None + + _urls = pydantic.field_validator("traces", "logs", "metrics")(_credential_free_url) + + +class ObservabilityConfig(pydantic.BaseModel): + """Stable deployment identity and optional metadata-only diagnostic destinations.""" + + model_config = pydantic.ConfigDict(extra="forbid") + deployment_id: uuid.UUID + otlp_http_endpoints: TelemetryEndpoints | None = None + diagnostics_url: pydantic.AnyHttpUrl | None = None + + _url = pydantic.field_validator("diagnostics_url")(_credential_free_url) diff --git a/inkcre_core/schemas/organization.py b/inkcre_core/schemas/organization.py new file mode 100644 index 00000000..63212028 --- /dev/null +++ b/inkcre_core/schemas/organization.py @@ -0,0 +1,89 @@ +"""Organization rumination configuration, inbound, and Agent Tool forms.""" + +import typing + +import pydantic + +from inkcre_core.schemas.ai import JSONValue +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.info_base.main import GraphForm, NegativeBlockID + + +class RuminationConfig(pydantic.BaseModel): + """Deployment selection of one reusable Agent definition.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + agent: int + + +class MediaInterpretationConfig(pydantic.BaseModel): + """Deployment selection of independent modality-oriented Agents.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + image_agent: int + audio_agent: int + video_agent: int + + +class MediaInterpretationJobParameters(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + +class MediaInterpretationDiagnostic(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block: int + modality: typing.Literal["image", "audio", "video"] + outcome: typing.Literal["unavailable", "failed", "no_output"] + reason: str + + +class MediaInterpretationReport(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + selected: int = 0 + interpreted: int = 0 + unavailable: int = 0 + failed: int = 0 + no_output: int = 0 + diagnostics: tuple[MediaInterpretationDiagnostic, ...] = () + + +class RuminationRequest(pydantic.BaseModel): + """Explicit request to reconsider one focal Block.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block: BlockID + + +class GetDraftGraphSchemaInput(pydantic.BaseModel): + """Generic schema-discovery input narrowed when an Agent run binds Tools.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + resolver_types: tuple[str, ...] + + +class DraftGraphInput(pydantic.BaseModel): + """Generic draft request; Resolver input is validated by its bound run model.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + resolver_type: str + input: dict[str, JSONValue] = pydantic.Field( + description="Arguments matching the selected Resolver's input_schema." + ) + local_block_id_start: NegativeBlockID = pydantic.Field( + default=-1, description="First temporary ID; keep IDs disjoint when combining drafts." + ) + + +class SubmitGraphInput(pydantic.BaseModel): + """The sole rumination Tool input that can mutate the info-base graph.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + graph: GraphForm diff --git a/inkcre_core/schemas/organization_behavior.py b/inkcre_core/schemas/organization_behavior.py new file mode 100644 index 00000000..42314738 --- /dev/null +++ b/inkcre_core/schemas/organization_behavior.py @@ -0,0 +1,146 @@ +"""Contracts for exact Organization behaviors and their Agent adapters.""" + +import typing + +import pydantic + +from inkcre_core.schemas.graph_navigation_retrieval import ( + GraphModel, +) +from inkcre_core.schemas.info_base.block import BlockID, ResolverType +from inkcre_core.schemas.info_base.relation import RelationID + + +class BehaviorAgentConfig(pydantic.BaseModel): + """Deployment selection of one purpose-built Agent definition.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + agent: int + + +class AutomaticOrganizationJobParameters(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + max_seeds: int = pydantic.Field(default=10, ge=3, le=100) + + +class RelationWriteResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + relation_id: RelationID + created: bool + + +class CandidateWriteResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + descriptor_block_id: BlockID + relation_id: RelationID + created: bool + + +class SupersessionProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + successor_block_id: BlockID + predecessor_block_id: BlockID + + +class RefinementProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + refinement_block_id: BlockID + predecessor_block_id: BlockID + + +class EvidenceStanceProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + evidence_block_id: BlockID + assertion_block_id: BlockID + stance: typing.Literal["supports", "challenges"] + + +class SynthesisProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + text: str + source_block_ids: tuple[BlockID, ...] = pydantic.Field( + min_length=2, description="Actual contributing sources, not all inspected context." + ) + previous_synthesis_block_id: BlockID | None = pydantic.Field( + default=None, description="Earlier synthesis revised through an edited relation." + ) + + @pydantic.field_validator("text") + @classmethod + def non_empty_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("text must not be empty") + return value + + @pydantic.field_validator("source_block_ids") + @classmethod + def distinct_sources(cls, value: tuple[BlockID, ...]) -> tuple[BlockID, ...]: + if len(set(value)) != len(value): + raise ValueError("source_block_ids must be distinct") + return value + + +class SynthesisWriteResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + synthesis_block_id: BlockID + synthesis_created: bool + basis: tuple[RelationWriteResult, ...] + edited: RelationWriteResult | None = None + + +class ExistingReferentAnchorProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + source_block_id: BlockID + selected_text: str = pydantic.Field( + description="Minimal sufficient referring fragment from the source." + ) + referent_block_id: BlockID + + @pydantic.field_validator("selected_text") + @classmethod + def non_empty_selected_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("selected_text must not be empty") + return value + + +class ExistingReferentAnchorResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + fragment_block_id: BlockID + fragment_created: bool + has_mention: RelationWriteResult | None + refers_to: RelationWriteResult + + +class DuplicateAssertionProposal(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + left_block_id: BlockID + right_block_id: BlockID + + +class RecordOrganizationCandidateInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block_id: BlockID + behavior: ResolverType + + +class SupersessionLineage(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + graph: GraphModel + current_block_ids: tuple[BlockID, ...] + truncated: bool + cycle_detected: bool diff --git a/inkcre_core/schemas/peer/__init__.py b/inkcre_core/schemas/peer/__init__.py new file mode 100644 index 00000000..6c1618f4 --- /dev/null +++ b/inkcre_core/schemas/peer/__init__.py @@ -0,0 +1,35 @@ +from .main import ( + CapabilityID, + CorePeerConfig, + PeerCapabilityAdvertisement, + PeerInboundInterface, + PeerModel, + PeerProtocolID, + PeerRef, + normalize_capability_snapshot, +) +from .protocol import ( + PEER_EXECUTION_HEADER, + PEER_HTTP_PROTOCOL, + PEER_NOT_EXECUTED, + PeerHTTPInboundParameters, + PeerProtocolRequest, + PeerProtocolResponse, +) + +__all__ = [ + "CapabilityID", + "CorePeerConfig", + "PEER_EXECUTION_HEADER", + "PEER_HTTP_PROTOCOL", + "PEER_NOT_EXECUTED", + "PeerCapabilityAdvertisement", + "PeerHTTPInboundParameters", + "PeerInboundInterface", + "PeerModel", + "PeerProtocolID", + "PeerProtocolRequest", + "PeerProtocolResponse", + "PeerRef", + "normalize_capability_snapshot", +] diff --git a/inkcre_core/schemas/peer/main.py b/inkcre_core/schemas/peer/main.py new file mode 100644 index 00000000..d71db6db --- /dev/null +++ b/inkcre_core/schemas/peer/main.py @@ -0,0 +1,178 @@ +"""Peer identity, configuration, capability advertisement, and persistence.""" + +import datetime +import typing +from urllib.parse import urlsplit, urlunsplit +import uuid + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + +from inkcre_core.schemas.ai import JSONValue + + +PeerRef: typing.TypeAlias = uuid.UUID +CapabilityID: typing.TypeAlias = str +PeerProtocolID: typing.TypeAlias = str + + +class PeerInboundInterface(pydantic.BaseModel): + """One protocol-discriminated construction descriptor for a caller outbound.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + protocol: PeerProtocolID = pydantic.Field(min_length=1) + parameters: dict[str, JSONValue] + + +class PeerCapabilityAdvertisement(pydantic.BaseModel): + """One exact capability and its provider-owned inbound interface.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + id: CapabilityID = pydantic.Field(min_length=1) + inbound: PeerInboundInterface + + +_CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[PeerCapabilityAdvertisement, ...]) + + +def normalize_capability_snapshot( + value: typing.Any, +) -> tuple[PeerCapabilityAdvertisement, ...]: + """Validate, de-duplicate, and deterministically order one full snapshot.""" + capabilities = _CAPABILITIES_ADAPTER.validate_python(value or ()) + ids = tuple(capability.id for capability in capabilities) + if len(ids) != len(set(ids)): + raise ValueError("Peer capability IDs must be unique") + return tuple(sorted(capabilities, key=lambda capability: capability.id)) + + +class CorePeerConfig(pydantic.BaseModel): + """impl-py-owned values stored in this Peer row's config object.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + http_public_base_url: str | None = pydantic.Field(default=None, title="Public HTTP URL") + extension_registry_url: str | None = pydantic.Field( + default=None, title="Extension Registry URL" + ) + + @pydantic.field_validator("http_public_base_url") + @classmethod + def valid_public_http_base(cls, value: str | None) -> str | None: + if value is None: + return None + parts = urlsplit(value) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.query + or parts.fragment + ): + raise ValueError( + "http_public_base_url must be an absolute HTTP(S) URL without " + "credentials, query, or fragment" + ) + path = parts.path.rstrip("/") + return urlunsplit((parts.scheme, parts.netloc, path, "", "")) + + @pydantic.field_validator("extension_registry_url") + @classmethod + def valid_extension_registry_origin(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return None + parts = urlsplit(value.strip()) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.path not in {"", "/"} + or parts.query + or parts.fragment + ): + raise ValueError("extension_registry_url must be one HTTP(S) origin") + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + +class PeerModel(sqlmodel.SQLModel, table=True): + """One equal deployment Peer with a runtime-owned capability/lease snapshot.""" + + __tablename__ = "peers" # type: ignore + + id: PeerRef = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ), + ) + name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) + application_version: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + labels: list[str] = sqlmodel.Field( + default_factory=list, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.ARRAY(sqlalchemy.Text), + nullable=False, + server_default=sqlalchemy.text("'{}'::text[]"), + ), + ) + config: dict[str, JSONValue] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + config_schema: dict[str, JSONValue] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + capabilities: list[dict[str, JSONValue]] = sqlmodel.Field( + default_factory=list, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'[]'::jsonb"), + ), + ) + lease_expires_at: datetime.datetime | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=True, + ), + ) + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + + def capability_snapshot(self) -> tuple[PeerCapabilityAdvertisement, ...]: + """Validate a possibly externally edited persisted snapshot at use time.""" + return _CAPABILITIES_ADAPTER.validate_python(self.capabilities or ()) diff --git a/inkcre_core/schemas/peer/protocol.py b/inkcre_core/schemas/peer/protocol.py new file mode 100644 index 00000000..4a7f7441 --- /dev/null +++ b/inkcre_core/schemas/peer/protocol.py @@ -0,0 +1,99 @@ +"""Normalized Peer HTTP v1 request/response envelopes.""" + +import re +import typing +from urllib.parse import urlsplit + +import pydantic + +from inkcre_core.schemas.ai import JSONValue + + +PEER_HTTP_PROTOCOL = "core.peer.protocol.http.v1" +PEER_EXECUTION_HEADER = "InkCre-Peer-Execution" +PEER_NOT_EXECUTED = "not-executed" + +_HTTP_TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + + +def _normalized_multimap(value: typing.Any) -> dict[str, tuple[str, ...]]: + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError("normalized map must be an object") + normalized: dict[str, tuple[str, ...]] = {} + for raw_name, raw_values in value.items(): + if not isinstance(raw_name, str) or not raw_name: + raise ValueError("normalized map names must be non-empty strings") + name = raw_name.lower() + if name in normalized: + raise ValueError(f"normalized map contains duplicate name: {name}") + if not isinstance(raw_values, (list, tuple)) or not all( + isinstance(item, str) for item in raw_values + ): + raise ValueError("normalized map values must be string arrays") + normalized[name] = tuple(raw_values) + return normalized + + +class PeerProtocolRequest(pydantic.BaseModel): + """One protocol payload; query, headers, and JSON body may coexist.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) + headers: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) + body: JSONValue = None + + @pydantic.field_validator("query", "headers", mode="before") + @classmethod + def normalized_maps(cls, value: typing.Any) -> dict[str, tuple[str, ...]]: + return _normalized_multimap(value) + + +class PeerProtocolResponse(pydantic.BaseModel): + """One executed HTTP response projected back into the normalized protocol.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + status: int = pydantic.Field(ge=100, le=599) + headers: dict[str, tuple[str, ...]] = pydantic.Field(default_factory=dict) + body: JSONValue = None + + @pydantic.field_validator("headers", mode="before") + @classmethod + def normalized_headers(cls, value: typing.Any) -> dict[str, tuple[str, ...]]: + return _normalized_multimap(value) + + +class PeerHTTPInboundParameters(pydantic.BaseModel): + """Static parameters published by one HTTP business inbound.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + method: str + url: str + + @pydantic.field_validator("method") + @classmethod + def valid_method(cls, value: str) -> str: + method = value.upper() + if not _HTTP_TOKEN.fullmatch(method): + raise ValueError("HTTP method must be one valid token") + return method + + @pydantic.field_validator("url") + @classmethod + def valid_absolute_url(cls, value: str) -> str: + parts = urlsplit(value) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.fragment + ): + raise ValueError( + "Peer HTTP inbound URL must be absolute HTTP(S) without credentials or fragment" + ) + return value diff --git a/inkcre_core/schemas/semantic_retrieval.py b/inkcre_core/schemas/semantic_retrieval.py new file mode 100644 index 00000000..bcfef09c --- /dev/null +++ b/inkcre_core/schemas/semantic_retrieval.py @@ -0,0 +1,129 @@ +"""Semantic retrieval configuration, maintenance and ranked result contracts.""" + +import math +import typing + +import pydantic + +from inkcre_core.schemas.ai import EmbeddingProfileID +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel + + +SemanticEntityType: typing.TypeAlias = typing.Literal["block", "relation"] + + +def _default_entity_types() -> frozenset[SemanticEntityType]: + return frozenset(("block", "relation")) + + +class SemanticRetrievalConfig(pydantic.BaseModel): + """Deployment-owned selection of the default vector space.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + default_profile: EmbeddingProfileID | None = None + + +class VectorRetrievalOptions(pydantic.BaseModel): + """Query-scoped controls over one already compatible vector space.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + limit: int = pydantic.Field(default=20, ge=1, le=100) + min_score: float | None = pydantic.Field(default=None, ge=-1, le=1) + entity_types: frozenset[SemanticEntityType] = pydantic.Field( + default_factory=_default_entity_types, + min_length=1, + ) + + @pydantic.field_validator("min_score") + @classmethod + def finite_min_score(cls, value: float | None) -> float | None: + if value is not None and not math.isfinite(value): + raise ValueError("min_score must be finite") + return value + + +class SemanticRetrievalRequest(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: str + profile: EmbeddingProfileID | None = None + options: VectorRetrievalOptions = pydantic.Field(default_factory=VectorRetrievalOptions) + + @pydantic.field_validator("query") + @classmethod + def non_empty_query(cls, value: str) -> str: + if not value.strip(): + raise ValueError("query must not be empty") + return value + + +class BlockSemanticRetrievalMatch(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["block"] = "block" + entity: BlockModel + score: float = pydantic.Field(ge=-1, le=1) + + +class RelationSemanticRetrievalMatch(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["relation"] = "relation" + entity: RelationModel + score: float = pydantic.Field(ge=-1, le=1) + + +SemanticRetrievalMatch: typing.TypeAlias = typing.Annotated[ + BlockSemanticRetrievalMatch | RelationSemanticRetrievalMatch, + pydantic.Field(discriminator="type"), +] + + +class SemanticRetrievalResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + profile: EmbeddingProfileID + metric: typing.Literal["cosine"] = "cosine" + matches: tuple[SemanticRetrievalMatch, ...] + + +class EmbeddingMaintenanceOptions(pydantic.BaseModel): + """Peer-local bounds for one resumable maintenance invocation.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + max_embeddings: int = pydantic.Field(default=100, ge=1) + batch_size: int = pydantic.Field(default=20, ge=1) + scan_page_size: int = pydantic.Field(default=100, ge=1) + diagnostic_limit: int = pydantic.Field(default=20, ge=0) + + +class EmbeddingMaintenanceDiagnostic(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entity_type: SemanticEntityType + entity: int + outcome: typing.Literal["unavailable", "failed"] + reason: str + + +class EmbeddingMaintenanceReport(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + profile: EmbeddingProfileID + embedded: int = 0 + unavailable: int = 0 + failed: int = 0 + diagnostics: tuple[EmbeddingMaintenanceDiagnostic, ...] = () + + +class EmbeddingMaintenanceJobParameters(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + profile: EmbeddingProfileID | None = None + options: EmbeddingMaintenanceOptions = pydantic.Field( + default_factory=EmbeddingMaintenanceOptions + ) diff --git a/inkcre_core/schemas/sink.py b/inkcre_core/schemas/sink.py new file mode 100644 index 00000000..ff3739be --- /dev/null +++ b/inkcre_core/schemas/sink.py @@ -0,0 +1,82 @@ +"""Persisted Sink catalog and instance contracts.""" + +import typing +import uuid + +import pydantic +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlmodel + + +SinkTypeID: typing.TypeAlias = str +SinkID: typing.TypeAlias = int + + +class SinkTypeModel(sqlmodel.SQLModel, table=True): + __tablename__ = "sink_types" # type: ignore + + id: SinkTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True) + ) + description: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + config_schema: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + + +class SinkModel(sqlmodel.SQLModel, table=True): + __tablename__ = "sinks" # type: ignore + + id: SinkID | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.BigInteger, + sqlalchemy.Identity(), + primary_key=True, + ), + ) + type: SinkTypeID = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("sink_types.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False, + ) + ) + nickname: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + config: dict[str, typing.Any] = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + ) + enabled: list[uuid.UUID] = sqlmodel.Field( + default_factory=list, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.ARRAY( + sqlalchemy.dialects.postgresql.UUID(as_uuid=True) + ), + nullable=False, + server_default=sqlalchemy.text("'{}'::uuid[]"), + ), + ) + + +class SinkCreateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: SinkTypeID + nickname: str | None = None + config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) diff --git a/inkcre_core/schemas/source/__init__.py b/inkcre_core/schemas/source/__init__.py new file mode 100644 index 00000000..a400ba16 --- /dev/null +++ b/inkcre_core/schemas/source/__init__.py @@ -0,0 +1,19 @@ +__all__ = [ + "SourceID", + "SourceModel", + "SourceTypesModel", + "SourceCollectParameters", + "SourceBackfillParameters", + "SourceCreateForm", + "SourceUpdateForm", +] + +from .main import ( + SourceBackfillParameters, + SourceCollectParameters, + SourceID, + SourceModel, + SourceTypesModel, + SourceCreateForm, + SourceUpdateForm, +) diff --git a/inkcre_core/schemas/source/main.py b/inkcre_core/schemas/source/main.py new file mode 100644 index 00000000..2bc109fd --- /dev/null +++ b/inkcre_core/schemas/source/main.py @@ -0,0 +1,186 @@ +__all__ = [ + "SourceID", + "SourceModel", + "SourceTypesModel", + "SourceCollectParameters", + "SourceBackfillParameters", +] + +import datetime +import pydantic +import sqlalchemy +import sqlalchemy.dialects +import sqlalchemy.dialects.postgresql +import sqlmodel +import typing +from typing import Optional as Opt + +from inkcre_core.schemas.info_base.block import BlockID +from inkcre_core.schemas.info_base.storage import StorageID + +SourceID: typing.TypeAlias = int + + +class SourceCreateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + type: str + nickname: str | None = None + config: dict = pydantic.Field(default_factory=dict) + storage: StorageID | None = None + + +class SourceUpdateForm(pydantic.BaseModel): + """Only submitted fields are written; config is replaced, not deep-merged.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + nickname: str | None = None + config: dict = pydantic.Field(default_factory=dict) + storage: StorageID | None = None + + +class CollectAt(sqlmodel.SQLModel): + """Historical migration-only value retained for the append-only baseline.""" + + day_of_week: int | None = None + hour: int = 0 + minute: int = 0 + + +class CollectAtType(sqlmodel.TypeDecorator): + """Historical migration-only PostgreSQL type; not a current Source field.""" + + impl = sqlalchemy.dialects.postgresql.JSONB + cache_ok = True + + def process_bind_param(self, value, dialect): + del dialect + return value.model_dump() if isinstance(value, CollectAt) else value + + def process_result_value(self, value, dialect): + del dialect + return CollectAt.model_validate(value) if value else None + + +class SourceCollectParameters(pydantic.BaseModel): + """Parameters of the exact ordinary Source Job type.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + source: SourceID + config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + + +class SourceBackfillParameters(SourceCollectParameters): + """Parameters of the exact historical Source Job type.""" + + +class SourceTypesModel(sqlmodel.SQLModel, table=True): + __tablename__: str = "sources_types" # type: ignore + + id: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, primary_key=True)) + """Type of source. + + An absolute import path to the module where souce class at. + """ + description: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text)) + """Description of this source type.""" + config_schema: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default=dict, + ) + collect_config_schema: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default=dict, + ) + backfill_config_schema: dict | None = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + nullable=True, + ), + default=None, + ) + + +class SourceModel(sqlmodel.SQLModel, table=True): + __tablename__: str = "sources" # type: ignore + + id: Opt[SourceID] = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True), + default=None, + ) + type: str = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.Text, + sqlalchemy.ForeignKey("sources_types.id", onupdate="CASCADE", ondelete="CASCADE"), + ) + ) + """Type of source. + + An absolute import path to the module where souce class at. + When delete a source type, all sources of this type will be deleted too. + """ + nickname: Opt[str] = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) + config: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default=dict, + ) + storage: Opt[StorageID] = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("storages.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=True, + ), + ) + block: Opt[BlockID] = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.Integer, + sqlalchemy.ForeignKey("blocks.id", onupdate="CASCADE", ondelete="SET NULL"), + nullable=True, + unique=True, + ), + ) + state: dict = sqlmodel.Field( + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + default_factory=dict, + ) + """Store source-specific state (e.g., last_uid for mail, + latest_tweet_id for twitter).""" + created_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) + updated_at: datetime.datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) diff --git a/utils/AGENTS.md b/inkcre_core/utils/AGENTS.md similarity index 100% rename from utils/AGENTS.md rename to inkcre_core/utils/AGENTS.md diff --git a/inkcre_core/utils/__init__.py b/inkcre_core/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inkcre_core/utils/base.py b/inkcre_core/utils/base.py new file mode 100644 index 00000000..f0fd8aae --- /dev/null +++ b/inkcre_core/utils/base.py @@ -0,0 +1,15 @@ +import enum +import aiohttp +import ssl +import certifi +import pydantic + + +enum_serializer = pydantic.PlainSerializer( + lambda value: value.value if isinstance(value, enum.Enum) else value, + return_type=str, +) + + +def AIOHTTP_CONNECTOR_GETTER(): + return aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where())) diff --git a/inkcre_core/utils/datetime_.py b/inkcre_core/utils/datetime_.py new file mode 100644 index 00000000..a063f0ce --- /dev/null +++ b/inkcre_core/utils/datetime_.py @@ -0,0 +1,96 @@ +""" +author: Lan_zhijiang +date: 2024-11-10 +desc: 时间日期工具函数 +issues: + +references: + https://git.hadream.ltd/anana/backend/common/-/wikis/Util/Datetime +""" + +import datetime +import time + +timezone_delta: int = 8 # 默认东八区 + + +def get_timezone(timezone_delta: int = timezone_delta) -> datetime.timezone: + """ + 获取时区对象 + """ + return datetime.timezone(datetime.timedelta(hours=timezone_delta)) + + +def get_datetimez( + rfc3339: str | None = None, + timestamp: float | None = None, + iso8601: str | None = None, + timezone: datetime.timezone = get_timezone(), +) -> datetime.datetime: + """ + 获取当前时间对象(带时区) + + :param rfc3339: RFC3339格式时间字符串,如"2024-11-10T12:00:00+08:00" + :param timestamp: 时间戳(秒级浮点);如果使用此,时区固定为 UTC+0 + :param iso8601: ISO8601格式时间字符串,如"2024-11-10 12:00:00" + :param timezone: 时区对象,可以通过 `get_timezone(8)` 获取东八区时区 + :raise ValueError: 如果rfc3339或iso8601都未提供,则抛出异常 + """ + if timestamp: + return datetime.datetime.fromtimestamp(timestamp, tz=get_timezone(0)) + if rfc3339 or iso8601: + val = rfc3339 or iso8601 + if val is None: + raise ValueError("rfc3339 or iso8601 must be provided") + return datetime.datetime.fromisoformat(val) + return datetime.datetime.now(tz=timezone) + + +def get_datetime(timestamp: float | None = None) -> datetime.datetime: + """ + 获取当前时间对象(不带时区) + """ + if timestamp: + return datetime.datetime.fromtimestamp(timestamp) + return datetime.datetime.now() + + +def get_timestamp( + datetime_obj: datetime.datetime | None = None, rfc3339: str | None = None +) -> float: + """ + 获取时间戳(秒级浮点) + """ + if datetime_obj: + return datetime_obj.timestamp() + if rfc3339: + return datetime.datetime.fromisoformat(rfc3339).timestamp() + return time.time() + + +def get_rfc3339( + timestamp: float | None = None, datetime_obj: datetime.datetime | None = None +) -> str: + """ + 获取RFC3339格式时间 + """ + if timestamp: + return datetime.datetime.fromtimestamp(timestamp).isoformat() + elif datetime_obj: + return datetime_obj.isoformat() + + raise ValueError("timestamp or datetime_obj must be provided") + + +def format_datetime(datetime: datetime.datetime, format: str = "%Y-%m-%d %H:%M:%S") -> str: + """ + 格式化时间对象 + """ + return datetime.strftime(format) + + +def format_timestamp(timestamp: float, format: str = "%Y-%m-%d %H:%M:%S") -> str: + """ + 格式化时间戳 + """ + return datetime.datetime.fromtimestamp(timestamp).strftime(format) diff --git a/inkcre_core/utils/sql.py b/inkcre_core/utils/sql.py new file mode 100644 index 00000000..6670ac97 --- /dev/null +++ b/inkcre_core/utils/sql.py @@ -0,0 +1,32 @@ +"""Sql utilities.""" + +import typing +import sqlalchemy +from sqlalchemy.dialects.postgresql import JSONB + + +def find_by_json_field(column: typing.Any, field_name: str, value: str): + """Find by JSON field value. + + :param column: sqlalchemy column + + Examples: + >>> find_by_json_field(YourTable.column_name, "email", "example@example.com") + """ + return sqlalchemy.cast(column, JSONB)[field_name].astext == value + + +def find_by_nested_json(column: typing.Any, parent: str, child: str, value: str): + """Find by nested JSON field value. + + :param column: sqlalchemy column + """ + return sqlalchemy.cast(column, JSONB)[(parent, child)].astext == value + + +def find_by_json_contains(column: typing.Any, json_obj: dict): + """Find by JSON contains. + + :param column: sqlalchemy column + """ + return sqlalchemy.cast(column, JSONB).contains(json_obj) diff --git a/inkcre_core/utils/types_.py b/inkcre_core/utils/types_.py new file mode 100644 index 00000000..60f22cd0 --- /dev/null +++ b/inkcre_core/utils/types_.py @@ -0,0 +1,10 @@ +__all__ = ["Undefined", "_undefined"] + +import enum + + +class Undefined(enum.Enum): + _undefined = 0 + + +_undefined = Undefined._undefined diff --git a/inkcre_core/validation.py b/inkcre_core/validation.py new file mode 100644 index 00000000..beaee8e2 --- /dev/null +++ b/inkcre_core/validation.py @@ -0,0 +1,31 @@ +"""Path projection for nested input contracts; validation stays with their owners.""" + +import contextlib +import typing + +import jsonschema # pyrefly: ignore[untyped-import] +import pydantic + + +@contextlib.contextmanager +def input_path(*prefix: str | int): + """Attach a form nesting path to native errors without validating again.""" + try: + yield + except pydantic.ValidationError as error: + details = [ + {**item, "loc": (*prefix, *item["loc"])} for item in error.errors(include_url=False) + ] + raise pydantic.ValidationError.from_exception_data( + error.title, typing.cast(typing.Any, details) + ) from error + except jsonschema.ValidationError as error: + raise jsonschema.ValidationError( + error.message, + validator=error.validator, + validator_value=error.validator_value, + instance=error.instance, + schema=error.schema, + path=(*prefix, *error.absolute_path), + schema_path=error.absolute_schema_path, + ) from error diff --git a/libs/obsrv/__init__.py b/libs/obsrv/__init__.py index 39a4f499..43d3a30d 100644 --- a/libs/obsrv/__init__.py +++ b/libs/obsrv/__init__.py @@ -1,5 +1,3 @@ -"""Observability package.""" +"""Legacy import; use inkcre_core for new consumers.""" -__all__ = ["get_logger", "setup_obsrv"] - -from .main import get_logger, setup_obsrv +from inkcre_core.libs.obsrv import * diff --git a/libs/obsrv/log_handler_logtail.py b/libs/obsrv/log_handler_logtail.py index 61a93592..8e081e1c 100644 --- a/libs/obsrv/log_handler_logtail.py +++ b/libs/obsrv/log_handler_logtail.py @@ -1,29 +1,10 @@ -"""Logtail logging handler.""" +"""Legacy import; use inkcre_core for new consumers.""" -import logging -from typing import Optional +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from logtail import LogtailHandler as RealLogtailHandler +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.log_handler_logtail") - -class LogtailHandler: - """Wrapper for Logtail handler.""" - - def __init__(self, source_token: Optional[str] = None, host: Optional[str] = None): - self.source_token = source_token - self.host = host - self.handler = None - - def get_handler(self) -> Optional[logging.Handler]: - """Get the logtail handler if available.""" - if not self.source_token: - return None - if self.handler is None: - try: - self.handler = RealLogtailHandler(source_token=self.source_token) - if self.host: - self.handler.host = self.host - self.handler.setLevel(logging.INFO) - except Exception: - return None - return self.handler +if TYPE_CHECKING: + from inkcre_core.libs.obsrv.log_handler_logtail import * diff --git a/libs/obsrv/log_handler_postgresql.py b/libs/obsrv/log_handler_postgresql.py index ff4bbadd..0e4ebaa3 100644 --- a/libs/obsrv/log_handler_postgresql.py +++ b/libs/obsrv/log_handler_postgresql.py @@ -1,75 +1,10 @@ -"""Bounded bridge from synchronous logging calls to async PostgreSQL writes.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -import logging -from queue import Empty, Full, Queue +from typing import TYPE_CHECKING +from importlib import import_module import sys -from libs.obsrv.log_record import ENABLE_LOG_BACKEND, LogModel, adapt_log_record +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.log_handler_postgresql") - -class PostgreSQLHandler(logging.Handler): - """Capture caller context immediately; persist only while lifespan is running.""" - - def __init__(self, level: int): - super().__init__(level) - self._queue: Queue[LogModel] = Queue(maxsize=1024) - self._task: asyncio.Task[None] | None = None - self._accepting = False - self.dropped = 0 - - def start(self) -> None: - if self._task is not None: - raise RuntimeError("PostgreSQL log writer is already running") - self._accepting = True - self._task = asyncio.create_task(self._write(), name="postgresql-log-writer") - - def emit(self, record: logging.LogRecord) -> None: - if not self._accepting or not ENABLE_LOG_BACKEND.get(): - return - try: - self._queue.put_nowait(adapt_log_record(record)) - except Full: - self.dropped += 1 - if self.dropped == 1: - print("PostgreSQL log queue full; dropping backend records", file=sys.stderr) - except Exception: - self.handleError(record) - - async def _write(self) -> None: - from app.persistence.observability.uow import write_logs - - while self._accepting or not self._queue.empty(): - batch: list[LogModel] = [] - for _ in range(100): - try: - batch.append(self._queue.get_nowait()) - except Empty: - break - if not batch: - await asyncio.sleep(0.1) - continue - try: - await write_logs(batch) - except Exception as error: - # Report through stderr, never recursively through the failed backend. - print(f"PostgreSQL log batch failed: {type(error).__name__}", file=sys.stderr) - - async def aclose(self) -> None: - # Handler.handle() holds this lock even when emit originates on a worker thread. - self.acquire() - try: - self._accepting = False - finally: - self.release() - if self._task is not None: - try: - async with asyncio.timeout(5): - await self._task - except TimeoutError: - print("PostgreSQL log drain timed out; pending records lost", file=sys.stderr) - finally: - self._task = None - while not self._queue.empty(): - self._queue.get_nowait() - super().close() +if TYPE_CHECKING: + from inkcre_core.libs.obsrv.log_handler_postgresql import * diff --git a/libs/obsrv/log_record.py b/libs/obsrv/log_record.py index 16531203..c063876c 100644 --- a/libs/obsrv/log_record.py +++ b/libs/obsrv/log_record.py @@ -1,119 +1,10 @@ -__all__ = ["LogModel", "adapt_log_record", "TRACE_ID", "SPAN_ID", "ENABLE_LOG_BACKEND"] +"""Legacy import; use inkcre_core for new consumers.""" -from contextvars import ContextVar -import logging -import datetime -from typing import Mapping, Any, Dict, Optional as Opt -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import JSONB -import sqlmodel +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from app.database_contract.constants import PROTOCOL_SCHEMA +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.log_record") - -TRACE_ID: ContextVar[str | None] = ContextVar("TRACE_ID", default=None) -SPAN_ID: ContextVar[str | None] = ContextVar("SPAN_ID", default=None) -ENABLE_LOG_BACKEND: ContextVar[bool] = ContextVar("ENABLE_LOG_BACKEND", default=False) -"""Whether the log backend emit the logs or not""" - - -LOGGING_TO_OTEL: Mapping[int, int] = { - 10: 5, # DEBUG - 20: 9, # INFO - 30: 13, # WARNING - 40: 17, # ERROR - 50: 21, # CRITICAL -} - - -def logging_level_to_otel(levelno: int) -> int: - return LOGGING_TO_OTEL.get(levelno, 9) - - -class LogModel(sqlmodel.SQLModel, table=True): - """Log model""" - - __tablename__ = "logs" # type: ignore - __table_args__ = {"schema": PROTOCOL_SCHEMA} - - id: Opt[int] = sqlmodel.Field( - default=None, - sa_column=sa.Column(sa.BigInteger, primary_key=True, autoincrement=True), - ) - timestamp: datetime.datetime = sqlmodel.Field( - default_factory=datetime.datetime.now, - sa_column=sa.Column( - sa.TIMESTAMP(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - ) - severity_number: int = sqlmodel.Field(sa_type=sa.SmallInteger) - severity_text: str = sqlmodel.Field(sa_column=sa.Column(sa.Text, nullable=False)) - body: str = sqlmodel.Field(sa_column=sa.Column(sa.Text, nullable=False)) - trace_id: Opt[str] = sqlmodel.Field( - default=None, - sa_column=sa.Column(sa.Text, nullable=True), - ) - span_id: Opt[str] = sqlmodel.Field( - default=None, - sa_column=sa.Column(sa.Text, nullable=True), - ) - attributes: Dict[str, Any] = sqlmodel.Field( - sa_type=JSONB, - default_factory=dict, - sa_column_kwargs={"server_default": sa.text("'{}'::jsonb")}, - ) - - -def adapt_log_record( - record: logging.LogRecord, - # *, - # service_name: str, - # service_version: str | None = None, - # environment: str | None = None, -) -> LogModel: - attributes = { - k: v - for k, v in record.__dict__.items() - if k - not in { - "name", - "msg", - "message", - "args", - "levelname", - "levelno", - "pathname", - "filename", - "module", - "exc_info", - "exc_text", - "stack_info", - "lineno", - "funcName", - "created", - "msecs", - "relativeCreated", - "thread", - "threadName", - "processName", - "process", - "taskName", - "asctime", - } - and not k.startswith("_") - } - - return LogModel( - timestamp=datetime.datetime.fromtimestamp(record.created), - severity_number=logging_level_to_otel(record.levelno), - severity_text=record.levelname, - body=record.getMessage(), - trace_id=TRACE_ID.get(), - span_id=SPAN_ID.get(), - attributes=attributes, - # service_name=service_name, - # service_version=service_version, - # environment=environment, - ) +if TYPE_CHECKING: + from inkcre_core.libs.obsrv.log_record import * diff --git a/libs/obsrv/main.py b/libs/obsrv/main.py index 19e16ca2..c3674a5b 100644 --- a/libs/obsrv/main.py +++ b/libs/obsrv/main.py @@ -1,85 +1,10 @@ -"""Main observability setup.""" +"""Legacy import; use inkcre_core for new consumers.""" -import logging +from typing import TYPE_CHECKING +from importlib import import_module import sys -__all__ = ["get_logger"] +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.main") -LOGGER = logging.getLogger("inkcre") - - -def setup_obsrv() -> logging.Logger: - """Setup observability components.""" - from app.settings import settings - - # Create logger - global LOGGER - LOGGER = logging.getLogger("inkcre") - LOGGER.setLevel(logging.DEBUG) - - # Clear any existing handlers - LOGGER.handlers.clear() - - # Console handler (always enabled) - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.INFO) - console_formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - console_handler.setFormatter(console_formatter) - LOGGER.addHandler(console_handler) - - # Add backend-specific handlers - backend = settings.obsrv.logging_backend - if backend == "logtail": - from .log_handler_logtail import LogtailHandler - - logtail_wrapper = LogtailHandler( - source_token=settings.obsrv.logtail_source_token, - host=settings.obsrv.logtail_host, - ) - handler = logtail_wrapper.get_handler() - if handler: - LOGGER.addHandler(handler) - LOGGER.info("Logtail logging enabled") - else: - LOGGER.warning("Logtail logging configured but not available") - elif backend == "postgresql": - from .log_handler_postgresql import PostgreSQLHandler - - pg_handler = PostgreSQLHandler(level=settings.obsrv.logging_backend_level) - LOGGER.addHandler(pg_handler) - LOGGER.info("PostgreSQL logging enabled") - elif backend not in (None, "", "none"): - LOGGER.warning(f"Unknown logging backend: {backend}") - - return LOGGER - - -def get_logger() -> logging.Logger: - """Get the application logger instance. - - Returns: - Logger instance for the application - """ - global LOGGER - return LOGGER - - -def start_obsrv() -> None: - from .log_handler_postgresql import PostgreSQLHandler - - for handler in LOGGER.handlers: - if isinstance(handler, PostgreSQLHandler): - handler.start() - - -async def close_obsrv() -> None: - from .log_handler_postgresql import PostgreSQLHandler - from .telemetry import close_telemetry - - await close_telemetry() - - for handler in LOGGER.handlers: - if isinstance(handler, PostgreSQLHandler): - await handler.aclose() +if TYPE_CHECKING: + from inkcre_core.libs.obsrv.main import * diff --git a/libs/obsrv/setting.py b/libs/obsrv/setting.py index 8e3a3346..aad5c4b7 100644 --- a/libs/obsrv/setting.py +++ b/libs/obsrv/setting.py @@ -1,35 +1,10 @@ -"""Observability settings.""" +"""Legacy import; use inkcre_core for new consumers.""" -from typing import Optional +from typing import TYPE_CHECKING +from importlib import import_module +import sys -from pydantic import Field -from pydantic_settings import BaseSettings +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.setting") - -class ObsrvSetting(BaseSettings): - """Observability settings.""" - - telemetry_enabled: bool = Field( - default=False, - description="Enable optional metadata-only OpenTelemetry export for this Peer.", - ) - - agent_debug: bool = Field( - default=False, - description="Record Agent inputs, tool contracts and execution events for development.", - ) - - logging_backend: Optional[str] = Field( - default="postgresql", - description="Logging backend to use (e.g., 'logtail', 'postgresql')", - ) - logging_backend_level: int = Field( - default=20, - description="Minimum log level for sending logs to the backend", - ) - logtail_source_token: Optional[str] = Field( - default=None, description="Better Stack (Logtail) source token for remote logging" - ) - logtail_host: Optional[str] = Field( - default=None, description="Better Stack (Logtail) host URL" - ) +if TYPE_CHECKING: + from inkcre_core.libs.obsrv.setting import * diff --git a/libs/obsrv/telemetry.py b/libs/obsrv/telemetry.py index 6343e9d9..1aa47f4f 100644 --- a/libs/obsrv/telemetry.py +++ b/libs/obsrv/telemetry.py @@ -1,488 +1,10 @@ -"""Opt-in OTLP metadata transport; callers own allowed field names and value sources.""" +"""Legacy import; use inkcre_core for new consumers.""" -import asyncio -from collections.abc import Callable, Generator, Mapping, Sequence -from contextlib import ExitStack, contextmanager -from dataclasses import dataclass, field -from functools import partial -import logging -import math -import os +from typing import TYPE_CHECKING +from importlib import import_module import sys -import time -from typing import TYPE_CHECKING, Literal -from urllib.parse import urlsplit -import uuid -from opentelemetry.context import Context -from opentelemetry.trace import INVALID_SPAN, Link, Span, SpanKind, StatusCode, use_span -from opentelemetry.util.types import AttributeValue -from pydantic import SecretStr -from pydantic_settings import BaseSettings, SettingsConfigDict +sys.modules[__name__] = import_module("inkcre_core.libs.obsrv.telemetry") if TYPE_CHECKING: - from opentelemetry.sdk._logs import LoggerProvider - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.metrics import Counter, Histogram - from opentelemetry.trace import Tracer - from opentelemetry._logs import Logger - - -class _ExportSettings(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - - otel_exporter_otlp_traces_endpoint: str = "" - otel_exporter_otlp_logs_endpoint: str = "" - otel_exporter_otlp_metrics_endpoint: str = "" - otel_exporter_otlp_headers: SecretStr = SecretStr("") - otel_exporter_otlp_traces_headers: SecretStr = SecretStr("") - otel_exporter_otlp_logs_headers: SecretStr = SecretStr("") - otel_exporter_otlp_metrics_headers: SecretStr = SecretStr("") - # Validate each signal separately, so a bad timeout does not disable siblings. - otel_exporter_otlp_timeout: str = "10" - otel_exporter_otlp_traces_timeout: str = "" - otel_exporter_otlp_logs_timeout: str = "" - otel_exporter_otlp_metrics_timeout: str = "" - - -@dataclass -class _Runtime: - connections: dict[str, tuple[str, dict[str, str], float]] = field(default_factory=dict) - traces: "TracerProvider | None" = None - logs: "LoggerProvider | None" = None - metrics: "MeterProvider | None" = None - tracer: "Tracer | None" = None - logger: "Logger | None" = None - operations: "Counter | None" = None - duration: "Histogram | None" = None - tokens: "Counter | None" = None - missing_usage: "Counter | None" = None - - -_runtime: _Runtime | None = None - - -def _diagnostic(reason: str) -> None: - # This sink never receives endpoints, headers, exceptions, or arbitrary log messages. - try: - print(f"OpenTelemetry: {reason}", file=sys.stderr) - except Exception: - # Diagnostics cannot make a broken stderr stream a business failure. - pass - - -class _SDKDiagnostics(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - # SDK errors may contain response bodies, URLs or malformed secret headers. - _diagnostic("SDK warning or export failure; check the configured OTLP receiver") - - -def _configure_diagnostics() -> None: - logger = logging.getLogger("opentelemetry") - if not any(isinstance(handler, _SDKDiagnostics) for handler in logger.handlers): - handler = _SDKDiagnostics(level=logging.WARNING) - logger.addHandler(handler) - logger.propagate = False - - -def _endpoint(value: str) -> str: - parsed = urlsplit(value) - if ( - parsed.scheme not in ("http", "https") - or not parsed.hostname - or not parsed.path - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or any(character.isspace() for character in value) - ): - raise ValueError("invalid OTLP URL") - # Accessing port performs urllib's range and syntax validation. - parsed.port - return value - - -def is_enabled() -> bool: - """Whether at least one metadata signal initialized for this runtime.""" - return _runtime is not None - - -def get_export_connection(signal: str) -> tuple[str, dict[str, str], float] | None: - """Return private endpoint, copied headers and timeout seconds for server forwarding.""" - connection = _runtime.connections.get(signal) if _runtime is not None else None - return ( - (connection[0], dict(connection[1]), connection[2]) if connection is not None else None - ) - - -def start_telemetry( - *, - enabled: bool, - service_version: str, - resource_attributes: Mapping[str, AttributeValue] | None = None, - endpoints: Mapping[str, str] | None = None, -) -> None: - """Initialize only configured signals; configuration failures never gate readiness.""" - global _runtime - if not enabled or _runtime is not None: - return - - _configure_diagnostics() - try: - config = _ExportSettings(_env_file=os.getenv("INKCRE_ENV_FILE", ".env") or None) - except Exception: - _diagnostic("invalid runtime export configuration; telemetry is disabled") - return - - from opentelemetry.exporter.otlp.proto.http import Compression - from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter - from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - from opentelemetry.metrics import NoOpMeterProvider - from opentelemetry.sdk._logs import LoggerProvider, LogRecordLimits - from opentelemetry.sdk._logs.export import BatchLogRecordProcessor - from opentelemetry.sdk.metrics import MeterProvider, SimpleFixedSizeExemplarReservoir - from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader - from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import SpanLimits, TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - from opentelemetry.sdk.trace.sampling import ALWAYS_ON - from opentelemetry.util.re import parse_env_headers - from requests.utils import check_header_validity # type: ignore[untyped-import] - - # Resource.create invokes environment detectors; direct construction is deliberate. - resource = Resource( - { - **(resource_attributes or {}), - "service.name": "core-py", - "service.version": service_version, - "service.instance.id": str(uuid.uuid4()), - } - ) - runtime = _Runtime() - no_metrics = NoOpMeterProvider() - for signal in ("metrics", "traces", "logs"): - endpoint = getattr(config, f"otel_exporter_otlp_{signal}_endpoint") or ( - endpoints or {} - ).get(signal, "") - if not endpoint: - continue - cleanup: Callable[[], None] | None = None - internal_metrics = runtime.metrics or no_metrics - try: - endpoint = _endpoint(endpoint) - timeout = float( - getattr(config, f"otel_exporter_otlp_{signal}_timeout") - or config.otel_exporter_otlp_timeout - or "10" - ) - if not math.isfinite(timeout) or not 0 < timeout <= 30: - raise ValueError("OTLP timeout must be finite, positive and at most 30 seconds") - raw_headers = ( - getattr(config, f"otel_exporter_otlp_{signal}_headers").get_secret_value() - or config.otel_exporter_otlp_headers.get_secret_value() - ) - headers = parse_env_headers(raw_headers, liberal=True) - # The SDK parser drops malformed entries. Do not silently accept partial auth. - if len(headers) != len([part for part in raw_headers.split(",") if part.strip()]): - raise ValueError("invalid OTLP headers") - for header in headers.items(): - check_header_validity(header) - - if signal == "traces": - exporter = OTLPSpanExporter( - endpoint=endpoint, - headers=headers, - timeout=timeout, - compression=Compression.NoCompression, - meter_provider=internal_metrics, - ) - cleanup = exporter.shutdown - traces = TracerProvider( - resource=resource, - sampler=ALWAYS_ON, - shutdown_on_exit=False, - span_limits=SpanLimits( - max_attributes=64, - max_events=16, - max_links=16, - max_attribute_length=256, - max_span_attributes=64, - max_event_attributes=64, - max_link_attributes=64, - max_span_attribute_length=256, - ), - meter_provider=internal_metrics, - ) - processor = BatchSpanProcessor( - exporter, - max_queue_size=256, - max_export_batch_size=256, - schedule_delay_millis=5000, - export_timeout_millis=timeout * 1000, - meter_provider=internal_metrics, - ) - cleanup = processor.shutdown - traces.add_span_processor(processor) - cleanup = traces.shutdown - tracer = traces.get_tracer("inkcre.metadata") - runtime.traces, runtime.tracer = traces, tracer - elif signal == "logs": - log_exporter = OTLPLogExporter( - endpoint=endpoint, - headers=headers, - timeout=timeout, - compression=Compression.NoCompression, - meter_provider=internal_metrics, - ) - cleanup = log_exporter.shutdown - logs = LoggerProvider( - resource=resource, - shutdown_on_exit=False, - meter_provider=internal_metrics, - log_record_limits=LogRecordLimits(max_attributes=64, max_attribute_length=256), - ) - log_processor = BatchLogRecordProcessor( - log_exporter, - max_queue_size=256, - max_export_batch_size=256, - schedule_delay_millis=5000, - export_timeout_millis=timeout * 1000, - meter_provider=internal_metrics, - ) - cleanup = log_processor.shutdown - logs.add_log_record_processor(log_processor) - cleanup = logs.shutdown - logger = logs.get_logger("inkcre.metadata") - runtime.logs, runtime.logger = logs, logger - else: - metric_exporter = OTLPMetricExporter( - endpoint=endpoint, - headers=headers, - timeout=timeout, - compression=Compression.NoCompression, - meter_provider=no_metrics, - ) - cleanup = metric_exporter.shutdown - reader = PeriodicExportingMetricReader( - metric_exporter, - export_interval_millis=30000, - export_timeout_millis=timeout * 1000, - ) - cleanup = partial(reader.shutdown, timeout_millis=(timeout + 2) * 1000) - metrics = MeterProvider( - resource=resource, - metric_readers=[reader], - shutdown_on_exit=False, - views=[ - # SDK defaults are too coarse for a duration measured in seconds. - View( - instrument_name="inkcre.operation.duration", - aggregation=ExplicitBucketHistogramAggregation( - boundaries=( - 0.005, - 0.01, - 0.025, - 0.05, - 0.1, - 0.25, - 0.5, - 1, - 2.5, - 5, - 10, - 30, - 60, - 300, - ) - ), - ), - View( - meter_name="opentelemetry-sdk", - attribute_keys={ - "otel.component.type", - "error.type", - "http.response.status_code", - }, - # An exemplar can otherwise retain attributes excluded by the View. - exemplar_reservoir_factory=lambda _: ( - lambda _config=None, **_kwargs: SimpleFixedSizeExemplarReservoir(size=0) - ), - ), - ], - ) - cleanup = partial(metrics.shutdown, timeout_millis=(timeout + 2) * 1000) - meter = metrics.get_meter("inkcre.metadata") - operations = meter.create_counter("inkcre.operation.count", unit="{operation}") - duration = meter.create_histogram("inkcre.operation.duration", unit="s") - tokens = meter.create_counter("inkcre.ai.token.usage", unit="{token}") - missing_usage = meter.create_counter("inkcre.ai.usage.missing", unit="{field}") - runtime.metrics = metrics - runtime.operations, runtime.duration = operations, duration - runtime.tokens, runtime.missing_usage = tokens, missing_usage - except Exception: - if cleanup is not None: - _shutdown(cleanup) - _diagnostic(f"{signal} configuration or initialization failed; signal is disabled") - else: - runtime.connections[signal] = (endpoint, dict(headers), timeout) - - if any((runtime.traces, runtime.logs, runtime.metrics)): - _runtime = runtime - else: - _diagnostic("no usable per-signal endpoint; telemetry is disabled") - - -def _shutdown(shutdown: Callable[[], None]) -> None: - try: - shutdown() - except Exception: - _diagnostic("signal shutdown failed; remaining telemetry may be lost") - - -async def close_telemetry() -> None: - """Stop new records and concurrently drain SDK queues outside the event loop. - - SDK trace/log shutdown waits up to 30 seconds for each worker, then metrics gets - its configured request timeout plus two seconds. These worker wait budgets are - not transport-wide hard deadlines; freezing or termination can lose records. - """ - global _runtime - runtime, _runtime = _runtime, None - if runtime is None: - return - shutdowns: list[Callable[[], None]] = [] - for provider in (runtime.traces, runtime.logs): - if provider is not None: - shutdowns.append(provider.shutdown) - # SDK 1.45 force_flush ignores its timeout; shutdown owns the bounded queue drain. - await asyncio.gather(*(asyncio.to_thread(_shutdown, shutdown) for shutdown in shutdowns)) - if runtime.metrics is not None: - # Collect final native processor/exporter metrics after their workers have drained. - await asyncio.to_thread( - _shutdown, - partial( - runtime.metrics.shutdown, - timeout_millis=(runtime.connections["metrics"][2] + 2) * 1000, - ), - ) - - -def _record(action: Callable[[], object]) -> None: - """Isolate one owned telemetry call; never wrap the caller's business block.""" - try: - action() - except Exception: - _diagnostic("recording failed; optional telemetry may be lost") - - -@dataclass -class Operation: - """One execution scope's result, independent of whether its Span is recorded. - - Business boundaries set outcome when they return a failure or handle cancellation. - Span methods remain available explicitly through span; Span status is not a metric. - Those standard SDK methods require the caller's admitted primitive metadata values; - the facade isolates its own SDK calls, not arbitrary caller code inside the scope. - """ - - span: Span - outcome: Literal["success", "error", "cancelled"] = "success" - - -@contextmanager -def operation( - name: str, - *, - attributes: Mapping[str, AttributeValue] | None = None, - context: Context | None = None, - links: Sequence[Link] | None = None, - kind: SpanKind = SpanKind.INTERNAL, -) -> Generator[Operation, None, None]: - """Measure a fixed operation name; callers supply only admitted metadata values. - - Exceptions and cancellation propagate unchanged. Their messages, stack traces and - status descriptions are never recorded by this context manager. - """ - runtime = _runtime - if runtime is None: - yield Operation(INVALID_SPAN) - return - - span = INVALID_SPAN - if runtime.tracer is not None: - try: - span = runtime.tracer.start_span( - name, - attributes=attributes, - context=context, - links=links, - kind=kind, - record_exception=False, - set_status_on_exception=False, - ) - except Exception: - _diagnostic("span creation failed; operation metrics remain available") - observation = Operation(span) - started = time.perf_counter() - activation = ExitStack() - _record( - lambda: activation.enter_context( - use_span(span, record_exception=False, set_status_on_exception=False) - ) - ) - try: - yield observation - except BaseException as error: - observation.outcome = ( - "cancelled" if isinstance(error, asyncio.CancelledError) else "error" - ) - _record(partial(span.set_attribute, "error.type", type(error).__name__)) - raise - finally: - # Exit the SDK context with no exception and end separately. This prevents SDK - # context cleanup or span processors from masking the caller's original error. - if observation.outcome != "success": - _record(partial(span.set_status, StatusCode.ERROR)) - _record(partial(span.set_attribute, "inkcre.outcome", observation.outcome)) - labels = {"operation": name, "outcome": observation.outcome} - if runtime.operations is not None: - _record(partial(runtime.operations.add, 1, labels)) - if runtime.duration is not None: - _record(partial(runtime.duration.record, time.perf_counter() - started, labels)) - _record(activation.close) - _record(span.end) - - -def emit_event(name: str, attributes: Mapping[str, AttributeValue]) -> None: - """Emit a fixed event name and admitted metadata without a Python logging bridge.""" - runtime = _runtime - if runtime is not None and runtime.logger is not None: - from opentelemetry._logs import SeverityNumber - - _record( - partial( - runtime.logger.emit, - body=name, - severity_number=SeverityNumber.INFO, - attributes=attributes, - ) - ) - - -def record_ai_usage( - operation: str, input_tokens: int | None, output_tokens: int | None -) -> None: - """Count known nonnegative provider usage and count missing fields separately.""" - runtime = _runtime - if runtime is None or runtime.tokens is None or runtime.missing_usage is None: - return - for kind, value in (("input", input_tokens), ("output", output_tokens)): - labels = {"operation": operation, "token.type": kind} - if isinstance(value, int) and not isinstance(value, bool) and value >= 0: - _record(partial(runtime.tokens.add, value, labels)) - else: - _record(partial(runtime.missing_usage.add, 1, labels)) + from inkcre_core.libs.obsrv.telemetry import * diff --git a/migrations/metadata.py b/migrations/metadata.py index ba93fd03..dcb08351 100644 --- a/migrations/metadata.py +++ b/migrations/metadata.py @@ -2,9 +2,9 @@ from sqlalchemy import MetaData -from app.database_contract import PROTOCOL_SCHEMA -from app.schemas import Base -from libs.obsrv.log_record import LogModel +from inkcre_core.database_contract import PROTOCOL_SCHEMA +from inkcre_core.schemas import Base +from inkcre_core.libs.obsrv.log_record import LogModel _qualified_log_table = f"{PROTOCOL_SCHEMA}.{LogModel.__tablename__}" diff --git a/pdm.lock b/pdm.lock index 7643e3fd..54f5c530 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "dev", "extension-preview", "extension-publisher"] +groups = ["default", "app", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:da973293e877e2840a1f344c4338b3fd1f808cb81bf02d5c80a9a3279585a924" +content_hash = "sha256:e8bc9b47fa7a1e15463f9582dbbbd990776ba38bb6a34ca59f0601ca130f17cf" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -80,7 +80,7 @@ name = "alembic" version = "1.19.1" requires_python = ">=3.10" summary = "A database migration tool for SQLAlchemy." -groups = ["default"] +groups = ["app"] dependencies = [ "Mako", "SQLAlchemy>=1.4.23", @@ -97,7 +97,7 @@ name = "alembic-postgresql-enum" version = "1.10.0" requires_python = ">=3.7.0" summary = "Alembic autogenerate support for creation, alteration and deletion of enums" -groups = ["default"] +groups = ["app"] dependencies = [ "alembic>=1.7", "sqlalchemy>=1.4", @@ -112,7 +112,7 @@ name = "alembic-utils" version = "0.8.8" requires_python = ">=3.9" summary = "A sqlalchemy/alembic extension for migrating procedures and views" -groups = ["default"] +groups = ["app"] dependencies = [ "alembic>=1.9", "flupy", @@ -130,7 +130,7 @@ name = "annotated-doc" version = "0.0.5" requires_python = ">=3.9" summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["app", "extension-preview", "extension-publisher"] files = [ {file = "annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101"}, {file = "annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"}, @@ -141,7 +141,7 @@ name = "annotated-types" version = "0.8.0" requires_python = ">=3.10" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] files = [ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, @@ -152,7 +152,7 @@ name = "anyio" version = "4.15.1" requires_python = ">=3.10" summary = "High-level concurrency and networking framework on top of asyncio or Trio" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -168,7 +168,7 @@ name = "apscheduler" version = "3.11.3" requires_python = ">=3.8" summary = "In-process task scheduler with Cron-like capabilities" -groups = ["default"] +groups = ["app"] dependencies = [ "backports-zoneinfo; python_version < \"3.9\"", "tzlocal>=3.0", @@ -183,7 +183,7 @@ name = "attrs" version = "26.1.0" requires_python = ">=3.9" summary = "Classes Without Boilerplate" -groups = ["default"] +groups = ["default", "app"] files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -194,7 +194,7 @@ name = "authlib" version = "1.7.2" requires_python = ">=3.10" summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -groups = ["default"] +groups = ["app"] dependencies = [ "cryptography", "joserfc>=1.6.0", @@ -228,7 +228,7 @@ name = "babel" version = "2.18.0" requires_python = ">=3.8" summary = "Internationalization utilities" -groups = ["default"] +groups = ["app"] dependencies = [ "pytz>=2015.7; python_version < \"3.9\"", ] @@ -242,7 +242,7 @@ name = "beautifulsoup4" version = "4.15.0" requires_python = ">=3.7.0" summary = "Screen-scraping library" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "soupsieve>=1.6.1", "typing-extensions>=4.0.0", @@ -275,7 +275,7 @@ name = "certifi" version = "2026.7.22" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] files = [ {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, @@ -286,7 +286,7 @@ name = "cffi" version = "2.1.1" requires_python = ">=3.10" summary = "Foreign Function Interface for Python calling C code." -groups = ["default"] +groups = ["app"] marker = "platform_python_implementation != \"PyPy\"" dependencies = [ "pycparser; implementation_name != \"PyPy\"", @@ -334,7 +334,7 @@ name = "charset-normalizer" version = "3.5.1" requires_python = ">=3.7" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["default"] +groups = ["default", "app"] files = [ {file = "charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f"}, {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa"}, @@ -377,7 +377,7 @@ name = "click" version = "8.4.2" requires_python = ">=3.10" summary = "Composable command line interface toolkit" -groups = ["default", "dev", "extension-preview", "extension-publisher"] +groups = ["app", "dev", "extension-preview", "extension-publisher"] dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -391,7 +391,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default", "dev", "extension-preview", "extension-publisher"] +groups = ["default", "app", "dev", "extension-preview", "extension-publisher"] marker = "os_name == \"nt\" or sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -403,7 +403,7 @@ name = "courlan" version = "1.4.0" requires_python = ">=3.10" summary = "Clean, filter and sample URLs to optimize data collection – includes spam, content type and language filters." -groups = ["default"] +groups = ["app"] dependencies = [ "babel>=2.16.0", "tld>=0.13", @@ -433,7 +433,7 @@ name = "cryptography" version = "50.0.0" requires_python = ">=3.9, !=3.9.0, !=3.9.1" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -groups = ["default"] +groups = ["app"] dependencies = [ "cffi>=2.0.0; platform_python_implementation != \"PyPy\"", "typing-extensions>=4.13.2; python_full_version < \"3.11\"", @@ -495,7 +495,7 @@ name = "dateparser" version = "1.4.2" requires_python = ">=3.10" summary = "Date parsing library designed to parse dates from HTML pages" -groups = ["default"] +groups = ["app"] dependencies = [ "python-dateutil>=2.7.0", "pytz>=2024.2", @@ -512,7 +512,7 @@ name = "defusedxml" version = "0.7.1" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" summary = "XML bomb protection for Python stdlib modules" -groups = ["default"] +groups = ["app"] files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -544,7 +544,7 @@ name = "fastapi" version = "0.139.2" requires_python = ">=3.10" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -groups = ["default"] +groups = ["app"] dependencies = [ "annotated-doc>=0.0.2", "pydantic>=2.9.0", @@ -562,7 +562,7 @@ name = "feedparser" version = "6.0.14" requires_python = ">=3.10" summary = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" -groups = ["default"] +groups = ["app"] dependencies = [ "feedparser-sgmllib<3,>=2", ] @@ -576,7 +576,7 @@ name = "feedparser-sgmllib" version = "2.1.0" requires_python = ">=3.10" summary = "sgmllib from Python 2.7. For feedparser use only." -groups = ["default"] +groups = ["app"] files = [ {file = "feedparser_sgmllib-2.1.0-py3-none-any.whl", hash = "sha256:2cab2d43b95a954f920f18aebce7a4dbbb3f539780b127e2aa114f579821e01d"}, {file = "feedparser_sgmllib-2.1.0.tar.gz", hash = "sha256:61facf2918c4389b5b00714f76c5e03431ffcd94cd1f51d657edd6cd7c396579"}, @@ -597,7 +597,7 @@ files = [ name = "filetype" version = "1.2.0" summary = "Infer file type and MIME type of any file/buffer. No external dependencies." -groups = ["default"] +groups = ["app"] files = [ {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, @@ -608,7 +608,7 @@ name = "flupy" version = "1.2.4" requires_python = ">=3.10" summary = "Fluent data processing in Python - a chainable stream processing library for expressive data manipulation using method chaining" -groups = ["default"] +groups = ["app"] files = [ {file = "flupy-1.2.4-py3-none-any.whl", hash = "sha256:231192ded613ebde6d0435e9d1bf0e305cda3a5115be9a142ef368e3f5724f81"}, {file = "flupy-1.2.4.tar.gz", hash = "sha256:dad657113e4a271a322229dc2926c5154d4a926c6ba8154b604f7e9cdda5c3e6"}, @@ -660,7 +660,7 @@ name = "greenlet" version = "3.5.5" requires_python = ">=3.10" summary = "Lightweight in-process concurrent programming" -groups = ["default"] +groups = ["default", "app"] files = [ {file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"}, {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"}, @@ -680,7 +680,7 @@ name = "h11" version = "0.16.0" requires_python = ">=3.8" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -702,7 +702,7 @@ name = "htmldate" version = "1.10.0" requires_python = ">=3.10" summary = "Fast and robust extraction of original and updated publication dates from URLs and web pages." -groups = ["default"] +groups = ["app"] dependencies = [ "charset-normalizer>=3.4.0", "dateparser>=1.1.2", @@ -720,7 +720,7 @@ name = "httpcore" version = "1.0.9" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "certifi", "h11>=0.16", @@ -735,7 +735,7 @@ name = "httpcore2" version = "2.12.0" requires_python = ">=3.10" summary = "A minimal low-level HTTP client." -groups = ["default"] +groups = ["app"] marker = "sys_platform != \"emscripten\"" dependencies = [ "h11>=0.16", @@ -751,7 +751,7 @@ name = "httpx" version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "anyio", "certifi", @@ -768,7 +768,7 @@ name = "httpx2" version = "2.12.0" requires_python = ">=3.10" summary = "The next generation HTTP client." -groups = ["default"] +groups = ["app"] dependencies = [ "anyio>=4.10; sys_platform != \"emscripten\"", "httpcore2==2.12.0; sys_platform != \"emscripten\"", @@ -787,7 +787,7 @@ name = "httpx2-jsfetch" version = "1.0" requires_python = ">=3.12" summary = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." -groups = ["default"] +groups = ["app"] marker = "sys_platform == \"emscripten\" and python_version >= \"3.12\"" files = [ {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, @@ -800,7 +800,7 @@ version = "0.28.1" extras = ["socks"] requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default"] +groups = ["app"] dependencies = [ "httpx==0.28.1", "socksio==1.*", @@ -826,7 +826,7 @@ name = "idna" version = "3.19" requires_python = ">=3.9" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] files = [ {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, @@ -837,7 +837,7 @@ name = "imapclient" version = "3.1.0" requires_python = ">=3.8.0" summary = "Easy-to-use, Pythonic and complete IMAP client library" -groups = ["default"] +groups = ["app"] files = [ {file = "imapclient-3.1.0-py2.py3-none-any.whl", hash = "sha256:34c192ffe82936e940a998ac89c474296942cd7c4f6c208f0fccfcd972946038"}, {file = "imapclient-3.1.0.tar.gz", hash = "sha256:b0413a516ffcc4b4d69f2c611c0e95da57968edf5a5c78ac82d1bf882305facc"}, @@ -860,7 +860,7 @@ version = "0.2.0" requires_python = "<3.14,>=3.12" url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.2.0/inkcre_extension_runtime_core_py-0.2.0-py3-none-any.whl" summary = "InKCre Core Python Extension Host Runtime" -groups = ["default"] +groups = ["app"] dependencies = [ "fastapi<0.142,>=0.139.2", "packaging<27,>=25", @@ -949,7 +949,7 @@ name = "joserfc" version = "1.7.4" requires_python = ">=3.10" summary = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" -groups = ["default"] +groups = ["app"] dependencies = [ "cryptography>=45.0.1", ] @@ -962,7 +962,7 @@ files = [ name = "js2py-3-13" version = "0.74.1" summary = "JavaScript to Python Translator & JavaScript interpreter written in 100% pure Python." -groups = ["default"] +groups = ["app"] dependencies = [ "pyjsparser>=2.5.1", "six>=1.10", @@ -978,7 +978,7 @@ name = "jsonschema" version = "4.26.0" requires_python = ">=3.10" summary = "An implementation of JSON Schema validation for Python" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "attrs>=22.2.0", "jsonschema-specifications>=2023.03.6", @@ -995,7 +995,7 @@ name = "jsonschema-specifications" version = "2025.9.1" requires_python = ">=3.9" summary = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "referencing>=0.31.0", ] @@ -1008,7 +1008,7 @@ files = [ name = "justext" version = "3.0.2" summary = "Heuristic based boilerplate removal tool" -groups = ["default"] +groups = ["app"] dependencies = [ "backports-functools-lru-cache; python_version < \"3.2\"", "lxml[html_clean]>=4.4.2", @@ -1023,7 +1023,7 @@ name = "libpass" version = "1.9.4" requires_python = ">=3.9" summary = "Fork of passlib, a comprehensive password hashing framework supporting over 30 schemes" -groups = ["default"] +groups = ["app"] files = [ {file = "libpass-1.9.4-py3-none-any.whl", hash = "sha256:a608621af2d7d64579957988fac287f230f88792c83a2d0f895afb05ee429489"}, {file = "libpass-1.9.4.tar.gz", hash = "sha256:4ff9a5ebf51aad1e8164453bc29c4a06926cf175a9501d87205612ba247f3cbd"}, @@ -1034,7 +1034,7 @@ name = "logtail-python" version = "0.4.0" requires_python = ">=3.10" summary = "Better Stack client library" -groups = ["default"] +groups = ["app"] dependencies = [ "msgpack>=0.5.6", "requests>=2.18.4", @@ -1049,7 +1049,7 @@ name = "lxml" version = "6.1.2" requires_python = ">=3.8" summary = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -groups = ["default"] +groups = ["default", "app"] files = [ {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"}, {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"}, @@ -1076,7 +1076,7 @@ files = [ name = "lxml-html-clean" version = "0.4.5" summary = "HTML cleaner from lxml project" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "lxml>=6.1.1", ] @@ -1091,7 +1091,7 @@ version = "6.1.2" extras = ["html_clean"] requires_python = ">=3.8" summary = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -groups = ["default"] +groups = ["default", "app"] dependencies = [ "lxml-html-clean", "lxml==6.1.2", @@ -1123,7 +1123,7 @@ name = "m3u8" version = "6.0.0" requires_python = ">=3.7" summary = "Python m3u8 parser" -groups = ["default"] +groups = ["app"] dependencies = [ "backports-datetime-fromisoformat; python_version < \"3.11\"", ] @@ -1137,7 +1137,7 @@ name = "mako" version = "1.4.1" requires_python = ">=3.10" summary = "A super-fast templating language that borrows the best ideas from the existing templating languages." -groups = ["default"] +groups = ["app"] dependencies = [ "MarkupSafe>=2.0", ] @@ -1165,7 +1165,7 @@ name = "markupsafe" version = "3.0.3" requires_python = ">=3.9" summary = "Safely add untrusted strings to HTML/XML markup." -groups = ["default", "dev"] +groups = ["app", "dev"] files = [ {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, @@ -1186,7 +1186,7 @@ name = "mcp" version = "2.1.1" requires_python = ">=3.10" summary = "Model Context Protocol SDK" -groups = ["default"] +groups = ["app"] dependencies = [ "anyio>=4.10; python_version >= \"3.14\"", "anyio>=4.9; python_version < \"3.14\"", @@ -1215,7 +1215,7 @@ name = "mcp-types" version = "2.1.1" requires_python = ">=3.10" summary = "Model Context Protocol wire types" -groups = ["default"] +groups = ["app"] dependencies = [ "pydantic>=2.12.0", "typing-extensions>=4.13.0", @@ -1241,7 +1241,7 @@ name = "msgpack" version = "1.2.1" requires_python = ">=3.10" summary = "MessagePack serializer" -groups = ["default"] +groups = ["app"] files = [ {file = "msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35"}, {file = "msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c"}, @@ -1347,7 +1347,7 @@ name = "opentelemetry-api" version = "1.45.0" requires_python = ">=3.10" summary = "OpenTelemetry Python API" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "typing-extensions>=4.5.0", ] @@ -1485,7 +1485,7 @@ name = "packaging" version = "25.0" requires_python = ">=3.8" summary = "Core utilities for Python packages" -groups = ["default", "dev", "extension-preview", "extension-publisher"] +groups = ["app", "dev", "extension-preview", "extension-publisher"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1495,7 +1495,7 @@ files = [ name = "parse" version = "1.22.1" summary = "parse() is the opposite of format()" -groups = ["default"] +groups = ["app"] files = [ {file = "parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32"}, {file = "parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911"}, @@ -1520,7 +1520,7 @@ name = "pika" version = "1.4.4" requires_python = ">=3.7" summary = "Pika Python AMQP Client Library" -groups = ["default"] +groups = ["app"] files = [ {file = "pika-1.4.4-py3-none-any.whl", hash = "sha256:48de960c97a93b55db06b8be4c53eb977c9c8a2754c57cdae9097abcbd70ce04"}, {file = "pika-1.4.4.tar.gz", hash = "sha256:8cfc8b33a5cb16e733bd60cffca9732c0d1d761ecd80a89f34ed7df2cd38d6d6"}, @@ -1550,7 +1550,7 @@ name = "pip" version = "25.3" requires_python = ">=3.9" summary = "The PyPA recommended tool for installing Python packages." -groups = ["default"] +groups = ["app"] files = [ {file = "pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd"}, {file = "pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343"}, @@ -1709,7 +1709,7 @@ name = "pycparser" version = "3.0" requires_python = ">=3.10" summary = "C parser in Python" -groups = ["default"] +groups = ["app"] marker = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, @@ -1721,7 +1721,7 @@ name = "pydantic" version = "2.13.4" requires_python = ">=3.9" summary = "Data validation using Python type hints" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "annotated-types>=0.6.0", "pydantic-core==2.46.4", @@ -1738,7 +1738,7 @@ name = "pydantic-core" version = "2.46.4" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "typing-extensions>=4.14.1", ] @@ -1782,7 +1782,7 @@ name = "pygithub" version = "2.10.0" requires_python = ">=3.10" summary = "Use the full Github API v3" -groups = ["default"] +groups = ["app"] dependencies = [ "pyjwt[crypto]>=2.4.0", "pynacl>=1.4.0", @@ -1810,7 +1810,7 @@ files = [ name = "pyjsparser" version = "2.7.1" summary = "Fast javascript parser (based on esprima.js)" -groups = ["default"] +groups = ["app"] files = [ {file = "pyjsparser-2.7.1.tar.gz", hash = "sha256:be60da6b778cc5a5296a69d8e7d614f1f870faf94e1b1b6ac591f2ad5d729579"}, ] @@ -1820,7 +1820,7 @@ name = "pyjwt" version = "2.13.0" requires_python = ">=3.9" summary = "JSON Web Token implementation in Python" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "typing-extensions>=4.0; python_version < \"3.11\"", ] @@ -1835,7 +1835,7 @@ version = "2.13.0" extras = ["crypto"] requires_python = ">=3.9" summary = "JSON Web Token implementation in Python" -groups = ["default"] +groups = ["app"] dependencies = [ "cryptography>=3.4.0", "pyjwt==2.13.0", @@ -1850,7 +1850,7 @@ name = "pynacl" version = "1.6.2" requires_python = ">=3.8" summary = "Python binding to the Networking and Cryptography (NaCl) library" -groups = ["default"] +groups = ["app"] dependencies = [ "cffi>=1.4.1; platform_python_implementation != \"PyPy\" and python_version < \"3.9\"", "cffi>=2.0.0; platform_python_implementation != \"PyPy\" and python_version >= \"3.9\"", @@ -1876,7 +1876,7 @@ name = "pyotp" version = "2.10.0" requires_python = ">=3.8" summary = "Python One Time Password Library" -groups = ["default"] +groups = ["app"] files = [ {file = "pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c"}, {file = "pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f"}, @@ -1953,7 +1953,7 @@ name = "python-dateutil" version = "2.9.0.post0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Extensions to the standard Python datetime module" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "six>=1.5", ] @@ -1992,7 +1992,7 @@ name = "python-multipart" version = "0.0.32" requires_python = ">=3.10" summary = "A streaming multipart parser for Python" -groups = ["default"] +groups = ["app"] files = [ {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, @@ -2003,7 +2003,7 @@ name = "python-telegram-bot" version = "22.8" requires_python = ">=3.10" summary = "We have made you a wrapper you can't refuse" -groups = ["default"] +groups = ["app"] dependencies = [ "httpcore>=1.0.9; python_version >= \"3.14\"", "httpx<0.29,>=0.27", @@ -2017,7 +2017,7 @@ files = [ name = "pytz" version = "2026.3.post1" summary = "World timezone definitions, modern and historical" -groups = ["default"] +groups = ["app"] files = [ {file = "pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815"}, {file = "pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d"}, @@ -2028,7 +2028,7 @@ name = "pywin32" version = "312" requires_python = ">=3.9" summary = "Python for Windows Extensions" -groups = ["default"] +groups = ["app"] marker = "sys_platform == \"win32\"" files = [ {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, @@ -2061,7 +2061,7 @@ name = "radicale" version = "3.8.3" requires_python = ">=3.9.0" summary = "CalDAV and CardDAV Server" -groups = ["default"] +groups = ["app"] dependencies = [ "defusedxml", "libpass>=1.9.3", @@ -2095,7 +2095,7 @@ name = "referencing" version = "0.37.0" requires_python = ">=3.10" summary = "JSON Referencing + Python" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "attrs>=22.2.0", "rpds-py>=0.7.0", @@ -2111,7 +2111,7 @@ name = "regex" version = "2026.7.19" requires_python = ">=3.10" summary = "Alternative regular expression module, to replace re." -groups = ["default"] +groups = ["app"] files = [ {file = "regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d"}, {file = "regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd"}, @@ -2137,7 +2137,7 @@ name = "requests" version = "2.34.2" requires_python = ">=3.10" summary = "Python HTTP for Humans." -groups = ["default"] +groups = ["default", "app"] dependencies = [ "certifi>=2023.5.7", "charset-normalizer<4,>=2", @@ -2169,7 +2169,7 @@ name = "rpds-py" version = "2026.6.3" requires_python = ">=3.11" summary = "Python bindings to Rust's persistent data structures (rpds)" -groups = ["default"] +groups = ["default", "app"] files = [ {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, @@ -2221,7 +2221,7 @@ name = "semantic-version" version = "2.10.0" requires_python = ">=2.7" summary = "A library implementing the 'SemVer' scheme." -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["app", "extension-preview", "extension-publisher"] files = [ {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, @@ -2254,7 +2254,7 @@ name = "six" version = "1.17.0" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" summary = "Python 2 and 3 compatibility utilities" -groups = ["default"] +groups = ["default", "app"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2276,7 +2276,7 @@ name = "socksio" version = "1.0.0" requires_python = ">=3.6" summary = "Sans-I/O implementation of SOCKS4, SOCKS4A, and SOCKS5." -groups = ["default"] +groups = ["app"] files = [ {file = "socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3"}, {file = "socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac"}, @@ -2287,7 +2287,7 @@ name = "soupsieve" version = "2.9.2" requires_python = ">=3.10" summary = "A modern CSS selector implementation for Beautiful Soup." -groups = ["default"] +groups = ["default", "app"] files = [ {file = "soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823"}, {file = "soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74"}, @@ -2298,7 +2298,7 @@ name = "sqlalchemy" version = "2.0.54" requires_python = ">=3.7" summary = "Database Abstraction Library" -groups = ["default"] +groups = ["default", "app"] dependencies = [ "greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"", "importlib-metadata; python_version < \"3.8\"", @@ -2359,7 +2359,7 @@ name = "sse-starlette" version = "3.4.8" requires_python = ">=3.10" summary = "SSE plugin for Starlette" -groups = ["default"] +groups = ["app"] dependencies = [ "anyio>=4.7.0", "starlette>=0.49.1", @@ -2374,7 +2374,7 @@ name = "starlette" version = "1.6.0" requires_python = ">=3.10" summary = "The little ASGI library that shines." -groups = ["default"] +groups = ["app"] dependencies = [ "anyio<5,>=3.6.2", "typing-extensions>=4.10.0; python_version < \"3.13\"", @@ -2415,7 +2415,7 @@ name = "tld" version = "0.13.2" requires_python = ">=3.7" summary = "Extract the top-level domain (TLD) from the URL given." -groups = ["default"] +groups = ["app"] files = [ {file = "tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c"}, {file = "tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345"}, @@ -2458,7 +2458,7 @@ name = "trafilatura" version = "2.2.0" requires_python = ">=3.10" summary = "Python & Command-line tool to gather text and metadata on the Web: Crawling, scraping, extraction, output as CSV, JSON, HTML, MD, TXT, XML." -groups = ["default"] +groups = ["app"] dependencies = [ "certifi", "charset-normalizer>=3.4.9", @@ -2478,7 +2478,7 @@ name = "truststore" version = "0.10.4" requires_python = ">= 3.10" summary = "Verify certificates using native system trust stores" -groups = ["default"] +groups = ["app"] marker = "sys_platform != \"emscripten\"" files = [ {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, @@ -2490,7 +2490,7 @@ name = "twikit" version = "2.3.3" requires_python = ">=3.8" summary = "Twitter API wrapper for python with **no API key required**." -groups = ["default"] +groups = ["app"] dependencies = [ "Js2Py-3-13", "beautifulsoup4", @@ -2528,7 +2528,7 @@ name = "typing-extensions" version = "4.16.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, @@ -2539,7 +2539,7 @@ name = "typing-inspection" version = "0.4.4" requires_python = ">=3.10" summary = "Runtime typing introspection tools" -groups = ["default", "extension-preview", "extension-publisher"] +groups = ["default", "app", "extension-preview", "extension-publisher"] dependencies = [ "typing-extensions>=4.15.0", ] @@ -2553,7 +2553,7 @@ name = "tzdata" version = "2026.3" requires_python = ">=2" summary = "Provider of IANA time zone data" -groups = ["default"] +groups = ["default", "app"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, @@ -2565,7 +2565,7 @@ name = "tzlocal" version = "5.4.4" requires_python = ">=3.10" summary = "tzinfo object for the local timezone" -groups = ["default"] +groups = ["app"] dependencies = [ "tzdata; platform_system == \"Windows\"", ] @@ -2579,7 +2579,7 @@ name = "urllib3" version = "2.7.0" requires_python = ">=3.10" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["default"] +groups = ["default", "app"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -2618,7 +2618,7 @@ name = "uvicorn" version = "0.35.0" requires_python = ">=3.9" summary = "The lightning-fast ASGI server." -groups = ["default"] +groups = ["app"] dependencies = [ "click>=7.0", "h11>=0.8", @@ -2652,7 +2652,7 @@ files = [ name = "vobject" version = "0.9.9" summary = "A full-featured Python package for parsing and creating iCalendar and vCard files" -groups = ["default"] +groups = ["app"] dependencies = [ "python-dateutil>=2.5.0; python_version < \"3.10\"", "python-dateutil>=2.7.0; python_version >= \"3.10\"", @@ -2669,7 +2669,7 @@ name = "webvtt-py" version = "0.5.1" requires_python = ">=3.7" summary = "WebVTT reader, writer and segmenter" -groups = ["default"] +groups = ["app"] files = [ {file = "webvtt-py-0.5.1.tar.gz", hash = "sha256:2040dd325277ddadc1e0c6cc66cbc4a1d9b6b49b24c57a0c3364374c3e8a3dc1"}, {file = "webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7"}, diff --git a/pyproject.toml b/pyproject.toml index 44f6581b..dcfd5673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,13 @@ [project] name = "inkcre-core" version = "0.10.0" -description = "Backend of InKCre." +description = "InKCre Python SDK for information-base operations and Peer capabilities." authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ - "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.2.0/inkcre_extension_runtime_core_py-0.2.0-py3-none-any.whl", - "authlib>=1.7.2,<2.0.0", "pydantic (>=2.11.7,<3.0.0)", "pydantic-settings (>=2.14.2,<3.0.0)", - "fastapi (>=0.139.2,<0.140.0)", - "packaging (>=25.0,<26.0)", - "pip (>=25.2,<26.0)", - "semantic-version (>=2.10.0,<3.0.0)", "sqlalchemy[asyncio]<3.0.0,>=2.0.41", "aiohttp (>=3.14.1,<4.0.0)", "tencentcloud-sdk-python-lke (>=3.0.1427,<4.0.0)", @@ -21,20 +15,12 @@ dependencies = [ "openai (>=1.97.1,<2.0.0)", "numpy (>=2.3.2,<3.0.0)", "pgvector (>=0.4.1,<0.5.0)", - "uvicorn (>=0.35.0,<0.36.0)", "requests (>=2.33.0,<3.0.0)", "httpx>=0.28.1,<0.29.0", "sqlmodel (>=0.0.24,<0.0.25)", "datadot (>=1.0.0,<2.0.0)", - "apscheduler>=3.11.0", "html2text>=2025.4.15", - "logtail-python>=0.3.0", - "python-telegram-bot==22.8", - "alembic>=1.16.4", - "alembic-utils>=0.8.8", - "alembic-postgresql-enum>=1.8.0", "PyJWT>=2.10.1", - # Fixed built-in extension profile "beautifulsoup4>=4.12.0,<5.0.0", "chardet>=5.0.0,<6.0.0", "lxml>=5.0.0,<7.0.0", @@ -43,19 +29,36 @@ dependencies = [ "av>=18.0.0,<19.0.0", "pypdf>=6.14.2,<7.0.0", "puremagic>=2.2.0,<3.0.0", + "jsonschema<5,>=4.25", + "croniter<7,>=6", + "opentelemetry-sdk==1.45.0", + "opentelemetry-exporter-otlp-proto-http==1.45.0", + "anyio<5,>=4", +] + +[project.optional-dependencies] +app = [ + "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.2.0/inkcre_extension_runtime_core_py-0.2.0-py3-none-any.whl", + "authlib>=1.7.2,<2.0.0", + "fastapi (>=0.139.2,<0.140.0)", + "packaging (>=25.0,<26.0)", + "pip (>=25.2,<26.0)", + "semantic-version (>=2.10.0,<3.0.0)", + "uvicorn (>=0.35.0,<0.36.0)", + "apscheduler>=3.11.0", + "logtail-python>=0.3.0", + "python-telegram-bot==22.8", + "alembic>=1.16.4", + "alembic-utils>=0.8.8", + "alembic-postgresql-enum>=1.8.0", "feedparser>=6.0.12,<7.0.0", "trafilatura>=2.1.0,<3.0.0", "PyGithub>=2.8.1,<3.0.0", "twikit>=2.3.3,<3.0.0", - "jsonschema<5,>=4.25", - "croniter<7,>=6", "imapclient<4,>=3.1", "mcp>=2.1.1,<2.2.0", - "opentelemetry-sdk==1.45.0", - "opentelemetry-exporter-otlp-proto-http==1.45.0", "Radicale==3.8.3", "vobject<0.10,>=0.9.9", - "anyio<5,>=4", ] [dependency-groups] @@ -81,11 +84,8 @@ dev = [ "towncrier==25.8.0", ] -[tool.poetry] -package-mode = false - [tool.pdm] -distribution = false +distribution = true [tool.pdm.scripts] dev = "python run.py" @@ -107,11 +107,12 @@ test = "python -m pytest -q" "check:releases" = "python scripts/release.py check {args}" "build:extension-preview" = "python scripts/build_extension_preview.py {args}" "release:prepare" = "python scripts/release.py prepare {args}" +"check:core-package" = { composite = ["python -m build --wheel --no-isolation", "python scripts/check_core_package.py"] } "check:foundation" = { composite = ["doctor", "check:lock", "check:migrations", "check:automation"] } "lint:database-imports" = "ruff check --config ruff.database.toml ." "lint:database-ownership" = "python scripts/check_database_boundaries.py" "lint:database-boundaries" = { composite = ["lint:database-imports", "lint:database-ownership"] } -"check" = { composite = ["check:foundation", "format:check", "lint", "lint:database-boundaries", "typecheck", "test"] } +"check" = { composite = ["check:foundation", "format:check", "lint", "lint:database-boundaries", "typecheck", "check:core-package", "test"] } "db:revision" = "alembic revision -m {args}" "db:generate" = "alembic revision --autogenerate -m {args}" "db:record" = "python scripts/check_migration_history.py --record-new" @@ -135,6 +136,7 @@ test = "python -m pytest -q" [tool.pyrefly] project-includes = [ + "inkcre_core", "app", "extensions", "libs", @@ -159,5 +161,11 @@ markers = [ ] [build-system] -requires = [] -build-backend = "none" +requires = ["setuptools>=80,<81"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["inkcre_core*"] + +[tool.setuptools.package-data] +inkcre_core = ["py.typed"] diff --git a/ruff.database.toml b/ruff.database.toml index 498d7cf3..40003697 100644 --- a/ruff.database.toml +++ b/ruff.database.toml @@ -1,6 +1,6 @@ # Runtime-wide database rules. Readiness/CLI contract adapters own separate connections. extend = "ruff.toml" -include = ["app/**/*.py", "extensions/**/*.py", "libs/**/*.py", "run.py"] +include = ["app/**/*.py", "inkcre_core/**/*.py", "extensions/**/*.py", "libs/**/*.py", "run.py"] extend-exclude = ["app/database_contract"] [lint] @@ -9,7 +9,7 @@ select = ["TID251"] [lint.flake8-tidy-imports.banned-api] "sqlmodel.Session".msg = "运行时持久化使用绑定 UoW 的 AsyncSession。" "sqlalchemy.orm.Session".msg = "运行时持久化使用绑定 UoW 的 AsyncSession。" -"sqlalchemy.orm.sessionmaker".msg = "运行时只允许 app.engine 的异步工厂。" +"sqlalchemy.orm.sessionmaker".msg = "运行时只允许 inkcre_core.context 的异步工厂。" "sqlalchemy.orm.scoped_session".msg = "Session 生命周期必须显式,不使用 scoped session。" "sqlalchemy.ext.asyncio.async_scoped_session".msg = "每个 task 独立 UoW,不隐藏 session。" "sqlmodel.create_engine".msg = "运行时不创建同步 engine。" diff --git a/ruff.toml b/ruff.toml index d1b4b67b..54f9eb8a 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,37 +2,37 @@ line-length = 92 indent-width = 2 # Task probes are disposable evidence; maintained checks belong in scripts/ or tests/. extend-exclude = ["migrations/versions", "*.md", "cli", ".agents", "tasks"] - + [format] indent-style = "space" line-ending = "lf" - -[lint] -select = [ - "E501", # line length limit - "F821", # undefined-name - "B006", # mutable-argument-default - "E722", # bare-except - "PLE0101", # return-in-init (Pylint) - "RUF006", # asyncio-dangling-task - - "S105", # hardcoded-password-string (Bandit) - "S301", # suspicious-pickle-usage (Bandit) - "S307", # suspicious-eval-usage (Bandit) - "S602", # subprocess-popen-with-shell-equals-true (Bandit) - "S603", # subprocess-without-shell-equals-true (Bandit) - - "F401", # unused-import - - "PLR0913", # too-many-arguments (Pylint) - - "UP007", # non-pep604-annotation (pyupgrade) - "UP032", # f-string (pyupgrade) - - "SIM115", # open-file-with-context-handler (flake8-simplify) - "PLW1510", # subprocess-run-without-check (Pylint) - "RUF100", # unused-noqa -] - -[lint.pycodestyle] + +[lint] +select = [ + "E501", # line length limit + "F821", # undefined-name + "B006", # mutable-argument-default + "E722", # bare-except + "PLE0101", # return-in-init (Pylint) + "RUF006", # asyncio-dangling-task + + "S105", # hardcoded-password-string (Bandit) + "S301", # suspicious-pickle-usage (Bandit) + "S307", # suspicious-eval-usage (Bandit) + "S602", # subprocess-popen-with-shell-equals-true (Bandit) + "S603", # subprocess-without-shell-equals-true (Bandit) + + "F401", # unused-import + + "PLR0913", # too-many-arguments (Pylint) + + "UP007", # non-pep604-annotation (pyupgrade) + "UP032", # f-string (pyupgrade) + + "SIM115", # open-file-with-context-handler (flake8-simplify) + "PLW1510", # subprocess-run-without-check (Pylint) + "RUF100", # unused-noqa +] + +[lint.pycodestyle] max-line-length = 92 diff --git a/run.py b/run.py index 69daf633..63ca9b97 100644 --- a/run.py +++ b/run.py @@ -12,9 +12,11 @@ from fastapi.responses import JSONResponse # Setup logging -from libs.obsrv.main import close_obsrv, setup_obsrv, start_obsrv +from inkcre_core.libs.obsrv.main import close_obsrv, setup_obsrv, start_obsrv -logger = setup_obsrv() +from app.settings import settings + +logger = setup_obsrv(settings.obsrv) # Setup logging end # Load flags @@ -26,8 +28,7 @@ # Load flags end from fastapi.middleware.cors import CORSMiddleware -from app.settings import settings -from app.engine import ASYNC_DB_ENGINE +from app.core import CORE, CoreMiddleware from app.routes.block import ROUTER as block_router from app.routes.relation import ROUTER as relation_router from app.routes.extension import PEER_INBOUND as extension_peer_inbound @@ -52,19 +53,19 @@ from app.routes.semantic_retrieval import ROUTER as semantic_retrieval_router from app.routes.sink import ROUTER as sink_router from app.routes.telemetry import ROUTER as telemetry_router -from app.business.source import SourceManager -from app.business.cron import CronManager -from app.business.job import JobManager +from inkcre_core.business.source import SourceManager +from inkcre_core.business.cron import CronManager +from inkcre_core.business.job import JobManager from app.business.extension import EXTENSION_HOST -from app.business.peer import PeerManager -from app.business.ai import AIManager +from inkcre_core.business.peer import PeerManager +from inkcre_core.business.ai import AIManager from app.business.sink import SinkManager # Import core-owned Job contracts before their catalog is synchronized. -from app.business.organization_job import MediaInterpretationJobHandler # noqa: F401 +from inkcre_core.business.organization_job import MediaInterpretationJobHandler # noqa: F401 # Import independent Organization Job contracts before catalog synchronization. -from app.business.organization.jobs import ( # noqa: F401 +from inkcre_core.business.organization.jobs import ( # noqa: F401 DuplicateAssertionJobHandler, EvidenceStanceJobHandler, ExistingReferentAnchoringJobHandler, @@ -75,7 +76,7 @@ ) from app.middleware import LoggingMiddleware, require_peer_jwt from app.observability import TelemetryMiddleware, initialize_telemetry -from app.schemas.peer import PEER_EXECUTION_HEADER +from inkcre_core.schemas.peer import PEER_EXECUTION_HEADER from app.health import check_database_readiness from app.runtime import ( RUNTIME_STATUS, @@ -137,10 +138,10 @@ def bootstrap_step(step: str): async def bootstrap_runtime(app: fastapi.FastAPI) -> None: """Initialize database-backed runtime services after migrations are ready.""" - from app.business.info_base.resolver import register_core_resolvers - from app.business.agent import register_core_agent_tools - from app.business.organization import register_core_organization_behaviors - from app.business.info_base.storage import StorageManager + from inkcre_core.business.info_base.resolver import register_core_resolvers + from inkcre_core.business.agent import register_core_agent_tools + from inkcre_core.business.organization import register_core_organization_behaviors + from inkcre_core.business.info_base.storage import StorageManager # Register this Peer first so extension enablement can resolve its identity. with bootstrap_step("peer_registration"): @@ -282,7 +283,7 @@ async def lifespan(app: fastapi.FastAPI): try: await close_obsrv() finally: - await ASYNC_DB_ENGINE.dispose() + await CORE.aclose() api_app = fastapi.FastAPI(title="InKCre", lifespan=lifespan) @@ -330,6 +331,10 @@ async def peer_execution_gate(request: fastapi.Request, call_next): ) +# Bind outer request middleware and lifespan to the same owned instance. +api_app.add_middleware(CoreMiddleware, core=CORE) + + # Set up routes @api_app.get("/livez") def liveness() -> dict[str, str]: diff --git a/scripts/automation/production_preflight.py b/scripts/automation/production_preflight.py index 57306c0a..05bdd12d 100644 --- a/scripts/automation/production_preflight.py +++ b/scripts/automation/production_preflight.py @@ -8,7 +8,7 @@ from urllib.parse import unquote, urlsplit from app.database_contract.connection import database_connection -from app.database_contract.constants import ( +from inkcre_core.database_contract.constants import ( ANONYMOUS_ROLE, AUTHENTICATED_ROLE, AUTHENTICATOR_ROLE, diff --git a/scripts/benchmark_database.py b/scripts/benchmark_database.py index da015047..c88e8fe5 100644 --- a/scripts/benchmark_database.py +++ b/scripts/benchmark_database.py @@ -23,11 +23,11 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from app.business.info_base.commands import submit_graph -from app.business.info_base.services import get_entity_records +from inkcre_core.business.info_base.commands import submit_graph +from inkcre_core.business.info_base.services import get_entity_records from app.engine import ASYNC_DB_ENGINE, AsyncSessionFactory -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.main import GraphBlockForm, GraphForm, GraphRelationForm +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.main import GraphBlockForm, GraphForm, GraphRelationForm def positive(value: str) -> int: diff --git a/scripts/check_core_package.py b/scripts/check_core_package.py new file mode 100644 index 00000000..ef3c1103 --- /dev/null +++ b/scripts/check_core_package.py @@ -0,0 +1,77 @@ +"""Consume the built SDK wheel outside the checkout, without application imports.""" + +import ast +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +PROBE = """ +import asyncio +import importlib.abc +import sys + +class NoApplication(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'app', 'fastapi', 'starlette', 'apscheduler'}: + raise AssertionError(f"SDK imported application dependency: {fullname}") + +sys.meta_path.insert(0, NoApplication()) +from inkcre_core import Core +from inkcre_core.business.info_base import BlockService, submit_graph +from inkcre_core.schemas.info_base.main import GraphForm +from inkcre_core.business.peer import PeerManager +from inkcre_core.business.job import JobManager + +async def main(): + core = Core('postgresql+psycopg://unused:x@127.0.0.1:1/unused') + with core.bind(): + assert PeerManager.get_current_peer_ref() == core.options.peer_id + assert not core.accepting_jobs + assert not core.pending_jobs + await core.aclose() + +asyncio.run(main()) +print('Independent wheel import and lifecycle passed; no database contacted') +""" + + +def main() -> None: + wheels = list((ROOT / "dist").glob("inkcre_core-*.whl")) + if len(wheels) != 1: + raise SystemExit("Build exactly one current SDK wheel in dist/ first") + with tempfile.TemporaryDirectory(prefix="inkcre-sdk-consumer-") as directory: + with zipfile.ZipFile(wheels[0]) as wheel: + assert all( + name.startswith(("inkcre_core/", "inkcre_core-")) for name in wheel.namelist() + ), "SDK wheel contains App source" + for name in wheel.namelist(): + if not name.endswith(".py"): + continue + for node in ast.walk(ast.parse(wheel.read(name))): + modules = [] + if isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and not node.level: + modules = [node.module or ""] + assert not any( + module.split(".")[0] in {"app", "fastapi", "starlette", "apscheduler"} + for module in modules + ), f"Application dependency in SDK: {name}" + wheel.extractall(directory) + environment = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "DATABASE_URL", "JWT_SECRET", "INKCRE_ENV_FILE"} + } + subprocess.run( # noqa: S603 - fixed probe in the current Python environment. + [sys.executable, "-c", PROBE], cwd=directory, env=environment, check=True + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_database_boundaries.py b/scripts/check_database_boundaries.py index 480e898b..21cf6a0a 100644 --- a/scripts/check_database_boundaries.py +++ b/scripts/check_database_boundaries.py @@ -19,11 +19,11 @@ def inspect_source(path: str, source: str) -> list[str]: tree = ast.parse(source) imports: dict[str, str] = {} errors: list[str] = [] - persistence = path.startswith("app/persistence/") + persistence = path.startswith("inkcre_core/persistence/") uow = persistence and path.endswith("/uow.py") - engine = path == "app/engine.py" + engine = path == "inkcre_core/context.py" repository = persistence and not uow - business = path.startswith(("app/business/", "extensions/")) + business = path.startswith(("app/business/", "inkcre_core/business/", "extensions/")) route = path.startswith("app/routes/") def report(node: ast.stmt | ast.expr, message: str) -> None: @@ -59,9 +59,9 @@ def qualified(node: ast.AST) -> str: for name in names: if name.startswith("app.engine") and not (uow or path == "run.py"): report(node, "数据库工厂只能由 UoW 使用;lifespan 仅拥有 engine 释放。") - if route and name.startswith("app.persistence"): + if route and name.startswith(("inkcre_core.persistence", "app.persistence")): report(node, "HTTP route 必须调用 business,不直接依赖 persistence。") - if persistence and name.startswith("app.business"): + if persistence and name.startswith(("inkcre_core.business", "app.business")): report(node, "persistence 不得反向依赖 business。") if (business or route) and name.startswith(DB_MODULES): if name.rsplit(".", 1)[-1] in SESSION_CONSTRUCTORS | SQL_OPERATIONS: @@ -88,7 +88,7 @@ def qualified(node: ast.AST) -> str: leaf = name.rsplit(".", 1)[-1] if name.startswith(DB_MODULES): if leaf in SESSION_CONSTRUCTORS | ENGINE_CONSTRUCTORS and not engine: - report(node, "Engine 和 session factory 只在 app.engine 创建。") + report(node, "Engine 和 session factory 只在 inkcre_core.context 创建。") if (business or route) and leaf in SQL_OPERATIONS: report(node, "SQL 查询与写入必须位于 persistence。") if repository and isinstance(node.func, ast.Attribute): @@ -108,7 +108,7 @@ def self_check() -> None: assert inspect_source("app/routes/example.py", "from ..persistence.info_base import uow") assert inspect_source("app/business/example.py", "import sqlalchemy as sa\nsa.select(1)") assert inspect_source( - "app/persistence/x/repository.py", + "inkcre_core/persistence/x/repository.py", """ from sqlalchemy.ext.asyncio import AsyncSession as Session async def save(session: Session): @@ -117,7 +117,7 @@ async def save(session: Session): """, ) assert not inspect_source( - "app/persistence/x/repository.py", + "inkcre_core/persistence/x/repository.py", """ from sqlalchemy.ext.asyncio import AsyncSession async def save(session: AsyncSession): @@ -125,11 +125,11 @@ async def save(session: AsyncSession): """, ) assert not inspect_source( - "app/persistence/x/uow.py", + "inkcre_core/persistence/x/uow.py", """ -from app.engine import AsyncSessionFactory +from inkcre_core.context import current_core async def operation(): - async with AsyncSessionFactory.begin() as session: + async with current_core().sessions.begin() as session: yield session """, ) diff --git a/scripts/configure_peer_runtime.py b/scripts/configure_peer_runtime.py index c7bbd5f4..fd29536c 100644 --- a/scripts/configure_peer_runtime.py +++ b/scripts/configure_peer_runtime.py @@ -17,7 +17,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from app.database_contract.connection import database_connection -from app.schemas.peer import CorePeerConfig, PEER_HTTP_PROTOCOL +from inkcre_core.schemas.peer import CorePeerConfig, PEER_HTTP_PROTOCOL CAPABILITY_PATHS = { diff --git a/scripts/container.py b/scripts/container.py index 1a22c5a8..02704e08 100644 --- a/scripts/container.py +++ b/scripts/container.py @@ -28,7 +28,16 @@ def _web() -> int: from app.business.extension.main import EXTENSION_HOST - asyncio.run(EXTENSION_HOST.provision_enabled_distributions()) + from app.core import create_core + + async def provision() -> None: + with create_core().bind() as core: + try: + await EXTENSION_HOST.provision_enabled_distributions() + finally: + await core.aclose() + + asyncio.run(provision()) os.execvp( sys.executable, diff --git a/scripts/database_manifest.py b/scripts/database_manifest.py index bd3edc04..c3981a83 100644 --- a/scripts/database_manifest.py +++ b/scripts/database_manifest.py @@ -13,7 +13,7 @@ from sqlalchemy.pool import NullPool from migrations.settings import MigrationSettings -from app.database_contract.constants import PROTOCOL_SCHEMA +from inkcre_core.database_contract.constants import PROTOCOL_SCHEMA from scripts.sanitize_preview_base import LINEAGE_TABLE diff --git a/scripts/deploy_render_neon.py b/scripts/deploy_render_neon.py index 30a957fd..fb0effae 100644 --- a/scripts/deploy_render_neon.py +++ b/scripts/deploy_render_neon.py @@ -21,7 +21,7 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, PROJECT_ROOT) -from app.database_contract.constants import JWT_MINIMUM_SECRET_BYTES +from inkcre_core.database_contract.constants import JWT_MINIMUM_SECRET_BYTES from app.database_contract.lifecycle import initialize from app.database_contract.readiness import check_database_contract from app.database_contract.roles import RoleSecrets diff --git a/scripts/dev_database.py b/scripts/dev_database.py index 61535fd0..a8ed2899 100644 --- a/scripts/dev_database.py +++ b/scripts/dev_database.py @@ -20,7 +20,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) -from app.database_contract.constants import ( +from inkcre_core.database_contract.constants import ( CONTRACT_REVISION, JWT_ALGORITHM, JWT_AUDIENCE, diff --git a/scripts/export_database_roles.py b/scripts/export_database_roles.py index 05473ca0..91011f55 100644 --- a/scripts/export_database_roles.py +++ b/scripts/export_database_roles.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from app.database_contract.connection import database_connection -from app.database_contract.constants import ( +from inkcre_core.database_contract.constants import ( ANONYMOUS_ROLE, AUTHENTICATED_ROLE, AUTHENTICATOR_ROLE, diff --git a/scripts/observability.py b/scripts/observability.py index a6a6b8cb..d20b8d4a 100644 --- a/scripts/observability.py +++ b/scripts/observability.py @@ -9,7 +9,7 @@ from psycopg.types.json import Jsonb from app.database_contract.connection import database_connection -from app.schemas.observability import CONFIG_KEY, CONFIG_SCHEMA, ObservabilityConfig +from inkcre_core.schemas.observability import CONFIG_KEY, CONFIG_SCHEMA, ObservabilityConfig def main() -> None: diff --git a/scripts/sanitize_preview_base.py b/scripts/sanitize_preview_base.py index b4157926..213d96f3 100644 --- a/scripts/sanitize_preview_base.py +++ b/scripts/sanitize_preview_base.py @@ -15,7 +15,7 @@ from migrations.metadata import get_target_metadata from migrations.settings import MigrationSettings -from app.database_contract import INTERNAL_SCHEMA, PROTOCOL_SCHEMA +from inkcre_core.database_contract import INTERNAL_SCHEMA, PROTOCOL_SCHEMA SANITIZE_GUARD = "ALLOW_PREVIEW_BASE_SANITIZE" diff --git a/scripts/tests/caldav/conftest.py b/scripts/tests/caldav/conftest.py index b0730929..ba953049 100644 --- a/scripts/tests/caldav/conftest.py +++ b/scripts/tests/caldav/conftest.py @@ -1,3 +1,5 @@ """Reuse the isolated environment for explicitly selected CalDAV script checks.""" from tests import conftest # noqa: F401 + +from tests.conftest import application_core_scope as application_core_scope diff --git a/scripts/tests/caldav/test_dav_graph.py b/scripts/tests/caldav/test_dav_graph.py index 82f99a79..67936606 100644 --- a/scripts/tests/caldav/test_dav_graph.py +++ b/scripts/tests/caldav/test_dav_graph.py @@ -11,8 +11,8 @@ from sqlalchemy.orm import Session from app.engine import ASYNC_DB_ENGINE -from app.persistence.info_base.uow import graph_uow -from app.business.info_base.resolver import ResolverManager +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.business.info_base.resolver import ResolverManager from extensions.caldav import Extension from extensions.caldav.schema import CALENDAR, GraphContent from tests.extensions.runtime_support import publish_extension @@ -201,7 +201,7 @@ def record_write(session, flush_context): def test_tasks_contacts_and_credential_revocation(dav): - from app.schemas.extension import ExtensionModel + from inkcre_core.schemas.extension import ExtensionModel from tests.database import TestSession task_path = f"/caldav/inkcre/tasks/{uuid4().hex}.ics" @@ -313,8 +313,8 @@ def test_collection_delete_serializes_direct_peer_membership_writes( import sqlmodel from sqlalchemy.exc import OperationalError - from app.schemas.info_base.block import BlockModel - from app.schemas.info_base.relation import RelationModel + from inkcre_core.schemas.info_base.block import BlockModel + from inkcre_core.schemas.info_base.relation import RelationModel from extensions.caldav.graph import DAVGraph from extensions.caldav.schema import COLLECTION, CollectionContent from tests.database import TestSession diff --git a/scripts/tests/caldav/test_semantic_graph.py b/scripts/tests/caldav/test_semantic_graph.py index a6ac2531..c15b89cb 100644 --- a/scripts/tests/caldav/test_semantic_graph.py +++ b/scripts/tests/caldav/test_semantic_graph.py @@ -12,15 +12,15 @@ import vobject # pyrefly: ignore[untyped-import] from sqlalchemy.exc import OperationalError -from app.business.info_base.resolver import ResolverManager -from app.business.info_base.storage import StorageManager -from app.business.info_base.storage.postgresql import ( +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.business.info_base.storage import StorageManager +from inkcre_core.business.info_base.storage.postgresql import ( PostgreSQLBinaryStorage, PostgreSQLBlobPointer, ) -from app.persistence.info_base.uow import graph_uow -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.storage import StorageBlobModel +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel from extensions.caldav.graph import DAVGraph, migrate_legacy_graph from extensions.caldav.resolver import solve_resource from extensions.caldav.schema import ( diff --git a/scripts/tests/twitter/conftest.py b/scripts/tests/twitter/conftest.py index 3015882b..c67f1943 100644 --- a/scripts/tests/twitter/conftest.py +++ b/scripts/tests/twitter/conftest.py @@ -1,3 +1,5 @@ """Reuse the existing hermetic environment for explicitly selected Twitter checks.""" from tests.conftest import async_runner as async_runner + +from tests.conftest import application_core_scope as application_core_scope diff --git a/scripts/tests/twitter/integration/test_archive_graph.py b/scripts/tests/twitter/integration/test_archive_graph.py index 739ee49c..43e8bd51 100644 --- a/scripts/tests/twitter/integration/test_archive_graph.py +++ b/scripts/tests/twitter/integration/test_archive_graph.py @@ -17,21 +17,21 @@ import sqlmodel import twikit -from app.business.info_base.resolver import ResolverManager -from app.business.info_base.storage import HTTPStorage -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.source import SourceManager -from app.persistence.info_base.repository import BlockRepository, RelationRepository -from app.persistence.info_base.uow import graph_uow -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageBlobModel -from app.schemas.job import JobModel -from app.schemas.lexical_retrieval import ( +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.business.info_base.storage import HTTPStorage +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.source import SourceManager +from inkcre_core.persistence.info_base.repository import BlockRepository, RelationRepository +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel +from inkcre_core.schemas.job import JobModel +from inkcre_core.schemas.lexical_retrieval import ( BlockLexicalRecordModel, LexicalMaintenanceOptions, ) -from app.schemas.source import SourceModel +from inkcre_core.schemas.source import SourceModel from extensions.twitter.api import ( TwitterAPI, TwitterAPIResult, diff --git a/scripts/tests/twitter/integration/test_archive_http_roundtrip.py b/scripts/tests/twitter/integration/test_archive_http_roundtrip.py index ea6cb89a..e30a9b75 100644 --- a/scripts/tests/twitter/integration/test_archive_http_roundtrip.py +++ b/scripts/tests/twitter/integration/test_archive_http_roundtrip.py @@ -13,11 +13,11 @@ import sqlalchemy import sqlmodel -from app.persistence.info_base.uow import graph_uow -from app.business.peer import PeerManager -from app.business.source import SourceManager -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.business.peer import PeerManager +from inkcre_core.business.source import SourceManager +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel from extensions.twitter.archive_session import SESSION_COOKIE_NAME, _issue_session_token from extensions.twitter.reconcile import MEDIA_RESOLVER, POST_RESOLVER, USER_RESOLVER from tests.database import TestSession diff --git a/scripts/tests/twitter/test_archive_http.py b/scripts/tests/twitter/test_archive_http.py index 71a846ce..c0703579 100644 --- a/scripts/tests/twitter/test_archive_http.py +++ b/scripts/tests/twitter/test_archive_http.py @@ -10,8 +10,8 @@ import jwt import pytest -from app.business.peer import PeerManager -from app.database_contract.constants import JWT_ALGORITHM +from inkcre_core.business.peer import PeerManager +from inkcre_core.database_contract.constants import JWT_ALGORITHM from app.middleware import create_peer_jwt from app.settings import Settings, settings from extensions.twitter import archive_http, archive_session @@ -303,7 +303,7 @@ def test_session_does_not_renew_for_no_cors_subresource(archive_client): def test_extension_publishes_claimed_archive_routes(monkeypatch): from extensions.twitter import Extension - from app.business.source import SourceManager + from inkcre_core.business.source import SourceManager async def sync_source_types(): return None diff --git a/scripts/verify_postgrest_contract.py b/scripts/verify_postgrest_contract.py index d42e3418..c15eb359 100644 --- a/scripts/verify_postgrest_contract.py +++ b/scripts/verify_postgrest_contract.py @@ -14,7 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) -from app.database_contract.constants import ( +from inkcre_core.database_contract.constants import ( JWT_ALGORITHM, JWT_AUDIENCE, JWT_ISSUER, diff --git a/tests/agent/integration/test_agent_definition.py b/tests/agent/integration/test_agent_definition.py index c7406e56..04772359 100644 --- a/tests/agent/integration/test_agent_definition.py +++ b/tests/agent/integration/test_agent_definition.py @@ -9,15 +9,15 @@ import sqlalchemy import sqlalchemy.exc -from app.business.agent import ( +from inkcre_core.business.agent import ( AgentManager, InMemoryThreadPersistenceBackend, TurnTermination, ) -from app.business.ai import AIManager +from inkcre_core.business.ai import AIManager from tests.database import TestSession -from app.schemas import AgentDefinitionModel -from app.schemas.ai import ( +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, AssistantMessage, diff --git a/tests/agent/test_debug_trace.py b/tests/agent/test_debug_trace.py index 1136d071..ab87c3b4 100644 --- a/tests/agent/test_debug_trace.py +++ b/tests/agent/test_debug_trace.py @@ -7,15 +7,15 @@ import pydantic import pytest -from app.business.agent import ( +from inkcre_core.business.agent import ( BoundAgentTool, InMemoryThreadPersistenceBackend, Thread, ThreadState, TurnTermination, ) -from app.business.ai import AIManager -from app.schemas.ai import ( +from inkcre_core.business.ai import AIManager +from inkcre_core.schemas.ai import ( AssistantMessage, FunctionTool, SystemMessage, @@ -23,8 +23,9 @@ UserMessage, TextContentPart, ) -from app.settings import settings -from libs.obsrv.log_record import TRACE_ID +from dataclasses import replace +from inkcre_core import current_core +from inkcre_core.libs.obsrv.log_record import TRACE_ID class _Input(pydantic.BaseModel): @@ -33,7 +34,9 @@ class _Input(pydantic.BaseModel): @pytest.mark.parametrize("budget", [1, 2]) def test_trace_retains_tool_errors_and_actual_budget_outcome(monkeypatch, caplog, budget): - monkeypatch.setattr(settings.obsrv, "agent_debug", True) + monkeypatch.setattr( + current_core(), "options", replace(current_core().options, agent_debug=True) + ) caplog.set_level(logging.INFO, logger="inkcre") invoked = [] @@ -105,7 +108,9 @@ async def run(): def test_trace_records_cancellation_without_turn_recovery(monkeypatch, caplog): - monkeypatch.setattr(settings.obsrv, "agent_debug", True) + monkeypatch.setattr( + current_core(), "options", replace(current_core().options, agent_debug=True) + ) caplog.set_level(logging.INFO, logger="inkcre") async def run(): @@ -138,14 +143,18 @@ async def chat(cls, *args): def test_debug_disabled_and_broken_destination_do_not_raise(monkeypatch): - from app.business.agent.debug import trace + from inkcre_core.business.agent.debug import trace import uuid - monkeypatch.setattr(settings.obsrv, "agent_debug", False) + monkeypatch.setattr( + current_core(), "options", replace(current_core().options, agent_debug=False) + ) asyncio.run(trace("example", uuid.uuid4(), unsupported=object())) - monkeypatch.setattr(settings.obsrv, "agent_debug", True) + monkeypatch.setattr( + current_core(), "options", replace(current_core().options, agent_debug=True) + ) asyncio.run(trace("example", uuid.uuid4(), unsupported=object())) - from libs.obsrv.main import get_logger + from inkcre_core.libs.obsrv.main import get_logger def broken_destination(*args, **kwargs): raise OSError("destination unavailable") diff --git a/tests/ai/integration/test_ai_registry.py b/tests/ai/integration/test_ai_registry.py index dc021d88..a8095739 100644 --- a/tests/ai/integration/test_ai_registry.py +++ b/tests/ai/integration/test_ai_registry.py @@ -8,9 +8,9 @@ import sqlalchemy import sqlalchemy.exc -from app.business.ai import AIManager +from inkcre_core.business.ai import AIManager from tests.database import TestSession -from app.schemas.ai import ( +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, ChatCapability, diff --git a/tests/conftest.py b/tests/conftest.py index 129244d7..0d88762f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,3 +49,11 @@ def async_runner(): yield runner finally: runner.run(ASYNC_DB_ENGINE.dispose()) + + +@pytest.fixture(autouse=True) +def application_core_scope(): + from app.core import CORE + + with CORE.bind(): + yield CORE diff --git a/tests/database.py b/tests/database.py index 3c8b8367..a4ab9416 100644 --- a/tests/database.py +++ b/tests/database.py @@ -12,8 +12,8 @@ from sqlalchemy.pool import NullPool from app.settings import settings -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel TEST_ENGINE = sqlmodel.create_engine(settings.database_url, poolclass=NullPool) diff --git a/tests/deployment_config/integration/test_config_resource.py b/tests/deployment_config/integration/test_config_resource.py index 5163521d..18f4cce1 100644 --- a/tests/deployment_config/integration/test_config_resource.py +++ b/tests/deployment_config/integration/test_config_resource.py @@ -1,6 +1,6 @@ """Black-box proof for shared config persistence and row timestamp ownership.""" -from app.business.deployment_config import DeploymentConfigService +from inkcre_core.business.deployment_config import DeploymentConfigService import os @@ -16,12 +16,12 @@ import sqlalchemy import sqlmodel -from app.business.deployment_config import DeploymentConfigManager +from inkcre_core.business.deployment_config import DeploymentConfigManager from app.engine import ASYNC_DB_ENGINE from tests.database import TestSession from app.routes.deployment_config import ROUTER -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel pytestmark = pytest.mark.skipif( diff --git a/tests/extensions/local_files/test_reader.py b/tests/extensions/local_files/test_reader.py index 2f9d5c87..1a399a5b 100644 --- a/tests/extensions/local_files/test_reader.py +++ b/tests/extensions/local_files/test_reader.py @@ -9,7 +9,7 @@ from extensions.local_files.schema import FileMetadata, file_location from extensions.local_files.resolver import LocalFileResolver -from app.business.info_base.services import BlockService +from inkcre_core.business.info_base.services import BlockService def test_location_retains_machine_path_without_remote_file_access(): @@ -80,8 +80,8 @@ def test_failed_current_file_keeps_successful_body_readable(monkeypatch, async_r ) def test_v2_uses_the_body_format(monkeypatch, async_runner, resolver, content, expected): from extensions.local_files.resolver import LocalFileV2Resolver - from app.business.info_base.resolver import register_core_resolvers - from app.schemas.info_base.block import BlockModel + from inkcre_core.business.info_base.resolver import register_core_resolvers + from inkcre_core.schemas.info_base.block import BlockModel register_core_resolvers() now = datetime.datetime.now(datetime.UTC) diff --git a/tests/extensions/mail/acceptance/test_mail_vertical.py b/tests/extensions/mail/acceptance/test_mail_vertical.py index 3ec3f406..d639d8ad 100644 --- a/tests/extensions/mail/acceptance/test_mail_vertical.py +++ b/tests/extensions/mail/acceptance/test_mail_vertical.py @@ -1,6 +1,7 @@ """J1–J3: real IMAP -> Job -> graph -> Resolver/Storage acceptance.""" from __future__ import annotations +from inkcre_core import current_core import collections.abc import datetime @@ -12,23 +13,23 @@ import sqlalchemy import sqlmodel -from app.business.info_base.resolver import ResolverManager, register_core_resolvers -from app.business.info_base.storage import StorageManager -from app.business.job import JobManager -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.source import ( +from inkcre_core.business.info_base.resolver import ResolverManager, register_core_resolvers +from inkcre_core.business.info_base.storage import StorageManager +from inkcre_core.business.job import JobManager +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.source import ( SOURCE_BACKFILL_JOB_TYPE, SOURCE_COLLECT_JOB_TYPE, SourceManager, ) from tests.database import TestSession -from app.schemas.extension import ExtensionModel -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageBlobModel -from app.schemas.job import JobModel, JobStatus -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions -from app.schemas.source import SourceModel +from inkcre_core.schemas.extension import ExtensionModel +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas.source import SourceModel from extensions.mail import Extension from extensions.mail.reconcile import ( EMAIL_ADDRESS_RESOLVER, @@ -102,7 +103,7 @@ def _reset_acceptance_database() -> None: } db.add(extension) db.commit() - SourceManager.SOURCES.clear() + current_core().sources.clear() async def _bootstrap_runtime_catalogs() -> None: diff --git a/tests/extensions/mail/test_newsletter.py b/tests/extensions/mail/test_newsletter.py index a82fea56..a9d27992 100644 --- a/tests/extensions/mail/test_newsletter.py +++ b/tests/extensions/mail/test_newsletter.py @@ -6,8 +6,8 @@ import pytest from bs4 import BeautifulSoup -from app.business.info_base.resolver import ResolverManager -from app.schemas.info_base.block import BlockModel +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.schemas.info_base.block import BlockModel from extensions.mail.adapter import IMAPAdapter, _mailing_list, _walk_body_structure from extensions.mail.newsletter import ( NEWSLETTER_HTML_RESOLVER, diff --git a/tests/extensions/mail/test_newsletter_graph.py b/tests/extensions/mail/test_newsletter_graph.py index baf5fc89..9c0a57a9 100644 --- a/tests/extensions/mail/test_newsletter_graph.py +++ b/tests/extensions/mail/test_newsletter_graph.py @@ -5,10 +5,10 @@ import pytest -from app.business.info_base.resolver import ResolverManager -from app.business.source import SourceManager -from app.schemas.info_base.block import BlockForm -from app.persistence.source.uow import source_uow +from inkcre_core.business.info_base.resolver import ResolverManager +from inkcre_core.business.source import SourceManager +from inkcre_core.schemas.info_base.block import BlockForm +from inkcre_core.persistence.source.uow import source_uow from extensions.mail.newsletter import MAILING_LIST_RESOLVER, NEWSLETTER_HTML_RESOLVER from extensions.mail.reconcile import MailGraphReconciler from extensions.mail.schema import ( diff --git a/tests/extensions/memos/integration/test_postgresql_attachment_graph.py b/tests/extensions/memos/integration/test_postgresql_attachment_graph.py index 9d5a4fc6..ec9ba1a9 100644 --- a/tests/extensions/memos/integration/test_postgresql_attachment_graph.py +++ b/tests/extensions/memos/integration/test_postgresql_attachment_graph.py @@ -11,12 +11,12 @@ import sqlalchemy import sqlmodel -from app.business.info_base.storage import StorageManager -from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer +from inkcre_core.business.info_base.storage import StorageManager +from inkcre_core.business.info_base.storage.postgresql import PostgreSQLBlobPointer from tests.database import TestSession -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageBlobModel +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel from extensions.memos import Extension from extensions.memos.family import ( AttachmentApplicationService, @@ -107,7 +107,7 @@ def memo_client(): def test_saved_pat_replacement_and_revocation_apply_without_restart(memo_client): """An admitted Peer can edit config without invoking the running Host.""" - from app.schemas.extension import ExtensionModel + from inkcre_core.schemas.extension import ExtensionModel old_token = "memos_pat_" + "A" * 32 new_token = "memos_pat_" + "B" * 32 @@ -135,9 +135,9 @@ def save(token: str | None): def test_connection_address_preserves_public_prefix_and_requires_peer_auth(memo_client): - from app.business.peer import PeerManager + from inkcre_core.business.peer import PeerManager from app.middleware import create_peer_jwt - from app.schemas.peer import PeerModel + from inkcre_core.schemas.peer import PeerModel from app.settings import settings client = memo_client diff --git a/tests/extensions/rss/integration/test_feed_collection.py b/tests/extensions/rss/integration/test_feed_collection.py index 423941e6..3e656718 100644 --- a/tests/extensions/rss/integration/test_feed_collection.py +++ b/tests/extensions/rss/integration/test_feed_collection.py @@ -1,6 +1,7 @@ """Real RSS/Atom HTTP doubles collected through PostgreSQL source jobs.""" from __future__ import annotations +from inkcre_core import current_core from tests.database import read_block, read_relations @@ -17,17 +18,17 @@ import pydantic import sqlmodel -from app.business.info_base.resolver import ResolverManager, register_core_resolvers -from app.business.info_base.storage import StorageManager -from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer -from app.business.job import JobManager -from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager +from inkcre_core.business.info_base.resolver import ResolverManager, register_core_resolvers +from inkcre_core.business.info_base.storage import StorageManager +from inkcre_core.business.info_base.storage.postgresql import PostgreSQLBlobPointer +from inkcre_core.business.job import JobManager +from inkcre_core.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager from tests.database import TestSession -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageBlobModel -from app.schemas.job import JobModel, JobStatus -from app.schemas.source import SourceModel +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.source import SourceModel from extensions.rss import Extension from extensions.rss.api import register_api from extensions.rss.reconcile import ( @@ -324,7 +325,7 @@ def _cleanup(source_ids: set[int]) -> None: db_session.delete(blob) db_session.commit() for source_id in source_ids: - SourceManager.SOURCES.pop(source_id, None) + current_core().sources.pop(source_id, None) async def _exercise_rss() -> None: diff --git a/tests/extensions/runtime_support.py b/tests/extensions/runtime_support.py index 28a09b3b..38a1b47c 100644 --- a/tests/extensions/runtime_support.py +++ b/tests/extensions/runtime_support.py @@ -56,14 +56,17 @@ def publish_extension( raise_server_exceptions: bool = True, persist_config: bool = False, ) -> PublishedExtension: + from app.core import CORE, CoreMiddleware + runtime_app = app or fastapi.FastAPI() + runtime_app.add_middleware(CoreMiddleware, core=CORE) runtime_config = dict(config or {}) runtime_state: dict[str, typing.Any] = {} if persist_config: if not os.getenv("INKCRE_TEST_DATABASE_URL"): raise RuntimeError("Persisted Extension setup requires an explicit test database") - from app.schemas.extension import ExtensionModel + from inkcre_core.schemas.extension import ExtensionModel from tests.database import TestSession with TestSession() as session: diff --git a/tests/info_base/integration/test_graph_commands.py b/tests/info_base/integration/test_graph_commands.py index 1737d229..18d41a2d 100644 --- a/tests/info_base/integration/test_graph_commands.py +++ b/tests/info_base/integration/test_graph_commands.py @@ -10,12 +10,16 @@ import psycopg.errors import sqlmodel -from app.business.info_base.commands import persist_graph, submit_graph, submit_stars -from app.persistence.info_base.uow import graph_uow -from app.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.info_base.commands import ( + persist_graph, + submit_graph, + submit_stars, +) +from inkcre_core.persistence.info_base.uow import graph_uow +from inkcre_core.business.info_base.resolver import register_core_resolvers from tests.database import TestSession -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.main import ( +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.main import ( GraphBlockForm, GraphForm, GraphRelationForm, @@ -23,7 +27,7 @@ OutArcForm, StarsGraphForm, ) -from app.schemas.info_base.relation import RelationForm, RelationModel +from inkcre_core.schemas.info_base.relation import RelationForm, RelationModel pytestmark = pytest.mark.skipif( @@ -228,9 +232,9 @@ async def cancel_then_retry(): def test_blob_graph_transaction_and_hydration(async_runner): - from app.business.info_base.storage import StorageManager, WritableStorage - from app.business.info_base.services import BlockService - from app.schemas.info_base.storage import StorageBlobModel + from inkcre_core.business.info_base.storage import StorageManager, WritableStorage + from inkcre_core.business.info_base.services import BlockService + from inkcre_core.schemas.info_base.storage import StorageBlobModel async def scenario(): await StorageManager.setup_builtin_storages_async() @@ -256,7 +260,7 @@ async def scenario(): async with graph_uow() as uow: rolled_back_pointer = await storage.create_content(b"not committed", uow.storage) raise RuntimeError("abort graph") - from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer + from inkcre_core.business.info_base.storage.postgresql import PostgreSQLBlobPointer blob_id = PostgreSQLBlobPointer.model_validate_json(rolled_back_pointer).blob_id with TestSession() as db: @@ -274,7 +278,7 @@ async def scenario(): def test_github_stars_retain_node_identity_and_reject_ambiguity(async_runner): from extensions.github.resolver import GitHubAccountResolver, GitHubGraphIntegrityError from extensions.github.schema import GitHubAccount - from app.business.info_base.commands import persist_stars + from inkcre_core.business.info_base.commands import persist_stars account = GitHubAccount( node_id=uuid.uuid4().hex, kind="user", login="before", url="https://github.com/before" diff --git a/tests/info_base/test_markdown.py b/tests/info_base/test_markdown.py index 34926663..f5569728 100644 --- a/tests/info_base/test_markdown.py +++ b/tests/info_base/test_markdown.py @@ -3,12 +3,12 @@ import datetime from unittest.mock import AsyncMock -from app.business.info_base.resolver import ( +from inkcre_core.business.info_base.resolver import ( MarkdownResolver, ResolverManager, register_core_resolvers, ) -from app.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.block import BlockModel def test_markdown_keeps_source_and_unicode(async_runner, monkeypatch): diff --git a/tests/lexical_retrieval/acceptance/test_multimodal_live.py b/tests/lexical_retrieval/acceptance/test_multimodal_live.py index bb69e69e..afb1960a 100644 --- a/tests/lexical_retrieval/acceptance/test_multimodal_live.py +++ b/tests/lexical_retrieval/acceptance/test_multimodal_live.py @@ -1,8 +1,8 @@ """Credentialed real-provider media -> graph -> lexical acceptance.""" -from app.business.info_base.commands import get_related_block -from app.business.deployment_config import DeploymentConfigService -from app.persistence.info_base.uow import graph_uow +from inkcre_core.business.info_base.commands import get_related_block +from inkcre_core.business.deployment_config import DeploymentConfigService +from inkcre_core.persistence.info_base.uow import graph_uow import os @@ -12,34 +12,34 @@ import pytest import sqlalchemy -from app.business.ai import AIManager -from app.business.info_base.resolver import ResolverManager, register_core_resolvers -from app.business.info_base.resolver.audio import ( +from inkcre_core.business.ai import AIManager +from inkcre_core.business.info_base.resolver import ResolverManager, register_core_resolvers +from inkcre_core.business.info_base.resolver.audio import ( AUDIO_RESOLVER_CONFIG_KEY, AUDIO_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.resolver.image import ( +from inkcre_core.business.info_base.resolver.image import ( IMAGE_RESOLVER_CONFIG_KEY, IMAGE_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.resolver.video import ( +from inkcre_core.business.info_base.resolver.video import ( VIDEO_RESOLVER_CONFIG_KEY, VIDEO_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.storage import StorageManager, WritableStorage -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.organization import SUBMIT_GRAPH_TOOL -from app.business.organization_media import ( +from inkcre_core.business.info_base.storage import StorageManager, WritableStorage +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.organization import SUBMIT_GRAPH_TOOL +from inkcre_core.business.organization_media import ( MEDIA_INTERPRETATION_CONFIG_KEY, MEDIA_INTERPRETATION_CONFIG_SCHEMA, interpret_missing_media, ) from tests.database import TestSession -from app.schemas import AgentDefinitionModel -from app.schemas.ai import AIModelModel, AIProviderModel, ChatCapability -from app.schemas.deployment_config import DeploymentConfigModel -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.ai import AIModelModel, AIProviderModel, ChatCapability +from inkcre_core.schemas.deployment_config import DeploymentConfigModel +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions from .prepare_media_assets import prepare_assets diff --git a/tests/lexical_retrieval/integration/test_lexical_retrieval.py b/tests/lexical_retrieval/integration/test_lexical_retrieval.py index b751b263..6c7f7ac5 100644 --- a/tests/lexical_retrieval/integration/test_lexical_retrieval.py +++ b/tests/lexical_retrieval/integration/test_lexical_retrieval.py @@ -1,6 +1,6 @@ """Real PostgreSQL proof for lexical projection, freshness, and ranking.""" -from app.business.info_base.services import BlockService +from inkcre_core.business.info_base.services import BlockService from tests.database import seed_block @@ -9,19 +9,19 @@ import pytest import sqlalchemy -from app.business.info_base.resolver import register_core_resolvers -from app.business.cron import CronManager -from app.business.job import JobManager -from app.business.lexical_retrieval import ( +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.cron import CronManager +from inkcre_core.business.job import JobManager +from inkcre_core.business.lexical_retrieval import ( LEXICAL_MAINTAIN_JOB_TYPE, LEXICAL_REBUILD_JOB_TYPE, LexicalRetrievalManager, ) from tests.database import TestSession -from app.schemas.info_base.block import BlockForm -from app.schemas.cron import CronModel -from app.schemas.job import JobModel, JobStatus -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas.info_base.block import BlockForm +from inkcre_core.schemas.cron import CronModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions pytestmark = pytest.mark.skipif( @@ -30,6 +30,15 @@ ) +@pytest.fixture +def job_execution(async_runner): + JobManager.start() + try: + yield + finally: + async_runner.run(JobManager.shutdown()) + + def _reset() -> None: with TestSession() as db: db.connection().execute( @@ -123,6 +132,7 @@ def test_unknown_resolver_is_bounded_unavailable_diagnostic( def test_direct_and_cron_created_jobs_share_exact_handler_path( async_runner, + job_execution, ): _reset() register_core_resolvers() diff --git a/tests/migrations/test_extension_peer_enabled_rpc.py b/tests/migrations/test_extension_peer_enabled_rpc.py index 19660330..3f37d3c3 100644 --- a/tests/migrations/test_extension_peer_enabled_rpc.py +++ b/tests/migrations/test_extension_peer_enabled_rpc.py @@ -19,15 +19,15 @@ import sqlmodel from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from app.business.cron import CronManager +from inkcre_core.business.cron import CronManager from app.business.extension.errors import ExtensionStateConflictError from app.business.extension.state import ExtensionStateService -from app.business.job import JobManager -from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager -from app.persistence.extension import uow as extension_uow -from app.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel -from app.schemas.peer import PeerModel -from app.schemas.cron import CronForm +from inkcre_core.business.job import JobManager +from inkcre_core.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager +from inkcre_core.persistence.extension import uow as extension_uow +from inkcre_core.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel +from inkcre_core.schemas.peer import PeerModel +from inkcre_core.schemas.cron import CronForm from extensions.twitter.bookmark import Source as BookmarkSource @@ -462,9 +462,9 @@ def test_setup_source_and_cron_use_simple_core_owned_operations( ): import asyncio from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - from app.persistence.cron import uow as cron_uow - from app.persistence.job import uow as job_uow - from app.persistence.source import uow as source_uow + from inkcre_core.persistence.cron import uow as cron_uow + from inkcre_core.persistence.job import uow as job_uow + from inkcre_core.persistence.source import uow as source_uow engine = create_async_engine(setup_domain_engine.url) factory = async_sessionmaker(engine, expire_on_commit=False) diff --git a/tests/migrations/test_memos_attachment_v2_migration.py b/tests/migrations/test_memos_attachment_v2_migration.py index 059a76e2..a2e267b8 100644 --- a/tests/migrations/test_memos_attachment_v2_migration.py +++ b/tests/migrations/test_memos_attachment_v2_migration.py @@ -17,15 +17,16 @@ import pytest import sqlalchemy import sqlmodel -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool from app.database_contract.connection import normalized_database_url, psycopg_database_url -from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer +from inkcre_core import Core +from inkcre_core.business.info_base.storage.postgresql import PostgreSQLBlobPointer from tests.database import TestSession -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.info_base.storage import StorageBlobModel +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.info_base.storage import StorageBlobModel from extensions.memos import Extension from extensions.memos.family import AttachmentApplicationService, CanonicalAttachment @@ -54,6 +55,7 @@ def attachment_database(monkeypatch): ) engine = sqlalchemy.create_engine(database_url, poolclass=NullPool) async_engine = create_async_engine(database_url, poolclass=NullPool) + core = Core(async_engine) config = Config(PROJECT_ROOT / "alembic.ini") config.set_main_option("script_location", str(PROJECT_ROOT / "migrations")) with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: @@ -61,10 +63,6 @@ def attachment_database(monkeypatch): try: monkeypatch.setenv("MIGRATION_DATABASE_URL", database_url) monkeypatch.setattr("tests.database.TEST_ENGINE", engine) - monkeypatch.setattr( - "app.persistence.info_base.uow.AsyncSessionFactory", - async_sessionmaker(async_engine, expire_on_commit=False), - ) command.upgrade(config, ATTACHMENT_REVISION) with engine.begin() as connection: connection.execute( @@ -79,8 +77,10 @@ def attachment_database(monkeypatch): "VALUES (-4, 'postgresql_binary', '{}'::jsonb)" ) ) - yield config + with core.bind(): + yield config finally: + asyncio.run(core.aclose()) engine.dispose() asyncio.run(async_engine.dispose()) with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: diff --git a/tests/organization/acceptance/test_black_box.py b/tests/organization/acceptance/test_black_box.py index 19f8b303..67cbc495 100644 --- a/tests/organization/acceptance/test_black_box.py +++ b/tests/organization/acceptance/test_black_box.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.business.deployment_config import DeploymentConfigService +from inkcre_core.business.deployment_config import DeploymentConfigService from tests.database import seed_block, seed_relation @@ -15,19 +15,19 @@ import sqlalchemy import sqlmodel -from app.business.ai import AIManager -from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager -from app.business.graph_navigation_retrieval.tools import ( +from inkcre_core.business.ai import AIManager +from inkcre_core.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from inkcre_core.business.graph_navigation_retrieval.tools import ( FIND_PATH_TOOL, GET_CONNECTED_COMPONENTS_TOOL, GET_ENTITY_NEIGHBORHOOD_TOOL, ) -from app.business.info_base.tools import GET_ENTITIES_TOOL, RETRIEVE_TOOL -from app.business.info_base.resolver import ResolverManager, register_core_resolvers -from app.business.info_base.resolver.tools import RESOLVER_TOOL -from app.business.job import JobManager -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.organization import ( +from inkcre_core.business.info_base.tools import GET_ENTITIES_TOOL, RETRIEVE_TOOL +from inkcre_core.business.info_base.resolver import ResolverManager, register_core_resolvers +from inkcre_core.business.info_base.resolver.tools import RESOLVER_TOOL +from inkcre_core.business.job import JobManager +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.organization import ( ANCHOR_EXISTING_REFERENT_TOOL, CREATE_SYNTHESIS_TOOL, DRAFT_GRAPH_TOOL, @@ -39,16 +39,16 @@ SUBMIT_GRAPH_TOOL, register_core_organization_behaviors, ) -from app.business.organization.duplicate_assertion import ( +from inkcre_core.business.organization.duplicate_assertion import ( DUPLICATE_ASSERTION_CONFIG_KEY, DUPLICATE_ASSERTION_CONFIG_SCHEMA, DUPLICATES_ASSERTION_RELATION, ) -from app.business.organization.evidence_stance import ( +from inkcre_core.business.organization.evidence_stance import ( EVIDENCE_STANCE_CONFIG_KEY, EVIDENCE_STANCE_CONFIG_SCHEMA, ) -from app.business.organization.jobs import ( +from inkcre_core.business.organization.jobs import ( DUPLICATE_ASSERTION_JOB_TYPE, EVIDENCE_STANCE_JOB_TYPE, REFERENT_ANCHORING_JOB_TYPE, @@ -57,39 +57,42 @@ SUPERSESSION_JOB_TYPE, SYNTHESIS_JOB_TYPE, ) -from app.business.organization.referent_anchoring import ( +from inkcre_core.business.organization.referent_anchoring import ( HAS_MENTION_RELATION, REFERENT_ANCHORING_CONFIG_KEY, REFERENT_ANCHORING_CONFIG_SCHEMA, REFERS_TO_RELATION, ) -from app.business.organization.refinement import ( +from inkcre_core.business.organization.refinement import ( REFINEMENT_CONFIG_KEY, REFINEMENT_CONFIG_SCHEMA, ) -from app.business.organization.rumination import ( +from inkcre_core.business.organization.rumination import ( RUMINATION_CONFIG_KEY, RUMINATION_CONFIG_SCHEMA, ) -from app.business.organization.supersession import ( +from inkcre_core.business.organization.supersession import ( SUPERSESSION_CONFIG_KEY, SUPERSESSION_CONFIG_SCHEMA, SUPERSESSION_BEHAVIOR, SUPERSEDES_RELATION, ) -from app.business.organization.synthesis import ( +from inkcre_core.business.organization.synthesis import ( SYNTHESIS_CONFIG_KEY, SYNTHESIS_CONFIG_SCHEMA, SYNTHESIS_RELATION, ) from tests.database import TestSession -from app.schemas import AgentDefinitionModel -from app.schemas.ai import AIModelModel, AIProviderModel, ChatCapability -from app.schemas.deployment_config import DeploymentConfigModel, DeploymentConfigView -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.job import JobModel, JobStatus -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.ai import AIModelModel, AIProviderModel, ChatCapability +from inkcre_core.schemas.deployment_config import ( + DeploymentConfigModel, + DeploymentConfigView, +) +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions from .corpus import CorpusManifest, load_manifest, read_artifact diff --git a/tests/organization/integration/test_behavior_graph.py b/tests/organization/integration/test_behavior_graph.py index c0f5cb1e..38f2d469 100644 --- a/tests/organization/integration/test_behavior_graph.py +++ b/tests/organization/integration/test_behavior_graph.py @@ -9,9 +9,9 @@ import pytest import sqlalchemy -from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager -from app.business.info_base.resolver import register_core_resolvers -from app.business.organization import ( +from inkcre_core.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.organization import ( DuplicateAssertionBehaviorResolver, EvidenceStanceBehaviorResolver, ExistingReferentAnchoringBehaviorResolver, @@ -21,7 +21,7 @@ register_core_organization_behaviors, ) from tests.database import TestSession -from app.schemas.info_base.block import BlockForm +from inkcre_core.schemas.info_base.block import BlockForm pytestmark = pytest.mark.skipif( @@ -222,8 +222,8 @@ def test_connected_component_reports_missing_and_bounded_incomplete_proof( def test_synthesis_rolls_back_block_and_basis_on_write_failure(async_runner, monkeypatch): - from app.persistence.info_base.repository import RelationRepository - from app.persistence.info_base.uow import graph_uow + from inkcre_core.persistence.info_base.repository import RelationRepository + from inkcre_core.persistence.info_base.uow import graph_uow marker = uuid.uuid4().hex diff --git a/tests/organization/integration/test_media_interpretation.py b/tests/organization/integration/test_media_interpretation.py index a0b0d999..7e538e89 100644 --- a/tests/organization/integration/test_media_interpretation.py +++ b/tests/organization/integration/test_media_interpretation.py @@ -1,7 +1,7 @@ """Real-storage vertical proof for media textualization and interpretation Jobs.""" -from app.business.deployment_config import DeploymentConfigService -from app.persistence.info_base.uow import graph_uow +from inkcre_core.business.deployment_config import DeploymentConfigService +from inkcre_core.persistence.info_base.uow import graph_uow from tests.database import read_relations @@ -13,34 +13,34 @@ import pytest import sqlalchemy -import app.business.organization_job # noqa: F401 # exact Job registration -from app.business.ai import AIManager -from app.business.cron import CronManager -from app.business.info_base.resolver import register_core_resolvers -from app.business.info_base.resolver.audio import ( +import inkcre_core.business.organization_job # noqa: F401 # exact Job registration +from inkcre_core.business.ai import AIManager +from inkcre_core.business.cron import CronManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.info_base.resolver.audio import ( AUDIO_RESOLVER_CONFIG_KEY, AUDIO_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.resolver.image import ( +from inkcre_core.business.info_base.resolver.image import ( IMAGE_RESOLVER_CONFIG_KEY, IMAGE_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.resolver.video import ( +from inkcre_core.business.info_base.resolver.video import ( VIDEO_RESOLVER_CONFIG_KEY, VIDEO_RESOLVER_CONFIG_SCHEMA, ) -from app.business.info_base.storage import StorageManager, WritableStorage -from app.business.job import JobManager -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.organization import SUBMIT_GRAPH_TOOL -from app.business.organization_media import ( +from inkcre_core.business.info_base.storage import StorageManager, WritableStorage +from inkcre_core.business.job import JobManager +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.organization import SUBMIT_GRAPH_TOOL +from inkcre_core.business.organization_media import ( MEDIA_INTERPRETATION_CONFIG_KEY, MEDIA_INTERPRETATION_CONFIG_SCHEMA, MEDIA_INTERPRETATION_JOB_TYPE, ) from tests.database import TestSession -from app.schemas import AgentDefinitionModel -from app.schemas.ai import ( +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, AssistantMessage, @@ -53,11 +53,11 @@ UserMessage, VideoContentPart, ) -from app.schemas.cron import CronModel -from app.schemas.deployment_config import DeploymentConfigModel -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.job import JobModel, JobStatus -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas.cron import CronModel +from inkcre_core.schemas.deployment_config import DeploymentConfigModel +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions pytestmark = pytest.mark.skipif( @@ -74,6 +74,15 @@ ) +@pytest.fixture +def job_execution(async_runner): + JobManager.start() + try: + yield + finally: + async_runner.run(JobManager.shutdown()) + + def _reset_runtime_rows() -> None: with TestSession() as db: db.connection().execute( @@ -133,6 +142,7 @@ def _related(block: int, role: str) -> tuple[BlockModel, ...]: def test_media_textualization_interpretation_and_lexical_recall( async_runner, + job_execution, monkeypatch: pytest.MonkeyPatch, semantic_content_assets: Path, ) -> None: diff --git a/tests/organization/integration/test_rumination_graph.py b/tests/organization/integration/test_rumination_graph.py index 6e3836d7..6368e509 100644 --- a/tests/organization/integration/test_rumination_graph.py +++ b/tests/organization/integration/test_rumination_graph.py @@ -1,6 +1,6 @@ """Real PostgreSQL context and draft-to-submit graph proof.""" -from app.business.deployment_config import DeploymentConfigService +from inkcre_core.business.deployment_config import DeploymentConfigService from tests.database import seed_block, seed_relation @@ -15,10 +15,10 @@ import sqlalchemy import sqlmodel -from app.business.agent import AgentManager -from app.business.ai import AIManager -from app.business.info_base.resolver import register_core_resolvers -from app.business.organization import ( +from inkcre_core.business.agent import AgentManager +from inkcre_core.business.ai import AIManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.organization import ( DRAFT_GRAPH_TOOL, RUMINATION_CONFIG_KEY, RUMINATION_CONFIG_SCHEMA, @@ -26,8 +26,8 @@ RuminationBehaviorResolver, ) from tests.database import TestSession -from app.schemas import AgentDefinitionModel -from app.schemas.ai import ( +from inkcre_core.schemas import AgentDefinitionModel +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, AssistantMessage, @@ -37,9 +37,9 @@ ToolCall, ToolResultMessage, ) -from app.schemas.deployment_config import DeploymentConfigModel -from app.schemas.info_base.block import BlockForm, BlockModel -from app.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.deployment_config import DeploymentConfigModel +from inkcre_core.schemas.info_base.block import BlockForm, BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel pytestmark = pytest.mark.skipif( diff --git a/tests/peer/integration/test_peer_runtime.py b/tests/peer/integration/test_peer_runtime.py index d75659e2..11a02f8d 100644 --- a/tests/peer/integration/test_peer_runtime.py +++ b/tests/peer/integration/test_peer_runtime.py @@ -1,5 +1,7 @@ """Real PostgreSQL proof for Peer snapshots, database-time lease and discovery.""" +from dataclasses import replace +from inkcre_core import current_core import datetime import os import time @@ -8,9 +10,9 @@ import pytest import sqlalchemy -from app.business.peer import PeerHTTPInbound, PeerManager +from inkcre_core.business.peer import PeerHTTPInbound, PeerManager from tests.database import TestSession -from app.schemas.peer import PEER_HTTP_PROTOCOL, PeerModel +from inkcre_core.schemas.peer import PEER_HTTP_PROTOCOL, PeerModel from app.version import APPLICATION_VERSION @@ -23,19 +25,22 @@ def test_real_snapshot_database_lease_and_candidate_filtering(monkeypatch, async_runner): - original_peer = PeerManager.get_current_peer_ref() - original_inbounds = PeerManager._INBOUNDS - original_outbounds = PeerManager._OUTBOUNDS + original_options = current_core().options + original_inbounds = current_core().inbounds + original_outbounds = current_core().outbounds local = uuid.uuid4() live = uuid.uuid4() expired = uuid.uuid4() malformed = uuid.uuid4() peer_ids = (local, live, expired, malformed) try: - monkeypatch.setattr(PeerManager, "_INBOUNDS", {}) - monkeypatch.setattr(PeerManager, "_OUTBOUNDS", {}) - monkeypatch.setattr("app.business.peer.main.settings.peer_id", local) - monkeypatch.setattr("app.business.peer.main.settings.peer_name", "integration-local") + monkeypatch.setattr(current_core(), "inbounds", {}) + monkeypatch.setattr(current_core(), "outbounds", {}) + monkeypatch.setattr( + current_core(), + "options", + replace(current_core().options, peer_id=local, peer_name="integration-local"), + ) async_runner.run(PeerManager.register_self()) PeerManager.setup_builtin_outbounds() @@ -132,9 +137,9 @@ def test_real_snapshot_database_lease_and_candidate_filtering(monkeypatch, async assert cleared is not None assert cleared.lease_expires_at is None finally: - monkeypatch.setattr(PeerManager, "_INBOUNDS", original_inbounds) - monkeypatch.setattr(PeerManager, "_OUTBOUNDS", original_outbounds) - monkeypatch.setattr("app.business.peer.main.settings.peer_id", original_peer) + monkeypatch.setattr(current_core(), "inbounds", original_inbounds) + monkeypatch.setattr(current_core(), "outbounds", original_outbounds) + monkeypatch.setattr(current_core(), "options", original_options) with TestSession() as db: db.connection().execute( sqlalchemy.text("DELETE FROM inkcre.peers WHERE id = ANY(:ids)"), @@ -148,11 +153,21 @@ def test_failed_cold_restore_preserves_intent_and_reports_not_ready(monkeypatch) from fastapi.testclient import TestClient import run - from app.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel + from inkcre_core.schemas.extension import ExtensionModel, ExtensionPeerParticipationModel + + from app.core import create_core peer = uuid.uuid4() name = f"issue121/missing-{peer.hex}" - monkeypatch.setattr("app.settings.settings.peer_id", peer) + # This lifespan closes its owned Core; do not close other tests' service instance. + service_core = create_core() + service_core.options = replace(service_core.options, peer_id=peer) + original_core = run.CORE + monkeypatch.setattr(run, "CORE", service_core) + monkeypatch.setattr(run.api_app, "middleware_stack", None) + for middleware in run.api_app.user_middleware: + if middleware.kwargs.get("core") is original_core: + monkeypatch.setitem(middleware.kwargs, "core", service_core) monkeypatch.setattr(run, "SKIP_EXTENSION_START", False) with TestSession() as db: db.add( diff --git a/tests/semantic_retrieval/acceptance/test_vertical_quality.py b/tests/semantic_retrieval/acceptance/test_vertical_quality.py index 1016e3bd..4204c000 100644 --- a/tests/semantic_retrieval/acceptance/test_vertical_quality.py +++ b/tests/semantic_retrieval/acceptance/test_vertical_quality.py @@ -1,9 +1,10 @@ """Black-box corpus ingestion and explicit real-provider semantic quality gates.""" from __future__ import annotations +from inkcre_core import current_core -from app.business.deployment_config import DeploymentConfigService -from app.business.info_base.commands import submit_stars +from inkcre_core.business.deployment_config import DeploymentConfigService +from inkcre_core.business.info_base.commands import submit_stars from tests.database import read_block, read_relations @@ -20,12 +21,12 @@ import sqlalchemy import sqlmodel -from app.business.ai import AIManager -from app.business.info_base.resolver import register_core_resolvers -from app.business.info_base.resolver.html import HTMLResolver -from app.business.info_base.storage import StorageManager -from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer -from app.business.organization import ( +from inkcre_core.business.ai import AIManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.info_base.resolver.html import HTMLResolver +from inkcre_core.business.info_base.storage import StorageManager +from inkcre_core.business.info_base.storage.postgresql import PostgreSQLBlobPointer +from inkcre_core.business.organization import ( DRAFT_GRAPH_TOOL, GET_DRAFT_GRAPH_SCHEMA_TOOL, RUMINATION_CONFIG_KEY, @@ -33,14 +34,14 @@ SUBMIT_GRAPH_TOOL, RuminationBehaviorResolver, ) -from app.business.semantic_retrieval import SemanticRetrievalManager -from app.business.job import JobManager -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager +from inkcre_core.business.semantic_retrieval import SemanticRetrievalManager +from inkcre_core.business.job import JobManager +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager from tests.database import TestSession -from app.schemas import AgentDefinitionModel +from inkcre_core.schemas import AgentDefinitionModel from tests.extensions.runtime_support import publish_extension -from app.schemas.ai import ( +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, AssistantMessage, @@ -53,16 +54,19 @@ ToolResultMessage, UserMessage, ) -from app.schemas.deployment_config import DeploymentConfigModel, DeploymentConfigView -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.semantic_retrieval import ( +from inkcre_core.schemas.deployment_config import ( + DeploymentConfigModel, + DeploymentConfigView, +) +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.semantic_retrieval import ( EmbeddingMaintenanceOptions, VectorRetrievalOptions, ) -from app.schemas.job import JobModel, JobStatus -from app.schemas.lexical_retrieval import LexicalMaintenanceOptions -from app.schemas.source import SourceModel +from inkcre_core.schemas.job import JobModel, JobStatus +from inkcre_core.schemas.lexical_retrieval import LexicalMaintenanceOptions +from inkcre_core.schemas.source import SourceModel from extensions.memos import Extension as MemosExtension from extensions.rss import Extension as RSSExtension from extensions.rss.reconcile import ( @@ -458,7 +462,7 @@ def _cleanup_corpus(run: CorpusRun) -> None: ) db.commit() for source_id in run.source_ids: - SourceManager.SOURCES.pop(source_id, None) + current_core().sources.pop(source_id, None) async def _close_corpus(run: CorpusRun) -> None: diff --git a/tests/semantic_retrieval/integration/test_semantic_retrieval.py b/tests/semantic_retrieval/integration/test_semantic_retrieval.py index 5a32a9b2..4245f35c 100644 --- a/tests/semantic_retrieval/integration/test_semantic_retrieval.py +++ b/tests/semantic_retrieval/integration/test_semantic_retrieval.py @@ -1,7 +1,7 @@ """Real PostgreSQL vertical proof for maintenance, freshness and ranking.""" -from app.business.deployment_config import DeploymentConfigService -from app.business.info_base.services import RelationService +from inkcre_core.business.deployment_config import DeploymentConfigService +from inkcre_core.business.info_base.services import RelationService import os @@ -10,25 +10,25 @@ import pytest import sqlalchemy -from app.business.ai import AIManager -from app.business.info_base.resolver import register_core_resolvers -from app.business.semantic_retrieval import ( +from inkcre_core.business.ai import AIManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.semantic_retrieval import ( EmbeddingProfileNotFoundError, SEMANTIC_RETRIEVAL_CONFIG_KEY, SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, SemanticRetrievalManager, ) from tests.database import TestSession -from app.schemas.ai import ( +from inkcre_core.schemas.ai import ( AIModelModel, AIProviderModel, BlockEmbeddingModel, EmbeddingCapability, EmbeddingProfileModel, ) -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.semantic_retrieval import ( +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.semantic_retrieval import ( EmbeddingMaintenanceOptions, VectorRetrievalOptions, ) diff --git a/tests/test_core_context.py b/tests/test_core_context.py new file mode 100644 index 00000000..3e221e22 --- /dev/null +++ b/tests/test_core_context.py @@ -0,0 +1,144 @@ +"""SDK bindings isolate concurrent deployments and preserve borrowed resources.""" + +import asyncio +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from inkcre_core import Core, CoreOptions, current_core +from inkcre_core.business.peer import PeerManager + + +def test_concurrent_instances_keep_their_identity_and_restore_nested_scope(async_runner): + first = Core("postgresql+psycopg://one:one@127.0.0.1:1/one") + second = Core("postgresql+psycopg://two:two@127.0.0.1:1/two") + caller = current_core() + + async def work(core, other): + with core.bind(): + assert PeerManager.get_current_peer_ref() == core.options.peer_id + with other.bind(): + assert current_core() is other + await asyncio.sleep(0) + assert current_core() is core + assert core.sources is not other.sources + assert core.active_jobs is not other.active_jobs + return PeerManager.get_current_peer_ref() + + async def run(): + try: + result = await asyncio.gather(work(first, second), work(second, first)) + assert result == [first.options.peer_id, second.options.peer_id] + finally: + await first.aclose() + await second.aclose() + + async_runner.run(run()) + assert current_core() is caller + with pytest.raises(RuntimeError, match="closed"): + with first.bind(): + pass + + +def test_borrowed_engine_is_not_disposed_and_options_are_explicit(async_runner): + engine = create_async_engine("postgresql+psycopg://borrowed:x@127.0.0.1:1/test") + options = CoreOptions(peer_name="external-consumer") + core = Core(engine, options=options) + with patch.object(type(engine), "dispose", new_callable=AsyncMock) as dispose: + async_runner.run(core.aclose()) + async_runner.run(core.aclose()) + dispose.assert_not_awaited() + async_runner.run(engine.dispose()) + assert core.options == options + assert replace(options, peer_name="another") != core.options + + +def test_owned_engine_is_disposed_once(async_runner): + core = Core("postgresql+psycopg://owned:x@127.0.0.1:1/test") + with patch.object(type(core.engine), "dispose", new_callable=AsyncMock) as dispose: + async_runner.run(core.aclose()) + async_runner.run(core.aclose()) + dispose.assert_awaited_once() + + +def test_legacy_imports_share_model_and_service_identity(): + from app.schemas.info_base.block import BlockModel as LegacyBlock + from inkcre_core.schemas.info_base.block import BlockModel + from app.business.info_base.main import InfoBaseManager as LegacyManager + from inkcre_core.business.info_base.main import InfoBaseManager + + assert LegacyBlock is BlockModel + assert LegacyManager is InfoBaseManager + + +def test_closing_one_core_drains_only_its_pending_work(async_runner): + first = Core("postgresql+psycopg://one:x@127.0.0.1:1/one") + second = Core("postgresql+psycopg://two:x@127.0.0.1:1/two") + + async def wait(): + await asyncio.Event().wait() + return True + + async def run(): + with first.bind(): + a = asyncio.create_task(wait()) + first.pending_jobs[1] = a + with second.bind(): + b = asyncio.create_task(wait()) + second.pending_jobs[2] = b + await first.aclose() + assert a.cancelled() + assert not b.done() + await second.aclose() + assert b.cancelled() + + async_runner.run(run()) + + +def test_asgi_binding_covers_lifespan_and_requests(async_runner): + from app.core import CoreMiddleware + + core = Core("postgresql+psycopg://test:x@127.0.0.1:1/test") + + async def application(scope, receive, send): + await asyncio.sleep(0) + assert current_core() is core + + async def receive(): + raise AssertionError("This probe does not receive HTTP data") + + async def send(message): + raise AssertionError("This probe does not send HTTP data") + + async def run(): + middleware = CoreMiddleware(application, core) + await middleware({"type": "lifespan"}, receive, send) + await middleware({"type": "http"}, receive, send) + await core.aclose() + + async_runner.run(run()) + + +def test_job_execution_keeps_logging_when_woken_by_a_quiet_poll(async_runner, monkeypatch): + from inkcre_core.business.job import JobManager + from inkcre_core.libs.obsrv.log_record import ENABLE_LOG_BACKEND, TRACE_ID + + async def execute(job_id): + assert job_id == 42 + assert ENABLE_LOG_BACKEND.get() + assert TRACE_ID.get() == "job.42" + return True + + monkeypatch.setattr(JobManager, "run", execute) + + async def run(): + token = ENABLE_LOG_BACKEND.set(False) + try: + assert await JobManager._run_traced(42) + assert not ENABLE_LOG_BACKEND.get() + finally: + ENABLE_LOG_BACKEND.reset(token) + + async_runner.run(run()) diff --git a/tests/test_extension_peer_status.py b/tests/test_extension_peer_status.py index 870d4648..f1836667 100644 --- a/tests/test_extension_peer_status.py +++ b/tests/test_extension_peer_status.py @@ -5,7 +5,7 @@ from app.business.extension.main import ExtensionHost from app.business.extension.state import ExtensionStore, InstalledExtension -from app.schemas.extension.main import ExtensionPeerStatus, SelectExtensionCommand +from inkcre_core.schemas.extension.main import ExtensionPeerStatus, SelectExtensionCommand def test_shared_protocol_preserves_a_distribution_unknown_to_python(): diff --git a/tests/test_extension_registry_config.py b/tests/test_extension_registry_config.py index 4b033439..e2b5ad82 100644 --- a/tests/test_extension_registry_config.py +++ b/tests/test_extension_registry_config.py @@ -2,14 +2,14 @@ import pytest -from app.business.deployment_config import DeploymentConfigService +from inkcre_core.business.deployment_config import DeploymentConfigService from app.business.extension.config import ( ExtensionRegistryDeploymentConfig, normalize_registry_origin, resolve_extension_registry_origin, ) -from app.business.peer import PeerManager -from app.schemas.peer import CorePeerConfig +from inkcre_core.business.peer import PeerManager +from inkcre_core.schemas.peer import CorePeerConfig from app.settings import settings diff --git a/tests/test_mcp_recall.py b/tests/test_mcp_recall.py index 4103eefc..c2018301 100644 --- a/tests/test_mcp_recall.py +++ b/tests/test_mcp_recall.py @@ -7,19 +7,22 @@ from mcp.client import Client from mcp.server import MCPServer -from app.business.info_base.resolver import register_core_resolvers -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.semantic_retrieval import SemanticRetrievalManager +from inkcre_core.business.info_base.resolver import register_core_resolvers +from inkcre_core.business.lexical_retrieval import LexicalRetrievalManager +from inkcre_core.business.semantic_retrieval import SemanticRetrievalManager from app.business.sink.mcp import MCPSink -from app.schemas.info_base.block import BlockModel -from app.schemas.info_base.relation import RelationModel -from app.schemas.lexical_retrieval import LexicalRetrievalMatch, LexicalRetrievalResult -from app.schemas.semantic_retrieval import ( +from inkcre_core.schemas.info_base.block import BlockModel +from inkcre_core.schemas.info_base.relation import RelationModel +from inkcre_core.schemas.lexical_retrieval import ( + LexicalRetrievalMatch, + LexicalRetrievalResult, +) +from inkcre_core.schemas.semantic_retrieval import ( BlockSemanticRetrievalMatch, RelationSemanticRetrievalMatch, SemanticRetrievalResult, ) -from app.schemas.sink import SinkModel +from inkcre_core.schemas.sink import SinkModel def test_recall_labels_preserve_evidence_and_unreadable_candidates(monkeypatch): diff --git a/utils/__init__.py b/utils/__init__.py index e69de29b..bf76c59d 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -0,0 +1,3 @@ +"""Legacy import; use inkcre_core for new consumers.""" + +from inkcre_core.utils import * diff --git a/utils/base.py b/utils/base.py index f0fd8aae..bfa51a4b 100644 --- a/utils/base.py +++ b/utils/base.py @@ -1,15 +1,10 @@ -import enum -import aiohttp -import ssl -import certifi -import pydantic +"""Legacy import; use inkcre_core for new consumers.""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -enum_serializer = pydantic.PlainSerializer( - lambda value: value.value if isinstance(value, enum.Enum) else value, - return_type=str, -) +sys.modules[__name__] = import_module("inkcre_core.utils.base") - -def AIOHTTP_CONNECTOR_GETTER(): - return aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where())) +if TYPE_CHECKING: + from inkcre_core.utils.base import * diff --git a/utils/datetime_.py b/utils/datetime_.py index a063f0ce..6113c7b5 100644 --- a/utils/datetime_.py +++ b/utils/datetime_.py @@ -1,96 +1,10 @@ -""" -author: Lan_zhijiang -date: 2024-11-10 -desc: 时间日期工具函数 -issues: +"""Legacy import; use inkcre_core for new consumers.""" -references: - https://git.hadream.ltd/anana/backend/common/-/wikis/Util/Datetime -""" +from typing import TYPE_CHECKING +from importlib import import_module +import sys -import datetime -import time +sys.modules[__name__] = import_module("inkcre_core.utils.datetime_") -timezone_delta: int = 8 # 默认东八区 - - -def get_timezone(timezone_delta: int = timezone_delta) -> datetime.timezone: - """ - 获取时区对象 - """ - return datetime.timezone(datetime.timedelta(hours=timezone_delta)) - - -def get_datetimez( - rfc3339: str | None = None, - timestamp: float | None = None, - iso8601: str | None = None, - timezone: datetime.timezone = get_timezone(), -) -> datetime.datetime: - """ - 获取当前时间对象(带时区) - - :param rfc3339: RFC3339格式时间字符串,如"2024-11-10T12:00:00+08:00" - :param timestamp: 时间戳(秒级浮点);如果使用此,时区固定为 UTC+0 - :param iso8601: ISO8601格式时间字符串,如"2024-11-10 12:00:00" - :param timezone: 时区对象,可以通过 `get_timezone(8)` 获取东八区时区 - :raise ValueError: 如果rfc3339或iso8601都未提供,则抛出异常 - """ - if timestamp: - return datetime.datetime.fromtimestamp(timestamp, tz=get_timezone(0)) - if rfc3339 or iso8601: - val = rfc3339 or iso8601 - if val is None: - raise ValueError("rfc3339 or iso8601 must be provided") - return datetime.datetime.fromisoformat(val) - return datetime.datetime.now(tz=timezone) - - -def get_datetime(timestamp: float | None = None) -> datetime.datetime: - """ - 获取当前时间对象(不带时区) - """ - if timestamp: - return datetime.datetime.fromtimestamp(timestamp) - return datetime.datetime.now() - - -def get_timestamp( - datetime_obj: datetime.datetime | None = None, rfc3339: str | None = None -) -> float: - """ - 获取时间戳(秒级浮点) - """ - if datetime_obj: - return datetime_obj.timestamp() - if rfc3339: - return datetime.datetime.fromisoformat(rfc3339).timestamp() - return time.time() - - -def get_rfc3339( - timestamp: float | None = None, datetime_obj: datetime.datetime | None = None -) -> str: - """ - 获取RFC3339格式时间 - """ - if timestamp: - return datetime.datetime.fromtimestamp(timestamp).isoformat() - elif datetime_obj: - return datetime_obj.isoformat() - - raise ValueError("timestamp or datetime_obj must be provided") - - -def format_datetime(datetime: datetime.datetime, format: str = "%Y-%m-%d %H:%M:%S") -> str: - """ - 格式化时间对象 - """ - return datetime.strftime(format) - - -def format_timestamp(timestamp: float, format: str = "%Y-%m-%d %H:%M:%S") -> str: - """ - 格式化时间戳 - """ - return datetime.datetime.fromtimestamp(timestamp).strftime(format) +if TYPE_CHECKING: + from inkcre_core.utils.datetime_ import * diff --git a/utils/sql.py b/utils/sql.py index 6670ac97..239dd895 100644 --- a/utils/sql.py +++ b/utils/sql.py @@ -1,32 +1,10 @@ -"""Sql utilities.""" +"""Legacy import; use inkcre_core for new consumers.""" -import typing -import sqlalchemy -from sqlalchemy.dialects.postgresql import JSONB +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.utils.sql") -def find_by_json_field(column: typing.Any, field_name: str, value: str): - """Find by JSON field value. - - :param column: sqlalchemy column - - Examples: - >>> find_by_json_field(YourTable.column_name, "email", "example@example.com") - """ - return sqlalchemy.cast(column, JSONB)[field_name].astext == value - - -def find_by_nested_json(column: typing.Any, parent: str, child: str, value: str): - """Find by nested JSON field value. - - :param column: sqlalchemy column - """ - return sqlalchemy.cast(column, JSONB)[(parent, child)].astext == value - - -def find_by_json_contains(column: typing.Any, json_obj: dict): - """Find by JSON contains. - - :param column: sqlalchemy column - """ - return sqlalchemy.cast(column, JSONB).contains(json_obj) +if TYPE_CHECKING: + from inkcre_core.utils.sql import * diff --git a/utils/types_.py b/utils/types_.py index 60f22cd0..913bfc99 100644 --- a/utils/types_.py +++ b/utils/types_.py @@ -1,10 +1,10 @@ -__all__ = ["Undefined", "_undefined"] +"""Legacy import; use inkcre_core for new consumers.""" -import enum +from typing import TYPE_CHECKING +from importlib import import_module +import sys +sys.modules[__name__] = import_module("inkcre_core.utils.types_") -class Undefined(enum.Enum): - _undefined = 0 - - -_undefined = Undefined._undefined +if TYPE_CHECKING: + from inkcre_core.utils.types_ import *