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:
ericwyuan
2026-09-01 13:57:35 +08:00
parent a746327560
commit c57c930949
21 changed files with 3677 additions and 22 deletions

View File

@@ -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