diff --git a/.changes/capture.added.md b/.changes/capture.added.md new file mode 100644 index 0000000..43c3d5a --- /dev/null +++ b/.changes/capture.added.md @@ -0,0 +1 @@ +新增 Capture 共享回执和 swift_bundled 参与格式;原生 Capture 分发在本 Peer 事务内保存图、原始图片和幂等回执,Mac 收入不依赖 Python Peer 或 PostgREST。 diff --git a/app/business/extension/state.py b/app/business/extension/state.py index 4ccf4df..5bf6fae 100644 --- a/app/business/extension/state.py +++ b/app/business/extension/state.py @@ -33,7 +33,7 @@ class ExtensionParticipation(pydantic.BaseModel): name: str peer_id: uuid.UUID - distribution: typing.Literal["python", "module_federation"] | None + distribution: typing.Literal["python", "module_federation", "swift_bundled"] | None version: str enabled: bool config_schema: dict[str, typing.Any] | None = None @@ -85,7 +85,7 @@ async def select_participation( self, peer_id: uuid.UUID, name: str, - distribution: typing.Literal["python", "module_federation"], + distribution: typing.Literal["python", "module_federation", "swift_bundled"], version: str, ) -> ExtensionParticipation: ... @@ -129,7 +129,7 @@ def _participation( name=model.name, peer_id=model.peer_id, distribution=typing.cast( - typing.Literal["python", "module_federation"] | None, + typing.Literal["python", "module_federation", "swift_bundled"] | None, model.distribution, ), version=model.version, @@ -254,7 +254,7 @@ async def select_participation( self, peer_id: uuid.UUID, name: str, - distribution: typing.Literal["python", "module_federation"], + distribution: typing.Literal["python", "module_federation", "swift_bundled"], version: str, ) -> ExtensionParticipation: async with extension_uow() as repository: diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index 984ed84..74d9a93 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -4,7 +4,7 @@ CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "peer-jwt-signing-defaults-v1" +CONTRACT_REVISION = "capture-native-v1" PROTOCOL_SCHEMA = "inkcre" INTERNAL_SCHEMA = "inkcre_internal" @@ -36,6 +36,7 @@ "block_embeddings", "block_lexical_records", "blocks", + "capture_receipts", "peers", "configs", "extensions", diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 0e79a00..e33bc41 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -23,6 +23,7 @@ "DeploymentConfigModel", "SinkModel", "SinkTypeModel", + "CaptureReceiptModel", ] import sqlalchemy.orm @@ -54,3 +55,4 @@ from .deployment_config import DeploymentConfigModel from .lexical_retrieval import BlockLexicalRecordModel from .sink import SinkModel, SinkTypeModel +from .capture import CaptureReceiptModel diff --git a/app/schemas/capture.py b/app/schemas/capture.py new file mode 100644 index 0000000..0f65e8b --- /dev/null +++ b/app/schemas/capture.py @@ -0,0 +1,37 @@ +"""Shared receipts for committed capture commands; graph content remains authoritative.""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy +import sqlmodel + + +class CaptureReceiptModel(sqlmodel.SQLModel, table=True): + __tablename__ = "capture_receipts" # type: ignore + __table_args__ = ( + sqlalchemy.CheckConstraint( + "payload_digest ~ '^[0-9a-f]{64}$'", name="capture_receipt_digest" + ), + sqlalchemy.CheckConstraint( + "graph_digest ~ '^[0-9a-f]{64}$'", name="capture_receipt_graph_digest" + ), + ) + + submission_id: UUID = sqlmodel.Field(primary_key=True) + payload_digest: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + graph_digest: str = sqlmodel.Field( + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False) + ) + # A historical reference must survive deletion, or delayed retries resurrect content. + root_block_id: int = sqlmodel.Field(nullable=False) + committed_at: datetime = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column( + sqlalchemy.TIMESTAMP(timezone=True), + nullable=False, + server_default=sqlalchemy.text("CURRENT_TIMESTAMP"), + ), + ) diff --git a/app/schemas/extension/main.py b/app/schemas/extension/main.py index 724324b..966e85c 100644 --- a/app/schemas/extension/main.py +++ b/app/schemas/extension/main.py @@ -32,7 +32,7 @@ class SelectExtensionCommand(pydantic.BaseModel): action: typing.Literal["select"] extension: ExtensionName - distribution: typing.Literal["python", "module_federation"] + distribution: typing.Literal["python", "module_federation", "swift_bundled"] version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) @@ -77,11 +77,13 @@ class ExtensionPeerStatusRow(pydantic.BaseModel): name: ExtensionName peer_id: uuid.UUID - distribution: typing.Literal["python", "module_federation"] | None + distribution: typing.Literal["python", "module_federation", "swift_bundled"] | None version: str | None enabled: bool config_schema: dict[str, typing.Any] | None - supported_distributions: tuple[typing.Literal["python", "module_federation"], ...] + supported_distributions: tuple[ + typing.Literal["python", "module_federation", "swift_bundled"], ... + ] loaded_version: str | None running: bool restart_required: bool @@ -93,14 +95,16 @@ class ExtensionPeerStatus(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) peer_id: uuid.UUID - supported_distributions: tuple[typing.Literal["python", "module_federation"], ...] + supported_distributions: tuple[ + typing.Literal["python", "module_federation", "swift_bundled"], ... + ] extensions: tuple[ExtensionPeerStatusRow, ...] class SelectExtensionInput(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - distribution: typing.Literal["python", "module_federation"] + distribution: typing.Literal["python", "module_federation", "swift_bundled"] version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) @@ -169,7 +173,8 @@ class ExtensionPeerParticipationModel(sqlmodel.SQLModel, table=True): name="extension_peer_participations_version_canonical", ), sqlalchemy.CheckConstraint( - "distribution IS NULL OR distribution IN ('python', 'module_federation')", + "distribution IS NULL OR distribution IN " + "('python', 'module_federation', 'swift_bundled')", name="extension_peer_participations_distribution_valid", ), sqlalchemy.CheckConstraint( diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 8a5ef9c..a688b95 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -12,6 +12,7 @@ This directory owns expensive internal design truth for logical units delivered | [agent-query-sink.md](agent-query-sink.md) | Asynchronous Agent-composed query lifecycle, result delivery, and setup contract | | [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries | | [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries | +| [capture-extension.md](capture-extension.md) | Capture submission identity, original materials, shared graph, and atomic recovery | | [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization | | [twitter-extension.md](twitter-extension.md) | Twitter 共同身份、Bookmark 过渡、事务合并和按需媒体读取 | | [security-model.md](security-model.md) | Core-py realization of the shared security boundary model | diff --git a/docs/30-unit-tdd/capture-extension.md b/docs/30-unit-tdd/capture-extension.md new file mode 100644 index 0000000..f2561f9 --- /dev/null +++ b/docs/30-unit-tdd/capture-extension.md @@ -0,0 +1,7 @@ +# Capture Extension 与数据库 schema + +`inkcre/capture` 拥有跨平台材料、图、收入和恢复合同,见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。原生分发在本 Peer 内完成收入;Python 分发提供自身支持的读取能力,不成为其他分发的写入执行者。 + +本仓库交付权威数据库 schema/迁移。未发布候选 dcf44bc9af73 创建 capture_receipts,材料和图摘要均为必填,并允许 swift_bundled 参与格式。根 ID 不级联删除,回执可证明历史收入,避免延迟重试复活内容。没有 Capture 专用 HTTP API、SQL RPC 或 Core 业务 UoW,事务由原生 Capture 分发编排。 + +Python 分发保留 Capture/Attachment resolver,图片通过标准 storage 读取;不公告采集执行服务。共享图结构未改变,不需要 Python Peer 或 PostgREST 在线才能从 Mac 收入。 diff --git a/extensions/capture/.changes/capture.added.md b/extensions/capture/.changes/capture.added.md new file mode 100644 index 0000000..a6b6744 --- /dev/null +++ b/extensions/capture/.changes/capture.added.md @@ -0,0 +1 @@ +提供可选的 Capture 图读取与内容解释,区分原材料、出处与附注;不执行采集提交,也不是 Mac 写入的前置条件。 diff --git a/extensions/capture/CHANGELOG.md b/extensions/capture/CHANGELOG.md new file mode 100644 index 0000000..5a759af --- /dev/null +++ b/extensions/capture/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +此文件记录 Capture Python 分发的独立版本。发布条目由项目内变更片段生成。 + + diff --git a/extensions/capture/LICENSE b/extensions/capture/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/capture/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/capture/__init__.py b/extensions/capture/__init__.py new file mode 100644 index 0000000..2f2b156 --- /dev/null +++ b/extensions/capture/__init__.py @@ -0,0 +1,15 @@ +"""Optional Capture graph interpretation for a Python Peer.""" + +import pydantic + +from app.business.extension import ExtensionBase + + +class CaptureConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + +class Extension(ExtensionBase[CaptureConfig], ext_id="capture", config_cls=CaptureConfig): + @classmethod + def _init_resolvers(cls) -> None: + from . import resolver as resolver_module # noqa: F401 diff --git a/extensions/capture/pyproject.toml b/extensions/capture/pyproject.toml new file mode 100644 index 0000000..54f1e08 --- /dev/null +++ b/extensions/capture/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=80,<81"] +build-backend = "setuptools.build_meta" + +[project] +name = "inkcre-ext-capture" +version = "0.1.0" +description = "Read shared Capture graphs in Python peers" +requires-python = ">=3.12,<3.13" +license = "MIT" +license-files = ["LICENSE"] +dependencies = [ + "pydantic>=2.11.7,<3.0.0", +] + +[project.entry-points."inkcre.core.extensions"] +capture = "extensions.capture:Extension" + +[tool.inkcre-extension] +name = "inkcre/capture" +nickname = "随手收集" +host-sdk = "core-py" +host-sdk-version = ">=0.3.3 <0.4.0" + +[tool.setuptools] +packages = ["extensions.capture"] + +[tool.setuptools.package-dir] +"extensions.capture" = "." diff --git a/extensions/capture/resolver.py b/extensions/capture/resolver.py new file mode 100644 index 0000000..c7dae35 --- /dev/null +++ b/extensions/capture/resolver.py @@ -0,0 +1,142 @@ +"""Interpret source roles from the shared graph without depending on the capture Peer.""" + +import re + +from app.business.info_base.resolver import Resolver, ResolverManager, TextProjectionContext +from app.business.info_base.resolver.label import format_label +from app.business.info_base.services import BlockService + +from .schema import ( + ATTACHMENT_RESOLVER, + CAPTURE_RESOLVER, + AttachmentMetadata, + CaptureMetadata, + SolvedAttachment, + SolvedCapture, + SolvedMaterial, +) + + +class AttachmentResolver(Resolver[SolvedAttachment, str], rso_type=ATTACHMENT_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedAttachment: + metadata = AttachmentMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + links = [relation for relation in relations if relation.content == "content"] + if len(links) != 1: + raise ValueError("采集附件必须包含一条 content 关系") + block = await BlockService.get(links[0].to_) + if block is None or block.resolver != "core.image.v1": + raise ValueError("采集附件的图片内容不存在或类型不符") + return SolvedAttachment(metadata=metadata, content_block_id=links[0].to_) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + block = await BlockService.get(solved.content_block_id) + if block is None: + raise ValueError("采集图片已不存在") + content = await ResolverManager.get(block).get_text( + context=context, refresh=refresh, materialize_missing=materialize_missing + ) + return "\n".join(filter(None, (solved.metadata.filename, content))) + + async def get_label(self, *, refresh: bool = False) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_label("采集图片", solved.metadata.filename or solved.metadata.mime_type) + + +class CaptureResolver(Resolver[SolvedCapture, str], rso_type=CAPTURE_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedCapture: + metadata = CaptureMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + material_ids: dict[int, int] = {} + annotations: list[int] = [] + for relation in relations: + match = re.fullmatch(r"material:(0|[1-9][0-9]*)", relation.content) + if match: + index = int(match[1]) + if index in material_ids: + raise ValueError("采集材料存在重复序号") + material_ids[index] = relation.to_ + elif relation.content.startswith("material:"): + raise ValueError("采集材料序号无效") + elif relation.content == "annotation": + annotations.append(relation.to_) + if sorted(material_ids) != list(range(len(material_ids))) or len(annotations) > 1: + raise ValueError("采集材料顺序或附注关系无效") + materials = [] + for index in range(len(material_ids)): + block = await BlockService.get(material_ids[index]) + if block is None or block.resolver not in ("core.text.v1", ATTACHMENT_RESOLVER): + raise ValueError("采集材料已不存在或类型不符") + materials.append( + SolvedMaterial(block_id=material_ids[index], resolver=block.resolver) + ) + if annotations: + block = await BlockService.get(annotations[0]) + if block is None or block.resolver != "core.text.v1": + raise ValueError("采集附注已不存在或类型不符") + return SolvedCapture( + metadata=metadata, + materials=tuple(materials), + annotation_block_id=annotations[0] if annotations else None, + ) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + origin = solved.metadata.origin + parts = [f"采集时间:{solved.metadata.captured_at.isoformat()}"] + if origin: + parts.extend( + f"{label}:{value}" + for label, value in ( + ("来源应用", origin.application_id), + ("来源标题", origin.title), + ("来源地址", origin.url), + ) + if value + ) + items = [(item.block_id, "材料") for item in solved.materials] + if solved.annotation_block_id is not None: + items.append((solved.annotation_block_id, "用户附注")) + for block_id, label in items: + block = await BlockService.get(block_id) + if block is None: + raise ValueError("采集内容已不存在") + text = await ResolverManager.get(block).get_text( + context=context, refresh=refresh, materialize_missing=materialize_missing + ) + parts.append(f"{label}:\n{text}") + return "\n\n".join(parts) + + async def get_label(self, *, refresh: bool = False) -> str: + metadata = CaptureMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + return format_label( + "随手收集", + (metadata.origin.title if metadata.origin else None) or metadata.entry, + ) diff --git a/extensions/capture/schema.py b/extensions/capture/schema.py new file mode 100644 index 0000000..26f0c36 --- /dev/null +++ b/extensions/capture/schema.py @@ -0,0 +1,53 @@ +"""Capture v1 persisted metadata interpreted by the optional Python reader.""" + +from datetime import UTC +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator + + +CAPTURE_RESOLVER = "extensions.capture.capture.v1" +ATTACHMENT_RESOLVER = "extensions.capture.attachment.v1" +ImageMIME = Literal["image/png", "image/jpeg", "image/gif", "image/webp"] + + +class ContractModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CaptureOrigin(ContractModel): + application_id: str | None = Field(default=None, max_length=1024) + title: str | None = Field(default=None, max_length=4096) + url: str | None = Field(default=None, max_length=8192) + + +class CaptureMetadata(ContractModel): + captured_at: AwareDatetime + entry: Literal["selection", "paste", "screenshot", "manual"] + origin: CaptureOrigin | None = None + + @field_validator("captured_at") + @classmethod + def utc_time(cls, value: AwareDatetime) -> AwareDatetime: + return value.astimezone(UTC) + + +class AttachmentMetadata(ContractModel): + mime_type: ImageMIME + filename: str | None = Field(default=None, max_length=512) + + +class SolvedMaterial(ContractModel): + block_id: int + resolver: str + + +class SolvedAttachment(ContractModel): + metadata: AttachmentMetadata + content_block_id: int + + +class SolvedCapture(ContractModel): + metadata: CaptureMetadata + materials: tuple[SolvedMaterial, ...] + annotation_block_id: int | None diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 181e3f6..773a103 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -24,6 +24,7 @@ "d41cc84db0c5_add_job_abort_request.py": "fc1fe04942e95c7b8c5f43dbcf369bb88479cb83f8e7dc58528f08caf7a536d2", "d4e6f8a0b2c3_merge_extension_setup_main.py": "2073996504ae5d5cb5d8cc0d468d3c9e93870a44bd5f454986662c22da48fba8", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", + "dcf44bc9af73_add_capture_receipts.py": "8f1073f8d96da2618be255565d6d13907a701c5e294df13761069df9269a4b5a", "e1f4a5b6c7d8_migrate_memos_attachment_v2.py": "5f3691149e06b28378f79eed7eaacd1c415ee6c92fb83b3b5745a429346d5e01", "e5a01f9e69ef_init.py": "a10de6c0818abc593951a34e51a974ffcd999f60ba5afc441f87ac6cef8d869e", "e6a1b2c3d4f5_extension_peer_participation_v2.py": "bf71d955b1e3e27015f412815d020666157eda6af78ed0472ca2b7d38ada872f", diff --git a/migrations/versions/dcf44bc9af73_add_capture_receipts.py b/migrations/versions/dcf44bc9af73_add_capture_receipts.py new file mode 100644 index 0000000..47e97d2 --- /dev/null +++ b/migrations/versions/dcf44bc9af73_add_capture_receipts.py @@ -0,0 +1,48 @@ +"""add_capture_receipts + +Revision ID: dcf44bc9af73 +Revises: 09a921a95314 +Create Date: 2026-10-21 01:38:42.379663 + +""" +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'dcf44bc9af73' +down_revision: str | Sequence[str] | None = '09a921a95314' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('capture_receipts', + sa.Column('submission_id', sa.Uuid(), nullable=False), + sa.Column('payload_digest', sa.Text(), nullable=False), + sa.Column('graph_digest', sa.Text(), nullable=False), + sa.Column('root_block_id', sa.Integer(), nullable=False), + sa.Column('committed_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.CheckConstraint("graph_digest ~ '^[0-9a-f]{64}$'", name='capture_receipt_graph_digest'), + sa.CheckConstraint("payload_digest ~ '^[0-9a-f]{64}$'", name='capture_receipt_digest'), + sa.PrimaryKeyConstraint('submission_id'), + schema='inkcre' + ) + op.drop_constraint('extension_peer_participations_distribution_valid', 'extension_peer_participations', schema='inkcre', type_='check') + op.create_check_constraint('extension_peer_participations_distribution_valid', 'extension_peer_participations', "distribution IS NULL OR distribution IN ('python','module_federation','swift_bundled')", schema='inkcre') + op.execute("UPDATE inkcre_internal.contract_state SET contract_revision='capture-native-v1',updated_at=CURRENT_TIMESTAMP WHERE singleton") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('capture_receipts', schema='inkcre') + op.drop_constraint('extension_peer_participations_distribution_valid', 'extension_peer_participations', schema='inkcre', type_='check') + op.create_check_constraint('extension_peer_participations_distribution_valid', 'extension_peer_participations', "distribution IS NULL OR distribution IN ('python','module_federation')", schema='inkcre') + op.execute("UPDATE inkcre_internal.contract_state SET contract_revision='peer-jwt-signing-defaults-v1',updated_at=CURRENT_TIMESTAMP WHERE singleton") + # ### end Alembic commands ###