chore(align): sync Git with running container as authoritative source

Standards alignment (STANDARDS_AUDIT_2026-07-24): Phase 1 标准化核心 +
Phase 2 质量层 landed in the running container and captured in Git so the
three sources (container := image := Git) agree.

Backend (Phase 1 标准化核心):
- mcp_client.py: replaced hand-rolled JSON-RPC with official `mcp` SDK
  (ClientSession, streamable_http/sse transports, protocol auto-negotiation)
- core/otel.py + core/observability.py: standard OpenTelemetry SDK bridge
  (OTLP gRPC), wired to Grafana Alloy (qihu-aiflow-alloy:4317) which forwards
  metrics to Prometheus; emits aiflow_span_duration_ms / aiflow_span_count
- deployments: Grafana Alloy container + Prometheus remote-write receiver

Backend (Phase 2 质量层):
- core/rag_pipeline.py: standardized RAG (recursive overlap chunking +
  rerank hook) reusing pgvector_search
- core/llm_client.py: SDK-ified LLM + embeddings (aembed/embed) collapsing
  hand-rolled httpx calls; trust_env=False to bypass container proxy

Migrations:
- 018_normalize_marketapp_scene.sql: fixed capability allowlist
  (prevents capability wipe on restart)

Alignment performed 2026-07-24 12:18:51. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
This commit is contained in:
QihuAI Design committed 2026-07-24 12:18:52 +08:00
1 parent acde481bed
commit e8a1791c9f
8 files changed
+5031 -4779

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
"""Shared, standards-based LLM client for web chat + IM bot.
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(`stream=True`);
* 结构化的 tool_calls / usage 解析;
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
红线在模型层的落地。
API:
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- stream=False -> dict,形状对齐上游 `data` 响应:
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
"""
from __future__ import annotations
import httpx
from openai import AsyncOpenAI, OpenAI
def _base_url(api_url: str) -> str:
"""从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
u = (api_url or "").rstrip()
for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
if u.endswith(suf):
return u[: -len(suf)]
return u
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
return AsyncOpenAI(
base_url=base,
api_key=api_key or "EMPTY",
timeout=timeout,
http_client=http_client,
)
def _create_kwargs(request_body: dict) -> dict:
kw = {
"model": request_body.get("model"),
"messages": request_body.get("messages", []),
"stream": False,
}
if request_body.get("temperature") is not None:
kw["temperature"] = request_body["temperature"]
if request_body.get("top_p") is not None:
kw["top_p"] = request_body["top_p"]
if request_body.get("max_tokens") is not None:
kw["max_tokens"] = request_body["max_tokens"]
if request_body.get("stop") is not None:
kw["stop"] = request_body["stop"]
tools = request_body.get("tools")
if tools:
kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto")
return kw
def _normalize_tool_calls(tcs):
if not tcs:
return None
out = []
for tc in tcs:
out.append(
{
"id": getattr(tc, "id", None),
"type": "function",
"function": {
"name": getattr(tc.function, "name", None),
"arguments": getattr(tc.function, "arguments", "") or "",
},
}
)
return out
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream:
async def _gen():
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
try:
gen = await client.chat.completions.create(
**_create_kwargs(request_body), stream=True
)
async for chunk in gen:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None)
if rc:
yield ("reasoning", rc)
ct = getattr(delta, "content", None)
if ct:
yield ("content", ct)
finally:
await client.close()
return _gen()
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
async with client:
resp = await client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False)
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
with client:
resp = client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
# ---------------------------------------------------------------------------
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
# ---------------------------------------------------------------------------
def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]}
async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
"""
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
async with client:
resp = await client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
with client:
resp = client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]