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:
0
server/src/bid_doctor/ai/__init__.py
Normal file
0
server/src/bid_doctor/ai/__init__.py
Normal file
31
server/src/bid_doctor/ai/models.py
Normal file
31
server/src/bid_doctor/ai/models.py
Normal 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]
|
||||
38
server/src/bid_doctor/ai/prompts.py
Normal file
38
server/src/bid_doctor/ai/prompts.py
Normal 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(),
|
||||
)
|
||||
0
server/src/bid_doctor/ai/providers/__init__.py
Normal file
0
server/src/bid_doctor/ai/providers/__init__.py
Normal file
28
server/src/bid_doctor/ai/providers/base.py
Normal file
28
server/src/bid_doctor/ai/providers/base.py
Normal 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: ...
|
||||
40
server/src/bid_doctor/ai/providers/fake.py
Normal file
40
server/src/bid_doctor/ai/providers/fake.py
Normal 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")
|
||||
54
server/src/bid_doctor/ai/providers/openai_compat.py
Normal file
54
server/src/bid_doctor/ai/providers/openai_compat.py
Normal 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"),
|
||||
)
|
||||
70
server/src/bid_doctor/ai/service.py
Normal file
70
server/src/bid_doctor/ai/service.py
Normal 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
|
||||
Reference in New Issue
Block a user