feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路

- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、
  problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点
- identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话
- billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局
- document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号
- checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐
- ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水
- 迁移:data-model 基线、套餐、Procrastinate、会话状态函数
- 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性
- Compose(含 migrate)、镜像、CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent 5747a523bb
commit ad7fba6021
106 changed files with 8094 additions and 0 deletions

View File

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from sqlalchemy.orm import Mapped
from bid_doctor.core.columns import PK, Bytes, DbTs, UuidCol
from bid_doctor.core.db import Base
class AiCall(Base):
"""调用流水账:不存原文(架构 §3.6)。"""
__tablename__ = "ai_call"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
job_type: Mapped[str]
job_id: Mapped[UuidCol]
task: Mapped[str]
capability: Mapped[str]
provider: Mapped[str]
model: Mapped[str]
prompt_id: Mapped[str]
prompt_version: Mapped[str]
prompt_hash: Mapped[Bytes]
input_hash: Mapped[Bytes]
output_hash: Mapped[Bytes | None]
tokens_in: Mapped[int | None]
tokens_out: Mapped[int | None]
latency_ms: Mapped[int | None]
output_mode: Mapped[str | None]
error_class: Mapped[str | None]
created_at: Mapped[DbTs]

View File

@@ -0,0 +1,38 @@
"""提示词注册表:启动时加载 prompts/ 下的文件并计算内容哈希(架构 §3.6)。"""
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
DEFAULT_DIR = Path(__file__).resolve().parents[4] / "prompts"
@dataclass(frozen=True, slots=True)
class Prompt:
id: str
version: str
capability: str
body: str
sha256: bytes
@lru_cache
def get(prompt_id: str, version: int) -> Prompt:
base = Path(os.environ.get("BD_PROMPTS_DIR", DEFAULT_DIR))
raw = (base / prompt_id / f"v{version}.md").read_text(encoding="utf-8")
_, head, body = raw.split("---", 2)
meta = dict(line.split(":", 1) for line in head.strip().splitlines())
meta = {k.strip(): v.strip() for k, v in meta.items()}
if meta["id"] != prompt_id or meta["version"] != str(version):
raise ValueError(f"提示词文件头和路径不一致:{prompt_id} v{version}")
return Prompt(
id=prompt_id,
version=f"v{version}",
capability=meta["capability"],
body=body.strip(),
sha256=hashlib.sha256(raw.encode()).digest(),
)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
@dataclass(frozen=True, slots=True)
class Completion:
data: dict[str, Any]
raw: str
model: str
tokens_in: int | None = None
tokens_out: int | None = None
output_mode: str = "json_mode"
class TransientError(Exception):
"""限流、5xx、网络抖动:可以重试;重试后仍失败按基础设施错误处理(退回额度)。"""
def __init__(self, error_class: str, message: str) -> None:
super().__init__(message)
self.error_class = error_class
class Provider(Protocol):
name: str
async def complete_json(self, system: str, user: str, capability: str) -> Completion: ...

View File

@@ -0,0 +1,40 @@
"""确定性的假模型:开发和测试用,CI 不调用真实模型(架构 §8)。
只认 extract.substantive:把带 ★ 或 <iu> 的句子当作实质性条款,逐字摘一段原文作为引文。
"""
from __future__ import annotations
import json
import re
from bid_doctor.ai.providers.base import Completion
LINE = re.compile(r"^\[([^\]]+)\] (.*)$")
TAG = re.compile(r"</?(iu|u|i)>")
class FakeProvider:
name = "fake"
async def complete_json(self, system: str, user: str, capability: str) -> Completion:
del system, capability
items = []
for line in user.split("## 待审分块", 1)[-1].splitlines():
m = LINE.match(line.strip())
if not m:
continue
sid, marked = m.groups()
plain = TAG.sub("", marked)
if "★" in plain:
basis = "star"
elif "<iu>" in marked:
basis = "italic_underline"
else:
continue
if any(k in plain for k in ("标注", "实质性要求", "条款响应")) and len(plain) < 60:
continue # 只是在说明标注规则的句子,不算条款(和提示词的「不要输出」一致)
body = plain.lstrip("★** ").strip()
items.append({"summary": body[:40], "basis": basis, "evidence": [{"sid": sid, "quote": body[:20]}]})
data = {"items": items}
return Completion(data=data, raw=json.dumps(data, ensure_ascii=False), model="fake-extractor")

View File

@@ -0,0 +1,54 @@
"""OpenAI 兼容协议的国产模型(DeepSeek、通义千问、豆包、GLM、Kimi 等,T1)。
供应商地址和模型由运维在部署配置里设置,用户和租户都不能填(架构 §3.6)。
TODO(路由表):按能力配候选列表、按「供应商 × 租户」限流;现在只有一个供应商一个模型。
"""
from __future__ import annotations
import json
import httpx
from bid_doctor.ai.providers.base import Completion, TransientError
from bid_doctor.core.config import get_settings
class OpenAICompatProvider:
name = "openai_compat"
async def complete_json(self, system: str, user: str, capability: str) -> Completion:
del capability
s = get_settings()
body = {
"model": s.ai_model,
"temperature": 0,
"response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
}
try:
async with httpx.AsyncClient(base_url=s.ai_base_url, timeout=httpx.Timeout(180, connect=10)) as c:
r = await c.post("/chat/completions", json=body, headers={"Authorization": f"Bearer {s.ai_api_key}"})
except httpx.TimeoutException as e:
raise TransientError("timeout", "模型调用超时") from e
except httpx.TransportError as e:
raise TransientError("network", "模型调用网络错误") from e
if r.status_code == 429:
raise TransientError("rate_limited", "模型接口限流")
if r.status_code >= 500:
raise TransientError("server", f"模型接口 {r.status_code}")
r.raise_for_status()
payload = r.json()
raw = payload["choices"][0]["message"]["content"]
usage = payload.get("usage") or {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = {"items": []} # 内容质量问题:不算任务失败,相关条目由规则兜底进待确认区(架构 §3.6)
return Completion(
data=data,
raw=raw,
model=payload.get("model", s.ai_model),
tokens_in=usage.get("prompt_tokens"),
tokens_out=usage.get("completion_tokens"),
)

View File

@@ -0,0 +1,70 @@
"""模型调用的唯一入口:业务只声明任务和提示词版本,供应商由路由决定;每次调用写一条 ai_call(架构 §3.6)。"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from uuid import UUID
from bid_doctor.ai import prompts
from bid_doctor.ai.models import AiCall
from bid_doctor.ai.providers.base import Completion, Provider, TransientError
from bid_doctor.ai.providers.fake import FakeProvider
from bid_doctor.ai.providers.openai_compat import OpenAICompatProvider
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import sha256
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.ids import uuid7
RETRIES = 2
def provider() -> Provider:
return OpenAICompatProvider() if get_settings().ai_provider == "openai_compat" else FakeProvider()
async def run_json(
ctx: TenantContext, *, job_type: str, job_id: UUID, task: str, prompt_id: str, version: int, user: str
) -> dict[str, Any]:
"""外部内容(招标文件)只放在 user 的数据区;系统提示词声明数据区里的指令一律不执行。"""
p = prompts.get(prompt_id, version)
prov = provider()
started = time.monotonic()
result: Completion | None = None
error: TransientError | None = None
for attempt in range(RETRIES + 1):
try:
result = await prov.complete_json(p.body, user, p.capability)
error = None
break
except TransientError as e: # 只对临时错误重试
error = e
await asyncio.sleep(2**attempt)
async with tenant_tx(ctx) as s:
s.add(
AiCall(
id=uuid7(),
tenant_id=ctx.tenant_id,
job_type=job_type,
job_id=job_id,
task=task,
capability=p.capability,
provider=prov.name,
model=result.model if result else get_settings().ai_model,
prompt_id=p.id,
prompt_version=p.version,
prompt_hash=p.sha256,
input_hash=sha256(user),
output_hash=sha256(result.raw) if result else None,
tokens_in=result.tokens_in if result else None,
tokens_out=result.tokens_out if result else None,
latency_ms=int((time.monotonic() - started) * 1000),
output_mode=result.output_mode if result else None,
error_class=error.error_class if error else None,
)
)
if error:
raise error
assert result is not None
return result.data