✨ feat: 灵台引擎 Python 绑定与消息路径接入(WP-15/WP-03 交界)
- engine/bindings:nanobind 官方薄绑定包 hunjing-lingtai(LingtaiEngine RAII、HjStatus→LingtaiError 映射、HJ_ABI_VERSION 导出);scikit-build-core 构建,直接编译引擎源码自包含 - backend 以 editable 路径依赖接入;typings/lingtai.pyi 类型存根(mypy strict 全绿) - app/core/lingtai.py:懒初始化单例 + LingtaiUnavailable fail-closed(禁止宿主第二实现降级) - 迁移 3:message.parser_version;post_message 的 markdown 输入经 hj_transform 规范化为 canonical blocks(展开 code/mermaid/math 并标注 parser_version,契约第 4 节) - 测试 29 项全绿:消息展开、Python/C++ golden 字节一致、引擎注入失败时消息路径显式 500(fail-closed 验证)
This commit is contained in:
1 parent
127191c889
commit
491136c770
15 files changed
+314
-4
No files matched your search
@@ -112,6 +112,7 @@ target/
|
|||||||
|
|
||||||
# C++ 引擎与契约生成产物(源码构建可再生)
|
# C++ 引擎与契约生成产物(源码构建可再生)
|
||||||
engine/build*/
|
engine/build*/
|
||||||
|
engine/bindings/build/
|
||||||
openapi/bundled.json
|
openapi/bundled.json
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
sandbox-work/
|
sandbox-work/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""消息记录 parser 版本
|
||||||
|
|
||||||
|
Revision ID: 4cc407ec8268
|
||||||
|
Revises: 140fe7181a6e
|
||||||
|
Create Date: 2026-09-10 11:24:51.731097
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4cc407ec8268'
|
||||||
|
down_revision: str | Sequence[str] | None = '140fe7181a6e'
|
||||||
|
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.add_column('message', sa.Column('parser_version', sa.String(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('message', 'parser_version')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -72,6 +72,7 @@ async def post_message(
|
|||||||
branch_id=message.branch_id,
|
branch_id=message.branch_id,
|
||||||
role=message.role,
|
role=message.role,
|
||||||
content=message.content,
|
content=message.content,
|
||||||
|
parser_version=message.parser_version,
|
||||||
created_at=message.created_at.isoformat(),
|
created_at=message.created_at.isoformat(),
|
||||||
),
|
),
|
||||||
run=(
|
run=(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class MessageOut(BaseModel):
|
|||||||
branch_id: str
|
branch_id: str
|
||||||
role: str
|
role: str
|
||||||
content: list[dict[str, object]]
|
content: list[dict[str, object]]
|
||||||
|
parser_version: str | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.conversations.schemas import MessageIn
|
from app.conversations.schemas import MessageIn
|
||||||
|
from app.core import lingtai
|
||||||
from app.core.ids import new_id
|
from app.core.ids import new_id
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
@@ -122,15 +123,25 @@ async def replay_or_execute_message(
|
|||||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||||
branch = await _get_default_branch(db, conversation.id)
|
branch = await _get_default_branch(db, conversation.id)
|
||||||
|
|
||||||
blocks: list[dict[str, Any]] = [
|
blocks: list[dict[str, Any]] = []
|
||||||
{**b.model_dump(exclude_none=True), "id": new_id("block_"), "status": "completed"}
|
parser_version: str | None = None
|
||||||
for b in body.content
|
for b in body.content:
|
||||||
]
|
if b.type == "markdown":
|
||||||
|
# 契约第 4 节:markdown 输入经确定性 parser 规范化为 canonical blocks
|
||||||
|
normalized = lingtai.transform_markdown(b.text)
|
||||||
|
parser_version = str(normalized["parser_version"])
|
||||||
|
blocks.extend(normalized["blocks"])
|
||||||
|
else:
|
||||||
|
blocks.append({**b.model_dump(exclude_none=True), "status": "completed"})
|
||||||
|
for block in blocks:
|
||||||
|
block["id"] = new_id("block_")
|
||||||
|
block.setdefault("status", "completed")
|
||||||
message = Message(
|
message = Message(
|
||||||
conversation_id=conversation.id,
|
conversation_id=conversation.id,
|
||||||
branch_id=branch.id,
|
branch_id=branch.id,
|
||||||
role="user",
|
role="user",
|
||||||
content=blocks,
|
content=blocks,
|
||||||
|
parser_version=parser_version,
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""灵台引擎薄封装(Python 宿主接入,ADR-016)。
|
||||||
|
|
||||||
|
- 懒初始化进程级单例;初始化或调用失败一律抛 LingtaiUnavailable,
|
||||||
|
由调用方 fail closed(禁止在宿主内用第二实现降级)。
|
||||||
|
- 仅做类型转换与错误映射,不缓存权威状态、不复制业务语义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import lingtai
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_engine: lingtai.LingtaiEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LingtaiUnavailable(RuntimeError):
|
||||||
|
"""引擎缺失、初始化失败或调用失败:依赖方必须显式失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> lingtai.LingtaiEngine:
|
||||||
|
global _engine
|
||||||
|
with _lock:
|
||||||
|
if _engine is None:
|
||||||
|
try:
|
||||||
|
_engine = lingtai.LingtaiEngine()
|
||||||
|
except Exception as exc: # nanobind LingtaiError 等
|
||||||
|
raise LingtaiUnavailable(f"灵台引擎初始化失败:{exc}") from exc
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def reset_engine() -> None:
|
||||||
|
"""测试辅助:丢弃单例,下次访问重新初始化。"""
|
||||||
|
global _engine
|
||||||
|
with _lock:
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def transform_markdown(text: str) -> dict[str, Any]:
|
||||||
|
"""Markdown → canonical ContentBlock(dict:parser_version + blocks)。"""
|
||||||
|
try:
|
||||||
|
engine = get_engine()
|
||||||
|
except LingtaiUnavailable:
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
raw = engine.transform_markdown(text)
|
||||||
|
except Exception as exc:
|
||||||
|
raise LingtaiUnavailable(f"灵台引擎调用失败:{exc}") from exc
|
||||||
|
parsed: dict[str, Any] = json.loads(raw)
|
||||||
|
for block in parsed["blocks"]:
|
||||||
|
block.setdefault("id", None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def transform_version() -> str:
|
||||||
|
return str(lingtai.transform_version())
|
||||||
@@ -191,6 +191,7 @@ class Message(Base):
|
|||||||
)
|
)
|
||||||
role: Mapped[str] = mapped_column(String, nullable=False)
|
role: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
||||||
|
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"asyncpg>=0.30",
|
"asyncpg>=0.30",
|
||||||
"uuid-utils>=0.10",
|
"uuid-utils>=0.10",
|
||||||
"greenlet>=3.5.5",
|
"greenlet>=3.5.5",
|
||||||
|
"hunjing-lingtai",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -45,7 +46,11 @@ select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
|||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
|
mypy_path = "typings"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
hunjing-lingtai = { path = "../engine/bindings", editable = true }
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -146,3 +147,52 @@ class TestAtomicity:
|
|||||||
outbox = (await session.scalars(select(models.OutboxEvent))).all()
|
outbox = (await session.scalars(select(models.OutboxEvent))).all()
|
||||||
assert [e for e in outbox if e.aggregate_type == "message"] == []
|
assert [e for e in outbox if e.aggregate_type == "message"] == []
|
||||||
assert (await session.scalars(select(models.IdempotencyRecord))).all() == []
|
assert (await session.scalars(select(models.IdempotencyRecord))).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("client")
|
||||||
|
class TestLingtaiNormalization:
|
||||||
|
async def test_markdown_input_expanded_by_engine(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "norm@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages",
|
||||||
|
json={
|
||||||
|
"content": [
|
||||||
|
{"type": "markdown", "text": "说明\n```mermaid\nA-->B\n```\n$$\nz\n$$"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers={**headers, "Idempotency-Key": "idem-norm"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
blocks = resp.json()["message"]["content"]
|
||||||
|
assert [b["type"] for b in blocks] == ["markdown", "mermaid", "math"]
|
||||||
|
assert all(b["id"].startswith("block_") for b in blocks)
|
||||||
|
assert resp.json()["message"].get("parser_version") == "hunjing-markdown-v1"
|
||||||
|
|
||||||
|
async def test_python_cpp_golden_consistency(self) -> None:
|
||||||
|
"""绑定输出与 C++ doctest 期望字节一致(跨端一致性最小 golden)。"""
|
||||||
|
from app.core import lingtai
|
||||||
|
|
||||||
|
out = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
|
||||||
|
assert out["parser_version"] == "hunjing-markdown-v1"
|
||||||
|
assert [b["type"] for b in out["blocks"]] == ["markdown", "mermaid", "math"]
|
||||||
|
# 与 engine/tests/test_transform.cpp 的确定性用例保持同一输入输出
|
||||||
|
out2 = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
|
||||||
|
assert out == out2
|
||||||
|
|
||||||
|
async def test_engine_failure_fails_closed(self, client: AsyncClient, monkeypatch) -> None:
|
||||||
|
from app.core import lingtai as lt
|
||||||
|
|
||||||
|
def boom(text: str):
|
||||||
|
raise lt.LingtaiUnavailable("灵台引擎调用失败:注入")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lt, "transform_markdown", boom)
|
||||||
|
headers = await login(client, "fc@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages",
|
||||||
|
json={"content": [{"type": "markdown", "text": "hi"}]},
|
||||||
|
headers={**headers, "Idempotency-Key": "idem-fc"},
|
||||||
|
)
|
||||||
|
# fail closed:引擎不可用时显式失败,不静默降级直存
|
||||||
|
assert resp.status_code == 500
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# lingtai(灵台引擎 nanobind 绑定)类型存根:与 engine/bindings/src/lingtai.cpp 同步维护。
|
||||||
|
HJ_ABI_VERSION: int
|
||||||
|
|
||||||
|
|
||||||
|
def abi_version() -> int: ...
|
||||||
|
def transform_version() -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LingtaiError(RuntimeError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class LingtaiEngine:
|
||||||
|
def __init__(self) -> None: ...
|
||||||
|
def transform_markdown(self, input: str) -> str: ...
|
||||||
Generated
+7
@@ -347,6 +347,7 @@ dependencies = [
|
|||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
|
{ name = "hunjing-lingtai" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
@@ -369,6 +370,7 @@ requires-dist = [
|
|||||||
{ name = "asyncpg", specifier = ">=0.30" },
|
{ name = "asyncpg", specifier = ">=0.30" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "greenlet", specifier = ">=3.5.5" },
|
{ name = "greenlet", specifier = ">=3.5.5" },
|
||||||
|
{ name = "hunjing-lingtai", editable = "../engine/bindings" },
|
||||||
{ name = "pydantic", specifier = ">=2.9" },
|
{ name = "pydantic", specifier = ">=2.9" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
@@ -385,6 +387,11 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.8" },
|
{ name = "ruff", specifier = ">=0.8" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hunjing-lingtai"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "../engine/bindings" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.19"
|
version = "3.19"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.18...3.30)
|
||||||
|
|
||||||
|
project(lingtai_bindings LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
find_package(Python COMPONENTS Interpreter Development REQUIRED)
|
||||||
|
find_package(nanobind CONFIG REQUIRED)
|
||||||
|
|
||||||
|
# 直接编译引擎源码(自包含,不依赖 dylib 安装路径)
|
||||||
|
nanobind_add_module(lingtai
|
||||||
|
src/lingtai.cpp
|
||||||
|
../src/lingtai.cpp
|
||||||
|
../src/transform.cpp)
|
||||||
|
|
||||||
|
target_include_directories(lingtai PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../include
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../src)
|
||||||
|
|
||||||
|
install(TARGETS lingtai DESTINATION .)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["scikit-build-core>=0.10", "nanobind>=2.4"]
|
||||||
|
build-backend = "scikit_build_core.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "hunjing-lingtai"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "灵台引擎(Lingtai Engine)官方 Python 薄绑定(ADR-016)"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.scikit-build]
|
||||||
|
build-dir = "build/{wheel_tag}"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 灵台引擎官方 Python 绑定(nanobind,ADR-016)。
|
||||||
|
// 薄封装:类型转换、句柄 RAII 与错误映射;不复制业务语义(契约 lingtai-ffi 第 8 节)。
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <nanobind/nanobind.h>
|
||||||
|
#include <nanobind/stl/string.h>
|
||||||
|
|
||||||
|
#include "hunjing/lingtai.h"
|
||||||
|
|
||||||
|
namespace nb = nanobind;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct LingtaiError : std::runtime_error {
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PyEngine {
|
||||||
|
HjEngine *handle = nullptr;
|
||||||
|
|
||||||
|
PyEngine() {
|
||||||
|
const HjStatus st = hj_engine_new(HJ_ABI_VERSION, &handle);
|
||||||
|
if (st != HJ_OK) {
|
||||||
|
throw LingtaiError(std::string("灵台引擎初始化失败:") + hj_last_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PyEngine(const PyEngine &) = delete;
|
||||||
|
PyEngine &operator=(const PyEngine &) = delete;
|
||||||
|
|
||||||
|
~PyEngine() {
|
||||||
|
if (handle != nullptr) {
|
||||||
|
hj_engine_free(handle);
|
||||||
|
handle = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const char *status_name(HjStatus st) {
|
||||||
|
switch (st) {
|
||||||
|
case HJ_OK: return "HJ_OK";
|
||||||
|
case HJ_ERR_INVALID_ARGUMENT: return "HJ_ERR_INVALID_ARGUMENT";
|
||||||
|
case HJ_ERR_OUT_OF_MEMORY: return "HJ_ERR_OUT_OF_MEMORY";
|
||||||
|
case HJ_ERR_UNSUPPORTED_ABI: return "HJ_ERR_UNSUPPORTED_ABI";
|
||||||
|
case HJ_ERR_HANDLE_INVALID: return "HJ_ERR_HANDLE_INVALID";
|
||||||
|
case HJ_ERR_REENTRANCY: return "HJ_ERR_REENTRANCY";
|
||||||
|
case HJ_ERR_PARSE_FAILED: return "HJ_ERR_PARSE_FAILED";
|
||||||
|
case HJ_ERR_LIMIT_EXCEEDED: return "HJ_ERR_LIMIT_EXCEEDED";
|
||||||
|
case HJ_ERR_POLICY_DENIED: return "HJ_ERR_POLICY_DENIED";
|
||||||
|
case HJ_ERR_SIGNATURE_INVALID: return "HJ_ERR_SIGNATURE_INVALID";
|
||||||
|
case HJ_ERR_INDEX_CORRUPT: return "HJ_ERR_INDEX_CORRUPT";
|
||||||
|
case HJ_ERR_BUFFER_TOO_SMALL: return "HJ_ERR_BUFFER_TOO_SMALL";
|
||||||
|
case HJ_ERR_INTERNAL: return "HJ_ERR_INTERNAL";
|
||||||
|
}
|
||||||
|
return "HJ_ERR_UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
NB_MODULE(lingtai, m) {
|
||||||
|
m.doc() = "灵台引擎(Lingtai Engine)官方 Python 绑定";
|
||||||
|
|
||||||
|
m.attr("HJ_ABI_VERSION") = HJ_ABI_VERSION;
|
||||||
|
m.def("abi_version", [] { return hj_abi_version(); }, "引擎构建时的 ABI major");
|
||||||
|
m.def("transform_version", [] { return std::string(hj_transform_version()); },
|
||||||
|
"Markdown 解析器版本");
|
||||||
|
|
||||||
|
nb::exception<LingtaiError>(m, "LingtaiError");
|
||||||
|
|
||||||
|
nb::class_<PyEngine>(m, "LingtaiEngine")
|
||||||
|
.def(nb::init<>())
|
||||||
|
.def(
|
||||||
|
"transform_markdown",
|
||||||
|
[](PyEngine &self, const std::string &input) -> std::string {
|
||||||
|
HjBuffer *buf = nullptr;
|
||||||
|
const HjStatus st =
|
||||||
|
hj_transform_markdown(self.handle, input.data(), input.size(), &buf);
|
||||||
|
if (st != HJ_OK) {
|
||||||
|
throw LingtaiError(std::string(status_name(st)) + ": " + hj_last_error());
|
||||||
|
}
|
||||||
|
std::string out(static_cast<const char *>(hj_buffer_data(buf)), hj_buffer_len(buf));
|
||||||
|
hj_buffer_free(buf);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
nb::arg("input"),
|
||||||
|
"Markdown → canonical ContentBlock JSON(确定性、键序固定)");
|
||||||
|
}
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hunjing-lingtai"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
Reference in new issue
Block a user