diff --git a/README.ja.md b/README.ja.md
index 2d23852..981b4cc 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -27,7 +27,7 @@
| 機能 | 説明 |
| :--- | :--- |
| **スマートアップロード** | 衣類の写真をアップロードすると `rembg` で背景を自動除去し、AI ビジョンモデルでカテゴリ・色・スタイルを分析 |
-| **天気連動スタイリング** | QWeather API と連携し、リアルタイムの天気に基づいたコーディネートを提案 |
+| **天気連動スタイリング** | Open-Meteo の無料グローバル天気 API を利用し、リアルタイム天気に基づいたコーディネートを提案 |
| **デジタルワードローブ** | 構造化されたビューで衣類を閲覧・検索・管理 |
| **AI レコメンデーション** | Gemini や OpenAI 互換プロバイダーによるパーソナライズされたコーディネート生成 |
| **レスポンシブ UI** | Tailwind CSS によるモダンなインターフェースで、デスクトップ・タブレット・モバイルに対応 |
@@ -60,7 +60,6 @@
- **Node.js** `v20+` | **Python** `v3.10+`
- [Google Gemini API Key](https://aistudio.google.com/app/apikey) または OpenAI 互換 API キー
-- [QWeather API Key](https://console.qweather.com)
### 1. クローンと設定
diff --git a/README.md b/README.md
index 47dd046..619f56f 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Upload clothing photos, remove backgrounds automatically, classify garments with
| Feature | Description |
| :--- | :--- |
| **Smart Upload** | Upload clothing photos, auto-remove backgrounds with `rembg`, analyze category, color, and style via AI vision models |
-| **Weather-Based Styling** | Integrates QWeather API to generate outfit suggestions based on real-time conditions |
+| **Weather-Based Styling** | Integrates free global weather data (Open-Meteo) to generate outfit suggestions based on real-time conditions |
| **Digital Wardrobe** | Browse, search, and manage your clothing in a structured wardrobe view |
| **AI Recommendations** | Supports Gemini and OpenAI-compatible providers for personalized outfit generation |
| **Responsive UI** | Optimized for desktop, tablet, and mobile with a modern Tailwind CSS interface |
@@ -60,7 +60,6 @@ Upload clothing photos, remove backgrounds automatically, classify garments with
- **Node.js** `v20+` | **Python** `v3.10+`
- [Google Gemini API Key](https://aistudio.google.com/app/apikey) or an OpenAI-compatible API key
-- [QWeather API Key](https://console.qweather.com)
### 1. Clone & Configure
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a8198af..35b081d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -27,7 +27,7 @@
| 特性 | 描述 |
| :--- | :--- |
| **智能上传** | 上传衣服照片后,使用 `rembg` 自动去背景,并通过视觉模型识别类别、颜色和风格 |
-| **天气穿搭** | 集成和风天气 API,根据实时天气生成更合适的穿搭建议 |
+| **天气穿搭** | 集成 Open-Meteo 免费全球天气接口,根据实时天气生成更合适的穿搭建议 |
| **虚拟衣柜** | 以结构化方式浏览、搜索和管理所有衣物 |
| **AI 推荐** | 支持 Gemini 和 OpenAI 风格接口,用于生成个性化穿搭方案 |
| **响应式界面** | 基于 Tailwind CSS,适配桌面、平板和手机 |
@@ -60,7 +60,6 @@
- **Node.js** `v20+` | **Python** `v3.10+`
- [Google Gemini API Key](https://aistudio.google.com/app/apikey) 或 OpenAI 兼容接口 Key
-- [和风天气 API Key](https://console.qweather.com)
### 1. 克隆与配置
diff --git a/backend/api/__pycache__/upload.cpython-314.pyc b/backend/api/__pycache__/upload.cpython-314.pyc
index 27275d7..3d0bbd3 100644
Binary files a/backend/api/__pycache__/upload.cpython-314.pyc and b/backend/api/__pycache__/upload.cpython-314.pyc differ
diff --git a/backend/api/config.py b/backend/api/config.py
index 2588c12..12a13cf 100644
--- a/backend/api/config.py
+++ b/backend/api/config.py
@@ -25,8 +25,7 @@ async def set_config(config_update: LLMConfigUpdate):
model=config_update.model,
removebg_api_key=config_update.removebg_api_key,
bg_removal_method=config_update.bg_removal_method,
- qweather_api_key=config_update.qweather_api_key,
- qweather_api_host=config_update.qweather_api_host,
+ weather_location=config_update.weather_location,
zodiac_sign=config_update.zodiac_sign
)
return {
@@ -34,6 +33,8 @@ async def set_config(config_update: LLMConfigUpdate):
"message": "配置已更新",
"config": get_masked_config()
}
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
diff --git a/backend/api/horoscope.py b/backend/api/horoscope.py
index d002222..08d9744 100644
--- a/backend/api/horoscope.py
+++ b/backend/api/horoscope.py
@@ -7,7 +7,7 @@
from pydantic import BaseModel
from services.horoscope import get_daily_horoscope
-from services.weather import get_weather
+from services.weather import get_weather, normalize_location_request, DEFAULT_LOCATION_QUERY
router = APIRouter()
@@ -23,24 +23,47 @@ class HoroscopeResponse(BaseModel):
lucky_color: str
lucky_number: int
suggestion: str
+ source_provider: str
+ llm_status: str
+ llm_reasoning: str
@router.get("/horoscope/daily", response_model=HoroscopeResponse)
async def get_today_horoscope(
location: str = Query(
- default="101020100",
- description="LocationID 或 经纬度坐标"
+ default=DEFAULT_LOCATION_QUERY,
+ description="城市名 或 经纬度坐标"
),
+ city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"),
+ state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"),
+ country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"),
zodiac_sign: Optional[str] = Query(
default=None,
description="可选,若传入会覆盖设置中的星座"
- )
+ ),
+ include_inference: bool = Query(
+ default=True,
+ description="是否执行并返回 LLM 推理结果。false 时仅返回并缓存基础运势。"
+ ),
):
"""
获取今日星座运势
"""
- weather = await get_weather(location)
+ normalized_location, validation_error = normalize_location_request(
+ location=location,
+ city=city,
+ state=state,
+ country=country,
+ )
+ if validation_error:
+ raise HTTPException(status_code=422, detail=validation_error)
+
+ weather = await get_weather(normalized_location)
if not weather:
raise HTTPException(status_code=500, detail="获取天气信息失败")
- return await get_daily_horoscope(weather=weather, zodiac_sign=zodiac_sign)
+ return await get_daily_horoscope(
+ weather=weather,
+ zodiac_sign=zodiac_sign,
+ include_inference=include_inference,
+ )
diff --git a/backend/api/recommendation.py b/backend/api/recommendation.py
index ccdabd4..eb67ee7 100644
--- a/backend/api/recommendation.py
+++ b/backend/api/recommendation.py
@@ -4,9 +4,9 @@
"""
from fastapi import APIRouter, HTTPException, Query
from typing import Optional
-from services.weather import get_weather
+from services.weather import get_weather, normalize_location_request, DEFAULT_LOCATION_QUERY
from services.recommendation import get_ai_recommendation
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
router = APIRouter()
@@ -14,41 +14,56 @@
class RecommendationResponse(BaseModel):
"""推荐响应"""
weather: dict
+ horoscope: Optional[dict] = None
+ temperature_rule: Optional[dict] = None
recommendation_text: str
+ outfit_summary: Optional[str] = None
+ selection_reasons: Optional[dict] = None
suggested_top: Optional[dict] = None
suggested_bottom: Optional[dict] = None
+ suggested_shoes: Optional[dict] = None
+ suggested_accessories: list[dict] = Field(default_factory=list)
+ purchase_suggestions: list[dict] = Field(default_factory=list)
@router.get("/recommendation", response_model=RecommendationResponse)
async def get_outfit_recommendation(
location: str = Query(
- default="101020100",
- description="LocationID 或 经纬度坐标(如 '116.41,39.92')"
- )
+ default=DEFAULT_LOCATION_QUERY,
+ description="城市名 或 经纬度坐标(如 '31.23,121.47' 或 '121.47,31.23')"
+ ),
+ city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"),
+ state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"),
+ country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"),
+ zodiac_sign: Optional[str] = Query(
+ default=None,
+ description="可选,临时指定星座(会覆盖设置中的星座)"
+ ),
):
"""
获取AI穿搭推荐
参数:
- location: LocationID(如 101010100=北京)或 经纬度坐标(如 116.41,39.92)
-
+ location: 城市名(如 上海、Tokyo)或 经纬度坐标(如 31.23,121.47)
返回:
天气信息 + AI推荐文本 + 推荐的衣服和裤子
-
- 常用城市 LocationID:
- - 101010100: 北京
- - 101020100: 上海
- - 101280101: 广州
- - 101280601: 深圳
- - 101210101: 杭州
"""
+ normalized_location, validation_error = normalize_location_request(
+ location=location,
+ city=city,
+ state=state,
+ country=country,
+ )
+ if validation_error:
+ raise HTTPException(status_code=422, detail=validation_error)
+
# 获取天气信息
- weather = await get_weather(location)
+ weather = await get_weather(normalized_location)
if not weather:
raise HTTPException(status_code=500, detail="获取天气信息失败")
# 获取AI推荐
- recommendation = await get_ai_recommendation(weather)
+ recommendation = await get_ai_recommendation(weather, zodiac_sign=zodiac_sign)
return recommendation
diff --git a/backend/api/upload.py b/backend/api/upload.py
index 8a79a90..5b2618b 100644
--- a/backend/api/upload.py
+++ b/backend/api/upload.py
@@ -9,7 +9,7 @@
from services.removebg import remove_background_api
from services.openai_compatible import analyze_clothes_openai
from storage.config_store import load_config
-from domain.clothes import ClothesSemantics, ClothesCreate, ClothesItem
+from domain.clothes import ClothesSemantics, ClothesCreate, ClothesItem, normalize_category_value
from storage.db import add_clothes, get_clothes_by_id
router = APIRouter()
@@ -18,6 +18,8 @@
UPLOAD_DIR = Path(__file__).parent.parent / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True)
+ALLOWED_CATEGORIES = {"top", "bottom", "shoes", "accessory"}
+
@router.post("/upload", response_model=ClothesItem)
async def upload_image(file: UploadFile = File(...)):
@@ -68,9 +70,13 @@ async def upload_image(file: UploadFile = File(...)):
with open(filepath, "wb") as f:
f.write(processed_bytes)
+ normalized_category = normalize_category_value(semantics.category)
+ if normalized_category not in ALLOWED_CATEGORIES:
+ normalized_category = "accessory"
+
# 保存到数据库
clothes_data = ClothesCreate(
- category=semantics.category,
+ category=normalized_category,
item=semantics.item,
style_semantics=semantics.style_semantics,
season_semantics=semantics.season_semantics,
diff --git a/backend/api/wardrobe.py b/backend/api/wardrobe.py
index 913b657..7329319 100644
--- a/backend/api/wardrobe.py
+++ b/backend/api/wardrobe.py
@@ -2,12 +2,11 @@
衣柜 API - 获取和管理衣物
"""
from fastapi import APIRouter, HTTPException
-from typing import Optional
from domain.clothes import ClothesItem, WardrobeResponse, ClothesCreate
+from domain.clothes import normalize_category_value
from storage.db import (
get_all_clothes,
- get_clothes_by_category,
get_clothes_by_id,
delete_clothes,
update_clothes
@@ -21,18 +20,20 @@ async def get_wardrobe():
"""
获取整个衣柜
- 按 top/bottom/shoes 三类返回所有衣物
+ 按 top/bottom/shoes/accessory 四类返回所有衣物
"""
all_clothes = await get_all_clothes()
- tops = [c for c in all_clothes if c.category == "top"]
- bottoms = [c for c in all_clothes if c.category == "bottom"]
- shoes = [c for c in all_clothes if c.category == "shoes"]
+ tops = [c for c in all_clothes if normalize_category_value(c.category) == "top"]
+ bottoms = [c for c in all_clothes if normalize_category_value(c.category) == "bottom"]
+ shoes = [c for c in all_clothes if normalize_category_value(c.category) == "shoes"]
+ accessories = [c for c in all_clothes if normalize_category_value(c.category) == "accessory"]
return WardrobeResponse(
tops=tops,
bottoms=bottoms,
- shoes=shoes
+ shoes=shoes,
+ accessories=accessories
)
@@ -42,15 +43,17 @@ async def get_wardrobe_category(category: str):
按类别获取衣物
Args:
- category: top, bottom, shoes
+ category: top, bottom, shoes, accessory
"""
- if category not in ["top", "bottom", "shoes"]:
+ category = normalize_category_value(category)
+ if category not in ["top", "bottom", "shoes", "accessory"]:
raise HTTPException(
status_code=400,
- detail="类别必须是 top, bottom 或 shoes"
+ detail="类别必须是 top, bottom, shoes 或 accessory"
)
- return await get_clothes_by_category(category)
+ all_clothes = await get_all_clothes()
+ return [item for item in all_clothes if normalize_category_value(item.category) == category]
@router.get("/clothes/{clothes_id}", response_model=ClothesItem)
diff --git a/backend/api/weather.py b/backend/api/weather.py
index 2b2483b..b6387cb 100644
--- a/backend/api/weather.py
+++ b/backend/api/weather.py
@@ -10,6 +10,8 @@
get_season_from_weather,
get_clothing_suggestion,
search_city,
+ normalize_location_request,
+ DEFAULT_LOCATION_QUERY,
WeatherInfo,
WeatherResponse,
CityInfo
@@ -21,30 +23,36 @@
@router.get("/weather", response_model=WeatherInfo)
async def get_current_weather(
location: str = Query(
- default="101020100",
- description="LocationID 或 经纬度坐标(如 '116.41,39.92')"
- )
+ default=DEFAULT_LOCATION_QUERY,
+ description="城市名 或 经纬度坐标(如 '31.23,121.47' 或 '121.47,31.23')"
+ ),
+ city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"),
+ state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"),
+ country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"),
):
"""
获取当前天气信息
参数:
- location: LocationID(如 101010100=北京)或 经纬度坐标(如 116.41,39.92)
+ location: 城市名(如 上海、Tokyo)或 经纬度坐标(如 31.23,121.47)
返回:
简化的天气信息
- 常用城市 LocationID:
- - 101010100: 北京
- - 101020100: 上海
- - 101280101: 广州
- - 101280601: 深圳
- - 101210101: 杭州
-
- 更多城市 ID 可通过和风天气 GeoAPI 查询:
- https://dev.qweather.com/docs/api/geoapi/
+ 说明:
+ - 天气数据使用 Open-Meteo 免费全球接口
+ - 无需配置天气 API Key
"""
- weather = await get_weather(location)
+ normalized_location, validation_error = normalize_location_request(
+ location=location,
+ city=city,
+ state=state,
+ country=country,
+ )
+ if validation_error:
+ raise HTTPException(status_code=422, detail=validation_error)
+
+ weather = await get_weather(normalized_location)
if not weather:
raise HTTPException(status_code=500, detail="获取天气信息失败")
@@ -55,20 +63,32 @@ async def get_current_weather(
@router.get("/weather/raw", response_model=WeatherResponse)
async def get_raw_weather(
location: str = Query(
- default="101020100",
- description="LocationID 或 经纬度坐标"
- )
+ default=DEFAULT_LOCATION_QUERY,
+ description="城市名 或 经纬度坐标"
+ ),
+ city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"),
+ state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"),
+ country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"),
):
"""
- 获取和风天气原始数据
+ 获取天气原始数据(兼容旧响应结构)
参数:
- location: LocationID 或 经纬度坐标
+ location: 城市名 或 经纬度坐标
返回:
- 完整的和风天气 API 响应数据
+ 兼容 WeatherResponse 的原始数据
"""
- weather = await get_qweather_now(location)
+ normalized_location, validation_error = normalize_location_request(
+ location=location,
+ city=city,
+ state=state,
+ country=country,
+ )
+ if validation_error:
+ raise HTTPException(status_code=422, detail=validation_error)
+
+ weather = await get_qweather_now(normalized_location)
if not weather:
raise HTTPException(status_code=500, detail="获取天气信息失败")
@@ -79,20 +99,32 @@ async def get_raw_weather(
@router.get("/weather/suggestion")
async def get_weather_suggestion(
location: str = Query(
- default="101020100",
- description="LocationID 或 经纬度坐标"
- )
+ default=DEFAULT_LOCATION_QUERY,
+ description="城市名 或 经纬度坐标"
+ ),
+ city: Optional[str] = Query(default=None, description="城市(结构化查询参数)"),
+ state: Optional[str] = Query(default=None, description="省/州(结构化查询参数)"),
+ country: Optional[str] = Query(default=None, description="国家(结构化查询参数)"),
):
"""
获取基于天气的穿搭建议
参数:
- location: LocationID 或 经纬度坐标
+ location: 城市名 或 经纬度坐标
返回:
天气信息 + 穿搭建议 + 适合季节
"""
- weather = await get_weather(location)
+ normalized_location, validation_error = normalize_location_request(
+ location=location,
+ city=city,
+ state=state,
+ country=country,
+ )
+ if validation_error:
+ raise HTTPException(status_code=422, detail=validation_error)
+
+ weather = await get_weather(normalized_location)
if not weather:
raise HTTPException(status_code=500, detail="获取天气信息失败")
@@ -131,7 +163,7 @@ async def search_cities(
limit: 返回结果数量(默认10,最多20)
返回:
- 城市信息列表,包含城市名称、LocationID等
+ 城市信息列表,包含城市名称和坐标 ID(经度,纬度)
示例:
- /api/cities?query=北京
diff --git a/backend/domain/clothes.py b/backend/domain/clothes.py
index 4ca933f..cf673b8 100644
--- a/backend/domain/clothes.py
+++ b/backend/domain/clothes.py
@@ -5,10 +5,26 @@
from typing import List, Optional
from datetime import datetime
+CATEGORY_ALIASES = {
+ "top": {"top", "tops", "上衣", "上装", "外套"},
+ "bottom": {"bottom", "bottoms", "裤子", "下装", "裙子"},
+ "shoes": {"shoes", "shoe", "鞋", "鞋子", "鞋履"},
+ "accessory": {"accessory", "accessories", "饰品", "配饰", "首饰", "珠宝"},
+}
+
+
+def normalize_category_value(category: str) -> str:
+ """将类别归一化为 top/bottom/shoes/accessory。"""
+ value = (category or "").strip().lower()
+ for canonical, aliases in CATEGORY_ALIASES.items():
+ if value in aliases:
+ return canonical
+ return value
+
class ClothesSemantics(BaseModel):
"""Gemini Vision 返回的语义数据结构"""
- category: str # top | bottom | shoes
+ category: str # top | bottom | shoes | accessory
item: str # 具体衣物名称
style_semantics: List[str] # 风格标签
season_semantics: List[str] # 季节
@@ -48,3 +64,4 @@ class WardrobeResponse(BaseModel):
tops: List[ClothesItem]
bottoms: List[ClothesItem]
shoes: List[ClothesItem]
+ accessories: List[ClothesItem]
diff --git a/backend/domain/config.py b/backend/domain/config.py
index 7840f74..d31ced5 100644
--- a/backend/domain/config.py
+++ b/backend/domain/config.py
@@ -13,9 +13,8 @@ class LLMConfig(BaseModel):
# remove.bg 配置
removebg_api_key: str = "mcigdPJZy9oU6c2SMiEwj9VA"
bg_removal_method: Literal["local", "removebg"] = "removebg" # 本地 rembg 或 remove.bg API
- # 和风天气 API 配置
- qweather_api_key: str = "baaa1bb687294acc949bf2a979e0084e"
- qweather_api_host: str = "devapi.qweather.com" # 免费版: devapi.qweather.com | 付费版: api.qweather.com
+ # 默认天气城市(用于首页与推荐页)
+ weather_location: str = "上海, 上海市, 中国"
# 用户星座配置(用于首页运势)
zodiac_sign: str = ""
@@ -27,8 +26,7 @@ class LLMConfigUpdate(BaseModel):
model: Optional[str] = None
removebg_api_key: Optional[str] = None
bg_removal_method: Optional[Literal["local", "removebg"]] = None
- qweather_api_key: Optional[str] = None
- qweather_api_host: Optional[str] = None
+ weather_location: Optional[str] = None
zodiac_sign: Optional[str] = None
diff --git a/backend/domain/prompts.py b/backend/domain/prompts.py
index cd867ce..d68a978 100644
--- a/backend/domain/prompts.py
+++ b/backend/domain/prompts.py
@@ -13,7 +13,7 @@
JSON Schema:
{
- "category": "top | bottom | shoes",
+ "category": "top | bottom | shoes | accessory",
"item": "具体衣物名称,如 T恤、牛仔裤、运动鞋",
"style_semantics": ["风格标签,如 休闲、正式、运动"],
"season_semantics": ["春", "夏", "秋", "冬"],
@@ -22,5 +22,7 @@
"description": "一句话语义总结"
}
+当图片主体是首饰/配件(如项链、手链、帽子、围巾、手表、眼镜、腰带)时,category 必须是 accessory。
+
如果无法判断,请填 "unknown"。
"""
diff --git a/backend/services/horoscope.py b/backend/services/horoscope.py
index 361343a..5b9d80b 100644
--- a/backend/services/horoscope.py
+++ b/backend/services/horoscope.py
@@ -1,16 +1,24 @@
"""
星座运势服务
-根据日期、天气和用户星座生成今日运势
+1) 先拉取并存储 aztro 原始数据
+2) 再按需执行 LLM 推理
"""
+import os
from datetime import datetime
from typing import Optional
import httpx
from storage.config_store import load_config
-from services.openai_compatible import extract_json_from_response
+from storage.db import (
+ get_horoscope_record,
+ upsert_horoscope_source,
+ update_horoscope_inference,
+)
from services.weather import WeatherInfo
+AZTRO_API_URL = os.getenv("AZTRO_API_URL", "https://aztro.sameerkumar.website").rstrip("/")
+
ZODIAC_NAMES = {
"aries": "白羊座",
"taurus": "金牛座",
@@ -127,80 +135,121 @@ def build_weather_tip(weather: WeatherInfo) -> str:
return "整体体感平稳,穿搭上可兼顾舒适与层次感。"
-def fallback_horoscope(sign_key: str, weather: WeatherInfo) -> dict:
- """在未配置 LLM 或调用失败时提供基础运势。"""
- today = datetime.now().strftime("%Y-%m-%d")
+def _to_lucky_number(raw_value: object, default: int = 7) -> int:
+ try:
+ lucky_number = int(raw_value)
+ except Exception:
+ lucky_number = default
+ return min(max(lucky_number, 1), 99)
+
+
+def fallback_horoscope_source(sign_key: str, weather: WeatherInfo, today: str) -> dict:
+ """aztro 不可用时的兜底源数据。"""
day_seed = datetime.now().toordinal()
sign_index = list(ZODIAC_NAMES.keys()).index(sign_key)
-
lucky_number = ((day_seed + sign_index * 7) % 89) + 11
trait = ZODIAC_TRAITS.get(sign_key, "节奏感")
- weather_tip = build_weather_tip(weather)
return {
- "date": today,
- "summary": f"今天你的关键词是{trait},把精力集中在一件最重要的事上,会有更稳定的收获。",
+ "current_date": today,
+ "date_range": "",
+ "description": f"今天你的关键词是{trait},把精力集中在一件最重要的事上,会有更稳定的收获。",
"mood": "稳中有进",
- "lucky_color": DEFAULT_COLORS.get(sign_key, "浅蓝色"),
+ "color": DEFAULT_COLORS.get(sign_key, "浅蓝色"),
"lucky_number": lucky_number,
- "suggestion": weather_tip
+ "lucky_time": "",
+ "compatibility": "",
+ "weather_tip": build_weather_tip(weather),
}
-def sanitize_horoscope_payload(payload: dict, weather_tip: str) -> dict:
- """清洗 LLM 输出,保证字段完整可用。"""
- summary = str(payload.get("summary", "")).strip() or "今天整体节奏平稳,适合把注意力放在核心目标。"
+def sanitize_aztro_payload(payload: dict, sign_key: str, today: str, weather: WeatherInfo) -> dict:
+ """清洗 aztro 输出,保证字段完整可用。"""
+ description = str(payload.get("description", "")).strip() or "今天整体节奏平稳,适合把注意力放在核心目标。"
mood = str(payload.get("mood", "")).strip() or "平稳"
- lucky_color = str(payload.get("lucky_color", "")).strip() or "浅蓝色"
- suggestion = str(payload.get("suggestion", "")).strip() or weather_tip
-
- raw_number = payload.get("lucky_number", 7)
- try:
- lucky_number = int(raw_number)
- except Exception:
- lucky_number = 7
- lucky_number = min(max(lucky_number, 1), 99)
+ color = str(payload.get("color", "")).strip() or DEFAULT_COLORS.get(sign_key, "浅蓝色")
+ lucky_time = str(payload.get("lucky_time", "")).strip()
+ compatibility = str(payload.get("compatibility", "")).strip()
+ date_range = str(payload.get("date_range", "")).strip()
+ current_date = str(payload.get("current_date", "")).strip() or today
return {
- "summary": summary,
+ "current_date": current_date,
+ "date_range": date_range,
+ "description": description,
"mood": mood,
- "lucky_color": lucky_color,
- "lucky_number": lucky_number,
- "suggestion": suggestion
+ "color": color,
+ "lucky_number": _to_lucky_number(payload.get("lucky_number", 7)),
+ "lucky_time": lucky_time,
+ "compatibility": compatibility,
+ "weather_tip": build_weather_tip(weather),
}
-async def generate_llm_horoscope(sign_key: str, weather: WeatherInfo) -> Optional[dict]:
- """调用 OpenAI 兼容接口生成运势文本。"""
+async def fetch_aztro_horoscope(sign_key: str, today: str, weather: WeatherInfo) -> Optional[dict]:
+ """获取 aztro 今日运势。"""
+ url = f"{AZTRO_API_URL}/?sign={sign_key}&day=today"
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(url, headers={"Accept": "application/json"})
+ if response.status_code in (404, 405):
+ response = await client.get(url, headers={"Accept": "application/json"})
+
+ if response.status_code != 200:
+ print(f"aztro 请求失败: {response.status_code} {response.text[:200]}")
+ return None
+
+ data = response.json()
+ if not isinstance(data, dict):
+ print("aztro 返回格式异常")
+ return None
+
+ return sanitize_aztro_payload(data, sign_key=sign_key, today=today, weather=weather)
+ except Exception as exc:
+ print(f"aztro 调用异常: {exc}")
+ return None
+
+
+async def generate_llm_reasoning(
+ sign_key: str,
+ zodiac_name: str,
+ weather: WeatherInfo,
+ source_payload: dict,
+) -> tuple[Optional[str], str, str]:
+ """
+ 基于已存储的原始运势数据做 LLM 推理。
+ Returns:
+ (推理文本, 状态, 错误信息)
+ """
config = load_config()
if not config.api_key:
- return None
+ return None, "skipped", "未配置 LLM API Key"
api_base = config.api_base.rstrip("/")
if not api_base.endswith("/v1"):
api_base = f"{api_base}/v1"
- today = datetime.now().strftime("%Y-%m-%d")
- zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key)
- weather_tip = build_weather_tip(weather)
-
prompt = f"""
-你是一个风格克制、实用导向的星座顾问。请为用户生成今日运势。
-
-日期:{today}
-星座:{zodiac_name}
-天气:{weather.condition},气温 {weather.temperature}°C,体感 {weather.feelsLike}°C,湿度 {weather.humidity}%
-
-请输出 JSON,字段必须完整:
-{{
- "summary": "40-80字,描述今日整体运势,避免绝对化和恐吓表述",
- "mood": "2-6字情绪关键词",
- "lucky_color": "1个颜色词",
- "lucky_number": 1-99 的整数,
- "suggestion": "结合天气和运势给出 1 条可执行建议(20-50字)"
-}}
-
-只返回 JSON,不要代码块,不要额外说明。
+你是一名理性、可执行导向的运势分析助手。请基于以下星座原始数据给出穿搭场景推理。
+
+星座:{zodiac_name}({sign_key})
+aztro 原始数据:
+- 日期:{source_payload.get('current_date', '')}
+- 描述:{source_payload.get('description', '')}
+- 心情:{source_payload.get('mood', '')}
+- 幸运色:{source_payload.get('color', '')}
+- 幸运数字:{source_payload.get('lucky_number', '')}
+- 幸运时段:{source_payload.get('lucky_time', '')}
+- 契合星座:{source_payload.get('compatibility', '')}
+
+天气:
+- {weather.condition},温度 {weather.temperature}°C,体感 {weather.feelsLike}°C,湿度 {weather.humidity}%
+
+输出要求:
+1. 输出 2-3 句中文
+2. 给出可执行的穿搭/配色建议
+3. 不要绝对化、不要神秘化
+4. 不要代码块,不要 JSON
"""
payload = {
@@ -208,46 +257,99 @@ async def generate_llm_horoscope(sign_key: str, weather: WeatherInfo) -> Optiona
"messages": [
{
"role": "system",
- "content": "你输出结构化 JSON,内容友好、理性、可执行。"
+ "content": "你做简洁、务实的推理,避免夸张表达。"
},
{
"role": "user",
"content": prompt
}
],
- "temperature": 0.7
+ "temperature": 0.6
}
try:
- async with httpx.AsyncClient(timeout=12.0) as client:
+ async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
f"{api_base}/chat/completions",
headers={
"Authorization": f"Bearer {config.api_key}",
- "Content-Type": "application/json"
+ "Content-Type": "application/json",
},
- json=payload
+ json=payload,
)
if response.status_code != 200:
- print(f"LLM 星座运势请求失败: {response.status_code} {response.text[:200]}")
- return None
-
- content = response.json()["choices"][0]["message"]["content"]
- parsed = extract_json_from_response(content)
- return sanitize_horoscope_payload(parsed, weather_tip)
+ err = f"LLM 星座推理请求失败: {response.status_code}"
+ print(err)
+ return None, "failed", err
+
+ content = (
+ response.json()
+ .get("choices", [{}])[0]
+ .get("message", {})
+ .get("content", "")
+ .strip()
+ )
+ if not content:
+ return None, "failed", "LLM 返回空内容"
+
+ return content, "done", ""
except Exception as exc:
- print(f"LLM 星座运势调用异常: {exc}")
- return None
+ err = f"LLM 星座推理异常: {exc}"
+ print(err)
+ return None, "failed", err
+
+
+def build_suggestion(weather: WeatherInfo, source_payload: dict) -> str:
+ weather_tip = str(source_payload.get("weather_tip", "")).strip() or build_weather_tip(weather)
+ lucky_time = str(source_payload.get("lucky_time", "")).strip()
+ if lucky_time:
+ return f"幸运时段:{lucky_time}。{weather_tip}"
+ return weather_tip
+
+
+def build_horoscope_response(
+ *,
+ today: str,
+ sign_key: str,
+ source_provider: str,
+ source_payload: dict,
+ weather: WeatherInfo,
+ llm_status: str,
+ llm_reasoning: str,
+) -> dict:
+ zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key)
+ summary = str(source_payload.get("description", "")).strip() or "今天整体节奏平稳,建议聚焦最重要的一件事。"
+ mood = str(source_payload.get("mood", "")).strip() or "平稳"
+ lucky_color = str(source_payload.get("color", "")).strip() or DEFAULT_COLORS.get(sign_key, "浅蓝色")
+ lucky_number = _to_lucky_number(source_payload.get("lucky_number", 7))
+
+ return {
+ "date": today,
+ "zodiac_sign": sign_key,
+ "zodiac_name": zodiac_name,
+ "is_configured": True,
+ "summary": summary,
+ "mood": mood,
+ "lucky_color": lucky_color,
+ "lucky_number": lucky_number,
+ "suggestion": build_suggestion(weather, source_payload),
+ "source_provider": source_provider,
+ "llm_status": llm_status,
+ "llm_reasoning": llm_reasoning or "",
+ }
-async def get_daily_horoscope(weather: WeatherInfo, zodiac_sign: Optional[str] = None) -> dict:
- """获取今日星座运势。"""
+async def get_daily_horoscope(
+ weather: WeatherInfo,
+ zodiac_sign: Optional[str] = None,
+ include_inference: bool = True,
+) -> dict:
+ """获取今日星座运势(先源数据,后可选推理)。"""
today = datetime.now().strftime("%Y-%m-%d")
config = load_config()
sign_key = normalize_zodiac_sign(zodiac_sign) or normalize_zodiac_sign(config.zodiac_sign)
-
if not sign_key:
return {
"date": today,
@@ -258,21 +360,63 @@ async def get_daily_horoscope(weather: WeatherInfo, zodiac_sign: Optional[str] =
"mood": "待设置",
"lucky_color": "云白色",
"lucky_number": 6,
- "suggestion": build_weather_tip(weather)
+ "suggestion": build_weather_tip(weather),
+ "source_provider": "none",
+ "llm_status": "skipped",
+ "llm_reasoning": "",
}
- llm_result = await generate_llm_horoscope(sign_key, weather)
- if not llm_result:
- llm_result = fallback_horoscope(sign_key, weather)
+ zodiac_name = ZODIAC_NAMES.get(sign_key, sign_key)
- return {
- "date": today,
- "zodiac_sign": sign_key,
- "zodiac_name": ZODIAC_NAMES.get(sign_key, sign_key),
- "is_configured": True,
- "summary": llm_result["summary"],
- "mood": llm_result["mood"],
- "lucky_color": llm_result["lucky_color"],
- "lucky_number": llm_result["lucky_number"],
- "suggestion": llm_result["suggestion"]
- }
+ cached = await get_horoscope_record(record_date=today, zodiac_sign=sign_key)
+ if cached:
+ source_payload = cached.get("source_payload") or {}
+ source_provider = cached.get("source_provider", "cached")
+ llm_status = cached.get("llm_status", "pending")
+ llm_reasoning = cached.get("llm_reasoning", "")
+ record_id = int(cached["id"])
+ else:
+ source_payload = await fetch_aztro_horoscope(sign_key=sign_key, today=today, weather=weather)
+ source_provider = "aztro"
+ if not source_payload:
+ source_payload = fallback_horoscope_source(sign_key=sign_key, weather=weather, today=today)
+ source_provider = "fallback"
+
+ record_id = await upsert_horoscope_source(
+ record_date=today,
+ zodiac_sign=sign_key,
+ zodiac_name=zodiac_name,
+ source_provider=source_provider,
+ source_payload=source_payload,
+ )
+ llm_status = "pending"
+ llm_reasoning = ""
+
+ # 每天只推理一次:只有当天首次记录的 pending 状态才执行推理
+ if include_inference and llm_status == "pending":
+ reasoning, status, err = await generate_llm_reasoning(
+ sign_key=sign_key,
+ zodiac_name=zodiac_name,
+ weather=weather,
+ source_payload=source_payload,
+ )
+ if reasoning:
+ llm_reasoning = reasoning
+ llm_status = status
+
+ await update_horoscope_inference(
+ record_id=record_id,
+ llm_status=llm_status,
+ llm_reasoning=llm_reasoning,
+ llm_error=err,
+ )
+
+ return build_horoscope_response(
+ today=today,
+ sign_key=sign_key,
+ source_provider=source_provider,
+ source_payload=source_payload,
+ weather=weather,
+ llm_status=llm_status,
+ llm_reasoning=llm_reasoning,
+ )
diff --git a/backend/services/recommendation.py b/backend/services/recommendation.py
index 156966a..2c7241b 100644
--- a/backend/services/recommendation.py
+++ b/backend/services/recommendation.py
@@ -1,31 +1,280 @@
"""
AI穿搭推荐服务
-基于天气和衣橱数据生成个性化推荐
+基于天气、星座运势和衣橱数据生成个性化推荐
"""
import httpx
-from typing import Optional
+from typing import Any
+
+from domain.clothes import normalize_category_value
+from services.horoscope import get_daily_horoscope
+from services.weather import WeatherInfo
from storage.config_store import load_config
-from services.weather import WeatherInfo, get_season_from_weather
from storage.db import get_all_clothes
+SEASON_ALIASES = {
+ "春": {"春", "春季", "spring"},
+ "夏": {"夏", "夏季", "summer"},
+ "秋": {"秋", "秋季", "autumn", "fall"},
+ "冬": {"冬", "冬季", "winter"},
+}
+
+ZODIAC_STYLE_HINTS = {
+ "aries": {"运动", "街头", "休闲", "sport", "casual"},
+ "taurus": {"简约", "质感", "通勤", "minimal", "business"},
+ "gemini": {"轻松", "层次", "日常", "casual", "daily"},
+ "cancer": {"柔和", "舒适", "居家", "comfort", "daily"},
+ "leo": {"亮眼", "时髦", "正式", "formal", "fashion"},
+ "virgo": {"利落", "简约", "通勤", "minimal", "business"},
+ "libra": {"平衡", "优雅", "约会", "elegant", "formal"},
+ "scorpio": {"深色", "干练", "都市", "formal", "vintage"},
+ "sagittarius": {"户外", "运动", "旅行", "sport", "casual"},
+ "capricorn": {"通勤", "商务", "经典", "business", "formal"},
+ "aquarius": {"个性", "创意", "街头", "street", "casual"},
+ "pisces": {"柔和", "文艺", "轻盈", "vintage", "casual"},
+}
+
+ACCESSORY_KEYWORDS = (
+ "帽", "帽子", "围巾", "项链", "耳环", "耳钉", "手链", "戒指",
+ "手表", "墨镜", "眼镜", "领带", "腰带", "cap", "hat", "scarf",
+ "necklace", "earring", "bracelet", "ring", "watch", "sunglasses",
+ "tie", "belt"
+)
+
+
+def normalize_seasons(raw_values: list[str]) -> set[str]:
+ normalized: set[str] = set()
+ for value in raw_values or []:
+ token = (value or "").strip().lower()
+ if not token:
+ continue
+ for canonical, aliases in SEASON_ALIASES.items():
+ if token in aliases:
+ normalized.add(canonical)
+ break
+ return normalized
+
+
+def build_temperature_profile(weather: WeatherInfo) -> dict[str, Any]:
+ feels_like = weather.feelsLike
+
+ if feels_like <= 5:
+ return {
+ "label": "寒冷",
+ "allowed_seasons": {"冬"},
+ "advice": "优先保暖,建议选择厚实上衣、长裤和防滑保暖鞋。",
+ "purchase_hints": {
+ "top": ["羽绒服", "保暖内搭", "羊毛衫"],
+ "bottom": ["加绒长裤", "保暖打底裤"],
+ "shoes": ["保暖短靴", "防滑鞋"],
+ "accessory": ["围巾", "针织帽", "手套"],
+ },
+ }
+ if feels_like <= 14:
+ return {
+ "label": "偏凉",
+ "allowed_seasons": {"秋", "冬"},
+ "advice": "建议轻量叠穿,外套和长裤优先,鞋履尽量包裹性更强。",
+ "purchase_hints": {
+ "top": ["风衣", "针织衫", "卫衣"],
+ "bottom": ["直筒长裤", "牛仔裤"],
+ "shoes": ["运动鞋", "乐福鞋"],
+ "accessory": ["薄围巾", "金属手表"],
+ },
+ }
+ if feels_like <= 24:
+ return {
+ "label": "舒适",
+ "allowed_seasons": {"春", "秋"},
+ "advice": "温度适中,保持透气与层次,注意早晚温差。",
+ "purchase_hints": {
+ "top": ["衬衫", "薄针织", "轻薄夹克"],
+ "bottom": ["休闲长裤", "九分裤"],
+ "shoes": ["小白鞋", "休闲鞋"],
+ "accessory": ["细链项链", "简约手链"],
+ },
+ }
+ if feels_like <= 30:
+ return {
+ "label": "偏热",
+ "allowed_seasons": {"春", "夏"},
+ "advice": "优先透气轻薄单品,避免厚重面料。",
+ "purchase_hints": {
+ "top": ["短袖T恤", "亚麻衬衫"],
+ "bottom": ["轻薄休闲裤", "短裤"],
+ "shoes": ["透气运动鞋", "凉鞋"],
+ "accessory": ["棒球帽", "太阳镜"],
+ },
+ }
+ return {
+ "label": "炎热",
+ "allowed_seasons": {"夏"},
+ "advice": "尽量选择吸汗速干和透气面料,减少叠穿。",
+ "purchase_hints": {
+ "top": ["速干短袖", "背心"],
+ "bottom": ["速干短裤", "轻薄短裤"],
+ "shoes": ["凉鞋", "网面运动鞋"],
+ "accessory": ["遮阳帽", "太阳镜"],
+ },
+ }
+
+
+def is_temperature_compatible(item: dict, allowed_seasons: set[str]) -> bool:
+ item_seasons = normalize_seasons(item.get("season_semantics", []))
+ if not item_seasons:
+ return False
+ return bool(item_seasons & allowed_seasons)
+
+
+def build_color_tokens(raw_color: str) -> list[str]:
+ base = (raw_color or "").strip().lower()
+ if not base:
+ return []
+ compact = base.replace("色", "").replace("系", "").replace(" ", "")
+ tokens = {base, compact}
+ if len(compact) >= 2:
+ tokens.add(compact[:2])
+ return [token for token in tokens if token]
+
+
+def score_item(
+ item: dict,
+ category: str,
+ horoscope: dict,
+ weather: WeatherInfo,
+ temperature_profile: dict[str, Any],
+) -> tuple[int, list[str]]:
+ score = 5
+ reasons = [f"季节标签匹配{temperature_profile['label']}温度策略"]
+
+ lucky_color = horoscope.get("lucky_color", "")
+ color_tokens = build_color_tokens(lucky_color)
+ searchable_text = " ".join([
+ str(item.get("item", "")),
+ str(item.get("color_semantics", "")),
+ str(item.get("description", "")),
+ ]).lower()
+
+ if color_tokens and any(token in searchable_text for token in color_tokens):
+ score += 4
+ reasons.append(f"颜色接近今日幸运色「{lucky_color}」")
+
+ sign_key = horoscope.get("zodiac_sign", "")
+ style_hints = ZODIAC_STYLE_HINTS.get(sign_key, set())
+ style_values = {
+ str(v).strip().lower()
+ for v in item.get("style_semantics", [])
+ if str(v).strip()
+ }
+ if style_hints and (style_values & style_hints):
+ score += 3
+ reasons.append("风格与今日星座运势倾向一致")
+
+ if category == "shoes" and ("雨" in weather.condition or "雪" in weather.condition):
+ if any(keyword in searchable_text for keyword in ("防水", "短靴", "boot", "靴")):
+ score += 2
+ reasons.append("天气有降水,鞋履更注重防滑/防水")
+
+ return score, reasons
+
+
+def pick_best_item(
+ candidates: list[dict],
+ category: str,
+ horoscope: dict,
+ weather: WeatherInfo,
+ temperature_profile: dict[str, Any],
+) -> tuple[dict | None, str]:
+ if not candidates:
+ return None, ""
+
+ best_item = None
+ best_score = -1
+ best_reasons: list[str] = []
+
+ for item in candidates:
+ score, reasons = score_item(item, category, horoscope, weather, temperature_profile)
+ if score > best_score:
+ best_score = score
+ best_item = item
+ best_reasons = reasons
+
+ return best_item, ";".join(best_reasons)
+
+
+def build_purchase_suggestion(
+ category: str,
+ temperature_profile: dict[str, Any],
+ horoscope: dict,
+) -> dict[str, Any]:
+ names = {
+ "top": "上装",
+ "bottom": "下装",
+ "shoes": "鞋履",
+ }
+ hints = temperature_profile["purchase_hints"].get(category, [])
+ zodiac_name = horoscope.get("zodiac_name", "今日运势")
+ lucky_color = horoscope.get("lucky_color", "中性色")
+ return {
+ "category": category,
+ "title": f"建议补充{names.get(category, category)}",
+ "reason": f"衣柜中暂无满足当前温度策略的{names.get(category, category)},建议优先补齐温度刚需单品。",
+ "keywords": hints,
+ "horoscope_hint": f"{zodiac_name}今日幸运色为{lucky_color},可优先选择该色系。",
+ }
+
+
+def extract_wardrobe_accessories(all_clothes: list[dict]) -> list[dict]:
+ accessories = []
+ for item in all_clothes:
+ if normalize_category_value(str(item.get("category", ""))) == "accessory":
+ accessories.append(item)
+ continue
+ text = f"{item.get('item', '')} {item.get('description', '')}".lower()
+ if any(keyword in text for keyword in ACCESSORY_KEYWORDS):
+ accessories.append(item)
+ return accessories
+
+
+def build_purchase_accessories(
+ temperature_profile: dict[str, Any],
+ horoscope: dict,
+) -> list[dict]:
+ lucky_color = horoscope.get("lucky_color", "中性色")
+ zodiac_name = horoscope.get("zodiac_name", "今日运势")
+ base_items = temperature_profile["purchase_hints"].get("accessory", ["简约手链", "通勤手表"])
+ return [
+ {
+ "name": accessory_name,
+ "reason": f"结合{zodiac_name}与天气体感,选择{lucky_color}或同色系点缀更协调。",
+ "from_wardrobe": False,
+ "should_buy": True,
+ }
+ for accessory_name in base_items[:2]
+ ]
-async def get_ai_recommendation(weather: WeatherInfo) -> dict:
+
+def build_recommendation_summary(
+ selected: dict[str, dict | None],
+ purchase_suggestions: list[dict],
+) -> str:
+ outfit_parts = []
+ for category in ("top", "bottom", "shoes"):
+ item = selected.get(category)
+ if item:
+ outfit_parts.append(f"{category}: {item.get('item', '未命名')}")
+
+ summary = ",".join(outfit_parts) if outfit_parts else "暂无可直接搭配的单品。"
+ if purchase_suggestions:
+ summary += f" 需要补充 {len(purchase_suggestions)} 类温度必需单品。"
+ return summary
+
+
+async def get_ai_recommendation(weather: WeatherInfo, zodiac_sign: str | None = None) -> dict:
"""
- 根据天气获取AI穿搭推荐
-
- Args:
- weather: 天气信息
-
- Returns:
- 推荐信息(包含文本和推荐的衣物)
+ 根据天气和星座运势获取AI穿搭推荐。
+ 温度约束为硬条件:衣柜单品必须满足温度策略,不满足时给出购买兜底。
"""
- # 获取适合的季节
- seasons = get_season_from_weather(weather)
-
- # 从数据库获取所有衣物
all_clothes_items = await get_all_clothes()
-
- # 转换为字典格式
all_clothes = [
{
"id": item.id,
@@ -36,35 +285,88 @@ async def get_ai_recommendation(weather: WeatherInfo) -> dict:
"usage_semantics": item.usage_semantics,
"color_semantics": item.color_semantics,
"description": item.description,
- "image_url": item.image_url
+ "image_url": item.image_url,
}
for item in all_clothes_items
]
-
- # 过滤出适合当前季节的衣物
- suitable_tops = [
- c for c in all_clothes
- if c.get("category") == "上衣" and any(s in c.get("season_semantics", []) for s in seasons)
- ]
- suitable_bottoms = [
- c for c in all_clothes
- if c.get("category") == "裤子" and any(s in c.get("season_semantics", []) for s in seasons)
- ]
-
- # 如果没有合适的衣物,就从全部中选择
- if not suitable_tops:
- suitable_tops = [c for c in all_clothes if c.get("category") == "上衣"]
- if not suitable_bottoms:
- suitable_bottoms = [c for c in all_clothes if c.get("category") == "裤子"]
-
- # 向LLM请求推荐文本
- recommendation_text = await get_llm_recommendation(weather, seasons, suitable_tops, suitable_bottoms)
-
- # 随机选择一件上衣和裤子
- import random
- suggested_top = random.choice(suitable_tops) if suitable_tops else None
- suggested_bottom = random.choice(suitable_bottoms) if suitable_bottoms else None
-
+
+ horoscope = await get_daily_horoscope(
+ weather=weather,
+ zodiac_sign=zodiac_sign,
+ include_inference=True,
+ )
+ temperature_profile = build_temperature_profile(weather)
+
+ by_category: dict[str, list[dict]] = {"top": [], "bottom": [], "shoes": []}
+ for item in all_clothes:
+ category = normalize_category_value(str(item.get("category", "")))
+ if category not in by_category:
+ continue
+ if is_temperature_compatible(item, temperature_profile["allowed_seasons"]):
+ by_category[category].append(item)
+
+ selected: dict[str, dict | None] = {}
+ selection_reasons: dict[str, str] = {}
+ purchase_suggestions: list[dict] = []
+
+ for category in ("top", "bottom", "shoes"):
+ chosen, reason = pick_best_item(
+ by_category[category],
+ category=category,
+ horoscope=horoscope,
+ weather=weather,
+ temperature_profile=temperature_profile,
+ )
+ selected[category] = chosen
+ selection_reasons[category] = reason
+ if chosen is None:
+ purchase_suggestions.append(
+ build_purchase_suggestion(category, temperature_profile, horoscope)
+ )
+
+ accessory_candidates = extract_wardrobe_accessories(all_clothes)
+ compatible_accessories = []
+ for item in accessory_candidates:
+ # 饰品优先按季节匹配;无季节标签时保留可选。
+ if is_temperature_compatible(item, temperature_profile["allowed_seasons"]) or not item.get("season_semantics"):
+ compatible_accessories.append(item)
+
+ suggested_accessories: list[dict[str, Any]] = []
+ if compatible_accessories:
+ scored = []
+ for item in compatible_accessories:
+ score, reasons = score_item(
+ item,
+ category="accessory",
+ horoscope=horoscope,
+ weather=weather,
+ temperature_profile=temperature_profile,
+ )
+ scored.append((score, item, ";".join(reasons)))
+ scored.sort(key=lambda value: value[0], reverse=True)
+ for _, item, reason in scored[:2]:
+ suggested_accessories.append(
+ {
+ "name": item.get("item", "饰品"),
+ "reason": reason or "与今日运势风格匹配",
+ "from_wardrobe": True,
+ "should_buy": False,
+ "item": item,
+ }
+ )
+ else:
+ suggested_accessories = build_purchase_accessories(temperature_profile, horoscope)
+
+ recommendation_text = await get_llm_recommendation(
+ weather=weather,
+ horoscope=horoscope,
+ temperature_profile=temperature_profile,
+ selected=selected,
+ selection_reasons=selection_reasons,
+ purchase_suggestions=purchase_suggestions,
+ suggested_accessories=suggested_accessories,
+ )
+
return {
"weather": {
"temperature": weather.temperature,
@@ -74,135 +376,198 @@ async def get_ai_recommendation(weather: WeatherInfo) -> dict:
"humidity": weather.humidity,
"windDir": weather.windDir,
"windScale": weather.windScale,
- "obsTime": weather.obsTime
+ "location": weather.location,
+ "obsTime": weather.obsTime,
+ },
+ "horoscope": horoscope,
+ "temperature_rule": {
+ "label": temperature_profile["label"],
+ "allowed_seasons": sorted(list(temperature_profile["allowed_seasons"])),
+ "advice": temperature_profile["advice"],
},
"recommendation_text": recommendation_text,
- "suggested_top": suggested_top,
- "suggested_bottom": suggested_bottom
+ "outfit_summary": build_recommendation_summary(selected, purchase_suggestions),
+ "selection_reasons": selection_reasons,
+ "suggested_top": selected.get("top"),
+ "suggested_bottom": selected.get("bottom"),
+ "suggested_shoes": selected.get("shoes"),
+ "suggested_accessories": suggested_accessories,
+ "purchase_suggestions": purchase_suggestions,
}
async def get_llm_recommendation(
- weather: WeatherInfo,
- seasons: list[str],
- tops: list[dict],
- bottoms: list[dict]
+ weather: WeatherInfo,
+ horoscope: dict,
+ temperature_profile: dict[str, Any],
+ selected: dict[str, dict | None],
+ selection_reasons: dict[str, str],
+ purchase_suggestions: list[dict],
+ suggested_accessories: list[dict],
) -> str:
"""
- 使用LLM生成个性化推荐文本
-
- Args:
- weather: 天气信息
- seasons: 适合的季节
- tops: 可用的上衣列表
- bottoms: 可用的裤子列表
-
- Returns:
- 推荐文本
+ 使用 LLM 生成推荐文案,失败时回退到规则文本。
"""
config = load_config()
-
if not config.api_key:
- # 如果没有配置API,返回基础推荐
- return generate_basic_recommendation(weather, seasons)
-
- # 构建提示词
+ return generate_basic_recommendation(
+ weather=weather,
+ horoscope=horoscope,
+ temperature_profile=temperature_profile,
+ selected=selected,
+ selection_reasons=selection_reasons,
+ purchase_suggestions=purchase_suggestions,
+ suggested_accessories=suggested_accessories,
+ )
+
+ def item_name(category: str) -> str:
+ item = selected.get(category)
+ return item["item"] if item else "缺失"
+
+ purchase_lines = "\n".join(
+ [f"- {entry['title']}:{', '.join(entry.get('keywords', []))}" for entry in purchase_suggestions]
+ ) or "- 无需购买补充"
+
+ accessory_lines = "\n".join(
+ [
+ f"- {entry.get('name', '饰品')}({'衣柜已有' if entry.get('from_wardrobe') else '建议补购'}):{entry.get('reason', '')}"
+ for entry in suggested_accessories
+ ]
+ ) or "- 暂无饰品建议"
+
prompt = f"""
-你是一位专业的时尚穿搭顾问。请根据以下天气信息,为用户提供穿搭建议:
+你是一位务实的穿搭顾问。请基于以下信息输出一段 Markdown 推荐:
+
+天气:
+- 温度 {weather.temperature}°C,体感 {weather.feelsLike}°C,{weather.condition}
+- 湿度 {weather.humidity}% / 风力 {weather.windScale}级
-当前天气:
-- 温度:{weather.temperature}°C
-- 体感温度:{weather.feelsLike}°C
-- 天气状况:{weather.condition}
-- 湿度:{weather.humidity}%
-- 风向风力:{weather.windDir} {weather.windScale}级
+星座运势:
+- 星座:{horoscope.get('zodiac_name', '未设置')}
+- 今日关键词:{horoscope.get('mood', '平稳')}
+- 幸运色:{horoscope.get('lucky_color', '中性色')}
+- 运势摘要:{horoscope.get('summary', '')}
-适合的季节:{', '.join(seasons)}
+温度策略:
+- 档位:{temperature_profile['label']}
+- 可用季节标签:{', '.join(sorted(list(temperature_profile['allowed_seasons'])))}
+- 策略建议:{temperature_profile['advice']}
-用户衣橱中有 {len(tops)} 件上衣和 {len(bottoms)} 件裤子可供选择。
+从衣柜挑选结果(仅保留温度匹配单品):
+- 上装:{item_name('top')}({selection_reasons.get('top', '未命中')})
+- 下装:{item_name('bottom')}({selection_reasons.get('bottom', '未命中')})
+- 鞋履:{item_name('shoes')}({selection_reasons.get('shoes', '未命中')})
+缺失补购建议:
+{purchase_lines}
-请生成一段友好、实用的穿搭推荐(150词左右),使用 Markdown 格式以便于阅读:
-1. **针对当前天气的穿搭建议**(使用粗体强调重点衣物)
-2. **需要注意的事项**(如防晒、保暖、防雨等)
-3. **穿搭风格建议**
+饰品建议:
+{accessory_lines}
-请直接输出 Markdown 格式的文本,不要包含代码块标记(如 ```markdown)。
+输出要求:
+1. 先给出今日穿搭结论(2-3句)
+2. 明确指出哪些是衣柜现有、哪些需要补购
+3. 补充1条与星座运势相关的饰品搭配建议
+4. 不要输出代码块
"""
-
+
try:
- # 确保 api_base 格式正确
api_base = config.api_base.rstrip("/")
if not api_base.endswith("/v1"):
- api_base = api_base + "/v1"
-
- url = f"{api_base}/chat/completions"
-
+ api_base = f"{api_base}/v1"
+
payload = {
"model": config.model,
"messages": [
- {"role": "system", "content": "你是一位专业的时尚穿搭顾问,擅长根据天气提供实用的穿搭建议。"},
- {"role": "user", "content": prompt}
+ {
+ "role": "system",
+ "content": "你是专业穿搭顾问,强调可执行建议和温度适配。",
+ },
+ {"role": "user", "content": prompt},
],
- "temperature": 0.7
+ "temperature": 0.6,
}
-
- async with httpx.AsyncClient(timeout=30.0) as client:
+
+ async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
- url,
+ f"{api_base}/chat/completions",
headers={
"Authorization": f"Bearer {config.api_key}",
- "Content-Type": "application/json"
+ "Content-Type": "application/json",
},
- json=payload
+ json=payload,
+ )
+
+ if response.status_code != 200:
+ print(f"LLM API请求失败: {response.status_code}")
+ return generate_basic_recommendation(
+ weather=weather,
+ horoscope=horoscope,
+ temperature_profile=temperature_profile,
+ selected=selected,
+ selection_reasons=selection_reasons,
+ purchase_suggestions=purchase_suggestions,
+ suggested_accessories=suggested_accessories,
)
-
- if response.status_code == 200:
- data = response.json()
- return data["choices"][0]["message"]["content"].strip()
- else:
- print(f"LLM API请求失败: {response.status_code}")
- return generate_basic_recommendation(weather, seasons)
-
- except Exception as e:
- print(f"调用LLM失败: {e}")
- return generate_basic_recommendation(weather, seasons)
-
-
-def generate_basic_recommendation(weather: WeatherInfo, seasons: list[str]) -> str:
+
+ data = response.json()
+ return data["choices"][0]["message"]["content"].strip()
+ except Exception as exc:
+ print(f"调用LLM失败: {exc}")
+ return generate_basic_recommendation(
+ weather=weather,
+ horoscope=horoscope,
+ temperature_profile=temperature_profile,
+ selected=selected,
+ selection_reasons=selection_reasons,
+ purchase_suggestions=purchase_suggestions,
+ suggested_accessories=suggested_accessories,
+ )
+
+
+def generate_basic_recommendation(
+ weather: WeatherInfo,
+ horoscope: dict,
+ temperature_profile: dict[str, Any],
+ selected: dict[str, dict | None],
+ selection_reasons: dict[str, str],
+ purchase_suggestions: list[dict],
+ suggested_accessories: list[dict],
+) -> str:
"""
- 生成基础推荐(不使用LLM)
-
- Args:
- weather: 天气信息
- seasons: 适合的季节
-
- Returns:
- 基础推荐文本
+ 生成规则版推荐文本(不依赖 LLM)。
"""
- temp = weather.feelsLike
- condition = weather.condition
-
- # 基于温度的建议
- if temp < 0:
- base_text = "🧥 今天非常寒冷,建议穿厚羽绒服、棉衣、毛衣等保暖衣物,搭配厚裤子和保暖鞋。"
- elif temp < 10:
- base_text = "🧥 今天比较冷,建议穿风衣、大衣、夹克等外套,内搭长袖衬衫或卫衣,搭配长裤。"
- elif temp < 20:
- base_text = "👔 今天温度适中,建议穿薄外套、长袖衬衫、卫衣等,可以采用叠穿搭配,方便调节。"
- elif temp < 28:
- base_text = "👕 今天天气舒适,建议穿短袖、薄长袖等轻便衣物,搭配休闲裤或牛仔裤。"
- else:
- base_text = "👕 今天天气炎热,建议穿短袖、短裤等夏季清凉衣物,选择透气吸汗的面料。"
-
- # 根据天气状况补充建议
- if "雨" in condition:
- base_text += " 今天有雨,记得带伞,避免穿浅色衣物,选择防水鞋。☂️"
- elif "雪" in condition:
- base_text += " 今天有雪,注意防滑保暖,选择防水防滑的鞋子。❄️"
- elif "晴" in condition and temp > 25:
- base_text += " 今天阳光充足,外出注意防晒,可以搭配太阳镜和遮阳帽。☀️"
- elif "阴" in condition or "云" in condition:
- base_text += " 今天多云,建议准备一件薄外套以备不时之需。☁️"
-
- return base_text
+ lines = [
+ f"### 今日穿搭结论",
+ f"体感温度约 **{weather.feelsLike}°C**,按 **{temperature_profile['label']}** 策略搭配:{temperature_profile['advice']}",
+ "",
+ "### 衣柜命中单品",
+ ]
+
+ category_names = {"top": "上装", "bottom": "下装", "shoes": "鞋履"}
+ for category in ("top", "bottom", "shoes"):
+ item = selected.get(category)
+ if item:
+ lines.append(
+ f"- {category_names[category]}:**{item.get('item', '未命名')}**({selection_reasons.get(category, '温度匹配')})"
+ )
+ else:
+ lines.append(f"- {category_names[category]}:暂无温度匹配单品")
+
+ if purchase_suggestions:
+ lines.extend(["", "### 补购清单(兜底)"])
+ for entry in purchase_suggestions:
+ keywords = "、".join(entry.get("keywords", [])) or "基础款"
+ lines.append(f"- {entry['title']}:{keywords}。{entry['horoscope_hint']}")
+
+ if suggested_accessories:
+ lines.extend(["", "### 饰品建议(结合星座运势)"])
+ for entry in suggested_accessories[:2]:
+ source = "衣柜已有" if entry.get("from_wardrobe") else "建议补购"
+ lines.append(f"- **{entry.get('name', '饰品')}**({source}):{entry.get('reason', '')}")
+
+ horoscope_tip = horoscope.get("suggestion", "")
+ if horoscope_tip:
+ lines.extend(["", f"运势提醒:{horoscope_tip}"])
+
+ return "\n".join(lines)
diff --git a/backend/services/weather.py b/backend/services/weather.py
index 2e5f35c..d488697 100644
--- a/backend/services/weather.py
+++ b/backend/services/weather.py
@@ -1,97 +1,90 @@
"""
-天气服务 - 和风天气 API 集成
-文档: https://dev.qweather.com/docs/api/weather/weather-now/
-GeoAPI: https://dev.qweather.com/docs/api/geoapi/city-lookup/
+天气服务 - Open-Meteo 免费全球天气接口(无需 API Key)
+文档: https://open-meteo.com/
"""
-import httpx
-import os
import re
from typing import Optional, List
+
+import httpx
from pydantic import BaseModel
class CityInfo(BaseModel):
"""城市信息"""
- name: str # 城市名称
- id: str # LocationID
- adm1: str # 省份
- adm2: str # 市
- country: str # 国家
- lat: str # 纬度
- lon: str # 经度
+ name: str
+ id: str
+ adm1: str
+ adm2: str
+ country: str
+ lat: str
+ lon: str
class WeatherNow(BaseModel):
- """实时天气数据"""
- obsTime: str # 数据观测时间
- temp: str # 温度,默认单位:摄氏度
- feelsLike: str # 体感温度
- icon: str # 天气状况图标代码
- text: str # 天气状况的文字描述
- wind360: str # 风向360角度
- windDir: str # 风向
- windScale: str # 风力等级
- windSpeed: str # 风速,公里/小时
- humidity: str # 相对湿度,百分比数值
- precip: str # 过去1小时降水量,默认单位:毫米
- pressure: str # 大气压强,默认单位:百帕
- vis: str # 能见度,默认单位:公里
- cloud: Optional[str] = None # 云量,百分比数值
- dew: Optional[str] = None # 露点温度
+ """实时天气数据(兼容旧响应结构)"""
+ obsTime: str
+ temp: str
+ feelsLike: str
+ icon: str
+ text: str
+ wind360: str
+ windDir: str
+ windScale: str
+ windSpeed: str
+ humidity: str
+ precip: str
+ pressure: str
+ vis: str
+ cloud: Optional[str] = None
+ dew: Optional[str] = None
class WeatherResponse(BaseModel):
- """和风天气 API 响应"""
- code: str # 状态码
- updateTime: str # API最近更新时间
- fxLink: str # 响应式页面链接
- now: WeatherNow # 实时天气数据
+ """天气 API 原始响应(兼容旧响应结构)"""
+ code: str
+ updateTime: str
+ fxLink: str
+ now: WeatherNow
class WeatherInfo(BaseModel):
"""简化的天气信息(用于应用)"""
- temperature: float # 温度
- feelsLike: float # 体感温度
- condition: str # 天气状况文字
- icon: str # 天气图标代码
- humidity: float # 湿度
- windDir: str # 风向
- windScale: str # 风力等级
- location: str # 位置
- obsTime: str # 观测时间
-
-
-# 常用城市列表(免费API降级方案)
+ temperature: float
+ feelsLike: float
+ condition: str
+ icon: str
+ humidity: float
+ windDir: str
+ windScale: str
+ location: str
+ obsTime: str
+
+
+# 兼容老版本 LocationID 输入,并作为地理编码失败时的兜底城市列表
COMMON_CITIES = [
- {"name": "北京", "adm1": "北京市", "country": "中国", "id": "101010100", "keywords": ["beijing", "北京", "bj"]},
- {"name": "上海", "adm1": "上海市", "country": "中国", "id": "101020100", "keywords": ["shanghai", "上海", "sh"]},
- {"name": "广州", "adm1": "广东省", "country": "中国", "id": "101280101", "keywords": ["guangzhou", "广州", "gz"]},
- {"name": "深圳", "adm1": "广东省", "country": "中国", "id": "101280601", "keywords": ["shenzhen", "深圳", "sz"]},
- {"name": "杭州", "adm1": "浙江省", "country": "中国", "id": "101210101", "keywords": ["hangzhou", "杭州", "hz"]},
- {"name": "成都", "adm1": "四川省", "country": "中国", "id": "101270101", "keywords": ["chengdu", "成都", "cd"]},
- {"name": "重庆", "adm1": "重庆市", "country": "中国", "id": "101040100", "keywords": ["chongqing", "重庆", "cq"]},
- {"name": "武汉", "adm1": "湖北省", "country": "中国", "id": "101200101", "keywords": ["wuhan", "武汉", "wh"]},
- {"name": "西安", "adm1": "陕西省", "country": "中国", "id": "101110101", "keywords": ["xian", "西安", "xa"]},
- {"name": "南京", "adm1": "江苏省", "country": "中国", "id": "101190101", "keywords": ["nanjing", "南京", "nj"]},
- {"name": "天津", "adm1": "天津市", "country": "中国", "id": "101030100", "keywords": ["tianjin", "天津", "tj"]},
- {"name": "苏州", "adm1": "江苏省", "country": "中国", "id": "101190401", "keywords": ["suzhou", "苏州", "su"]},
- {"name": "长沙", "adm1": "湖南省", "country": "中国", "id": "101250101", "keywords": ["changsha", "长沙", "cs"]},
- {"name": "郑州", "adm1": "河南省", "country": "中国", "id": "101180101", "keywords": ["zhengzhou", "郑州", "zz"]},
- {"name": "济南", "adm1": "山东省", "country": "中国", "id": "101120101", "keywords": ["jinan", "济南", "jn"]},
- {"name": "青岛", "adm1": "山东省", "country": "中国", "id": "101120201", "keywords": ["qingdao", "青岛", "qd"]},
- {"name": "厦门", "adm1": "福建省", "country": "中国", "id": "101230201", "keywords": ["xiamen", "厦门", "xm"]},
- {"name": "大连", "adm1": "辽宁省", "country": "中国", "id": "101070201", "keywords": ["dalian", "大连", "dl"]},
- {"name": "沈阳", "adm1": "辽宁省", "country": "中国", "id": "101070101", "keywords": ["shenyang", "沈阳", "sy"]},
- {"name": "哈尔滨", "adm1": "黑龙江", "country": "中国", "id": "101050101", "keywords": ["haerbin", "哈尔滨", "heb"]},
+ {"name": "北京", "adm1": "北京市", "country": "中国", "legacy_id": "101010100", "lat": "39.9042", "lon": "116.4074", "keywords": ["beijing", "北京", "bj"]},
+ {"name": "上海", "adm1": "上海市", "country": "中国", "legacy_id": "101020100", "lat": "31.2304", "lon": "121.4737", "keywords": ["shanghai", "上海", "sh"]},
+ {"name": "广州", "adm1": "广东省", "country": "中国", "legacy_id": "101280101", "lat": "23.1291", "lon": "113.2644", "keywords": ["guangzhou", "广州", "gz"]},
+ {"name": "深圳", "adm1": "广东省", "country": "中国", "legacy_id": "101280601", "lat": "22.5431", "lon": "114.0579", "keywords": ["shenzhen", "深圳", "sz"]},
+ {"name": "杭州", "adm1": "浙江省", "country": "中国", "legacy_id": "101210101", "lat": "30.2741", "lon": "120.1551", "keywords": ["hangzhou", "杭州", "hz"]},
+ {"name": "成都", "adm1": "四川省", "country": "中国", "legacy_id": "101270101", "lat": "30.5728", "lon": "104.0668", "keywords": ["chengdu", "成都", "cd"]},
+ {"name": "重庆", "adm1": "重庆市", "country": "中国", "legacy_id": "101040100", "lat": "29.5630", "lon": "106.5516", "keywords": ["chongqing", "重庆", "cq"]},
+ {"name": "武汉", "adm1": "湖北省", "country": "中国", "legacy_id": "101200101", "lat": "30.5928", "lon": "114.3055", "keywords": ["wuhan", "武汉", "wh"]},
+ {"name": "西安", "adm1": "陕西省", "country": "中国", "legacy_id": "101110101", "lat": "34.3416", "lon": "108.9398", "keywords": ["xian", "西安", "xa"]},
+ {"name": "南京", "adm1": "江苏省", "country": "中国", "legacy_id": "101190101", "lat": "32.0603", "lon": "118.7969", "keywords": ["nanjing", "南京", "nj"]},
]
+LEGACY_CITY_BY_ID = {city["legacy_id"]: city for city in COMMON_CITIES}
+
LOCATION_SUFFIXES = (
"特别行政区", "自治区", "自治州", "地区", "盟", "省", "市", "区", "县"
)
+LOCATION_PART_SEPARATOR_REGEX = re.compile(r"[,,]+")
+DEFAULT_LOCATION_QUERY = "上海, 上海市, 中国"
def normalize_location_query(query: str) -> str:
- """归一化地区输入,提升省市区县等写法的匹配率。"""
+ """归一化地区输入,提升匹配率。"""
normalized = (query or "").strip().lower()
normalized = re.sub(r"[\s,,/·\-]+", "", normalized)
@@ -102,17 +95,105 @@ def normalize_location_query(query: str) -> str:
return normalized
+def is_complete_text_location(location: str) -> bool:
+ """
+ 判断文本地点是否足够完整(如:南京, 江苏, 中国)。
+ """
+ parts = [
+ part.strip()
+ for part in LOCATION_PART_SEPARATOR_REGEX.split((location or "").strip())
+ if part.strip()
+ ]
+ return len(parts) >= 3
+
+
+def validate_location_input(location: str) -> Optional[str]:
+ """
+ 校验地点输入。返回 None 表示合法,否则返回错误文案。
+ """
+ raw_location = (location or "").strip()
+ if not raw_location:
+ return None
+
+ if is_location_id(raw_location) or is_coordinate_location(raw_location):
+ return None
+
+ if not is_complete_text_location(raw_location):
+ return "地点格式不完整,请使用“城市, 省/州, 国家”格式,例如:南京, 江苏, 中国"
+
+ return None
+
+
+def normalize_location_request(
+ location: Optional[str] = None,
+ city: Optional[str] = None,
+ state: Optional[str] = None,
+ country: Optional[str] = None,
+) -> tuple[str, Optional[str]]:
+ """
+ 统一处理地点请求:
+ - 优先使用分字段 city/state/country 组装查询
+ - 否则回退到 location 文本
+ 返回 (normalized_location, validation_error)
+ """
+ raw_location = (location or "").strip()
+ city_value = (city or "").strip()
+ state_value = (state or "").strip()
+ country_value = (country or "").strip()
+
+ has_structured_parts = any([city_value, state_value, country_value])
+ if has_structured_parts:
+ if not city_value or not state_value or not country_value:
+ return "", "使用分字段查询时,请同时提供 city、state、country。"
+
+ structured_location = ", ".join(
+ part for part in (city_value, state_value, country_value) if part
+ )
+ return structured_location, validate_location_input(structured_location)
+
+ if not raw_location:
+ return DEFAULT_LOCATION_QUERY, None
+
+ return raw_location, validate_location_input(raw_location)
+
+
def is_location_id(location: str) -> bool:
- return bool(re.fullmatch(r"\d{9}", location.strip()))
+ return bool(re.fullmatch(r"\d{9}", (location or "").strip()))
def is_coordinate_location(location: str) -> bool:
- return bool(re.fullmatch(r"\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*", location.strip()))
+ return bool(re.fullmatch(r"\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*", (location or "").strip()))
+
+
+def parse_coordinate_location(location: str) -> Optional[tuple[float, float]]:
+ """
+ 解析输入坐标为 (纬度, 经度)。
+ 兼容 "纬度,经度" 和老输入格式 "经度,纬度"。
+ """
+ if not is_coordinate_location(location):
+ return None
+
+ first_raw, second_raw = [part.strip() for part in location.split(",", maxsplit=1)]
+ first = float(first_raw)
+ second = float(second_raw)
+
+ # 优先按纬度,经度解析
+ if -90 <= first <= 90 and -180 <= second <= 180:
+ return first, second
+
+ # 兼容历史格式: 经度,纬度
+ if -180 <= first <= 180 and -90 <= second <= 90:
+ return second, first
+
+ return None
+
+
+def format_coordinate_id(latitude: float, longitude: float) -> str:
+ return f"{longitude:.4f},{latitude:.4f}"
def format_city_display_name(city: CityInfo) -> str:
parts = []
-
if city.name:
parts.append(city.name)
@@ -126,19 +207,19 @@ def format_city_display_name(city: CityInfo) -> str:
return " · ".join(parts)
-def city_matches_query(city: CityInfo, query: str) -> bool:
- return city_match_score(city, query) > 0
-
-
def city_match_score(city: CityInfo, query: str) -> int:
normalized_query = normalize_location_query(query)
+ if not normalized_query:
+ return 0
+
candidates = [
city.name,
city.adm1,
city.adm2,
+ city.country,
f"{city.adm1}{city.name}",
f"{city.adm2}{city.name}",
- f"{city.adm1}{city.adm2}{city.name}",
+ f"{city.country}{city.adm1}{city.name}",
]
best_score = 0
@@ -146,6 +227,7 @@ def city_match_score(city: CityInfo, query: str) -> int:
normalized_candidate = normalize_location_query(candidate or "")
if not normalized_candidate:
continue
+
if normalized_query == normalized_candidate:
best_score = max(best_score, 100)
elif normalized_query in normalized_candidate:
@@ -163,86 +245,97 @@ def city_match_score(city: CityInfo, query: str) -> int:
return best_score
+def city_matches_query(city: CityInfo, query: str) -> bool:
+ return city_match_score(city, query) > 0
+
+
+def _city_from_common(city_data: dict) -> CityInfo:
+ return CityInfo(
+ name=city_data["name"],
+ id=format_coordinate_id(float(city_data["lat"]), float(city_data["lon"])),
+ adm1=city_data["adm1"],
+ adm2=city_data["name"],
+ country=city_data["country"],
+ lat=str(city_data["lat"]),
+ lon=str(city_data["lon"]),
+ )
+
+
async def search_city(query: str, limit: int = 10) -> List[CityInfo]:
"""
搜索城市(支持模糊查询)
- 优先使用和风天气GeoAPI,如果失败则使用预定义城市列表
-
- Args:
- query: 城市名称关键词(支持中文、拼音)
- limit: 返回结果数量限制
-
- Returns:
- 城市信息列表
+ 优先使用 Open-Meteo 地理编码 API,失败则回退到内置城市列表。
"""
normalized_query = normalize_location_query(query)
if not normalized_query:
return []
- # 优先从配置系统读取 API Key
+ # 兼容老版 LocationID 输入
+ if is_location_id(query):
+ legacy_city = LEGACY_CITY_BY_ID.get(query.strip())
+ if legacy_city:
+ return [_city_from_common(legacy_city)]
+
+ # 坐标输入直接返回一个虚拟城市项,便于前端复用现有流程
+ parsed_coordinate = parse_coordinate_location(query)
+ if parsed_coordinate:
+ latitude, longitude = parsed_coordinate
+ return [
+ CityInfo(
+ name="坐标定位",
+ id=format_coordinate_id(latitude, longitude),
+ adm1="",
+ adm2="",
+ country="",
+ lat=f"{latitude}",
+ lon=f"{longitude}",
+ )
+ ]
+
+ # Open-Meteo Geocoding
try:
- from storage.config_store import load_config
- config = load_config()
- api_key = config.qweather_api_key
- api_host = config.qweather_api_host
- except Exception:
- api_key = os.getenv("QWEATHER_API_KEY")
- api_host = os.getenv("QWEATHER_API_HOST", "devapi.qweather.com")
-
- # 如果有API Key,尝试使用GeoAPI
- if api_key and api_key != "your_qweather_api_key_here":
- try:
- url = f"https://{api_host}/geo/v2/city/lookup"
- params = {
- "location": query.strip(),
- "number": limit,
- "lang": "zh",
- "key": api_key
- }
-
- async with httpx.AsyncClient() as client:
- response = await client.get(url, params=params, timeout=10.0)
-
- if response.status_code == 200:
- data = response.json()
- if data.get("code") == "200" and data.get("location"):
- cities = []
- for location in data.get("location", []):
- city = CityInfo(
- name=location.get("name"),
- id=location.get("id"),
- adm1=location.get("adm1"),
- adm2=location.get("adm2"),
- country=location.get("country"),
- lat=location.get("lat"),
- lon=location.get("lon")
- )
- if city_matches_query(city, query):
- cities.append(city)
-
- if cities:
- cities.sort(key=lambda city: city_match_score(city, query), reverse=True)
- return cities[:limit]
-
- # GeoAPI 返回为空或排序不理想时,退回原始结果
- cities = []
- for location in data.get("location", []):
- cities.append(CityInfo(
- name=location.get("name"),
- id=location.get("id"),
- adm1=location.get("adm1"),
- adm2=location.get("adm2"),
- country=location.get("country"),
- lat=location.get("lat"),
- lon=location.get("lon")
- ))
- return cities
- except Exception as e:
- print(f"⚠️ GeoAPI调用失败,使用预定义城市列表: {e}")
-
- # 降级方案:使用预定义城市列表进行模糊搜索
- matched_cities = []
-
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ "https://geocoding-api.open-meteo.com/v1/search",
+ params={
+ "name": query.strip(),
+ "count": min(max(limit, 1), 20),
+ "language": "zh",
+ "format": "json",
+ },
+ timeout=10.0,
+ )
+ response.raise_for_status()
+ payload = response.json()
+
+ cities: List[CityInfo] = []
+ for row in payload.get("results", []):
+ latitude = row.get("latitude")
+ longitude = row.get("longitude")
+ if latitude is None or longitude is None:
+ continue
+
+ city = CityInfo(
+ name=str(row.get("name") or "未知地区"),
+ id=format_coordinate_id(float(latitude), float(longitude)),
+ adm1=str(row.get("admin1") or ""),
+ adm2=str(row.get("admin2") or ""),
+ country=str(row.get("country") or ""),
+ lat=str(latitude),
+ lon=str(longitude),
+ )
+
+ if city_matches_query(city, query):
+ cities.append(city)
+
+ if cities:
+ cities.sort(key=lambda city: city_match_score(city, query), reverse=True)
+ return cities[:limit]
+ except Exception as e:
+ print(f"⚠️ Geocoding 查询失败,使用内置城市兜底: {e}")
+
+ # 回退方案:内置城市模糊匹配
+ matched_cities: List[CityInfo] = []
for city_data in COMMON_CITIES:
keyword_matched = any(
normalized_query in normalize_location_query(keyword)
@@ -257,15 +350,7 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]:
)
if keyword_matched or region_matched:
- matched_cities.append(CityInfo(
- name=city_data["name"],
- id=city_data["id"],
- adm1=city_data["adm1"],
- adm2=city_data["name"],
- country=city_data["country"],
- lat="0", # 预定义列表不包含坐标
- lon="0"
- ))
+ matched_cities.append(_city_from_common(city_data))
matched_cities.sort(key=lambda city: city_match_score(city, query), reverse=True)
return matched_cities[:limit]
@@ -273,15 +358,33 @@ async def search_city(query: str, limit: int = 10) -> List[CityInfo]:
async def resolve_location(location: str) -> tuple[str, str]:
"""
- 将用户输入解析为和风天气可识别的 location 参数,并返回显示名称。
+ 解析用户输入为 "经度,纬度" 的坐标字符串,并返回展示用地区名。
"""
raw_location = (location or "").strip()
if not raw_location:
- return "101020100", "上海"
-
- if is_location_id(raw_location) or is_coordinate_location(raw_location):
+ shanghai = LEGACY_CITY_BY_ID["101020100"]
+ shanghai_city = _city_from_common(shanghai)
+ return format_coordinate_id(float(shanghai["lat"]), float(shanghai["lon"])), format_city_display_name(shanghai_city)
+
+ validation_error = validate_location_input(raw_location)
+ if validation_error:
+ raise ValueError(validation_error)
+
+ if is_location_id(raw_location):
+ city_data = LEGACY_CITY_BY_ID.get(raw_location)
+ if city_data:
+ city = _city_from_common(city_data)
+ return (
+ format_coordinate_id(float(city_data["lat"]), float(city_data["lon"])),
+ format_city_display_name(city),
+ )
return raw_location, raw_location
+ parsed_coordinate = parse_coordinate_location(raw_location)
+ if parsed_coordinate:
+ latitude, longitude = parsed_coordinate
+ return format_coordinate_id(latitude, longitude), raw_location
+
cities = await search_city(raw_location, limit=1)
if cities:
city = cities[0]
@@ -290,74 +393,147 @@ async def resolve_location(location: str) -> tuple[str, str]:
return raw_location, raw_location
-async def get_qweather_now(location: str) -> Optional[WeatherResponse]:
- """
- 调用和风天气 API 获取实时天气
-
- Args:
- location: LocationID 或 经纬度坐标(逗号分隔,如 "116.41,39.92")
-
- Returns:
- WeatherResponse 或 None(失败时)
-
- 示例:
- - location="101010100" (北京的LocationID)
- - location="116.41,39.92" (经纬度坐标)
- """
- # 优先从配置系统读取 API Key
- try:
- from storage.config_store import load_config
- config = load_config()
- api_key = config.qweather_api_key
- api_host = config.qweather_api_host
- except Exception:
- # 回退到环境变量
- api_key = os.getenv("QWEATHER_API_KEY")
- api_host = os.getenv("QWEATHER_API_HOST", "devapi.qweather.com")
-
- if not api_key or api_key == "your_qweather_api_key_here":
- print("⚠️ 和风天气 API Key 未配置,请在前端设置界面或 .env 文件中配置")
- return None
-
- url = f"https://{api_host}/v7/weather/now"
+def wind_direction_text(degrees: float) -> str:
+ labels = ["北风", "东北风", "东风", "东南风", "南风", "西南风", "西风", "西北风"]
+ index = int(((degrees % 360) + 22.5) // 45) % 8
+ return labels[index]
+
+
+def wind_speed_to_beaufort(speed_kmh: float) -> int:
+ # Beaufort 风级阈值(km/h)
+ thresholds = [1, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118]
+ for level, threshold in enumerate(thresholds):
+ if speed_kmh < threshold:
+ return level
+ return 12
+
+
+def map_weather_code(code: int, is_day: int) -> tuple[str, str]:
+ # 复用前端既有的 QWeather 图标编码映射,避免改 UI。
+ if code == 0:
+ return ("晴", "100" if is_day else "150")
+ if code == 1:
+ return ("晴间多云", "101" if is_day else "150")
+ if code == 2:
+ return ("多云", "102")
+ if code == 3:
+ return ("阴", "104")
+ if code in (45, 48):
+ return ("雾", "501")
+ if code in (51, 53, 55):
+ return ("毛毛雨", "305")
+ if code in (56, 57):
+ return ("冻雨", "314")
+ if code in (61, 63, 65, 66, 67):
+ return ("雨", "306")
+ if code in (71, 73, 75, 77):
+ return ("降雪", "400")
+ if code in (80, 81, 82):
+ return ("阵雨", "309")
+ if code in (85, 86):
+ return ("阵雪", "404")
+ if code in (95, 96, 99):
+ return ("雷暴", "302")
+ return ("未知", "999")
+
+
+async def _fetch_open_meteo_now(latitude: float, longitude: float) -> Optional[WeatherResponse]:
params = {
- "location": location,
- "key": api_key
+ "latitude": latitude,
+ "longitude": longitude,
+ "timezone": "auto",
+ "current": (
+ "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,is_day,"
+ "wind_speed_10m,wind_direction_10m,precipitation,pressure_msl,cloud_cover,dew_point_2m"
+ ),
}
-
+
try:
async with httpx.AsyncClient() as client:
- response = await client.get(url, params=params, timeout=10.0)
+ response = await client.get(
+ "https://api.open-meteo.com/v1/forecast",
+ params=params,
+ timeout=10.0,
+ )
response.raise_for_status()
- data = response.json()
-
- if data.get("code") != "200":
- print(f"❌ 和风天气 API 错误: code={data.get('code')}")
- return None
-
- return WeatherResponse(**data)
-
+ payload = response.json()
+
+ current = payload.get("current") or {}
+
+ temperature = float(current.get("temperature_2m", 20.0))
+ feels_like = float(current.get("apparent_temperature", temperature))
+ humidity = float(current.get("relative_humidity_2m", 60.0))
+ wind_speed = float(current.get("wind_speed_10m", 8.0))
+ wind_degrees = float(current.get("wind_direction_10m", 180.0))
+ precip = float(current.get("precipitation", 0.0))
+ pressure = float(current.get("pressure_msl", 1013.0))
+ cloud = current.get("cloud_cover")
+ dew = current.get("dew_point_2m")
+ obs_time = str(current.get("time") or "")
+
+ weather_code = int(current.get("weather_code", 0))
+ is_day = int(current.get("is_day", 1))
+ condition_text, icon_code = map_weather_code(weather_code, is_day)
+
+ wind_dir_text = wind_direction_text(wind_degrees)
+ wind_scale = str(wind_speed_to_beaufort(wind_speed))
+
+ now = WeatherNow(
+ obsTime=obs_time,
+ temp=str(round(temperature, 1)),
+ feelsLike=str(round(feels_like, 1)),
+ icon=icon_code,
+ text=condition_text,
+ wind360=str(round(wind_degrees, 1)),
+ windDir=wind_dir_text,
+ windScale=wind_scale,
+ windSpeed=str(round(wind_speed, 1)),
+ humidity=str(round(humidity, 1)),
+ precip=str(round(precip, 2)),
+ pressure=str(round(pressure, 1)),
+ vis="10",
+ cloud=str(cloud) if cloud is not None else None,
+ dew=str(round(float(dew), 1)) if dew is not None else None,
+ )
+
+ return WeatherResponse(
+ code="200",
+ updateTime=obs_time,
+ fxLink="https://open-meteo.com/",
+ now=now,
+ )
except Exception as e:
- print(f"❌ 获取天气信息失败: {e}")
+ print(f"❌ 获取 Open-Meteo 天气信息失败: {e}")
return None
-async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]:
+async def get_qweather_now(location: str) -> Optional[WeatherResponse]:
+ """
+ 兼容旧函数名:实际改为调用 Open-Meteo 免费天气接口。
+ """
+ resolved_location, _ = await resolve_location(location)
+ coordinate = parse_coordinate_location(resolved_location)
+ if not coordinate:
+ return None
+
+ latitude, longitude = coordinate
+ return await _fetch_open_meteo_now(latitude, longitude)
+
+
+async def get_weather(location: str = DEFAULT_LOCATION_QUERY) -> Optional[WeatherInfo]:
"""
获取天气信息(简化版)
-
+
Args:
- location: LocationID 或 经纬度坐标
- 默认: 101020100 (上海)
-
+ location: 城市名 / 经纬度坐标 / 历史 LocationID
+
Returns:
WeatherInfo 或 None
"""
resolved_location, display_location = await resolve_location(location)
weather_response = await get_qweather_now(resolved_location)
-
+
if not weather_response:
- # 返回模拟数据作为降级方案
print("⚠️ 使用模拟天气数据")
return WeatherInfo(
temperature=20.0,
@@ -368,9 +544,9 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]:
windDir="南风",
windScale="2",
location=display_location,
- obsTime="2024-01-01T12:00+08:00"
+ obsTime="2026-01-01T12:00",
)
-
+
now = weather_response.now
return WeatherInfo(
temperature=float(now.temp),
@@ -381,45 +557,26 @@ async def get_weather(location: str = "101020100") -> Optional[WeatherInfo]:
windDir=now.windDir,
windScale=now.windScale,
location=display_location,
- obsTime=now.obsTime
+ obsTime=now.obsTime,
)
def get_season_from_weather(weather: WeatherInfo) -> list[str]:
- """
- 根据天气推断适合的季节标签
-
- Args:
- weather: 天气信息
-
- Returns:
- 季节标签列表
- """
+ """根据天气推断适合的季节标签。"""
temp = weather.temperature
-
+
if temp < 10:
return ["冬"]
- elif temp < 20:
+ if temp < 20:
return ["春", "秋"]
- else:
- return ["夏"]
+ return ["夏"]
def get_clothing_suggestion(weather: WeatherInfo) -> str:
- """
- 根据天气推荐穿搭建议
-
- Args:
- weather: 天气信息
-
- Returns:
- 穿搭建议文字
- """
- temp = weather.temperature
+ """根据天气推荐穿搭建议。"""
feels_like = weather.feelsLike
condition = weather.condition
-
- # 基于温度的建议
+
if feels_like < 0:
suggestion = "🧥 建议穿厚羽绒服、棉衣等保暖衣物"
elif feels_like < 10:
@@ -430,13 +587,12 @@ def get_clothing_suggestion(weather: WeatherInfo) -> str:
suggestion = "👕 建议穿短袖、薄长袖等轻便衣物"
else:
suggestion = "👕 建议穿短袖、短裤等夏季清凉衣物"
-
- # 根据天气状况补充建议
+
if "雨" in condition:
suggestion += ",记得带伞☂️"
elif "雪" in condition:
suggestion += ",注意防滑保暖❄️"
elif "晴" in condition and feels_like > 25:
suggestion += ",注意防晒☀️"
-
+
return suggestion
diff --git a/backend/storage/config_store.py b/backend/storage/config_store.py
index d9aceff..7cb3e5f 100644
--- a/backend/storage/config_store.py
+++ b/backend/storage/config_store.py
@@ -5,6 +5,7 @@
from pathlib import Path
from typing import Optional
from domain.config import LLMConfig
+from services.weather import validate_location_input, DEFAULT_LOCATION_QUERY
CONFIG_FILE = Path(__file__).parent / "llm_config.json"
@@ -33,8 +34,7 @@ def update_config(
model: Optional[str] = None,
removebg_api_key: Optional[str] = None,
bg_removal_method: Optional[str] = None,
- qweather_api_key: Optional[str] = None,
- qweather_api_host: Optional[str] = None,
+ weather_location: Optional[str] = None,
zodiac_sign: Optional[str] = None
) -> LLMConfig:
"""更新配置"""
@@ -50,10 +50,12 @@ def update_config(
config.removebg_api_key = removebg_api_key.strip()
if bg_removal_method is not None:
config.bg_removal_method = bg_removal_method
- if qweather_api_key is not None:
- config.qweather_api_key = qweather_api_key.strip()
- if qweather_api_host is not None:
- config.qweather_api_host = qweather_api_host.strip()
+ if weather_location is not None:
+ normalized_location = weather_location.strip() or DEFAULT_LOCATION_QUERY
+ validation_error = validate_location_input(normalized_location)
+ if validation_error:
+ raise ValueError(validation_error)
+ config.weather_location = normalized_location
if zodiac_sign is not None:
config.zodiac_sign = zodiac_sign.strip().lower()
@@ -73,6 +75,9 @@ def _mask_key(key: str) -> str:
def get_masked_config() -> dict:
"""获取脱敏后的配置(隐藏 API Key)"""
config = load_config()
+ weather_location = (config.weather_location or "").strip() or DEFAULT_LOCATION_QUERY
+ if validate_location_input(weather_location):
+ weather_location = DEFAULT_LOCATION_QUERY
return {
"api_base": config.api_base,
@@ -82,8 +87,6 @@ def get_masked_config() -> dict:
"removebg_api_key_masked": _mask_key(config.removebg_api_key),
"has_removebg_key": bool(config.removebg_api_key),
"bg_removal_method": config.bg_removal_method,
- "qweather_api_key_masked": _mask_key(config.qweather_api_key),
- "has_qweather_key": bool(config.qweather_api_key),
- "qweather_api_host": config.qweather_api_host,
+ "weather_location": weather_location,
"zodiac_sign": config.zodiac_sign
}
diff --git a/backend/storage/db.py b/backend/storage/db.py
index 8a3f33e..4f4ca37 100644
--- a/backend/storage/db.py
+++ b/backend/storage/db.py
@@ -4,10 +4,15 @@
import aiosqlite
import json
from pathlib import Path
-from typing import List, Optional
+from typing import Any, List, Optional
from datetime import datetime
from domain.clothes import ClothesItem, ClothesCreate
-from storage.models import CLOTHES_TABLE_SQL, CLOTHES_INDEX_SQL
+from storage.models import (
+ CLOTHES_TABLE_SQL,
+ CLOTHES_INDEX_SQL,
+ HOROSCOPE_RECORDS_TABLE_SQL,
+ HOROSCOPE_RECORDS_INDEX_SQL,
+)
# 数据库文件路径
# 优先使用环境变量,方便 Docker 挂载 volume
@@ -25,6 +30,8 @@ async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(CLOTHES_TABLE_SQL)
await db.execute(CLOTHES_INDEX_SQL)
+ await db.execute(HOROSCOPE_RECORDS_TABLE_SQL)
+ await db.execute(HOROSCOPE_RECORDS_INDEX_SQL)
await db.commit()
@@ -135,6 +142,98 @@ async def update_clothes(clothes_id: int, clothes: ClothesCreate) -> bool:
return cursor.rowcount > 0
+async def get_horoscope_record(record_date: str, zodiac_sign: str) -> Optional[dict[str, Any]]:
+ """按日期和星座获取缓存的运势记录。"""
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ cursor = await db.execute(
+ """
+ SELECT * FROM horoscope_records
+ WHERE record_date = ? AND zodiac_sign = ?
+ LIMIT 1
+ """,
+ (record_date, zodiac_sign),
+ )
+ row = await cursor.fetchone()
+ if not row:
+ return None
+ return _row_to_horoscope_record(row)
+
+
+async def upsert_horoscope_source(
+ record_date: str,
+ zodiac_sign: str,
+ zodiac_name: str,
+ source_provider: str,
+ source_payload: dict[str, Any],
+) -> int:
+ """
+ 写入或更新星座原始数据(aztro/fallback)。
+ 已存在记录时,保留现有推理状态与推理内容。
+ """
+ payload_json = json.dumps(source_payload, ensure_ascii=False)
+
+ async with aiosqlite.connect(DB_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ cursor = await db.execute(
+ """
+ SELECT id FROM horoscope_records
+ WHERE record_date = ? AND zodiac_sign = ?
+ LIMIT 1
+ """,
+ (record_date, zodiac_sign),
+ )
+ existing = await cursor.fetchone()
+
+ if existing:
+ await db.execute(
+ """
+ UPDATE horoscope_records
+ SET zodiac_name = ?,
+ source_provider = ?,
+ source_payload = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (zodiac_name, source_provider, payload_json, existing["id"]),
+ )
+ await db.commit()
+ return int(existing["id"])
+
+ cursor = await db.execute(
+ """
+ INSERT INTO horoscope_records (
+ record_date, zodiac_sign, zodiac_name, source_provider, source_payload, llm_status
+ ) VALUES (?, ?, ?, ?, ?, 'pending')
+ """,
+ (record_date, zodiac_sign, zodiac_name, source_provider, payload_json),
+ )
+ await db.commit()
+ return int(cursor.lastrowid)
+
+
+async def update_horoscope_inference(
+ record_id: int,
+ llm_status: str,
+ llm_reasoning: Optional[str] = None,
+ llm_error: Optional[str] = None,
+) -> None:
+ """更新运势推理状态与结果。"""
+ async with aiosqlite.connect(DB_PATH) as db:
+ await db.execute(
+ """
+ UPDATE horoscope_records
+ SET llm_status = ?,
+ llm_reasoning = ?,
+ llm_error = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (llm_status, llm_reasoning, llm_error, record_id),
+ )
+ await db.commit()
+
+
def _row_to_clothes_item(row: aiosqlite.Row) -> ClothesItem:
"""将数据库行转换为 ClothesItem"""
return ClothesItem(
@@ -149,3 +248,20 @@ def _row_to_clothes_item(row: aiosqlite.Row) -> ClothesItem:
image_url=f"/uploads/{row['image_filename']}",
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now()
)
+
+
+def _row_to_horoscope_record(row: aiosqlite.Row) -> dict[str, Any]:
+ """将数据库行转换为星座记录字典。"""
+ return {
+ "id": int(row["id"]),
+ "record_date": row["record_date"],
+ "zodiac_sign": row["zodiac_sign"],
+ "zodiac_name": row["zodiac_name"],
+ "source_provider": row["source_provider"] or "unknown",
+ "source_payload": json.loads(row["source_payload"] or "{}"),
+ "llm_status": row["llm_status"] or "pending",
+ "llm_reasoning": row["llm_reasoning"] or "",
+ "llm_error": row["llm_error"] or "",
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"],
+ }
diff --git a/backend/storage/models.py b/backend/storage/models.py
index 85e5c02..1d4371d 100644
--- a/backend/storage/models.py
+++ b/backend/storage/models.py
@@ -7,7 +7,7 @@
CLOTHES_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS clothes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
- category TEXT NOT NULL, -- top, bottom, shoes
+ category TEXT NOT NULL, -- top, bottom, shoes, accessory
item TEXT NOT NULL,
style_semantics TEXT, -- JSON array
season_semantics TEXT, -- JSON array
@@ -23,3 +23,25 @@
CLOTHES_INDEX_SQL = """
CREATE INDEX IF NOT EXISTS idx_clothes_category ON clothes(category);
"""
+
+# 星座运势缓存表
+HOROSCOPE_RECORDS_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS horoscope_records (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ record_date TEXT NOT NULL, -- YYYY-MM-DD
+ zodiac_sign TEXT NOT NULL,
+ zodiac_name TEXT NOT NULL,
+ source_provider TEXT NOT NULL, -- aztro / fallback
+ source_payload TEXT NOT NULL, -- JSON
+ llm_status TEXT NOT NULL DEFAULT 'pending', -- pending / done / failed / skipped
+ llm_reasoning TEXT,
+ llm_error TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(record_date, zodiac_sign)
+);
+"""
+
+HOROSCOPE_RECORDS_INDEX_SQL = """
+CREATE INDEX IF NOT EXISTS idx_horoscope_date_sign ON horoscope_records(record_date, zodiac_sign);
+"""
diff --git a/backend/test_weather.py b/backend/test_weather.py
index f265b0a..b5a0e3a 100644
--- a/backend/test_weather.py
+++ b/backend/test_weather.py
@@ -1,5 +1,5 @@
"""
-测试和风天气 API 集成
+测试 Open-Meteo 天气 API 集成
"""
import asyncio
from services.weather import (
@@ -13,7 +13,7 @@
async def test_weather():
"""测试天气服务"""
print("=" * 50)
- print("🌤️ 测试和风天气 API 集成")
+ print("🌤️ 测试 Open-Meteo 天气 API 集成")
print("=" * 50)
# 测试不同城市
@@ -52,7 +52,7 @@ async def test_weather():
print(f"❌ 获取 {city_name} 天气信息失败")
print("\n" + "=" * 50)
- print("🔍 测试和风天气原始数据")
+ print("🔍 测试天气原始数据(兼容结构)")
print("=" * 50)
# 测试原始数据
diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx
index 7901a63..56f911f 100644
--- a/frontend/src/components/Settings.jsx
+++ b/frontend/src/components/Settings.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useTheme } from '../contexts/ThemeContext'
-import { Sun, Moon, Globe, Sparkles } from 'lucide-react'
+import { Sun, Moon, Globe, Sparkles, MapPin } from 'lucide-react'
const LANGUAGES = [
{ code: 'zh', label: '中文' },
@@ -24,6 +24,24 @@ const ZODIAC_SIGNS = [
'pisces'
]
+const DEFAULT_LOCATION = '上海, 上海市, 中国'
+const LOCATION_ID_REGEX = /^\d{9}$/
+const COORDINATE_LOCATION_REGEX = /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/
+const LOCATION_PART_SEPARATOR_REGEX = /[,,]+/
+
+const isCompleteLocationInput = (location) => {
+ const raw = (location || '').trim()
+ if (!raw) return false
+ if (LOCATION_ID_REGEX.test(raw) || COORDINATE_LOCATION_REGEX.test(raw)) return true
+
+ const parts = raw
+ .split(LOCATION_PART_SEPARATOR_REGEX)
+ .map(part => part.trim())
+ .filter(Boolean)
+
+ return parts.length >= 3
+}
+
const Settings = ({ isOpen, onClose, onSave }) => {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
@@ -33,8 +51,7 @@ const Settings = ({ isOpen, onClose, onSave }) => {
model: 'gpt-4o',
removebg_api_key: '',
bg_removal_method: 'local',
- qweather_api_key: '',
- qweather_api_host: 'devapi.qweather.com',
+ weather_location: DEFAULT_LOCATION,
zodiac_sign: ''
})
const [models, setModels] = useState([])
@@ -43,7 +60,6 @@ const Settings = ({ isOpen, onClose, onSave }) => {
const [testResult, setTestResult] = useState(null)
const [hasExistingKey, setHasExistingKey] = useState(false)
const [hasRemoveBgKey, setHasRemoveBgKey] = useState(false)
- const [hasQweatherKey, setHasQweatherKey] = useState(false)
const [showModelSelect, setShowModelSelect] = useState(false)
const API_BASE = `http://${window.location.hostname}:8000/api`
@@ -70,12 +86,11 @@ const Settings = ({ isOpen, onClose, onSave }) => {
api_base: data.api_base || 'https://api.openai.com/v1',
model: data.model || 'gpt-4o',
bg_removal_method: data.bg_removal_method || 'local',
- qweather_api_host: data.qweather_api_host || 'devapi.qweather.com',
+ weather_location: data.weather_location || DEFAULT_LOCATION,
zodiac_sign: data.zodiac_sign || ''
}))
setHasExistingKey(data.has_api_key)
setHasRemoveBgKey(data.has_removebg_key)
- setHasQweatherKey(data.has_qweather_key)
}
} catch (error) {
console.error('Failed to fetch config:', error)
@@ -104,7 +119,11 @@ const Settings = ({ isOpen, onClose, onSave }) => {
setTesting(true)
setTestResult(null)
- await handleSave(false)
+ const saveSuccess = await handleSave(false)
+ if (!saveSuccess) {
+ setTesting(false)
+ return
+ }
try {
const response = await fetch(`${API_BASE}/test-connection`, {
@@ -128,11 +147,20 @@ const Settings = ({ isOpen, onClose, onSave }) => {
const handleSave = async (closeAfter = true) => {
try {
+ const normalizedLocation = (config.weather_location || '').trim() || DEFAULT_LOCATION
+ if (!isCompleteLocationInput(normalizedLocation)) {
+ setTestResult({
+ success: false,
+ message: t('settings.defaultCityFormatError')
+ })
+ return false
+ }
+
const payload = {
api_base: config.api_base,
model: config.model,
bg_removal_method: config.bg_removal_method,
- qweather_api_host: config.qweather_api_host,
+ weather_location: normalizedLocation,
zodiac_sign: config.zodiac_sign
}
@@ -142,9 +170,6 @@ const Settings = ({ isOpen, onClose, onSave }) => {
if (config.removebg_api_key) {
payload.removebg_api_key = config.removebg_api_key
}
- if (config.qweather_api_key) {
- payload.qweather_api_key = config.qweather_api_key
- }
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
@@ -159,9 +184,17 @@ const Settings = ({ isOpen, onClose, onSave }) => {
onSave && onSave()
onClose()
}
+ return true
}
+ const errorPayload = await response.json().catch(() => ({}))
+ setTestResult({
+ success: false,
+ message: errorPayload.detail || t('settings.defaultCityFormatError')
+ })
+ return false
} catch (error) {
console.error('Failed to save config:', error)
+ return false
}
}
@@ -262,6 +295,25 @@ const Settings = ({ isOpen, onClose, onSave }) => {
))}
+
+
+
+ {
+ setConfig(prev => ({ ...prev, weather_location: e.target.value }))
+ if (testResult?.success === false) {
+ setTestResult(null)
+ }
+ }}
+ placeholder={t('settings.defaultCityPlaceholder')}
+ />
+
@@ -456,46 +508,7 @@ const Settings = ({ isOpen, onClose, onSave }) => {
)}
-
-
- {/* Weather API Section */}
-
-
{t('settings.weatherSection')}
-
-
-
- setConfig(prev => ({ ...prev, qweather_api_key: e.target.value }))}
- placeholder={hasQweatherKey ? `••••••••(${t('settings.keepEmpty')})` : t('settings.removebgKeyPlaceholder')}
- />
-
-
-
-
-
setConfig(prev => ({ ...prev, qweather_api_host: e.target.value }))}
- placeholder="devapi.qweather.com"
- />
-
-
-
+
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index cdd27da..11d8aa5 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -21,6 +21,7 @@
"categoryTop": "Top",
"categoryBottom": "Bottom",
"categoryShoes": "Shoes",
+ "categoryAccessory": "Accessory",
"color": "Color",
"colorPlaceholder": "Main color",
"style": "Style",
@@ -48,6 +49,7 @@
"tops": "Tops",
"bottoms": "Bottoms",
"shoes": "Shoes",
+ "accessories": "Accessories",
"loading": "Loading...",
"noMatch": "No matching items",
"deleteConfirm": "Are you sure you want to delete this item?",
@@ -77,6 +79,7 @@
"top": "Top",
"bottom": "Bottom",
"shoes": "Shoes",
+ "accessory": "Accessory",
"noItems": "No {{label}}"
},
"recommendation": {
@@ -87,7 +90,7 @@
"currentLocation": "Current Location",
"searchCity": "Enter city, district, or province",
"searchAction": "Check Weather",
- "searchHint": "Supports city names, districts, province-city combinations, pinyin, or QWeather LocationID.",
+ "searchHint": "Supports city names, districts, province-city combinations, pinyin, or coordinates (e.g. 31.23,121.47).",
"searching": "Searching locations...",
"noCity": "No matching city found",
"enterCity": "Enter a location to search",
@@ -97,10 +100,24 @@
"suggestedCombo": "Suggested Combo",
"topWear": "Top",
"bottomWear": "Bottom",
+ "shoesWear": "Shoes",
"humidity": "Humidity",
"wind": "Wind",
"windLevel": "",
- "feelsLike": "Feels like"
+ "feelsLike": "Feels like",
+ "horoscopeSign": "Zodiac",
+ "mood": "Mood",
+ "luckyColor": "Lucky color",
+ "luckyNumber": "Lucky number",
+ "horoscopeSummary": "Horoscope summary",
+ "temperatureRule": "Temperature strategy",
+ "allowedSeasons": "Matched seasons",
+ "outfitSummary": "Outfit summary",
+ "accessories": "Accessory picks",
+ "fromWardrobe": "In wardrobe",
+ "needBuy": "Need to buy",
+ "purchaseFallback": "Shopping fallback",
+ "buyKeywords": "Suggested items"
},
"home": {
"today": "Today",
@@ -115,6 +132,7 @@
"categoryTop": "Top",
"categoryBottom": "Bottom",
"categoryShoes": "Shoes",
+ "categoryAccessory": "Accessory",
"viewAll": "View all",
"emptyWardrobe": "Your wardrobe is empty. Add a few pieces first.",
"goEntry": "Add Clothes",
@@ -130,7 +148,10 @@
"unknownWeather": "Unknown",
"unknownLocation": "Unknown location",
"unknownZodiac": "No zodiac",
- "horoscopeFallback": "Horoscope is temporarily unavailable. Please try again later."
+ "horoscopeFallback": "Horoscope is temporarily unavailable. Please try again later.",
+ "llmReasoningTitle": "LLM Reasoning",
+ "llmReasoningLoading": "Reasoning in progress...",
+ "llmReasoningFallback": "No reasoning result yet."
},
"settings": {
"title": "API Settings",
@@ -167,6 +188,9 @@
"appSection": "App Settings",
"zodiac": "Zodiac Sign",
"zodiacPlaceholder": "Select your zodiac sign",
+ "defaultCity": "Default City",
+ "defaultCityPlaceholder": "e.g. Nanjing, Jiangsu, China",
+ "defaultCityFormatError": "Location is incomplete. Use \"City, State/Province, Country\", e.g. Nanjing, Jiangsu, China.",
"zodiacOptions": {
"aries": "Aries",
"taurus": "Taurus",
diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json
index ccca0b0..148f76a 100644
--- a/frontend/src/i18n/locales/ja.json
+++ b/frontend/src/i18n/locales/ja.json
@@ -21,6 +21,7 @@
"categoryTop": "トップス",
"categoryBottom": "ボトムス",
"categoryShoes": "シューズ",
+ "categoryAccessory": "アクセサリー",
"color": "カラー",
"colorPlaceholder": "メインカラー",
"style": "スタイル",
@@ -48,6 +49,7 @@
"tops": "トップス",
"bottoms": "ボトムス",
"shoes": "シューズ",
+ "accessories": "アクセサリー",
"loading": "読み込み中...",
"noMatch": "一致する衣類がありません",
"deleteConfirm": "この衣類を削除しますか?",
@@ -77,6 +79,7 @@
"top": "トップス",
"bottom": "ボトムス",
"shoes": "シューズ",
+ "accessory": "アクセサリー",
"noItems": "{{label}}がありません"
},
"recommendation": {
@@ -87,7 +90,7 @@
"currentLocation": "現在地",
"searchCity": "都市・地区・都道府県を入力",
"searchAction": "天気を確認",
- "searchHint": "都市名、地区名、都道府県との組み合わせ、ピンイン、QWeather LocationID に対応します。",
+ "searchHint": "都市名、地区名、都道府県の組み合わせ、ピンイン、または緯度経度(例: 31.23,121.47)に対応します。",
"searching": "地点を検索中...",
"noCity": "該当する都市が見つかりません",
"enterCity": "地点名を入力して検索",
@@ -97,10 +100,24 @@
"suggestedCombo": "おすすめ組合せ",
"topWear": "トップス",
"bottomWear": "ボトムス",
+ "shoesWear": "シューズ",
"humidity": "湿度",
"wind": "風力",
"windLevel": "級",
- "feelsLike": "体感"
+ "feelsLike": "体感",
+ "horoscopeSign": "今日の星座",
+ "mood": "今日の気分",
+ "luckyColor": "ラッキーカラー",
+ "luckyNumber": "ラッキーナンバー",
+ "horoscopeSummary": "運勢サマリー",
+ "temperatureRule": "気温コーデ戦略",
+ "allowedSeasons": "適用シーズン",
+ "outfitSummary": "コーデ結論",
+ "accessories": "アクセサリー提案",
+ "fromWardrobe": "手持ちあり",
+ "needBuy": "購入推奨",
+ "purchaseFallback": "不足分の購入提案",
+ "buyKeywords": "おすすめアイテム"
},
"home": {
"today": "今日",
@@ -115,6 +132,7 @@
"categoryTop": "トップス",
"categoryBottom": "ボトムス",
"categoryShoes": "シューズ",
+ "categoryAccessory": "アクセサリー",
"viewAll": "すべて表示",
"emptyWardrobe": "ワードローブが空です。先に数点登録しましょう。",
"goEntry": "登録へ",
@@ -130,7 +148,10 @@
"unknownWeather": "不明",
"unknownLocation": "場所不明",
"unknownZodiac": "星座未設定",
- "horoscopeFallback": "運勢を取得できませんでした。しばらくして再試行してください。"
+ "horoscopeFallback": "運勢を取得できませんでした。しばらくして再試行してください。",
+ "llmReasoningTitle": "LLM 推論",
+ "llmReasoningLoading": "推論中...",
+ "llmReasoningFallback": "推論結果はまだありません。"
},
"settings": {
"title": "API 設定",
@@ -167,6 +188,9 @@
"appSection": "アプリ設定",
"zodiac": "星座",
"zodiacPlaceholder": "星座を選択してください",
+ "defaultCity": "デフォルト都市",
+ "defaultCityPlaceholder": "例:Nanjing, Jiangsu, China",
+ "defaultCityFormatError": "地点の形式が不完全です。「都市, 州/省, 国」の形式で入力してください(例:Nanjing, Jiangsu, China)。",
"zodiacOptions": {
"aries": "牡羊座",
"taurus": "牡牛座",
diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json
index 424b537..4f88514 100644
--- a/frontend/src/i18n/locales/zh.json
+++ b/frontend/src/i18n/locales/zh.json
@@ -21,6 +21,7 @@
"categoryTop": "上装 (Top)",
"categoryBottom": "下装 (Bottom)",
"categoryShoes": "鞋履 (Shoes)",
+ "categoryAccessory": "饰品 (Accessory)",
"color": "颜色",
"colorPlaceholder": "主要颜色",
"style": "风格",
@@ -48,6 +49,7 @@
"tops": "上装",
"bottoms": "下装",
"shoes": "鞋履",
+ "accessories": "饰品",
"loading": "加载中...",
"noMatch": "暂无匹配衣物",
"deleteConfirm": "确定要删除这件衣物吗?",
@@ -77,6 +79,7 @@
"top": "上衣",
"bottom": "下装",
"shoes": "鞋履",
+ "accessory": "饰品",
"noItems": "暂无{{label}}"
},
"recommendation": {
@@ -87,7 +90,7 @@
"currentLocation": "当前位置",
"searchCity": "输入城市、区县或省份",
"searchAction": "查询天气并生成推荐",
- "searchHint": "支持城市名、区县名、省市组合、拼音或和风天气 LocationID。",
+ "searchHint": "支持城市名、区县名、省市组合、拼音,或经纬度坐标(如 31.23,121.47)。",
"searching": "正在搜索地区...",
"noCity": "未找到匹配的城市",
"enterCity": "请输入地区名称进行搜索",
@@ -97,10 +100,24 @@
"suggestedCombo": "建议组合",
"topWear": "上装",
"bottomWear": "下装",
+ "shoesWear": "鞋履",
"humidity": "湿度",
"wind": "风力",
"windLevel": "级",
- "feelsLike": "体感"
+ "feelsLike": "体感",
+ "horoscopeSign": "今日星座",
+ "mood": "今日状态",
+ "luckyColor": "幸运色",
+ "luckyNumber": "幸运数字",
+ "horoscopeSummary": "运势摘要",
+ "temperatureRule": "温度穿搭策略",
+ "allowedSeasons": "适配季节",
+ "outfitSummary": "搭配结论",
+ "accessories": "饰品推荐",
+ "fromWardrobe": "衣柜已有",
+ "needBuy": "建议补购",
+ "purchaseFallback": "购买补充建议",
+ "buyKeywords": "推荐单品"
},
"home": {
"today": "今天",
@@ -115,6 +132,7 @@
"categoryTop": "上装",
"categoryBottom": "下装",
"categoryShoes": "鞋履",
+ "categoryAccessory": "饰品",
"viewAll": "查看全部",
"emptyWardrobe": "你的衣柜还是空的,先录入几件单品吧。",
"goEntry": "去录入",
@@ -130,7 +148,10 @@
"unknownWeather": "未知天气",
"unknownLocation": "未知位置",
"unknownZodiac": "未设置星座",
- "horoscopeFallback": "暂时无法获取运势,稍后再试。"
+ "horoscopeFallback": "暂时无法获取运势,稍后再试。",
+ "llmReasoningTitle": "LLM 推理",
+ "llmReasoningLoading": "正在推理中...",
+ "llmReasoningFallback": "暂时没有推理结果。"
},
"settings": {
"title": "API 设置",
@@ -167,6 +188,9 @@
"appSection": "应用设置",
"zodiac": "星座",
"zodiacPlaceholder": "请选择你的星座",
+ "defaultCity": "默认城市",
+ "defaultCityPlaceholder": "例如:南京, 江苏, 中国",
+ "defaultCityFormatError": "地点格式不完整,请使用“城市, 省/州, 国家”,例如:南京, 江苏, 中国",
"zodiacOptions": {
"aries": "白羊座",
"taurus": "金牛座",
diff --git a/frontend/src/pages/Entry.jsx b/frontend/src/pages/Entry.jsx
index 52031ec..94add7d 100644
--- a/frontend/src/pages/Entry.jsx
+++ b/frontend/src/pages/Entry.jsx
@@ -136,6 +136,7 @@ export default function Entry() {
+
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
index 2c3c154..a6ef835 100644
--- a/frontend/src/pages/Home.jsx
+++ b/frontend/src/pages/Home.jsx
@@ -5,7 +5,7 @@ import { Settings as SettingsIcon, RefreshCw, Sparkles, CloudSun, Droplets, Wind
import Settings from '../components/Settings'
const API_BASE = `http://${window.location.hostname}:8000/api`
-const DEFAULT_LOCATION = '101020100'
+const FALLBACK_LOCATION = '上海, 上海市, 中国'
const formatDate = (locale) => {
const lang = locale?.startsWith('zh')
@@ -25,8 +25,10 @@ export default function Home() {
const navigate = useNavigate()
const [weather, setWeather] = useState(null)
- const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [] })
+ const [wardrobe, setWardrobe] = useState({ tops: [], bottoms: [], shoes: [], accessories: [] })
const [horoscope, setHoroscope] = useState(null)
+ const [horoscopeInferenceLoading, setHoroscopeInferenceLoading] = useState(false)
+ const [defaultLocation, setDefaultLocation] = useState(FALLBACK_LOCATION)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [showSettings, setShowSettings] = useState(false)
@@ -35,7 +37,8 @@ export default function Home() {
const carouselItems = useMemo(() => ([
...wardrobe.tops.map(item => ({ ...item, category: 'top' })),
...wardrobe.bottoms.map(item => ({ ...item, category: 'bottom' })),
- ...wardrobe.shoes.map(item => ({ ...item, category: 'shoes' }))
+ ...wardrobe.shoes.map(item => ({ ...item, category: 'shoes' })),
+ ...wardrobe.accessories.map(item => ({ ...item, category: 'accessory' }))
]), [wardrobe])
useEffect(() => {
@@ -51,7 +54,42 @@ export default function Home() {
return () => clearInterval(timer)
}, [carouselItems.length])
- const fetchDashboard = async (withLoading = true) => {
+ const fetchConfiguredLocation = async () => {
+ try {
+ const response = await fetch(`${API_BASE}/config`)
+ if (!response.ok) {
+ return FALLBACK_LOCATION
+ }
+ const data = await response.json()
+ return (data.weather_location || '').trim() || FALLBACK_LOCATION
+ } catch {
+ return FALLBACK_LOCATION
+ }
+ }
+
+ const fetchHoroscope = async (location, includeInference = false) => {
+ const response = await fetch(
+ `${API_BASE}/horoscope/daily?location=${encodeURIComponent(location)}&include_inference=${includeInference}`
+ )
+ if (!response.ok) return null
+ return response.json()
+ }
+
+ const runHoroscopeInference = async (location) => {
+ setHoroscopeInferenceLoading(true)
+ try {
+ const inferred = await fetchHoroscope(location, true)
+ if (inferred) {
+ setHoroscope(inferred)
+ }
+ } catch (error) {
+ console.error('Failed to fetch horoscope inference:', error)
+ } finally {
+ setHoroscopeInferenceLoading(false)
+ }
+ }
+
+ const fetchDashboard = async (withLoading = true, location = defaultLocation) => {
if (withLoading) {
setLoading(true)
} else {
@@ -59,10 +97,10 @@ export default function Home() {
}
try {
- const [weatherRes, wardrobeRes, horoscopeRes] = await Promise.all([
- fetch(`${API_BASE}/weather?location=${DEFAULT_LOCATION}`),
+ const [weatherRes, wardrobeRes, horoscopeData] = await Promise.all([
+ fetch(`${API_BASE}/weather?location=${encodeURIComponent(location)}`),
fetch(`${API_BASE}/wardrobe`),
- fetch(`${API_BASE}/horoscope/daily?location=${DEFAULT_LOCATION}`)
+ fetchHoroscope(location, false)
])
if (weatherRes.ok) {
@@ -70,11 +108,23 @@ export default function Home() {
}
if (wardrobeRes.ok) {
- setWardrobe(await wardrobeRes.json())
+ const data = await wardrobeRes.json()
+ setWardrobe({
+ tops: data.tops || [],
+ bottoms: data.bottoms || [],
+ shoes: data.shoes || [],
+ accessories: data.accessories || []
+ })
}
- if (horoscopeRes.ok) {
- setHoroscope(await horoscopeRes.json())
+ if (horoscopeData) {
+ setHoroscope(horoscopeData)
+ const shouldInfer = horoscopeData.llm_status === 'pending'
+ if (horoscopeData.is_configured && shouldInfer) {
+ void runHoroscopeInference(location)
+ } else {
+ setHoroscopeInferenceLoading(false)
+ }
}
} catch (error) {
console.error('Failed to fetch home dashboard:', error)
@@ -85,24 +135,35 @@ export default function Home() {
}
useEffect(() => {
- fetchDashboard(true)
+ const initializeDashboard = async () => {
+ const location = await fetchConfiguredLocation()
+ setDefaultLocation(location)
+ await fetchDashboard(true, location)
+ }
+
+ void initializeDashboard()
}, [])
+ const handleSettingsSaved = async () => {
+ const location = await fetchConfiguredLocation()
+ setDefaultLocation(location)
+ await fetchDashboard(false, location)
+ }
+
const getCategoryLabel = (category) => {
if (category === 'top') return t('home.categoryTop')
if (category === 'bottom') return t('home.categoryBottom')
- return t('home.categoryShoes')
+ if (category === 'shoes') return t('home.categoryShoes')
+ return t('home.categoryAccessory')
}
+ const sectionTitleClass = 'text-sm font-semibold text-zinc-800 dark:text-zinc-100 flex items-center gap-2'
+
return (