Files
yield-smart-app/app.py
zhenghu 9cb70267b6 feat: 初始化 YieldSmart 农业智能决策系统
基于多因子 Cobb-Douglas 产量模型的作物种植决策支持应用。

  新增文件:
  - app.py: Streamlit 主应用,包含产量预测模型、多作物数据库、
    雷达图/敏感性分析可视化、作物推荐排行及智能建议面板
  - main.py: 入口文件
  - pyproject.toml: 项目配置(Python 3.14+,依赖 streamlit/plotly/pandas/numpy)
  - Dockerfile: 基于 uv 镜像的容器化部署配置
  - justfile: 任务自动化(运行/格式化/检查/清理)
  - .gitignore: Python/IDE/缓存忽略规则
2026-04-13 17:45:14 +08:00

588 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
农业智能决策系统
基于多因子 Cobb-Douglas 产量模型的作物种植决策支持应用
"""
import streamlit as st
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
# ─── Page Config ────────────────────────────────────────────────────────────
st.set_page_config(
page_title="农业智能决策系统",
page_icon="🌾",
layout="wide",
initial_sidebar_state="expanded",
)
# ─── Custom CSS ──────────────────────────────────────────────────────────────
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg-dark: #0a1628;
--bg-card: #0f2040;
--accent-green: #4ade80;
--accent-gold: #f59e0b;
--accent-blue: #38bdf8;
--text-primary: #e2e8f0;
--text-muted: #64748b;
--border: rgba(74, 222, 128, 0.2);
}
html, body, [class*="css"] {
font-family: 'Noto Serif SC', serif;
background-color: var(--bg-dark);
color: var(--text-primary);
}
.stApp {
background: linear-gradient(135deg, #0a1628 0%, #0d1f3c 50%, #091520 100%);
}
/* Sidebar */
[data-testid="stSidebar"] {
background: linear-gradient(180deg, #0f2040 0%, #0a1628 100%);
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] .stSlider label,
[data-testid="stSidebar"] .stNumberInput label,
[data-testid="stSidebar"] .stSelectbox label {
color: var(--accent-green) !important;
font-size: 0.82rem;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.05em;
}
/* Metric cards */
.metric-card {
background: linear-gradient(135deg, #0f2040, #132b55);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
text-align: center;
position: relative;
overflow: hidden;
}
.metric-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-green), var(--accent-blue));
}
.metric-value {
font-family: 'JetBrains Mono', monospace;
font-size: 2.2rem;
font-weight: 700;
color: var(--accent-green);
line-height: 1;
}
.metric-unit {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
}
.metric-label {
font-size: 0.9rem;
color: var(--text-primary);
margin-top: 8px;
}
/* Section headers */
.section-header {
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.15em;
color: var(--accent-gold);
text-transform: uppercase;
border-bottom: 1px solid rgba(245,158,11,0.3);
padding-bottom: 6px;
margin-bottom: 16px;
margin-top: 24px;
}
/* Crop badge */
.crop-badge {
display: inline-block;
background: linear-gradient(135deg, rgba(74,222,128,0.15), rgba(56,189,248,0.15));
border: 1px solid var(--accent-green);
border-radius: 6px;
padding: 4px 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: var(--accent-green);
margin: 3px;
}
/* Recommendation card */
.rec-card {
background: linear-gradient(135deg, rgba(74,222,128,0.08), rgba(56,189,248,0.05));
border: 1px solid rgba(74,222,128,0.3);
border-radius: 12px;
padding: 18px 22px;
margin: 10px 0;
}
.rec-rank {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
color: var(--accent-gold);
font-weight: 700;
}
.rec-crop {
font-size: 1.1rem;
color: var(--text-primary);
font-weight: 600;
}
.rec-score {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--accent-blue);
}
/* Hero title */
.hero-title {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-green), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.hero-sub {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.1em;
margin-top: 4px;
}
/* Alert boxes */
.alert-good {
background: rgba(74,222,128,0.1);
border-left: 3px solid var(--accent-green);
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 8px 0;
font-size: 0.9rem;
}
.alert-warn {
background: rgba(245,158,11,0.1);
border-left: 3px solid var(--accent-gold);
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 8px 0;
font-size: 0.9rem;
}
/* Override streamlit slider colors */
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMin"],
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMax"] {
color: var(--text-muted);
}
</style>
""", unsafe_allow_html=True)
# ─── Crop Database ───────────────────────────────────────────────────────────
CROPS = {
"水稻": {
"emoji": "🌾",
"optimal": {"ph": (6.0, 7.0), "N": (80, 120), "P": (30, 60), "K": (40, 80),
"rainfall": (150, 250), "temp": (22, 30)},
"base_yield": 7500, # kg/ha
"color": "#4ade80"
},
"小麦": {
"emoji": "🌿",
"optimal": {"ph": (6.0, 7.5), "N": (60, 100), "P": (20, 50), "K": (30, 60),
"rainfall": (60, 120), "temp": (15, 22)},
"base_yield": 6000,
"color": "#f59e0b"
},
"玉米": {
"emoji": "🌽",
"optimal": {"ph": (5.8, 7.0), "N": (100, 150), "P": (40, 70), "K": (60, 100),
"rainfall": (100, 180), "temp": (20, 28)},
"base_yield": 8500,
"color": "#fbbf24"
},
"大豆": {
"emoji": "🫘",
"optimal": {"ph": (6.0, 7.0), "N": (20, 50), "P": (30, 60), "K": (40, 80),
"rainfall": (80, 150), "temp": (18, 26)},
"base_yield": 3500,
"color": "#a78bfa"
},
"油菜": {
"emoji": "🌻",
"optimal": {"ph": (6.0, 7.5), "N": (80, 130), "P": (30, 60), "K": (50, 90),
"rainfall": (80, 130), "temp": (15, 20)},
"base_yield": 3000,
"color": "#f97316"
},
"棉花": {
"emoji": "☁️",
"optimal": {"ph": (6.0, 8.0), "N": (60, 100), "P": (20, 45), "K": (40, 70),
"rainfall": (70, 120), "temp": (25, 32)},
"base_yield": 4500,
"color": "#e2e8f0"
},
}
# ─── Yield Model ─────────────────────────────────────────────────────────────
def compute_factor(value, optimal_low, optimal_high, penalty=0.5):
"""Score 0-1: 1 if in optimal range, decays outside."""
mid = (optimal_low + optimal_high) / 2
width = (optimal_high - optimal_low) / 2 + 1e-9
if optimal_low <= value <= optimal_high:
return 1.0
dist = min(abs(value - optimal_low), abs(value - optimal_high))
return max(0.0, 1.0 - penalty * (dist / width))
def predict_yield(crop_name, ph, N, P, K, rainfall, temp, pesticide, area):
crop = CROPS[crop_name]
opt = crop["optimal"]
f_ph = compute_factor(ph, *opt["ph"], penalty=0.6)
f_N = compute_factor(N, *opt["N"], penalty=0.4)
f_P = compute_factor(P, *opt["P"], penalty=0.4)
f_K = compute_factor(K, *opt["K"], penalty=0.4)
f_rain = compute_factor(rainfall, *opt["rainfall"], penalty=0.5)
f_temp = compute_factor(temp, *opt["temp"], penalty=0.7)
f_pest = 0.5 + 0.5 * min(pesticide / 100, 1.0)
# Cobb-Douglas style yield function
nutrient_idx = (f_N * f_P * f_K) ** (1/3)
soil_idx = f_ph
climate_idx = (f_rain * f_temp) ** 0.5
total_factor = soil_idx ** 0.2 * nutrient_idx ** 0.4 * climate_idx ** 0.3 * f_pest ** 0.1
yield_per_ha = crop["base_yield"] * total_factor
total_yield = yield_per_ha * area
factors = {
"土壤pH": f_ph, "氮(N)": f_N, "磷(P)": f_P,
"钾(K)": f_K, "降雨量": f_rain, "温度": f_temp, "农药": f_pest
}
return yield_per_ha, total_yield, factors
def rank_crops(ph, N, P, K, rainfall, temp, pesticide, area):
results = []
for name in CROPS:
yph, ytotal, factors = predict_yield(name, ph, N, P, K, rainfall, temp, pesticide, area)
score = np.mean(list(factors.values()))
results.append({
"crop": name,
"emoji": CROPS[name]["emoji"],
"yield_ha": yph,
"total_yield": ytotal,
"score": score,
"color": CROPS[name]["color"],
"factors": factors
})
results.sort(key=lambda x: x["score"], reverse=True)
return results
# ─── Sidebar Inputs ──────────────────────────────────────────────────────────
with st.sidebar:
st.markdown('<div class="hero-title">🌾 农业决策</div>', unsafe_allow_html=True)
st.markdown('<div class="hero-sub">SMART FARMING SYSTEM v2.0</div>', unsafe_allow_html=True)
st.markdown("---")
st.markdown('<div class="section-header">🧪 土壤参数</div>', unsafe_allow_html=True)
col1, col2 = st.columns(2)
with col1:
ph = st.slider("pH 值", 4.0, 9.0, 6.5, 0.1)
N = st.slider("氮 N (mg/kg)", 0, 200, 90, 5)
with col2:
P = st.slider("磷 P (mg/kg)", 0, 100, 45, 5)
K = st.slider("钾 K (mg/kg)", 0, 150, 60, 5)
st.markdown('<div class="section-header">🌦 气象数据</div>', unsafe_allow_html=True)
col3, col4 = st.columns(2)
with col3:
rainfall = st.slider("降雨量 (mm/月)", 0, 400, 120, 10)
with col4:
temp = st.slider("温度 (°C)", 0, 45, 22, 1)
st.markdown('<div class="section-header">🌱 种植参数</div>', unsafe_allow_html=True)
area = st.number_input("种植面积 (公顷)", 0.1, 10000.0, 100.0, 10.0)
pesticide = st.slider("农药用量 (kg/ha)", 0, 200, 50, 5)
st.markdown('<div class="section-header">🎯 目标作物</div>', unsafe_allow_html=True)
selected_crop = st.selectbox(
"选择分析作物",
list(CROPS.keys()),
format_func=lambda x: f"{CROPS[x]['emoji']} {x}"
)
# ─── Compute ──────────────────────────────────────────────────────────────────
yph, ytotal, factors = predict_yield(selected_crop, ph, N, P, K, rainfall, temp, pesticide, area)
rankings = rank_crops(ph, N, P, K, rainfall, temp, pesticide, area)
best_crop = rankings[0]
# ─── Main Layout ─────────────────────────────────────────────────────────────
st.markdown(f"""
<div style="display:flex; align-items:baseline; gap:16px; margin-bottom:4px;">
<div class="hero-title">农业智能决策系统</div>
</div>
<div class="hero-sub">YIELD = f(SOIL · WEATHER · PESTICIDE) &nbsp;|&nbsp; 基于多因子 Cobb-Douglas 产量模型</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# KPI row
k1, k2, k3, k4 = st.columns(4)
with k1:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{yph:,.0f}</div>
<div class="metric-unit">kg / 公顷</div>
<div class="metric-label">{CROPS[selected_crop]['emoji']} {selected_crop} 单产</div>
</div>""", unsafe_allow_html=True)
with k2:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{ytotal/1000:,.1f}</div>
<div class="metric-unit">吨 / 总产量</div>
<div class="metric-label">📦 {area:.0f} 公顷总产</div>
</div>""", unsafe_allow_html=True)
with k3:
overall = np.mean(list(factors.values()))
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{overall*100:.1f}%</div>
<div class="metric-unit">综合适宜度</div>
<div class="metric-label">🎯 环境匹配指数</div>
</div>""", unsafe_allow_html=True)
with k4:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{best_crop['emoji']}</div>
<div class="metric-unit">{best_crop['crop']} ({best_crop['score']*100:.0f}%)</div>
<div class="metric-label">🏆 最优推荐作物</div>
</div>""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# ─── Charts Row ──────────────────────────────────────────────────────────────
col_left, col_right = st.columns([3, 2])
with col_left:
st.markdown('<div class="section-header">📊 影响因子雷达图</div>', unsafe_allow_html=True)
factor_names = list(factors.keys())
factor_vals = [round(v * 100, 1) for v in factors.values()]
factor_names_closed = factor_names + [factor_names[0]]
factor_vals_closed = factor_vals + [factor_vals[0]]
fig_radar = go.Figure()
fig_radar.add_trace(go.Scatterpolar(
r=factor_vals_closed,
theta=factor_names_closed,
fill='toself',
fillcolor='rgba(74,222,128,0.15)',
line=dict(color='#4ade80', width=2),
name=selected_crop,
))
fig_radar.add_trace(go.Scatterpolar(
r=[100]*len(factor_names_closed),
theta=factor_names_closed,
line=dict(color='rgba(255,255,255,0.1)', width=1, dash='dot'),
mode='lines',
name='理想值',
))
fig_radar.update_layout(
polar=dict(
bgcolor='rgba(0,0,0,0)',
radialaxis=dict(range=[0, 100], showticklabels=True,
tickfont=dict(color='#64748b', size=9),
gridcolor='rgba(255,255,255,0.06)'),
angularaxis=dict(tickfont=dict(color='#e2e8f0', size=11),
gridcolor='rgba(255,255,255,0.08)'),
),
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
font=dict(color='#e2e8f0'),
legend=dict(orientation='h', y=-0.12, font=dict(size=10)),
margin=dict(t=20, b=40, l=40, r=40),
height=320,
)
st.plotly_chart(fig_radar, use_container_width=True)
with col_right:
st.markdown('<div class="section-header">🏅 作物推荐排行</div>', unsafe_allow_html=True)
for i, r in enumerate(rankings[:4]):
rank_icons = ["🥇", "🥈", "🥉", "4"]
bar_width = int(r['score'] * 100)
bar_color = r['color']
st.markdown(f"""
<div class="rec-card" style="margin-bottom:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<span class="rec-rank">{rank_icons[i]}</span>
<span class="rec-crop" style="margin-left:8px;">{r['emoji']} {r['crop']}</span>
</div>
<span class="rec-score">{r['score']*100:.1f}%</span>
</div>
<div style="background:rgba(255,255,255,0.06); border-radius:4px; height:4px; margin-top:8px; overflow:hidden;">
<div style="width:{bar_width}%; height:100%; background:{bar_color}; border-radius:4px;"></div>
</div>
<div style="font-size:0.78rem; color:#64748b; margin-top:4px; font-family:'JetBrains Mono',monospace;">
{r['yield_ha']:,.0f} kg/ha &nbsp;·&nbsp; 总产 {r['total_yield']/1000:,.1f}
</div>
</div>""", unsafe_allow_html=True)
# ─── Sensitivity Analysis ─────────────────────────────────────────────────────
st.markdown('<div class="section-header">📈 产量敏感性分析</div>', unsafe_allow_html=True)
sa_col1, sa_col2 = st.columns(2)
with sa_col1:
N_range = np.linspace(0, 200, 60)
y_N = [predict_yield(selected_crop, ph, n, P, K, rainfall, temp, pesticide, 1)[0] for n in N_range]
fig_N = go.Figure()
fig_N.add_trace(go.Scatter(
x=N_range, y=y_N,
mode='lines', line=dict(color='#4ade80', width=2.5),
fill='tozeroy', fillcolor='rgba(74,222,128,0.08)',
name='产量'
))
fig_N.add_vline(x=N, line=dict(color='#f59e0b', width=1.5, dash='dot'),
annotation_text=f"当前 {N}", annotation_font_color='#f59e0b')
fig_N.update_layout(
title=dict(text="氮肥用量 vs 产量", font=dict(color='#94a3b8', size=12)),
xaxis=dict(title="氮 N (mg/kg)", color='#64748b', gridcolor='rgba(255,255,255,0.05)'),
yaxis=dict(title="产量 (kg/ha)", color='#64748b', gridcolor='rgba(255,255,255,0.05)'),
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
font=dict(color='#e2e8f0', size=10),
margin=dict(t=36, b=36, l=50, r=20), height=220,
showlegend=False,
)
st.plotly_chart(fig_N, use_container_width=True)
with sa_col2:
rain_range = np.linspace(0, 400, 60)
y_rain = [predict_yield(selected_crop, ph, N, P, K, r, temp, pesticide, 1)[0] for r in rain_range]
fig_R = go.Figure()
fig_R.add_trace(go.Scatter(
x=rain_range, y=y_rain,
mode='lines', line=dict(color='#38bdf8', width=2.5),
fill='tozeroy', fillcolor='rgba(56,189,248,0.08)',
name='产量'
))
fig_R.add_vline(x=rainfall, line=dict(color='#f59e0b', width=1.5, dash='dot'),
annotation_text=f"当前 {rainfall}mm", annotation_font_color='#f59e0b')
fig_R.update_layout(
title=dict(text="月降雨量 vs 产量", font=dict(color='#94a3b8', size=12)),
xaxis=dict(title="降雨量 (mm/月)", color='#64748b', gridcolor='rgba(255,255,255,0.05)'),
yaxis=dict(title="产量 (kg/ha)", color='#64748b', gridcolor='rgba(255,255,255,0.05)'),
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
font=dict(color='#e2e8f0', size=10),
margin=dict(t=36, b=36, l=50, r=20), height=220,
showlegend=False,
)
st.plotly_chart(fig_R, use_container_width=True)
# ─── All Crops Comparison Bar Chart ───────────────────────────────────────────
st.markdown('<div class="section-header">🌐 全作物产量对比</div>', unsafe_allow_html=True)
crop_names = [f"{r['emoji']} {r['crop']}" for r in rankings]
crop_yields = [r['yield_ha'] for r in rankings]
crop_colors = [r['color'] for r in rankings]
fig_bar = go.Figure()
fig_bar.add_trace(go.Bar(
x=crop_names, y=crop_yields,
marker=dict(color=crop_colors, opacity=0.85,
line=dict(color='rgba(255,255,255,0.2)', width=1)),
text=[f"{y:,.0f}" for y in crop_yields],
textposition='outside',
textfont=dict(color='#94a3b8', size=10, family='JetBrains Mono'),
))
fig_bar.update_layout(
xaxis=dict(color='#64748b', gridcolor='rgba(255,255,255,0.04)'),
yaxis=dict(title="预期产量 (kg/ha)", color='#64748b', gridcolor='rgba(255,255,255,0.05)'),
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
font=dict(color='#e2e8f0', size=11),
margin=dict(t=20, b=30, l=60, r=20), height=240,
showlegend=False,
)
st.plotly_chart(fig_bar, use_container_width=True)
# ─── Advisory Panel ───────────────────────────────────────────────────────────
st.markdown('<div class="section-header">💡 智能建议</div>', unsafe_allow_html=True)
adv1, adv2 = st.columns(2)
with adv1:
crop_opt = CROPS[selected_crop]["optimal"]
advisories = []
if not (crop_opt["ph"][0] <= ph <= crop_opt["ph"][1]):
advisories.append(("warn", f"pH {ph} 偏离 {selected_crop} 适宜范围 {crop_opt['ph']},建议{'施石灰' if ph < crop_opt['ph'][0] else '施硫磺'}调节"))
else:
advisories.append(("good", f"土壤 pH {ph} 处于 {selected_crop} 适宜范围内 ✓"))
if N < crop_opt["N"][0]:
advisories.append(("warn", f"氮肥不足({N} vs 建议 {crop_opt['N'][0]}-{crop_opt['N'][1]} mg/kg建议追施尿素"))
elif N > crop_opt["N"][1]:
advisories.append(("warn", f"氮肥过量({N} mg/kg可能造成徒长建议减施"))
else:
advisories.append(("good", f"氮肥水平 {N} mg/kg 适宜 ✓"))
if rainfall < crop_opt["rainfall"][0]:
advisories.append(("warn", f"降雨量不足,建议增加灌溉(缺水 {crop_opt['rainfall'][0]-rainfall} mm"))
elif rainfall > crop_opt["rainfall"][1]:
advisories.append(("warn", f"降雨量偏多,注意防涝排水"))
else:
advisories.append(("good", f"降雨量 {rainfall}mm 适合 {selected_crop} 生长 ✓"))
for typ, msg in advisories:
css_class = "alert-good" if typ == "good" else "alert-warn"
st.markdown(f'<div class="{css_class}">{msg}</div>', unsafe_allow_html=True)
with adv2:
st.markdown(f"""
<div class="rec-card">
<div style="font-size:0.82rem; color:#64748b; font-family:'JetBrains Mono',monospace; margin-bottom:12px;">
当前环境参数下适宜种植:
</div>
""", unsafe_allow_html=True)
badges = "".join([
f'<span class="crop-badge">{r["emoji"]} {r["crop"]} {r["score"]*100:.0f}%</span>'
for r in rankings if r['score'] > 0.6
])
st.markdown(f'{badges}</div>', unsafe_allow_html=True)
st.markdown(f"""
<div style="margin-top:16px; font-size:0.85rem; color:#94a3b8; line-height:1.7;">
<b style="color:#4ade80;">最优方案:</b>{best_crop['emoji']} {best_crop['crop']}<br>
预期单产:<span style="font-family:'JetBrains Mono',monospace; color:#38bdf8;">{best_crop['yield_ha']:,.0f} kg/ha</span><br>
{area:.0f}公顷总产:<span style="font-family:'JetBrains Mono',monospace; color:#38bdf8;">{best_crop['total_yield']/1000:,.1f} 吨</span>
</div>
""", unsafe_allow_html=True)
# ─── Footer ───────────────────────────────────────────────────────────────────
st.markdown("<br>", unsafe_allow_html=True)
st.markdown("""
<div style="text-align:center; font-family:'JetBrains Mono',monospace; font-size:0.72rem;
color:#334155; padding:16px; border-top:1px solid rgba(74,222,128,0.1);">
YIELD = f(Soil, Weather, Pesticide) &nbsp;|&nbsp; Cobb-Douglas Multi-Factor Model &nbsp;|&nbsp; 农业智能决策系统
</div>
""", unsafe_allow_html=True)