mod city_data;
use std::sync::Arc;
use lellm_core::{ChatRequest, text_block};
use lellm_provider::LlmProvider;
#[derive(Debug, Clone, serde::Serialize)]
pub struct CityResult {
pub city_en: String, pub source: String, pub address: String, }
pub fn to_kebab(s: &str) -> String {
if let Some(&(_, pinyin)) = city_data::CITY_PINYIN_MAP.iter().find(|(key, _)| *key == s) {
return pinyin.to_string();
}
if let Some(longest) = city_data::CITY_PINYIN_MAP
.iter()
.filter(|(key, _)| s.starts_with(*key))
.map(|(key, pinyin)| (key.len(), pinyin))
.max_by_key(|&(len, _)| len)
{
return longest.1.to_string();
}
s.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|seg| !seg.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn resolve_via_tencent_map(address: &str) -> Option<CityResult> {
let api_key = match std::env::var("TENCENT_MAP_KEY") {
Ok(ak) => ak,
Err(_) => return None,
};
let url = format!(
"https://apis.map.qq.com/ws/geocoder/v1/?address={}&key={}&output=json",
urlencoding::encode(address),
api_key
);
tracing::debug!(address, url, "calling tencent map api");
let resp = match reqwest::blocking::Client::new().get(&url).send() {
Ok(r) => r,
Err(e) => {
tracing::debug!(address, error = %e, "tencent map api request failed");
return None;
}
};
let body: serde_json::Value = match resp.json() {
Ok(v) => v,
Err(e) => {
tracing::debug!(address, error = %e, "tencent map api json parse failed");
return None;
}
};
let status = body.get("status").and_then(|v| v.as_i64()).unwrap_or(-1);
if status != 0 {
tracing::debug!(
address,
status,
msg = ?body.get("message").and_then(|v| v.as_str()),
"tencent map api returned error status"
);
return None;
}
let result = match body.get("result") {
Some(r) => r,
None => {
tracing::debug!(address, "tencent map api missing result");
return None;
}
};
let address_components = match result.get("address_components") {
Some(r) => r,
None => {
tracing::debug!(address, "tencent map api missing address_components");
return None;
}
};
let city_raw = address_components.get("city").and_then(|v| v.as_str());
tracing::debug!(address, city_raw, "tencent map api city field");
let city_en = city_raw.map(|s| to_kebab(s)).unwrap_or_default();
tracing::debug!(address, city_en, "tencent map api resolved city_en");
Some(CityResult {
city_en,
source: "tencent".to_string(),
address: address.to_string(),
})
}
pub fn parse_city_from_json(text: &str) -> Option<String> {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(text.trim()) {
if let Some(city) = val.get("city").and_then(|c| c.as_str()) {
let city = city.trim().to_string();
if !city.is_empty() {
return Some(city);
}
}
}
if let Some(last_pos) = text.rfind("\"city\"") {
let after = &text[last_pos + 6..];
let after = after.trim_start_matches(':').trim_start();
if let Some(quote) = after.find('"') {
let after_quote = &after[quote + 1..];
if let Some(end_quote) = after_quote.find('"') {
let city = after_quote[..end_quote].trim();
if !city.is_empty() {
return Some(city.to_string());
}
}
}
}
None
}
pub async fn resolve_via_llm(provider: &Arc<dyn LlmProvider>, address: &str) -> Option<CityResult> {
let prompt = format!(
"地址「{0}」属于哪个城市?\n\
输出 wttr.in 可用的城市英文名(全小写,多词用连字符,如 new-york)。\n\
只输出紧凑的 JSON:{{\"city\": \"城市英文名\"}},不确定则 {{\"city\": \"unknown\"}}",
address
);
let req = ChatRequest {
model: "Qwen3.6".to_string(),
messages: vec![lellm_core::Message::user(text_block(prompt))],
tools: None,
temperature: Some(0.0),
max_tokens: Some(1000),
..Default::default()
};
let resp = provider.call(&req).await.ok()?;
let all_text: String = resp
.content
.iter()
.filter_map(|b| {
if let lellm_core::ContentBlock::Text(t) = b {
Some(t.text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(" ");
if all_text.is_empty() {
tracing::debug!("resolve_via_llm: no text in response");
return None;
}
let city_text = parse_city_from_json(&all_text)?;
let city_text = city_text.to_lowercase();
if city_text.is_empty() || city_text == "unknown" {
tracing::debug!(city = %city_text, "resolve_via_llm: filtered out");
return None;
}
tracing::debug!(city = %city_text, address = %address, "resolve_via_llm: success");
Some(CityResult {
city_en: city_text,
source: "llm".to_string(),
address: address.to_string(),
})
}
pub fn resolve_city(address: &str) -> CityResult {
if let Some(&city_en) = city_data::CITY_ALIASES.get(address) {
return CityResult {
city_en: city_en.to_string(),
source: "alias".to_string(),
address: address.to_string(),
};
}
if let Some(result) = resolve_via_tencent_map(address) {
return result;
}
CityResult {
city_en: "unknown".to_string(),
source: "unknown".to_string(),
address: address.to_string(),
}
}