Files
group_fqcd_jr/tools/configure_embedding_endpoint.py
张胜宇 d5e813b726 feat(model-gateway)+docs(W19): embedding 端点唯一性配置守卫 + 三份完整版/收敛版索引口径补注 + 门槛口径更正 + D3.7 难例口径统一
一、代码(2 文件 + 2 工具脚本注记)
* app/service/model_gateway.py:DatabaseModelEndpointResolver.resolve() 加配置守卫
  —— required == "embedding" 且 len(matched) > 1 时 logger.warning(只告警、不改行为)。
  多个 embedding 端点会让索引向量与查询向量可能来自不同模型(维度同为 1024、不报错),
  COSINE 相似度整体失真,表现为"越答越差"的哑故障。顺手删掉重复的 return endpoints(死代码)。
* tests/unit/service/test_model_gateway.py:新增 2 条单测(多端点告警且返回顺序不变 / 单端点静默)。
* tools/configure_embedding_endpoint.py:加「已废弃,勿重跑」标注 —— 它写的是
  qwen-embedding / qwen3.7-text-embedding-flash,与现役端点 knowledge-embedding-qwen-v3 /
  text-embedding-v3 不一致,重跑会凭空多出一个 embedding 端点。
* tools/build_knowledge_chunks.py:删掉与新口径冲突的注释「不泄露档位与门槛」,
  改为「registered 的依据是权益明细而非门槛;门槛属公开宣传口径」。

二、文档(8 份;D-1 选乙 + D-3 统一为 18)
* D2.4 v1.6 → v1.7:§4.4 + 附录B 更正「门槛金额不再单独构成 registered 的理由」
  (public 的 FAQ-0014 已完整给出五档门槛、FAQ-0050 含钻石门槛);
  HNW-004—HNW-007 保持 registered,依据收窄为"各层级权益明细";HNW-* 档位不动(分区键)。
* D3.1 v2.5 → v2.6:§5.3 加索引口径落地注(覆盖 §2.5 决策表 / FR-CS-007 / 排期 T4)
  + 补「字段表同属初稿」(实库 18 字段全 NOT NULL、doc_id 主键、无 metadata JSON)。
* D3.2 v1.2 → v1.6:§4.1 加同口径注 + 版本位追平(顶栏 v1.1 / doc-meta v1.2 落后于自身记录 v1.5)。
* D2.2 v2.6 → v2.7:§1.4.2 域 B 加注(TopK / 阈值 / 度量 / 集合选择均未变 ⇒ 不影响验收)。
* D3.7:§3 难例口径统一 —— 难例 32 条(改写 8 + 口语 16 + 多轮 4 + 禁忌 4)为定义式总数,
  M-2b 分母 = 其中带期望证据家族的 18 条;并补正 §3 初稿表格条数(以 cases_46.json 为准)。
* D1.1 v1.8 → v1.9:新增 §28;四处版本位同步;顺带修正两处历史遗留
  (D2.4 版本位长期停在 v1.3、D2.2 日期列停在 2026-09-17)。
* D1.6:新增 §4.47(含自我失误留痕)。
* D2.1 v6.33 → v6.34:新增本轮修订要点段。

三、实测门口(本机)
* tests/unit/service/test_model_gateway.py:10 passed
* pytest -q -p no:cacheprovider(全量,跑前已停 Worker):1917 passed / 3 skipped / 0 failed
* tools/check_authoritative_docs.py:54 文档无编号冲突(exit 0)
* _consistency.py:失效锚点 0、交叉引用全 ✅(exit 0)
* _fe_boundary_http.py(重建件):12/12 符合预期
* 服务已重启:/internal/health/ready 三依赖全绿(mysql / redis / milvus)

四、如实留痕(自我失误)
本轮清理临时文件时删除判据过宽,误删 _consistency.py(已原样恢复)、
_legacy_customer_service.py(已按 f72a545 逐字节重建,40,554 字节)、
_fe_boundary_http.py(原件不可恢复,已按既有判据重建并实跑 12/12)与若干历史轮次原始日志。
详见 D1.6 §4.47 五。
2026-09-20 17:53:06 +08:00

146 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""⚠️ 已废弃,请勿重跑;保留仅作历史参考。
现役 embedding 端点是 `knowledge-embedding-qwen-v3` / `text-embedding-v3`(实库 `id=1`),
而本脚本写的是 `qwen-embedding` / `qwen3.7-text-embedding-flash` —— 两者不一致。
**重跑会在 active 端点里凭空多出一个声明 `embedding` 的端点**:索引向量与查询向量可能
来自不同模型,Milvus 的 `COSINE` 相似度会整体失真且**不报错**(`DatabaseModelEndpointResolver`
有"embedding 端点应恰好 1 个"的 WARNING 守卫,但那只是留痕,故障已经发生)。
配置并激活 embedding 模型端点(走管理 API,不直接写库)。
为什么走 API 而不是 INSERT:端点配置要经过 draft → approved → active 状态机并留下
`interaction_audit`。直接写库会绕过审核与审计,而且 `DatabaseModelGateway` 只认
`status='active'`,手工写错状态会表现为「模型端点未注册或未激活」这种与病因无关的报错。
幂等:脚本先查该 endpoint_code 是否已存在,已存在则跳过创建,只做后续状态推进。
用法:python tools/configure_embedding_endpoint.py
"""
import asyncio
import datetime as dt
import sys
import uuid
from pathlib import Path
import asyncmy
import httpx
import jwt
from app.core.config import get_settings
from app.main import create_app
ADMIN = "9003"
ENDPOINT_CODE = "qwen-embedding"
MYSQL_DSN_HOST = "127.0.0.1"
MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB = "root", "123456", "jr"
def token(subject: str) -> str:
settings = get_settings()
private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="RS256",
)
async def endpoint_id_if_exists() -> int | None:
connection = await asyncmy.connect(
host=MYSQL_DSN_HOST, port=3306, user=MYSQL_USER, password=MYSQL_PASSWORD, db=MYSQL_DB
)
try:
cursor = connection.cursor()
await cursor.execute(
"SELECT id FROM model_endpoint_config WHERE endpoint_code=%s", (ENDPOINT_CODE,)
)
row = await cursor.fetchone()
return int(row[0]) if row else None
finally:
connection.close()
async def post(
client: httpx.AsyncClient, path: str, *, auth: dict[str, str],
payload: dict[str, object] | None = None, if_match: str | None = None,
) -> httpx.Response:
headers = {**auth, "Idempotency-Key": uuid.uuid4().hex}
if if_match:
headers["If-Match"] = if_match
return await client.post(path, json=payload, headers=headers)
async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None:
response = await client.get(path, headers=auth)
return response.headers.get("ETag")
async def main() -> int:
app = create_app()
auth = {"Authorization": f"Bearer {token(ADMIN)}"}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60
) as client:
endpoint_id = await endpoint_id_if_exists()
if endpoint_id is None:
created = await post(
client, "/api/v1/admin/model-endpoints", auth=auth,
payload={
"endpoint_code": ENDPOINT_CODE,
"provider": "dashscope",
"model_name": "qwen3.7-text-embedding-flash",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"secret_ref": "env:QWEN_EMBEDDING_API_KEY",
"capabilities": ["embedding"],
"allowed_data_levels": ["public", "internal"],
"context_window": 8192,
"timeout_ms": 30000,
},
)
print(f"创建端点:{created.status_code} {created.text[:160]}")
if created.status_code != 201:
return 1
endpoint_id = int(created.json()["data"]["id"])
else:
print(f"端点已存在,复用 id={endpoint_id}")
detail_path = f"/api/v1/admin/model-endpoints/{endpoint_id}"
current = (await client.get(detail_path, headers=auth)).json()["data"]
print(f"当前状态:{current['status']}")
if current["status"] == "draft":
reviewed = await post(
client, f"{detail_path}/reviews", auth=auth,
payload={"decision": "approved", "comment": "embedding 端点配置"},
if_match=await etag_of(client, detail_path, auth),
)
print(f"审核:{reviewed.status_code} {reviewed.text[:160]}")
if reviewed.status_code not in (200, 201):
return 1
current = (await client.get(detail_path, headers=auth)).json()["data"]
if current["status"] == "approved":
activated = await post(
client, f"{detail_path}/activations", auth=auth,
# activations 的 body 是必填的 EmptyPayload(extra=forbid),
# 不传 body 会得到 422 "Field required",因此显式给空对象。
payload={},
if_match=await etag_of(client, detail_path, auth),
)
print(f"激活:{activated.status_code} {activated.text[:200]}")
if activated.status_code not in (200, 201):
return 1
final = (await client.get(detail_path, headers=auth)).json()["data"]
print(f"最终状态:{final['status']} 模型={final['model_name']}")
return 0 if final["status"] == "active" else 1
sys.exit(asyncio.run(main()))