Skip to content
Merged
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 图的 Python 分发;共享参与协议移除分发格式白名单。Mac 收入使用标准图与 Storage,不依赖 Python Peer 或 Capture 专用表。
11 changes: 4 additions & 7 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: str | 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: str,
version: str,
) -> ExtensionParticipation: ...

Expand Down Expand Up @@ -128,10 +128,7 @@ def _participation(
return ExtensionParticipation(
name=model.name,
peer_id=model.peer_id,
distribution=typing.cast(
typing.Literal["python", "module_federation"] | None,
model.distribution,
),
distribution=model.distribution,
version=model.version,
enabled=model.enabled,
config_schema=(
Expand Down Expand Up @@ -254,7 +251,7 @@ async def select_participation(
self,
peer_id: uuid.UUID,
name: str,
distribution: typing.Literal["python", "module_federation"],
distribution: str,
version: str,
) -> ExtensionParticipation:
async with extension_uow() as repository:
Expand Down
2 changes: 1 addition & 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 = "extension-distribution-open-v1"

PROTOCOL_SCHEMA = "inkcre"
INTERNAL_SCHEMA = "inkcre_internal"
Expand Down
15 changes: 6 additions & 9 deletions app/schemas/extension/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@


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])?$"
Expand All @@ -32,7 +33,7 @@ class SelectExtensionCommand(pydantic.BaseModel):

action: typing.Literal["select"]
extension: ExtensionName
distribution: typing.Literal["python", "module_federation"]
distribution: NativeDistribution
version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN)


Expand Down Expand Up @@ -77,11 +78,11 @@ class ExtensionPeerStatusRow(pydantic.BaseModel):

name: ExtensionName
peer_id: uuid.UUID
distribution: typing.Literal["python", "module_federation"] | None
distribution: NativeDistribution | None
version: str | None
enabled: bool
config_schema: dict[str, typing.Any] | None
supported_distributions: tuple[typing.Literal["python", "module_federation"], ...]
supported_distributions: tuple[NativeDistribution, ...]
loaded_version: str | None
running: bool
restart_required: bool
Expand All @@ -93,14 +94,14 @@ 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[NativeDistribution, ...]
extensions: tuple[ExtensionPeerStatusRow, ...]


class SelectExtensionInput(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid", frozen=True)

distribution: typing.Literal["python", "module_federation"]
distribution: NativeDistribution
version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN)


Expand Down Expand Up @@ -168,10 +169,6 @@ class ExtensionPeerParticipationModel(sqlmodel.SQLModel, table=True):
f"version ~ '{EXTENSION_SEMVER_PATTERN}'",
name="extension_peer_participations_version_canonical",
),
sqlalchemy.CheckConstraint(
"distribution IS NULL OR distribution IN ('python', 'module_federation')",
name="extension_peer_participations_distribution_valid",
),
sqlalchemy.CheckConstraint(
"config_schema IS NULL OR jsonb_typeof(config_schema) = 'object'",
name="extension_peer_participations_config_schema_object",
Expand Down
2 changes: 2 additions & 0 deletions docs/30-unit-tdd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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 |
| [local-files-extension.md](local-files-extension.md) | 本地文件资料、原件状态与正文的读取分发 |
| [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
11 changes: 11 additions & 0 deletions docs/30-unit-tdd/capture-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Capture Extension 与数据库 schema

`inkcre/capture` 拥有跨平台材料、图、收入和恢复合同,见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。原生分发在本 Peer 内完成收入;Python 分发提供自身支持的读取能力,不成为其他分发的写入执行者。

Capture 不增加专用共享表、HTTP API 或 SQL RPC。Mac 将草稿、待办和确认结果保存在 SQLite;收入使用标准 Block/Relation/Storage,并通过根 metadata 的提交身份和材料摘要恢复。Python 分发只读取同一图合同,不参与 Mac 的收入事务。

数据库的 Extension participation 分发标识是开放字符串;宿主只在加载边界验证自己支持的格式。未发布候选 dcf44bc9af73 仅移除原 python/module_federation 白名单,不再创建 Capture 表。

Python 分发保留 Capture/Attachment resolver,图片通过标准 storage 读取;不公告采集执行服务。共享图结构未改变,不需要 Python Peer 或 PostgREST 在线才能从 Mac 收入。

迁移回退会恢复旧分发约束;信息库已经保存其他原生分发时,数据库会拒绝恢复该约束。回退应保留参与记录并处理部署版本,不得通过删除记录或把分发标识改写为 Python 来强行完成。
5 changes: 5 additions & 0 deletions docs/30-unit-tdd/local-files-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 本地文件读取分发

`extensions/local_files` 交付 `inkcre/local-files` 的 Python 读取能力,解释 `extensions.local_files.file.v1` 与 `body` 关系。源合同在 Hub 的 `20-product-tdd/local-files-contract.md`。目录监视、文件提取及共享写入由具备文件权限的执行分发完成;Python 分发不提供收入 API,也不注册目录 Source 占位类。

`LocalFileResolver` 读取元数据并校验唯一的 `core.text.v1` 正文,允许首次失败时尚无正文。原件失效或当前提取失败仍返回最后成功正文,同时保留状态和提取范围。位置只作为文字解释,不访问本机路径,不尝试远端取文件,也不物化其他副本。此功能复用已有 Source/Job/图模型,没有新增迁移。
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