Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/capture.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
新增 Capture 共享回执和 swift_bundled 参与格式;原生 Capture 分发在本 Peer 事务内保存图、原始图片和幂等回执,Mac 收入不依赖 Python Peer 或 PostgREST。
8 changes: 4 additions & 4 deletions app/business/extension/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ...

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion app/database_contract/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -36,6 +36,7 @@
"block_embeddings",
"block_lexical_records",
"blocks",
"capture_receipts",
"peers",
"configs",
"extensions",
Expand Down
2 changes: 2 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"DeploymentConfigModel",
"SinkModel",
"SinkTypeModel",
"CaptureReceiptModel",
]

import sqlalchemy.orm
Expand Down Expand Up @@ -54,3 +55,4 @@
from .deployment_config import DeploymentConfigModel
from .lexical_retrieval import BlockLexicalRecordModel
from .sink import SinkModel, SinkTypeModel
from .capture import CaptureReceiptModel
37 changes: 37 additions & 0 deletions app/schemas/capture.py
Original file line number Diff line number Diff line change
@@ -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"),
),
)
17 changes: 11 additions & 6 deletions app/schemas/extension/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions docs/30-unit-tdd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/30-unit-tdd/capture-extension.md
Original file line number Diff line number Diff line change
@@ -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 收入。
1 change: 1 addition & 0 deletions extensions/capture/.changes/capture.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
提供可选的 Capture 图读取与内容解释,区分原材料、出处与附注;不执行采集提交,也不是 Mac 写入的前置条件。
5 changes: 5 additions & 0 deletions extensions/capture/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

此文件记录 Capture Python 分发的独立版本。发布条目由项目内变更片段生成。

<!-- towncrier release notes start -->
21 changes: 21 additions & 0 deletions extensions/capture/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions extensions/capture/__init__.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions extensions/capture/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" = "."
142 changes: 142 additions & 0 deletions extensions/capture/resolver.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading