✨ 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:
chenxin committed 2026-09-10 11:26:40 +08:00
1 parent 127191c889
commit 491136c770
15 files changed
+314 -4

No files matched your search

@@ -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 ###
+1
View File
@@ -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=(
+1
View File
@@ -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
+15 -4
View File
@@ -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()
+57
View File
@@ -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())
+1
View File
@@ -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()
)
+5
View File
@@ -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 }
+50
View File
@@ -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
+14
View File
@@ -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: ...
+7
View File
@@ -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"