Stabilize the decision workspace data boundaries

The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests.

Constraint: Preserve existing global scan-terminal class names and API responses during the refactor

Constraint: No new dependencies; keep this as a file-boundary cleanup

Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path

Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording

Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules

Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
2569718930@qq.com
2026-04-28 14:45:34 +08:00
parent 12d90c5051
commit b122e7cbae
23 changed files with 5699 additions and 4940 deletions
+22 -296
View File
@@ -17,20 +17,24 @@ from loguru import logger
from web.analysis_service import _analyze, _build_city_market_scan_payload
from web.core import CITIES
from src.data_collection.city_registry import ALIASES
from web.scan_city_ai_helpers import (
CITY_AI_REQUIRED_FIELDS,
CITY_AI_STREAM_PROVIDER_FIELDS,
from web.scan_city_ai_fallback import (
_build_city_ai_fallback,
_city_ai_response_example,
_city_ai_stream_response_example,
_complete_city_ai_payload,
)
from web.scan_city_ai_helpers import (
_extract_ai_json_object,
_extract_provider_content,
_extract_provider_stream_delta,
_provider_response_meta,
_safe_float,
_truncate_ai_text,
)
from web.scan_city_ai_prompt import (
SCAN_CITY_AI_PROMPT_VERSION,
build_city_ai_stream_request,
)
from web.scan_city_ai_provider import (
_call_deepseek_city_ai as _call_deepseek_city_ai_provider,
)
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
@@ -118,9 +122,6 @@ SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int(
min_value=300,
max_value=64000,
)
SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6"
def _safe_int(value: Any, default: int) -> int:
try:
@@ -1129,248 +1130,14 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]:
api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip()
if not api_key:
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
normalized_locale = _normalize_locale(locale)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
observation_label_zh = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
observation_instruction = str(observation_anchor.get("instruction_zh") or "")
system_prompt = (
"你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON,"
"判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。"
f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。"
"必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。"
f"{observation_instruction}"
"如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。"
"你可以基于城市、时间、季节、站点位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
"观测解读必须具体:写清楚最新观测/报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。"
"涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;"
"不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。"
"涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;"
"如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。"
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视最新实测。"
"所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:"
"_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。"
"risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。"
"只返回 JSON object,不要 Markdown。"
)
user_payload = {
"locale": normalized_locale,
"task": (
"Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, "
"final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, "
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
"Fill every *_zh field in Simplified Chinese and every *_en field in English in the same response. "
"Use this exact JSON object shape; do not return an array, markdown, or prose outside JSON. "
"Keep final_judgment one short decision sentence. metar_read must explain the latest observation source "
"with report/observation time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. "
"For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, "
"and why in local city/station context. If mentioning cold/warm advection, name the wind direction or "
"direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no "
"explicit timing is available. 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."
),
"required_json_keys": CITY_AI_REQUIRED_FIELDS,
"json_example": _city_ai_response_example(str(ai_input.get("temp_symbol") or "°C")),
"city_snapshot": ai_input,
}
timeout = httpx.Timeout(
timeout=float(SCAN_CITY_AI_TIMEOUT_SEC),
connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)),
read=float(SCAN_CITY_AI_TIMEOUT_SEC),
write=10.0,
pool=5.0,
)
request_json = {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(user_payload, ensure_ascii=False),
},
],
}
logger.info(
"scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
ai_input.get("city"),
normalized_locale,
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
request_json.get("max_tokens"),
SCAN_CITY_AI_TIMEOUT_SEC,
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=request_json,
)
response.raise_for_status()
data = response.json()
content = _extract_provider_content(data)
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,
_provider_response_meta(data).get("finish_reason"),
)
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 = _extract_provider_content(data)
try:
parsed = _extract_ai_json_object(str(content or ""))
except ValueError as exc:
preview = _truncate_ai_text(content, 700)
logger.warning(
"scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
preview,
)
repair_payload = {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.0,
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 1200), 64000),
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You repair PolyWeather AI output into one strict JSON object. "
"Do not add facts that are not present in the original city snapshot or previous assistant content. "
"Return only JSON, no markdown."
),
},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"required_schema": [
"predicted_max",
"range_low",
"range_high",
"unit",
"confidence",
"final_judgment_zh",
"final_judgment_en",
"metar_read_zh",
"metar_read_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
"model_cluster_note_zh",
"model_cluster_note_en",
],
"previous_error": str(exc),
"previous_assistant_content": _truncate_ai_text(content, 5000),
"city_snapshot": ai_input,
"instruction": (
"Fill *_zh fields in Simplified Chinese and *_en fields in English; do not leave either language empty. "
"Make final_judgment one direct sentence about today's high temperature. "
"metar_read must interpret the latest observation source with report/observation time, temperature, "
"wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether "
"the current wind tends to warm, cool, or stay neutral for the temperature path, and why. "
"If mentioning cold/warm advection or TAF risk, include the responsible wind direction "
"or the concrete TAF time window; otherwise say timing is not explicit. "
"model_cluster_note must mention available model count/range and whether it supports DEB. "
"Keep the JSON compact."
),
},
ensure_ascii=False,
),
},
],
}
try:
repair_response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=repair_payload,
)
repair_response.raise_for_status()
repair_data = repair_response.json()
repair_content = _extract_provider_content(repair_data)
parsed = _extract_ai_json_object(str(repair_content or ""))
if isinstance(parsed, dict):
parsed["_polyweather_meta"] = {
**_provider_response_meta(repair_data),
"repaired_from_non_json": True,
"original_finish_reason": _provider_response_meta(data).get("finish_reason"),
"original_content_preview": preview,
}
return _complete_city_ai_payload(
parsed,
ai_input,
locale=normalized_locale,
)
except Exception as repair_exc:
logger.warning(
"scan city AI provider json repair failed city={} locale={} error={}",
ai_input.get("city"),
normalized_locale,
repair_exc,
)
return _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=str(content or ""),
provider_data=data,
)
if isinstance(data, dict):
parsed["_polyweather_meta"] = _provider_response_meta(data)
return _complete_city_ai_payload(
parsed,
return _call_deepseek_city_ai_provider(
ai_input,
locale=normalized_locale,
locale=locale,
api_key=str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(),
base_url=SCAN_AI_BASE_URL,
model=SCAN_CITY_AI_MODEL,
max_tokens=SCAN_CITY_AI_MAX_TOKENS,
timeout_sec=SCAN_CITY_AI_TIMEOUT_SEC,
)
@@ -1431,53 +1198,12 @@ def _build_city_ai_stream_request(
*,
locale: str,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
role_label = "机场 METAR 解读员" if is_airport_metar else "官方观测站解读员"
read_label = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
source_instruction = str(observation_anchor.get("instruction_zh") or "")
system_prompt = (
f"你是 PolyWeather 的{role_label}"
"只返回一个紧凑 JSON object,不要 Markdown。"
f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label}"
"最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。"
f"{source_instruction}"
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。"
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
"不要写交易建议、BUY/SELL、Kelly 或套利。"
return build_city_ai_stream_request(
ai_input,
locale=locale,
model=SCAN_CITY_AI_MODEL,
max_tokens=SCAN_CITY_AI_STREAM_MAX_TOKENS,
)
return {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_STREAM_MAX_TOKENS,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"task": (
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. "
"Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. "
"Return exactly one JSON object and no markdown."
),
"required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS,
"json_example": _city_ai_stream_response_example(
str(ai_input.get("temp_symbol") or "°C")
),
"city_snapshot": ai_input,
},
ensure_ascii=False,
),
},
],
}
def _cache_city_ai_payload(