feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,12 @@ class Provider:
|
||||
|
||||
name = "base"
|
||||
|
||||
# Whether this endpoint has an incremental transport of its own. False
|
||||
# means `stream` is the blocking call in disguise, which `stream_chat`
|
||||
# needs to know: retrying such a provider without streaming would just
|
||||
# run the same request a second time.
|
||||
streaming = False
|
||||
|
||||
def __init__(
|
||||
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None
|
||||
):
|
||||
@@ -131,9 +137,30 @@ class Provider:
|
||||
session.trust_env = self.use_proxy
|
||||
return session
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
"""Multi-turn completion.
|
||||
|
||||
`messages` is the OpenAI shape — a list of
|
||||
{"role": "system"|"user"|"assistant", "content": str}. Providers whose
|
||||
wire format differs translate it themselves.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
"""Single-turn convenience wrapper, kept for the recommendation path."""
|
||||
return self.chat([{"role": "user", "content": prompt}], timeout)
|
||||
|
||||
def stream(self, messages, timeout=None, max_tokens=None):
|
||||
"""Yield Completion deltas as the reply arrives.
|
||||
|
||||
The base implementation is not incremental: it waits for the whole
|
||||
answer and emits it as a single delta. That keeps every entry in the
|
||||
catalog streamable from the caller's point of view — a provider with
|
||||
no SSE transport produces one late chunk rather than an error, so the
|
||||
Copilot route does not need a per-provider branch.
|
||||
"""
|
||||
yield self.chat(messages, timeout, max_tokens)
|
||||
|
||||
|
||||
class GeminiProvider(Provider):
|
||||
"""Google AI Studio (generativelanguage.googleapis.com)."""
|
||||
@@ -141,18 +168,34 @@ class GeminiProvider(Provider):
|
||||
name = "gemini"
|
||||
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
if not self.is_configured():
|
||||
raise AIError(f"{self.api_key_env} 未配置")
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.BASE}/{self.model_id}:generateContent"
|
||||
# Gemini splits what OpenAI keeps in one list: system turns move to
|
||||
# `systemInstruction`, and the assistant role is spelled "model".
|
||||
contents, system = [], []
|
||||
for message in messages:
|
||||
role = message.get("role")
|
||||
if role == "system":
|
||||
system.append(message.get("content") or "")
|
||||
continue
|
||||
contents.append({
|
||||
"role": "model" if role == "assistant" else "user",
|
||||
"parts": [{"text": message.get("content") or ""}],
|
||||
})
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"contents": contents,
|
||||
"generationConfig": {
|
||||
"temperature": 0.4,
|
||||
"maxOutputTokens": self.max_tokens,
|
||||
"maxOutputTokens": max_tokens or self.max_tokens,
|
||||
},
|
||||
}
|
||||
if system:
|
||||
payload["systemInstruction"] = {
|
||||
"parts": [{"text": "\n\n".join(system)}]
|
||||
}
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url,
|
||||
@@ -188,6 +231,7 @@ class OpenAICompatProvider(Provider):
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
streaming = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -216,30 +260,35 @@ class OpenAICompatProvider(Provider):
|
||||
return False
|
||||
return bool(self.api_key) if self.requires_key else True
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def _request(self, messages, timeout, max_tokens, stream):
|
||||
if not self.is_configured():
|
||||
raise AIError(
|
||||
f"{self.api_key_env} 未配置" if self.requires_key
|
||||
else f"{self.base_url_env} 未配置"
|
||||
)
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.base_url.rstrip('/')}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"messages": messages,
|
||||
"temperature": 0.4,
|
||||
"max_tokens": self.max_tokens,
|
||||
"max_tokens": max_tokens or self.max_tokens,
|
||||
}
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url, headers=headers, json=payload, timeout=timeout
|
||||
return self._session().post(
|
||||
url, headers=headers, json=payload, timeout=timeout, stream=stream
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"{self.model_id} 请求失败: {e}") from e
|
||||
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
timeout = timeout or default_timeout()
|
||||
resp = self._request(messages, timeout, max_tokens, stream=False)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
@@ -253,6 +302,51 @@ class OpenAICompatProvider(Provider):
|
||||
except (ValueError, KeyError, IndexError) as e:
|
||||
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
|
||||
|
||||
def stream(self, messages, timeout=None, max_tokens=None):
|
||||
"""Server-sent chunks in the OpenAI streaming schema.
|
||||
|
||||
Note what "streaming" buys here in practice: the gateway forwards its
|
||||
upstream's chunks, and its primary upstream is a reasoning model that
|
||||
emits nothing until it has finished thinking. So this shortens the
|
||||
wait to first text on some upstreams and not at all on others — it is
|
||||
a transport, not a latency guarantee.
|
||||
|
||||
A `data:` frame carrying an `error` object is the gateway's way of
|
||||
reporting "no upstream answered" mid-stream, so it is raised rather
|
||||
than yielded as content.
|
||||
"""
|
||||
timeout = timeout or default_timeout()
|
||||
resp = self._request(messages, timeout, max_tokens, stream=True)
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
message = chunk["error"]
|
||||
if isinstance(message, dict):
|
||||
message = message.get("message") or message
|
||||
raise AIError(f"{self.model_id}: {message}")
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
text = (choices[0].get("delta") or {}).get("content")
|
||||
if text:
|
||||
yield Completion(text, chunk.get("provider"))
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"{self.model_id} 流式中断: {e}") from e
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
|
||||
# --- catalog ----------------------------------------------------------------
|
||||
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1"
|
||||
@@ -549,3 +643,168 @@ def generate(summary, activities=None, preferred_model=None, day_budget=None):
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
# --- generic chat entry points ----------------------------------------------
|
||||
# Used by the coach (briefing / trend insight / Copilot), which needs multi-turn
|
||||
# messages and a bigger output cap than the recommendation list: a reasoning
|
||||
# upstream spends part of its budget thinking out loud before the answer, and a
|
||||
# briefing is prose rather than five short strings.
|
||||
FALLBACK_COACH_MAX_TOKENS = 4000
|
||||
|
||||
|
||||
def coach_max_tokens():
|
||||
return int(os.environ.get("AI_COACH_MAX_TOKENS") or FALLBACK_COACH_MAX_TOKENS)
|
||||
|
||||
|
||||
def complete(messages, preferred_model=None, max_tokens=None, timeout=None):
|
||||
"""First healthy model in the chain answers. Returns (Completion, meta).
|
||||
|
||||
Same fallback policy as `generate`, but the caller supplies the whole
|
||||
message list and parses the reply itself.
|
||||
"""
|
||||
chain = resolve_chain(preferred_model)
|
||||
max_tokens = max_tokens or coach_max_tokens()
|
||||
errors = []
|
||||
|
||||
for model_id in chain:
|
||||
provider = CATALOG[model_id]
|
||||
try:
|
||||
completion = provider.chat(messages, timeout, max_tokens)
|
||||
except AIError as e:
|
||||
errors.append({"model": model_id, "error": str(e)})
|
||||
continue
|
||||
if not (completion.text or "").strip():
|
||||
errors.append({"model": model_id, "error": "空响应"})
|
||||
continue
|
||||
return completion, {
|
||||
"model": model_id,
|
||||
"provider": provider.name,
|
||||
"upstream": completion.upstream,
|
||||
"fallbackFrom": [e["model"] for e in errors],
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
def stream_chat(messages, preferred_model=None, max_tokens=None, timeout=None):
|
||||
"""Stream a reply, yielding Completion deltas.
|
||||
|
||||
Two failover rules, and the order matters:
|
||||
|
||||
1. **Same model, without streaming, before moving on.** Measured against
|
||||
the gateway with a real briefing prompt: the streaming request came
|
||||
back "所有模型均不可用" after 139s while the identical non-streaming
|
||||
request answered in 273s. Its streaming path is simply less reliable
|
||||
than its blocking one, so a stream that produces nothing is retried
|
||||
blind before the model is written off. The reply then arrives as a
|
||||
single late delta rather than not at all.
|
||||
2. **No failover once text has been yielded.** By then it has usually
|
||||
reached the user's screen, and switching models mid-answer splices two
|
||||
different replies together — the exact defect the gateway's own NVIDIA
|
||||
adapter has (its README, known issue #1). A half-written answer that
|
||||
fails visibly beats one finished in another voice.
|
||||
"""
|
||||
chain = resolve_chain(preferred_model)
|
||||
max_tokens = max_tokens or coach_max_tokens()
|
||||
errors = []
|
||||
|
||||
for model_id in chain:
|
||||
provider = CATALOG[model_id]
|
||||
started = False
|
||||
try:
|
||||
for delta in provider.stream(messages, timeout, max_tokens):
|
||||
started = True
|
||||
yield delta
|
||||
except AIError as e:
|
||||
if started:
|
||||
raise
|
||||
errors.append({"model": model_id, "error": f"流式: {e}"})
|
||||
if started:
|
||||
return
|
||||
|
||||
if not provider.streaming:
|
||||
# Its `stream` was the blocking call already; retrying it here
|
||||
# would spend a second full generation on the same failure.
|
||||
continue
|
||||
|
||||
try:
|
||||
completion = provider.chat(messages, timeout, max_tokens)
|
||||
except AIError as e:
|
||||
errors.append({"model": model_id, "error": str(e)})
|
||||
continue
|
||||
if (completion.text or "").strip():
|
||||
yield completion
|
||||
return
|
||||
errors.append({"model": model_id, "error": "空响应"})
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
# --- JSON extraction --------------------------------------------------------
|
||||
def extract_json(text):
|
||||
"""Pull the last complete JSON object or array out of a model reply.
|
||||
|
||||
The gateway's primary upstream is a reasoning model whose visible output
|
||||
*begins* with its chain of thought ("The user wants ... so we must ..."),
|
||||
with the real answer at the end. Scanning from the front therefore finds
|
||||
prose, or a JSON fragment the model was merely considering. Scanning
|
||||
backwards from the last closing brace finds the answer it settled on.
|
||||
|
||||
Brace counting is string-aware, because a Chinese briefing routinely
|
||||
contains a quoted `}` or an escaped quote and a naive count breaks on both.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise AIError("模型返回空响应")
|
||||
|
||||
cleaned = _FENCE.sub("", text).strip()
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for close, opener in (("}", "{"), ("]", "[")):
|
||||
end = cleaned.rfind(close)
|
||||
while end != -1:
|
||||
start = _matching_open(cleaned, end, opener, close)
|
||||
if start is not None:
|
||||
try:
|
||||
return json.loads(cleaned[start : end + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
end = cleaned.rfind(close, 0, end)
|
||||
|
||||
raise AIError(f"模型未返回可解析的 JSON: {text[-200:]}")
|
||||
|
||||
|
||||
def _matching_open(text, end, opener, close):
|
||||
"""Index of the bracket that `text[end]` closes, or None if unbalanced."""
|
||||
depth = 0
|
||||
in_string = False
|
||||
for i in range(end, -1, -1):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
# Walking backwards, a quote ends the string only when it is not
|
||||
# itself escaped — count the run of backslashes before it.
|
||||
if ch == '"':
|
||||
backslashes = 0
|
||||
j = i - 1
|
||||
while j >= 0 and text[j] == "\\":
|
||||
backslashes += 1
|
||||
j -= 1
|
||||
if backslashes % 2 == 0:
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == close:
|
||||
depth += 1
|
||||
elif ch == opener:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user