.
Если reviewer задан — берём только его записи (для разделения прав).
"""
try:
files = list_review_files(repo_id, dir_id, prefix)
except Exception:
return {}
index: Dict[str, Dict[str, Any]] = {}
ts_index: Dict[str, str] = {}
def pick_key(obj: Dict[str, Any]) -> Optional[str]:
sid = obj.get("source_id")
if isinstance(sid, str) and sid.strip():
return f"SRC::{sid.strip()}"
wid = obj.get("work_id")
if isinstance(wid, str) and wid.strip():
return f"W::{wid.strip()}"
return None
for relpath in files:
try:
path = hf_hub_download(repo_id=repo_id, filename=relpath, repo_type="dataset", token=HF_TOKEN)
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
obj = json.loads(line)
if reviewer and str(obj.get("reviewer") or "").strip() != reviewer:
continue
k = pick_key(obj)
if not k:
continue
t = str(obj.get("ts_utc") or obj.get("ts") or "")
if t >= ts_index.get(k, ""):
ts_index[k] = t
index[k] = obj
except Exception:
continue
return index
# =========================
# Reviews (write)
# =========================
def push_batch_to_reviews_repo(repo_id: str, dir_id: str, prefix: str, batch: List[Dict[str, Any]]) -> None:
if not batch:
return
if not HF_TOKEN:
raise RuntimeError("Нет HF_TOKEN (Secret) — нельзя записывать в reviews dataset.")
today = dt.date.today().isoformat()
batch_id = str(uuid.uuid4())
# Пишем в dir_id как есть (но если dir_id может быть DIR01, а у вас в БД DIR1 — нормализуйте в UI выбором)
canonical_dir = normalize_dir_id(dir_id, pad2=False)
path_in_repo = f"{prefix}/{today}/{canonical_dir}/{batch_id}.jsonl"
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8", suffix=".jsonl") as tmp:
for row in batch:
tmp.write(json.dumps(row, ensure_ascii=False) + "\n")
tmp_path = tmp.name
api.upload_file(
path_or_fileobj=tmp_path,
path_in_repo=path_in_repo,
repo_id=repo_id,
repo_type="dataset",
commit_message=f"add reviews batch {dir_id} {today} ({len(batch)})",
)
# =========================
# =========================
# MAIN APP (offline-first)
# =========================
auth = ensure_auth()
login = auth.get("login", "anonymous")
role = auth.get("role", "reviewer")
# Sidebar: только пользователь/админ-инструменты
flush_now = False
clear_cache = False
with st.sidebar:
st.markdown(f"**Пользователь:** {login}
**Роль:** {role}", unsafe_allow_html=True)
if st.button("Выйти", use_container_width=True):
if "auth" in st.session_state:
del st.session_state["auth"]
st.rerun()
if role != "demo":
with st.expander("Сервис", expanded=False):
if st.session_state.get("batch"):
flush_now = st.button("⬆️ Синхронизировать pending", use_container_width=True)
if role == "admin":
with st.expander("Админ", expanded=False):
clear_cache = st.button("🧹 Сбросить кэш", use_container_width=True)
# Preflight: окружение/доступ к репозиториям
require_env("PUBLICATIONS_REPO", PUBLICATIONS_REPO)
if role != "demo":
require_env("REVIEWS_REPO", REVIEWS_REPO)
pub_ok, pub_msg = check_dataset_repo(PUBLICATIONS_REPO, HF_TOKEN)
if not pub_ok:
st.error("PUBLICATIONS_REPO недоступен как dataset repo. Проверь repo_id и доступ.\n\n" + pub_msg)
st.stop()
if role != "demo":
rev_ok, rev_msg = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
if not rev_ok:
# создание допускаем только администратору и только если явно разрешено
if role == "admin":
created, _ = maybe_create_reviews_repo(REVIEWS_REPO)
if created:
rev_ok2, rev_msg2 = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
if not rev_ok2:
st.error("REVIEWS_REPO недоступен после create_repo.\n\n" + rev_msg2)
st.stop()
else:
st.success("REVIEWS_REPO создан/доступен.")
else:
st.error("REVIEWS_REPO недоступен (404/нет доступа).\n\n" + rev_msg)
st.stop()
else:
st.error("REVIEWS_REPO недоступен (404/нет доступа).\n\n" + rev_msg)
st.stop()
# reviewer берём из учётной записи
reviewer = login
reviewer_filter = None if role == 'admin' else reviewer
dirs = load_dir_registry(PUBLICATIONS_REPO, PUB_DIR_REGISTRY_PATH)
dir_ids = [d.get("dir_id") for d in dirs if d.get("dir_id")]
if not dir_ids:
st.error("В dir_registry.json не найдено ни одного dir_id.")
st.stop()
dir_map = {d.get("dir_id"): d for d in dirs if d.get("dir_id")}
def _fmt_dir(did: str) -> str:
meta = dir_map.get(did) or {}
name = meta.get("dir_name") or "—"
return f"DIR-{str(dir_no(did)).zfill(2)} — {name}"
st.title("Скоринг публикаций")
# Выбор DIR — в основном интерфейсе (не в сайдбаре)
selected_dir = st.selectbox("Направления исследований", dir_ids, index=0, format_func=_fmt_dir)
dir_meta = dir_map.get(selected_dir) or {}
# =========================
if clear_cache:
load_dir_registry.clear()
load_candidates.clear()
if hasattr(list_review_files, "clear"):
list_review_files.clear()
if hasattr(load_reviewed_keys, "clear"):
load_reviewed_keys.clear()
if hasattr(load_review_index, "clear"):
load_review_index.clear()
for k in [
"reviewed_remote",
"reviewed_remote_dir",
"review_index_remote",
"review_index_local",
"reviewed_local_committed",
"reviewed_local_pending",
"batch",
]:
if k in st.session_state:
del st.session_state[k]
st.toast("Кэш очищен.")
st.rerun()
# =========================
# DIR header
# =========================
defaults = (dir_meta.get("defaults") or {})
year_from = defaults.get("year_from")
year_to = defaults.get("year_to")
terms = (dir_meta.get("terms") or {})
anchor_str = join_terms(terms.get("anchor"))
support_str = join_terms(terms.get("support"))
noise_str = join_terms(terms.get("noise"))
topics_str = topics_line(dir_meta.get("topics") or [])
dir_desc = dir_meta.get("dir_description", "—")
st.markdown(
f"""
Краткое описание
{dir_desc}
""",
unsafe_allow_html=True,
)
with st.expander("Параметры поиска", expanded=True):
st.markdown(f"**Временной интервал:** {year_from} – {year_to}")
st.markdown(f"**Якоря:** {anchor_str}")
st.markdown(f"**Поддержка:** {support_str}")
st.markdown(f"**Шум:** {noise_str}")
st.markdown(f"**Topics:** {topics_str}")
st.divider()
# =========================
# Load candidates + fixed sorting (A: dir_score ↓)
# =========================
try:
df = load_candidates(PUBLICATIONS_REPO, selected_dir, PUB_CANDIDATES_PREFIX)
except Exception as e:
st.error(f"Не удалось загрузить кандидатов для {selected_dir}: {e}")
st.stop()
if df.empty:
st.warning(f"Пустой список кандидатов для {selected_dir}.")
st.stop()
# Fixed order: dir_score ↓ (stable ties)
sort_cols: List[str] = []
sort_asc: List[bool] = []
if "dir_score" in df.columns:
sort_cols.append("dir_score"); sort_asc.append(False)
if "quality_weight" in df.columns:
sort_cols.append("quality_weight"); sort_asc.append(False)
if "match_score" in df.columns:
sort_cols.append("match_score"); sort_asc.append(False)
if "year" in df.columns:
sort_cols.append("year"); sort_asc.append(False)
if sort_cols:
df = df.sort_values(sort_cols, ascending=sort_asc)
df = df.reset_index(drop=True)
total = len(df)
if total == 0:
st.warning("Пустой список кандидатов.")
st.stop()
# =========================
# State: batch + local/remote indexes
# =========================
def ensure_state():
if "batch" not in st.session_state:
st.session_state["batch"] = []
if "reviewed_local_committed" not in st.session_state:
st.session_state["reviewed_local_committed"] = set()
if "reviewed_local_pending" not in st.session_state:
st.session_state["reviewed_local_pending"] = set()
if "reviewed_remote" not in st.session_state:
st.session_state["reviewed_remote"] = set()
if "reviewed_remote_dir" not in st.session_state:
st.session_state["reviewed_remote_dir"] = None
if "review_index_remote" not in st.session_state:
st.session_state["review_index_remote"] = {}
if "review_index_local" not in st.session_state:
st.session_state["review_index_local"] = {}
ensure_state()
def row_keys_all(r: Dict[str, Any]) -> Set[str]:
keys = set()
sid = r.get("source_id")
if isinstance(sid, str) and sid.strip():
keys.add(f"SRC::{sid.strip()}")
wid = r.get("work_id")
if isinstance(wid, str) and wid.strip():
keys.add(f"W::{wid.strip()}")
return keys
canonical_dir = normalize_dir_id(selected_dir, pad2=False)
# Загрузка сохранённых оценок (тихо; remote-статусы не показываем)
need_reload = (st.session_state["reviewed_remote_dir"] != canonical_dir)
if need_reload:
if role != "demo":
# Тихо подгружаем только оценки текущего пользователя (или все, если admin)
st.session_state["reviewed_remote"] = load_reviewed_keys(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX, reviewer=reviewer_filter)
st.session_state["review_index_remote"] = load_review_index(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX, reviewer=reviewer_filter)
else:
st.session_state["reviewed_remote"] = set()
st.session_state["review_index_remote"] = {}
st.session_state["reviewed_remote_dir"] = canonical_dir
reviewed_committed = set(st.session_state["reviewed_remote"]) | set(st.session_state["reviewed_local_committed"])
reviewed_pending = set(st.session_state["reviewed_local_pending"])
reviewed_effective = reviewed_committed | reviewed_pending
def status_of_row(r: Dict[str, Any]) -> str:
keys = row_keys_all(r)
if keys and any(k in reviewed_committed for k in keys):
return "✅"
if keys and any(k in reviewed_pending for k in keys):
return "🕓"
return "🆕"
def is_reviewed_effective(r: Dict[str, Any]) -> bool:
keys = row_keys_all(r)
return bool(keys and any(k in reviewed_effective for k in keys))
def last_review_for_row(r: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Берём последнюю известную оценку (pending/local -> remote)."""
keys = list(row_keys_all(r))
if not keys:
return None
best = None
best_ts = ""
# local index first
for k in keys:
obj = st.session_state["review_index_local"].get(k)
if isinstance(obj, dict):
t = str(obj.get("ts_utc") or obj.get("ts") or "")
if t > best_ts:
best_ts = t
best = obj
# remote index fallback
for k in keys:
obj = st.session_state["review_index_remote"].get(k)
if isinstance(obj, dict):
t = str(obj.get("ts_utc") or obj.get("ts") or "")
if t > best_ts:
best_ts = t
best = obj
return best
# =========================
# Sidebar: statuses (без remote)
# =========================
def split_key_set(keys: Set[str]) -> Tuple[Set[str], Set[str]]:
src_ids: Set[str] = set()
w_ids: Set[str] = set()
for k in keys:
if isinstance(k, str) and k.startswith("SRC::"):
src_ids.add(k[5:])
elif isinstance(k, str) and k.startswith("W::"):
w_ids.add(k[3:])
return src_ids, w_ids
comm_src_ids, comm_w_ids = split_key_set(reviewed_committed)
pend_src_ids, pend_w_ids = split_key_set(reviewed_pending)
src_series = df["source_id"].fillna("") if "source_id" in df.columns else pd.Series([""] * len(df))
wid_series = df["work_id"].fillna("") if "work_id" in df.columns else pd.Series([""] * len(df))
mask_committed = src_series.astype(str).isin(comm_src_ids) | wid_series.astype(str).isin(comm_w_ids)
mask_pending = src_series.astype(str).isin(pend_src_ids) | wid_series.astype(str).isin(pend_w_ids)
mask_reviewed = mask_committed | mask_pending
committed_pub_count = int(mask_committed.sum())
pending_pub_count = int(mask_pending.sum())
unreviewed_pub_count = int((~mask_reviewed).sum())
# =========================
# Commit helper
# =========================
def commit_batch() -> Tuple[bool, str]:
batch = st.session_state.get("batch") or []
if not batch:
return False, "batch_empty"
try:
push_batch_to_reviews_repo(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX, batch)
except Exception as e:
return False, str(e)
# Update local sets: pending -> committed (keys)
for obj in batch:
sid = obj.get("source_id")
wid = obj.get("work_id")
if isinstance(sid, str) and sid.strip():
k = f"SRC::{sid.strip()}"
st.session_state["reviewed_local_committed"].add(k)
st.session_state["reviewed_local_pending"].discard(k)
if isinstance(wid, str) and wid.strip():
k = f"W::{wid.strip()}"
st.session_state["reviewed_local_committed"].add(k)
st.session_state["reviewed_local_pending"].discard(k)
st.session_state["batch"] = []
return True, "ok"
# manual flush
if flush_now and st.session_state.get("batch"):
with st.spinner("Синхронизирую pending батч…"):
ok, msg = commit_batch()
if ok:
st.success("Pending батч синхронизирован.")
st.rerun()
else:
st.error(f"Не удалось синхронизировать: {msg}")
# recompute after possible commit
reviewed_committed = set(st.session_state["reviewed_remote"]) | set(st.session_state["reviewed_local_committed"])
reviewed_pending = set(st.session_state["reviewed_local_pending"])
reviewed_effective = reviewed_committed | reviewed_pending
# =========================
# Navigation window + layout
# =========================
idx_key = f"idx_{canonical_dir}"
if idx_key not in st.session_state:
st.session_state[idx_key] = 0
current_idx = int(st.session_state[idx_key])
current_idx = max(0, min(total - 1, current_idx))
st.session_state[idx_key] = current_idx
window_size = 50
window_start = int(current_idx // window_size) * int(window_size)
window_end = min(window_start + int(window_size), total)
page_indices = list(range(window_start, window_end))
def fmt_idx(i: int) -> str:
r = df.iloc[i].to_dict()
ico = status_of_row(r)
year_val = safe_int(r.get("year"))
year = str(year_val) if year_val is not None else "—"
title = (str(r.get("title")) if r.get("title") is not None else "—")
title_short = title if len(title) <= 80 else title[:77] + "…"
return f"{ico} {i+1:03d} | {year} | {title_short}"
def next_unreviewed(from_idx: int) -> int:
i = max(0, from_idx)
while i < total:
r = df.iloc[i].to_dict()
if is_reviewed_effective(r):
i += 1
continue
return i
return total - 1
# main 2-column layout: left=list+scoring, right=publication
col_left, col_right = st.columns([1, 2], gap="large")
with col_left:
st.markdown("### Список публикаций")
st.caption(f"Окно: {window_start+1}–{window_end} из {total} • Неоценено: {unreviewed_pub_count}")
sel = st.selectbox(
"Публикации в текущем окне",
options=page_indices,
index=page_indices.index(current_idx) if current_idx in page_indices else 0,
format_func=fmt_idx,
label_visibility="collapsed",
)
if int(sel) != current_idx:
st.session_state[idx_key] = int(sel)
st.rerun()
# актуальная строка
current_idx = int(st.session_state[idx_key])
row = df.iloc[current_idx].to_dict()
# предзаполнение score/comment: если есть оценка -> она, иначе 0/пусто
existing = last_review_for_row(row) or {}
existing_score = safe_int(existing.get("score"))
if existing_score is None:
existing_score = 0
existing_comment = str(existing.get("comment") or "")
score_key = f"score_{canonical_dir}_{current_idx}"
comment_key = f"com_{canonical_dir}_{current_idx}"
if score_key not in st.session_state:
st.session_state[score_key] = int(existing_score)
# защита от старых/некорректных значений
if int(st.session_state[score_key]) not in (-2, -1, 0, 1, 2):
st.session_state[score_key] = 0
if comment_key not in st.session_state:
st.session_state[comment_key] = existing_comment
label_map = {
-2: "точно нет",
-1: "скорее нет",
0: "не знаю",
1: "скорее да",
2: "точно да",
}
score = st.radio(
"Оценка",
options=[-2, -1, 0, 1, 2],
format_func=lambda v: f"{v:+d} — {label_map.get(v, '')}".replace("+0", "0"),
index=[-2, -1, 0, 1, 2].index(int(st.session_state[score_key])),
key=score_key,
)
comment = st.text_area("Комментарий", height=140, key=comment_key)
b1, b2, b3 = st.columns([1, 2, 1])
with b1:
if st.button("⬅️ Назад", use_container_width=True):
st.session_state[idx_key] = max(0, current_idx - 1)
st.rerun()
with b2:
if st.button("✅ Сохранить и далее", use_container_width=True, disabled=(role=="demo")):
# собираем мета для review
authors, abstract = get_authors_and_abstract(row)
y = safe_int(row.get("year"))
cites = safe_int(row.get("cited_by"))
dir_score = safe_float(row.get("dir_score"))
delta = safe_float(row.get("match_score"))
qw = safe_float(row.get("quality_weight"))
work_id = row.get("work_id") if isinstance(row.get("work_id"), str) else None
source_id = row.get("source_id") if isinstance(row.get("source_id"), str) else None
review = {
"ts_utc": dt.datetime.utcnow().isoformat(timespec="seconds") + "Z",
"dir_id": selected_dir,
"dir_id_canonical": canonical_dir,
"source_id": source_id,
"work_id": work_id,
"doi": row.get("doi"),
"title": row.get("title"),
"authors": authors,
"abstract": abstract,
"year": y,
"cited_by": cites,
"auto": {
"dir_score": dir_score,
"delta": delta,
"quality_weight": qw,
"components": row.get("dir_native_components") or {},
"rank": int(current_idx),
},
"score": int(score),
"comment": comment,
"reviewer": reviewer,
}
st.session_state["batch"].append(review)
# pending keys + local index for prefill
for k in row_keys_all(row):
st.session_state["reviewed_local_pending"].add(k)
st.session_state["review_index_local"][k] = review
# auto flush by BATCH_SIZE
flushed = False
if len(st.session_state["batch"]) >= BATCH_SIZE:
with st.spinner("Сохраняю в reviews repo…"):
ok, msg = commit_batch()
if ok:
flushed = True
st.success("Сохранено.")
else:
st.error(f"Не удалось записать в reviews repo: {msg}")
st.warning("Оценка сохранена локально как pending. Скачайте pending reviews или повторите синхронизацию.")
# переход: следующая НЕоценённая (без тумблера)
nxt = next_unreviewed(current_idx + 1)
st.session_state[idx_key] = nxt
st.rerun()
with b3:
if st.button("➡️ Далее", use_container_width=True):
st.session_state[idx_key] = min(total - 1, current_idx + 1)
st.rerun()
# --- Статусы/прогресс (внизу левого блока) ---
total_pub = int(total)
prog_eval = int(committed_pub_count + pending_pub_count)
prog_pct = int(round(100.0 * prog_eval / total_pub)) if total_pub > 0 else 0
st.markdown(
f''
f'Всего: {total_pub} • '
f'Оценено: {committed_pub_count} • '
f'Pending: {pending_pub_count} • '
f'Осталось: {unreviewed_pub_count} • '
f'{prog_pct}% • '
f'#{int(current_idx)+1}/{total_pub}'
f'
',
unsafe_allow_html=True,
)
if role != "demo" and (st.session_state.get("batch") or []):
pending_jsonl = "".join(json.dumps(x, ensure_ascii=False) + "\\n" for x in (st.session_state.get("batch") or []))
st.download_button(
"⬇️ Скачать pending (.jsonl)",
data=pending_jsonl,
file_name=f"pending_{canonical_dir}_{dt.datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.jsonl",
mime="application/jsonl",
use_container_width=True,
)
with col_right:
# карточка публикации занимает максимум пространства справа
row = df.iloc[int(st.session_state[idx_key])].to_dict()
title = row.get("title") or "—"
authors, abstract = get_authors_and_abstract(row)
y = safe_int(row.get("year"))
cites = safe_int(row.get("cited_by"))
dir_score = safe_float(row.get("dir_score"))
delta = safe_float(row.get("match_score"))
qw = safe_float(row.get("quality_weight"))
work_id = row.get("work_id") if isinstance(row.get("work_id"), str) else None
source_id = row.get("source_id") if isinstance(row.get("source_id"), str) else None
doi_link = doi_url(row.get("doi"))
pdf_url = row.get("pdf_url")
pl_url = row.get("primary_location_url")
oa = row.get("open_access") if isinstance(row.get("open_access"), dict) else {}
oa_is = oa.get("is_oa") if isinstance(oa, dict) else None
oa_status = oa.get("oa_status") if isinstance(oa, dict) else None
oa_url = oa.get("oa_url") if isinstance(oa, dict) else None
keys = row_keys_all(row)
committed = bool(keys and any(k in reviewed_committed for k in keys))
pending = bool(keys and any(k in reviewed_pending for k in keys))
status_str = "✅ оценено" if committed else ("🕓 pending" if pending else "🆕 не оценено")
# badges
has_abs = (isinstance(abstract, str) and abstract.strip() and abstract.strip() != "—")
has_pdf = (isinstance(pdf_url, str) and isinstance(pdf_url, str) and pdf_url.strip())
has_pl = (isinstance(pl_url, str) and pl_url.strip())
has_doi = bool(doi_link)
badges = [
f"Abstract {'✅' if has_abs else '❌'}",
f"PDF {'✅' if has_pdf else '❌'}",
f"URL {'✅' if has_pl else '❌'}",
f"DOI {'✅' if has_doi else '❌'}",
]
if oa_is is not None:
badges.append(f"OA {'✅' if oa_is else '❌'}{(' (' + str(oa_status) + ')') if oa_status else ''}")
st.markdown(f"## {title}")
st.caption(status_str + " • " + " | ".join(badges))
id_lines = []
if source_id:
id_lines.append(f"**Source ID:** `{source_id}`")
if work_id:
id_lines.append(f"**Work ID:** `{work_id}`")
if id_lines:
st.markdown(" • ".join(id_lines))
# Метрики
y_s = str(y) if y is not None else "—"
c_s = str(cites) if cites is not None else "—"
ds_s = f"{dir_score:.3f}" if dir_score is not None else "—"
dlt_s = f"{delta:.4f}" if delta is not None else "—"
qw_s = f"{qw:.3f}" if qw is not None else "—"
st.markdown(f"**Год:** {y_s} | **Цитаты:** {c_s} | **dir_score:** {ds_s} | **delta:** {dlt_s} | **quality:** {qw_s}")
# Авторы
if authors and authors != "—":
st.markdown("**Авторы:**")
st.write(authors)
else:
st.caption("Авторы: — (в исходных DIR*.jsonl авторы не предоставляются)")
# Ссылки
links = []
if work_id:
links.append(f"[OpenAlex]({work_id})")
if doi_link:
links.append(f"[DOI]({doi_link})")
if isinstance(pdf_url, str) and pdf_url.strip():
links.append(f"[PDF]({pdf_url.strip()})")
if isinstance(pl_url, str) and pl_url.strip():
links.append(f"[Primary URL]({pl_url.strip()})")
if isinstance(oa_url, str) and oa_url.strip():
links.append(f"[OA URL]({oa_url.strip()})")
st.markdown("**Ссылки:** " + (" | ".join(links) if links else "—"))
# Аннотация
st.markdown("### Аннотация")
st.write(abstract if abstract else "—")
# Доп. детали автооценки (по желанию)
with st.expander("Детали автооценки", expanded=True):
comp = row.get("dir_native_components") or {}
if isinstance(comp, dict) and comp:
tr = comp.get("trace"); tp = comp.get("topic"); tx = comp.get("text"); nz = comp.get("noise")
sc = comp.get("score"); dl = comp.get("delta")
st.markdown(
f"**Компоненты (native DIR):** "
f"trace={tr if tr is not None else '—'} | "
f"topic={tp if tp is not None else '—'} | "
f"text={tx if tx is not None else '—'} | "
f"noise={nz if nz is not None else '—'} | "
f"score={sc if sc is not None else '—'} | "
f"delta={dl if dl is not None else '—'}"
)
expl = row.get("dir_score_explanation")
if expl:
st.markdown("**dir_score_explanation:**")
st.write(expl)