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

@@ -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"),
)