feat: implement ScanTerminalDashboard component and scan_terminal_service for real-time market opportunity monitoring
This commit is contained in:
@@ -64,6 +64,9 @@ type AiPinnedCity = {
|
|||||||
type AiCityForecastPayload = {
|
type AiCityForecastPayload = {
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
|
reason_zh?: string | null;
|
||||||
|
reason_en?: string | null;
|
||||||
|
raw_reason?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
city_forecast?: {
|
city_forecast?: {
|
||||||
@@ -584,11 +587,12 @@ function AiPinnedCityCard({
|
|||||||
item.cityName;
|
item.cityName;
|
||||||
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
||||||
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
||||||
const modelValues = modelView
|
const modelEntries = modelView
|
||||||
? Object.values(modelView.models || {})
|
? Object.entries(modelView.models || {})
|
||||||
.map((value) => Number(value))
|
.map(([name, value]) => [name, Number(value)] as const)
|
||||||
.filter((value) => Number.isFinite(value))
|
.filter(([, value]) => Number.isFinite(value))
|
||||||
: [];
|
: [];
|
||||||
|
const modelValues = modelEntries.map(([, value]) => value);
|
||||||
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
||||||
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
||||||
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
|
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
|
||||||
@@ -682,6 +686,21 @@ function AiPinnedCityCard({
|
|||||||
(isEn
|
(isEn
|
||||||
? aiCityForecast?.model_cluster_note_en
|
? aiCityForecast?.model_cluster_note_en
|
||||||
: aiCityForecast?.model_cluster_note_zh) || "";
|
: aiCityForecast?.model_cluster_note_zh) || "";
|
||||||
|
const modelPreview = modelEntries
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
|
||||||
|
.join(isEn ? " / " : " / ");
|
||||||
|
const localModelSupportNote = modelEntries.length
|
||||||
|
? isEn
|
||||||
|
? modelEntries.length <= 2
|
||||||
|
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and METAR.`
|
||||||
|
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
|
||||||
|
: modelEntries.length <= 2
|
||||||
|
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和 METAR 实测。`
|
||||||
|
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。`
|
||||||
|
: isEn
|
||||||
|
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
||||||
|
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
||||||
const localizedRisksRaw =
|
const localizedRisksRaw =
|
||||||
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
||||||
const localizedRisks = Array.isArray(localizedRisksRaw)
|
const localizedRisks = Array.isArray(localizedRisksRaw)
|
||||||
@@ -692,9 +711,13 @@ function AiPinnedCityCard({
|
|||||||
const aiBullets = [
|
const aiBullets = [
|
||||||
localizedMetarRead,
|
localizedMetarRead,
|
||||||
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
||||||
localizedModelNote,
|
localizedModelNote || localModelSupportNote,
|
||||||
...localizedRisks,
|
...localizedRisks,
|
||||||
].filter((line) => String(line || "").trim());
|
].filter((line) => String(line || "").trim());
|
||||||
|
const fallbackAiReason =
|
||||||
|
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
|
||||||
|
aiForecast.payload?.reason ||
|
||||||
|
"";
|
||||||
|
|
||||||
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
||||||
|
|
||||||
@@ -840,23 +863,47 @@ function AiPinnedCityCard({
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" ? (
|
) : aiForecast.status === "ready" ? (
|
||||||
<p>
|
<>
|
||||||
{aiForecast.payload?.status === "timeout"
|
<p>
|
||||||
? isEn
|
{aiForecast.payload?.status === "timeout"
|
||||||
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
|
? isEn
|
||||||
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
|
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
|
||||||
: aiForecast.payload?.reason ||
|
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
|
||||||
(isEn
|
: fallbackAiReason ||
|
||||||
? "AI read is unavailable for this city right now."
|
(isEn
|
||||||
: "该城市暂时没有可用的 AI 解读。")}
|
? "AI read is unavailable for this city right now."
|
||||||
</p>
|
: "该城市暂时没有可用的 AI 解读。")}
|
||||||
|
</p>
|
||||||
|
<ul className="scan-ai-weather-bullets">
|
||||||
|
<li>{localModelSupportNote}</li>
|
||||||
|
<li>
|
||||||
|
{report
|
||||||
|
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||||
|
: isEn
|
||||||
|
? "Raw METAR is unavailable."
|
||||||
|
: "暂无原始 METAR。"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
) : aiForecast.status === "failed" ? (
|
) : aiForecast.status === "failed" ? (
|
||||||
<p>
|
<>
|
||||||
{isEn
|
<p>
|
||||||
? "AI read failed. The raw METAR remains below as fallback context."
|
{isEn
|
||||||
: "AI 解读失败。下方仅保留原始 METAR 作为兜底上下文。"}
|
? "AI read failed. Model support and the raw METAR remain as fallback context."
|
||||||
{aiForecast.error ? ` ${aiForecast.error}` : ""}
|
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"}
|
||||||
</p>
|
{aiForecast.error ? ` ${aiForecast.error}` : ""}
|
||||||
|
</p>
|
||||||
|
<ul className="scan-ai-weather-bullets">
|
||||||
|
<li>{localModelSupportNote}</li>
|
||||||
|
<li>
|
||||||
|
{report
|
||||||
|
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||||
|
: isEn
|
||||||
|
? "Raw METAR is unavailable."
|
||||||
|
: "暂无原始 METAR。"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
{isEn
|
{isEn
|
||||||
|
|||||||
@@ -933,7 +933,9 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
|
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
|
||||||
f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. "
|
f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. "
|
||||||
"Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin "
|
"Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin "
|
||||||
"and how wind/cloud/visibility/dewpoint may affect the temperature path. Keep the whole JSON compact."
|
"and how wind/cloud/visibility/dewpoint may affect the temperature path. model_cluster_note must state "
|
||||||
|
"how many model sources are available, whether they support DEB, and whether the sample is too sparse. "
|
||||||
|
"Keep the whole JSON compact."
|
||||||
),
|
),
|
||||||
"city_snapshot": ai_input,
|
"city_snapshot": ai_input,
|
||||||
}
|
}
|
||||||
@@ -965,22 +967,69 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
request_json.get("max_tokens"),
|
request_json.get("max_tokens"),
|
||||||
SCAN_CITY_AI_TIMEOUT_SEC,
|
SCAN_CITY_AI_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
with httpx.Client(timeout=timeout) as client:
|
with httpx.Client(timeout=timeout) as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"{SCAN_AI_BASE_URL}/chat/completions",
|
f"{SCAN_AI_BASE_URL}/chat/completions",
|
||||||
headers={
|
headers=headers,
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json=request_json,
|
json=request_json,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
content = (
|
content = (
|
||||||
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
|
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
|
||||||
if isinstance(data, dict)
|
if isinstance(data, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
if not str(content or "").strip():
|
||||||
|
logger.warning(
|
||||||
|
"scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true",
|
||||||
|
ai_input.get("city"),
|
||||||
|
normalized_locale,
|
||||||
|
((data.get("choices") or [{}])[0] or {}).get("finish_reason") if isinstance(data, dict) else None,
|
||||||
|
)
|
||||||
|
retry_payload = dict(request_json)
|
||||||
|
retry_payload.pop("response_format", None)
|
||||||
|
retry_payload["temperature"] = 0.1
|
||||||
|
retry_payload["messages"] = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
system_prompt
|
||||||
|
+ " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。"
|
||||||
|
+ " If you cannot infer a field, still return the field with a cautious sentence."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": json.dumps(
|
||||||
|
{
|
||||||
|
**user_payload,
|
||||||
|
"retry_reason": "previous provider response had empty message.content",
|
||||||
|
"task": (
|
||||||
|
user_payload["task"]
|
||||||
|
+ " The previous response had empty content. Return only one compact JSON object now."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = client.post(
|
||||||
|
f"{SCAN_AI_BASE_URL}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=retry_payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
content = (
|
||||||
|
((data.get("choices") or [{}])[0].get("message") or {}).get("content")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
parsed = _extract_ai_json_object(str(content or ""))
|
parsed = _extract_ai_json_object(str(content or ""))
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
parsed["_polyweather_meta"] = {
|
parsed["_polyweather_meta"] = {
|
||||||
@@ -1118,12 +1167,24 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
duration_ms = int((time.time() - started_at) * 1000)
|
||||||
|
raw_reason = str(exc)
|
||||||
|
empty_ai_content = raw_reason.strip().lower() == "empty ai content"
|
||||||
|
reason_en = (
|
||||||
|
"DeepSeek V4-Pro returned no usable text. Retry the city analysis."
|
||||||
|
if empty_ai_content
|
||||||
|
else raw_reason
|
||||||
|
)
|
||||||
|
reason_zh = (
|
||||||
|
"DeepSeek V4-Pro 没有返回有效正文,请刷新重试。"
|
||||||
|
if empty_ai_content
|
||||||
|
else raw_reason
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"scan city AI forecast failed city={} duration_ms={} model={} error={}",
|
"scan city AI forecast failed city={} duration_ms={} model={} error={}",
|
||||||
data.get("name") or city_name,
|
data.get("name") or city_name,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
SCAN_AI_MODEL,
|
SCAN_AI_MODEL,
|
||||||
exc,
|
raw_reason,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
@@ -1132,7 +1193,10 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
"city": data.get("name") or city_name,
|
"city": data.get("name") or city_name,
|
||||||
"city_display_name": data.get("display_name") or city_name,
|
"city_display_name": data.get("display_name") or city_name,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"reason": str(exc),
|
"reason": reason_en if normalized_locale == "en-US" else reason_zh,
|
||||||
|
"reason_en": reason_en,
|
||||||
|
"reason_zh": reason_zh,
|
||||||
|
"raw_reason": raw_reason,
|
||||||
}
|
}
|
||||||
generated_at = datetime.utcnow().isoformat() + "Z"
|
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||||
with _SCAN_CITY_AI_CACHE_LOCK:
|
with _SCAN_CITY_AI_CACHE_LOCK:
|
||||||
|
|||||||
Reference in New Issue
Block a user