diff --git a/.gitignore b/.gitignore index ac7c53e..f934675 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ target/ # C++ 引擎与契约生成产物(源码构建可再生) engine/build*/ +engine/bindings/build/ openapi/bundled.json **/*.rs.bk sandbox-work/ diff --git a/backend/alembic/versions/4cc407ec8268_消息记录_parser_版本.py b/backend/alembic/versions/4cc407ec8268_消息记录_parser_版本.py new file mode 100644 index 0000000..2b869be --- /dev/null +++ b/backend/alembic/versions/4cc407ec8268_消息记录_parser_版本.py @@ -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 ### diff --git a/backend/app/conversations/routes.py b/backend/app/conversations/routes.py index 56f3113..8c6b861 100644 --- a/backend/app/conversations/routes.py +++ b/backend/app/conversations/routes.py @@ -72,6 +72,7 @@ async def post_message( branch_id=message.branch_id, role=message.role, content=message.content, + parser_version=message.parser_version, created_at=message.created_at.isoformat(), ), run=( diff --git a/backend/app/conversations/schemas.py b/backend/app/conversations/schemas.py index 5f5979d..c651788 100644 --- a/backend/app/conversations/schemas.py +++ b/backend/app/conversations/schemas.py @@ -44,6 +44,7 @@ class MessageOut(BaseModel): branch_id: str role: str content: list[dict[str, object]] + parser_version: str | None = None created_at: str diff --git a/backend/app/conversations/service.py b/backend/app/conversations/service.py index cd3c23e..691ea52 100644 --- a/backend/app/conversations/service.py +++ b/backend/app/conversations/service.py @@ -14,6 +14,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.conversations.schemas import MessageIn +from app.core import lingtai from app.core.ids import new_id from app.models import ( AuditEvent, @@ -122,15 +123,25 @@ async def replay_or_execute_message( conversation = await _require_conversation(db, domain_id, conversation_id) branch = await _get_default_branch(db, conversation.id) - blocks: list[dict[str, Any]] = [ - {**b.model_dump(exclude_none=True), "id": new_id("block_"), "status": "completed"} - for b in body.content - ] + blocks: list[dict[str, Any]] = [] + parser_version: str | None = None + 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( conversation_id=conversation.id, branch_id=branch.id, role="user", content=blocks, + parser_version=parser_version, ) db.add(message) await db.flush() diff --git a/backend/app/core/lingtai.py b/backend/app/core/lingtai.py new file mode 100644 index 0000000..c094321 --- /dev/null +++ b/backend/app/core/lingtai.py @@ -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()) diff --git a/backend/app/models.py b/backend/app/models.py index 8597087..33e17b7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -191,6 +191,7 @@ class Message(Base): ) role: Mapped[str] = mapped_column(String, 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( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1cc2229..de92bd2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "asyncpg>=0.30", "uuid-utils>=0.10", "greenlet>=3.5.5", + "hunjing-lingtai", ] [dependency-groups] @@ -45,7 +46,11 @@ select = ["E", "F", "I", "UP", "B", "ASYNC"] [tool.mypy] python_version = "3.12" strict = true +mypy_path = "typings" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] + +[tool.uv.sources] +hunjing-lingtai = { path = "../engine/bindings", editable = true } diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index 3ddbf1f..0e62432 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -2,6 +2,7 @@ import json +import pytest from httpx import AsyncClient from sqlalchemy import select @@ -146,3 +147,52 @@ class TestAtomicity: outbox = (await session.scalars(select(models.OutboxEvent))).all() assert [e for e in outbox if e.aggregate_type == "message"] == [] 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 diff --git a/backend/typings/lingtai.pyi b/backend/typings/lingtai.pyi new file mode 100644 index 0000000..31ef91d --- /dev/null +++ b/backend/typings/lingtai.pyi @@ -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: ... diff --git a/backend/uv.lock b/backend/uv.lock index 258fbb5..605a32a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -347,6 +347,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "greenlet" }, + { name = "hunjing-lingtai" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -369,6 +370,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", specifier = ">=0.115" }, { name = "greenlet", specifier = ">=3.5.5" }, + { name = "hunjing-lingtai", editable = "../engine/bindings" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "sqlalchemy", specifier = ">=2.0" }, @@ -385,6 +387,11 @@ dev = [ { name = "ruff", specifier = ">=0.8" }, ] +[[package]] +name = "hunjing-lingtai" +version = "0.1.0" +source = { editable = "../engine/bindings" } + [[package]] name = "idna" version = "3.19" diff --git a/engine/bindings/CMakeLists.txt b/engine/bindings/CMakeLists.txt new file mode 100644 index 0000000..df37fa1 --- /dev/null +++ b/engine/bindings/CMakeLists.txt @@ -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 .) diff --git a/engine/bindings/pyproject.toml b/engine/bindings/pyproject.toml new file mode 100644 index 0000000..1465aed --- /dev/null +++ b/engine/bindings/pyproject.toml @@ -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}" diff --git a/engine/bindings/src/lingtai.cpp b/engine/bindings/src/lingtai.cpp new file mode 100644 index 0000000..91a3867 --- /dev/null +++ b/engine/bindings/src/lingtai.cpp @@ -0,0 +1,88 @@ +// 灵台引擎官方 Python 绑定(nanobind,ADR-016)。 +// 薄封装:类型转换、句柄 RAII 与错误映射;不复制业务语义(契约 lingtai-ffi 第 8 节)。 +#include +#include + +#include +#include + +#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(m, "LingtaiError"); + + nb::class_(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(hj_buffer_data(buf)), hj_buffer_len(buf)); + hj_buffer_free(buf); + return out; + }, + nb::arg("input"), + "Markdown → canonical ContentBlock JSON(确定性、键序固定)"); +} diff --git a/engine/bindings/uv.lock b/engine/bindings/uv.lock new file mode 100644 index 0000000..6de8480 --- /dev/null +++ b/engine/bindings/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "hunjing-lingtai" +version = "0.1.0" +source = { editable = "." }