From 21dc30b8e0ff0a1882019223a05b2d1653677515 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 27 Oct 2026 18:58:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(shared):=20=E6=9B=B4=E6=96=B0=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=A0=BC=E5=BC=8F=E4=B8=8E=20Local=20Files=20v2=20?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index 311c69b..e748a56 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 311c69bdada4fb291cc7fae0ded5caefa4fdc867 +Subproject commit e748a564fb7f2a066fca98f826c9130d32e888c8 From 9debe16556223408f0c7db3ed8f17c4126a42d0e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 27 Oct 2026 19:03:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(info-base):=20=E8=A7=A3=E9=87=8A=20Mar?= =?UTF-8?q?kdown=20=E4=B8=8E=20Local=20Files=20=E7=BB=93=E6=9E=84=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留 Markdown 原文并注册普通格式 resolver - Local Files v2 按实际 HTML 或 Markdown 格式读取,兼容 v1 - HTML 文字投影保留媒体与字幕地址 --- .changes/hister-content.added.md | 1 + app/business/info_base/AGENTS.md | 2 +- app/business/info_base/resolver/AGENTS.md | 8 ++-- app/business/info_base/resolver/__init__.py | 2 + app/business/info_base/resolver/bootstrap.py | 2 + app/business/info_base/resolver/contracts.py | 2 + app/business/info_base/resolver/html.py | 9 ++++ app/business/info_base/resolver/main.py | 1 + app/business/info_base/resolver/markdown.py | 35 ++++++++++++++ app/version.py | 2 +- docs/30-unit-tdd/local-files-extension.md | 4 +- .../.changes/hister-content.added.md | 1 + extensions/local_files/pyproject.toml | 2 +- extensions/local_files/resolver.py | 33 +++++++++++-- extensions/local_files/schema.py | 1 + tests/extensions/local_files/test_reader.py | 46 +++++++++++++++++++ tests/info_base/test_markdown.py | 37 +++++++++++++++ 17 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 .changes/hister-content.added.md create mode 100644 app/business/info_base/resolver/markdown.py create mode 100644 extensions/local_files/.changes/hister-content.added.md create mode 100644 tests/info_base/test_markdown.py diff --git a/.changes/hister-content.added.md b/.changes/hister-content.added.md new file mode 100644 index 0000000..56b4da2 --- /dev/null +++ b/.changes/hister-content.added.md @@ -0,0 +1 @@ +增加普通 Markdown 源文解释与文字能力,并保持 HTML、纯文等既有内容合同。 diff --git a/app/business/info_base/AGENTS.md b/app/business/info_base/AGENTS.md index 1c7894f..7245ba4 100644 --- a/app/business/info_base/AGENTS.md +++ b/app/business/info_base/AGENTS.md @@ -55,7 +55,7 @@ ## Resolver Boundary - `ResolverManager` 只按 exact ID select/register;unknown ID 明确失败,不存在 default decoder fallback。 -- Core bootstrap 独立于 extension loading,注册九个 `core..v1` semantic resolver。 +- Core bootstrap 独立于 extension loading,注册十个 `core..v1` semantic resolver。 - Duplicate registration:同 class 重复注册 idempotent,不同 class 抢同 ID 抛错。 - `get_text()` 的 unsupported、supported-null 与 authored-empty 必须保持可区分;不要添加 use-specific `get_*_for_embedding()` projection。 diff --git a/app/business/info_base/resolver/AGENTS.md b/app/business/info_base/resolver/AGENTS.md index b75ce98..690a6e1 100644 --- a/app/business/info_base/resolver/AGENTS.md +++ b/app/business/info_base/resolver/AGENTS.md @@ -14,11 +14,11 @@ ## 关键文件 - `main.py`:exact registry、resolver instance、relation/solved caches; -- `contracts.py`:九个 core IDs 与 typed failures; +- `contracts.py`:十个 core IDs 与 typed failures; - `bootstrap.py`:core decoder registration; - `inspection.py`:bounded byte inspection helpers; - `label.py`:shared whitespace normalization and 96-code-point identifier bound; -- `{text,html,image,audio,video,pdf,epub,zip,file}.py`:exact core contract versions。 +- `{text,html,markdown,image,audio,video,pdf,epub,zip,file}.py`:exact core contract versions。 ## Construction And Registration @@ -30,7 +30,7 @@ 使 persisted Block decoder 在 active Extension route/resource 关闭后仍可用;exact version replacement 仍以进程 restart 为边界。 - `register_core_resolvers()` 独立于 extension sync/start;extension disabled 不能让 core decoder 消失。 -- Resolver ID 必须 namespaced + versioned。共享 semantic IDs 固定为九个 `core..v1`;不保留 bare alias。 +- Resolver ID 必须 namespaced + versioned。共享 semantic IDs 固定为十个 `core..v1`;不保留 bare alias。 - Agent graph drafting 是 opt-in capability:subclass 同时声明 `draft_description`、`draft_input_model` 并实现 `create_graph(input) -> StarsGraphForm`。普通 decode-capable Resolver 不会因此自动成为 draft-capable。 @@ -53,7 +53,7 @@ Concrete resolver 必须实现: `UnsupportedResolverCapability` 表示 exact contract 没有该能力;`None` 表示能力存在但当前 block 没有有意义的 result;empty string 只能来自真实 authored/derived empty。`UnknownResolverError` 是另一类 registry failure。 -九个 core semantic resolvers 中,text/HTML 提供 text;image/audio/video/PDF/EPUB/ZIP/file 在没有真实 +十个 core semantic resolvers 中,text/HTML/Markdown 提供 text;image/audio/video/PDF/EPUB/ZIP/file 在没有真实 OCR/STT/extraction 能力前显式 unsupported,不用 metadata 伪装正文。Embedding/retrieval owner 只消费这个 通用 projection,不要求 resolver 理解 AI model/profile。 diff --git a/app/business/info_base/resolver/__init__.py b/app/business/info_base/resolver/__init__.py index 80b815b..edf45ec 100644 --- a/app/business/info_base/resolver/__init__.py +++ b/app/business/info_base/resolver/__init__.py @@ -43,6 +43,7 @@ "PDFResolver", "VideoResolver", "TextResolver", + "MarkdownResolver", "HTMLResolver", "ZIPResolver", ] @@ -54,5 +55,6 @@ 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/app/business/info_base/resolver/bootstrap.py b/app/business/info_base/resolver/bootstrap.py index 7e40246..862a237 100644 --- a/app/business/info_base/resolver/bootstrap.py +++ b/app/business/info_base/resolver/bootstrap.py @@ -12,11 +12,13 @@ def register_core_resolvers() -> None: 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, diff --git a/app/business/info_base/resolver/contracts.py b/app/business/info_base/resolver/contracts.py index c3b1f14..fbcfa89 100644 --- a/app/business/info_base/resolver/contracts.py +++ b/app/business/info_base/resolver/contracts.py @@ -10,6 +10,7 @@ CoreResolverID: TypeAlias = Literal[ "core.text.v1", + "core.markdown.v1", "core.html.v1", "core.image.v1", "core.audio.v1", @@ -22,6 +23,7 @@ CORE_RESOLVER_IDS: tuple[CoreResolverID, ...] = ( "core.text.v1", + "core.markdown.v1", "core.html.v1", "core.image.v1", "core.audio.v1", diff --git a/app/business/info_base/resolver/html.py b/app/business/info_base/resolver/html.py index 4977f90..ad50648 100644 --- a/app/business/info_base/resolver/html.py +++ b/app/business/info_base/resolver/html.py @@ -44,6 +44,15 @@ async def get_text( 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: diff --git a/app/business/info_base/resolver/main.py b/app/business/info_base/resolver/main.py index a505390..5782078 100644 --- a/app/business/info_base/resolver/main.py +++ b/app/business/info_base/resolver/main.py @@ -221,6 +221,7 @@ def match_media_type(cls, media_type: str | None) -> ResolverType | 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", diff --git a/app/business/info_base/resolver/markdown.py b/app/business/info_base/resolver/markdown.py new file mode 100644 index 0000000..e381cde --- /dev/null +++ b/app/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/app/version.py b/app/version.py index 20eef61..9bae26f 100644 --- a/app/version.py +++ b/app/version.py @@ -3,7 +3,7 @@ from pathlib import Path import tomllib -CORE_VERSION = "0.3.3" +CORE_VERSION = "0.3.4" APPLICATION_VERSION = tomllib.loads( (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8") )["project"]["version"] diff --git a/docs/30-unit-tdd/local-files-extension.md b/docs/30-unit-tdd/local-files-extension.md index 081071d..8efcc4e 100644 --- a/docs/30-unit-tdd/local-files-extension.md +++ b/docs/30-unit-tdd/local-files-extension.md @@ -1,5 +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 占位类。 +`extensions/local_files` 交付 `inkcre/local-files` 的 Python 读取能力,解释 `extensions.local_files.file.v1`/`file.v2` 与 `body` 关系。源合同在 Hub 的 `20-product-tdd/local-files-contract.md`。目录监视、文件提取及共享写入由具备文件权限的执行分发完成;Python 分发不提供收入 API,也不注册目录 Source 占位类。 -`LocalFileResolver` 读取元数据并校验唯一的 `core.text.v1` 正文,允许首次失败时尚无正文。原件失效或当前提取失败仍返回最后成功正文,同时保留状态和提取范围。位置只作为文字解释,不访问本机路径,不尝试远端取文件,也不物化其他副本。此功能复用已有 Source/Job/图模型,没有新增迁移。 +`LocalFileResolver` 保持 v1 的唯一 inline `core.text.v1` 正文合同;`LocalFileV2Resolver` 按正文实际采用的 text、HTML 或 Markdown resolver 生成文字投影,允许首次失败时尚无正文。原件失效或当前提取失败仍返回最后成功正文,同时保留状态和提取范围。位置只作为文字解释,不访问本机路径,不尝试远端取文件,也不物化其他副本。此功能复用已有 Source/Job/图模型,没有新增迁移。 diff --git a/extensions/local_files/.changes/hister-content.added.md b/extensions/local_files/.changes/hister-content.added.md new file mode 100644 index 0000000..c98bf8b --- /dev/null +++ b/extensions/local_files/.changes/hister-content.added.md @@ -0,0 +1 @@ +读取 Local Files v2 的 HTML、Markdown 正文,同时保持 v1 文字合同和失败保留。 diff --git a/extensions/local_files/pyproject.toml b/extensions/local_files/pyproject.toml index 18272a1..c575847 100644 --- a/extensions/local_files/pyproject.toml +++ b/extensions/local_files/pyproject.toml @@ -20,7 +20,7 @@ local_files = "extensions.local_files:Extension" name = "inkcre/local-files" nickname = "本地文件" host-sdk = "core-py" -host-sdk-version = ">=0.3.3 <0.4.0" +host-sdk-version = ">=0.3.4 <0.4.0" [tool.setuptools] packages = ["extensions.local_files"] diff --git a/extensions/local_files/resolver.py b/extensions/local_files/resolver.py index e946734..d9c5f01 100644 --- a/extensions/local_files/resolver.py +++ b/extensions/local_files/resolver.py @@ -1,15 +1,18 @@ """Read persisted text without accessing the original Peer or executing a Source.""" from pathlib import PurePosixPath +from typing import ClassVar -from app.business.info_base.resolver import Resolver, TextProjectionContext +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 FILE_RESOLVER, FileMetadata, SolvedFile, file_location +from .schema import FILE_RESOLVER, FILE_RESOLVER_V2, FileMetadata, SolvedFile, file_location class LocalFileResolver(Resolver[SolvedFile, str], rso_type=FILE_RESOLVER): + body_resolvers: ClassVar[tuple[str, ...]] = ("core.text.v1",) + async def _get_solved_content( self, *, refresh: bool = False, materialize_missing: bool = True ) -> SolvedFile: @@ -23,9 +26,23 @@ async def _get_solved_content( if not bodies: return SolvedFile(metadata=metadata, body_id=None, text=None) block = await BlockService.get(bodies[0].to_) - if block is None or block.resolver != "core.text.v1" or block.storage is not None: + if ( + block is None + or block.resolver not in self.body_resolvers + or block.storage is not None + ): raise ValueError("文件正文不存在或类型不符") - return SolvedFile(metadata=metadata, body_id=block.id, text=block.content) + return SolvedFile( + metadata=metadata, + body_id=block.id, + text=( + block.content + if block.resolver == "core.text.v1" + else await ResolverManager.get(block).get_text( + refresh=refresh, materialize_missing=materialize_missing + ) + ), + ) async def get_text( self, @@ -51,3 +68,11 @@ async def get_label(self, *, refresh: bool = False) -> str: metadata = FileMetadata.model_validate_json(await self.get_raw_content(refresh=refresh)) _, path = file_location(metadata.location) return format_label("本地文件", PurePosixPath(path).name) + + +class LocalFileV2Resolver(LocalFileResolver, rso_type=FILE_RESOLVER_V2): + body_resolvers: ClassVar[tuple[str, ...]] = ( + "core.text.v1", + "core.html.v1", + "core.markdown.v1", + ) diff --git a/extensions/local_files/schema.py b/extensions/local_files/schema.py index 73f4261..ce908a5 100644 --- a/extensions/local_files/schema.py +++ b/extensions/local_files/schema.py @@ -7,6 +7,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator FILE_RESOLVER = "extensions.local_files.file.v1" +FILE_RESOLVER_V2 = "extensions.local_files.file.v2" def file_location(value: str) -> tuple[UUID, str]: diff --git a/tests/extensions/local_files/test_reader.py b/tests/extensions/local_files/test_reader.py index 44bc848..2f9d5c8 100644 --- a/tests/extensions/local_files/test_reader.py +++ b/tests/extensions/local_files/test_reader.py @@ -64,3 +64,49 @@ def test_failed_current_file_keeps_successful_body_readable(monkeypatch, async_r monkeypatch.setattr(reader, "get_relations", AsyncMock(return_value=())) with pytest.raises(ValueError, match="正文关系"): async_runner.run(reader.get_text()) + + +@pytest.mark.parametrize( + "resolver,content,expected", + [ + ("core.markdown.v1", "# 标题\n\n``", "``"), + ( + "core.html.v1", + '

标题

', + "https://example.org/captions.vtt", + ), + ], +) +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 + + register_core_resolvers() + now = datetime.datetime.now(datetime.UTC) + metadata = FileMetadata( + location="inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/Users/a/note.md", + format="markdown", + available=True, + observed_at=now, + ) + reader = object.__new__(LocalFileV2Resolver) + monkeypatch.setattr( + reader, "get_raw_content", AsyncMock(return_value=metadata.model_dump_json()) + ) + monkeypatch.setattr( + reader, + "get_relations", + AsyncMock(return_value=(SimpleNamespace(content="body", to_=20),)), + ) + block = BlockModel( + id=20, resolver=resolver, content=content, storage=None, created_at=now, updated_at=now + ) + monkeypatch.setattr(BlockService, "get", AsyncMock(return_value=block)) + assert expected in async_runner.run(reader.get_text(materialize_missing=False)) + legacy = object.__new__(LocalFileResolver) + monkeypatch.setattr(legacy, "get_raw_content", reader.get_raw_content) + monkeypatch.setattr(legacy, "get_relations", reader.get_relations) + with pytest.raises(ValueError, match="类型不符"): + async_runner.run(legacy.get_text()) diff --git a/tests/info_base/test_markdown.py b/tests/info_base/test_markdown.py new file mode 100644 index 0000000..3492666 --- /dev/null +++ b/tests/info_base/test_markdown.py @@ -0,0 +1,37 @@ +"""Unicode source remains readable without a browser or source Peer.""" + +import datetime +from unittest.mock import AsyncMock + +from app.business.info_base.resolver import ( + MarkdownResolver, + ResolverManager, + register_core_resolvers, +) +from app.schemas.info_base.block import BlockModel + + +def test_markdown_keeps_source_and_unicode(async_runner, monkeypatch): + register_core_resolvers() + assert ( + ResolverManager.match_media_type("text/markdown; charset=utf-8") == "core.markdown.v1" + ) + source = ( + "# 中文\n\n```html\n& value\n indented\n```\n[link](https://example.org)\n" + ) + now = datetime.datetime.now(datetime.UTC) + block = BlockModel( + id=1, + resolver="core.markdown.v1", + storage=None, + content=source, + created_at=now, + updated_at=now, + ) + reader = ResolverManager.get(block) + assert isinstance(reader, MarkdownResolver) + assert async_runner.run(reader.get_text()) == source + monkeypatch.setattr( + BlockModel, "get_hydrated_content", AsyncMock(return_value=source.encode("utf-16")) + ) + assert async_runner.run(reader.get_text(refresh=True)) == source From 4c4cc6ee87f88a6d2c85fdeb985a5fc0d37e4ce4 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 27 Oct 2026 21:32:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(shared):=20=E5=BC=95=E7=94=A8=E5=B7=B2?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=9A=84=E5=86=85=E5=AE=B9=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将共享文档固定到 docs#48 的主线提交 --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index e748a56..4e323b4 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit e748a564fb7f2a066fca98f826c9130d32e888c8 +Subproject commit 4e323b49ffcd00011f7e008fbb53866c6bec5a27