Spaces:
Runtime error
Runtime error
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +255 -102
src/streamlit_app.py
CHANGED
|
@@ -5,7 +5,11 @@ import gzip
|
|
| 5 |
import tempfile
|
| 6 |
import datetime as dt
|
| 7 |
import math
|
|
|
|
|
|
|
|
|
|
| 8 |
from typing import List, Dict, Any, Optional, Tuple, Set
|
|
|
|
| 9 |
|
| 10 |
import pandas as pd
|
| 11 |
import streamlit as st
|
|
@@ -20,6 +24,15 @@ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HF_API_TOKEN")
|
|
| 20 |
ALLOW_CREATE_REVIEWS_REPO = (os.environ.get("ALLOW_CREATE_REVIEWS_REPO", "0").strip().lower() in ("1","true","yes","y","on"))
|
| 21 |
REVIEWS_PRIVATE = (os.environ.get("REVIEWS_PRIVATE", "1").strip().lower() in ("1","true","yes","y","on"))
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# Надёжность по умолчанию: сразу пишем каждый review
|
| 24 |
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "1"))
|
| 25 |
|
|
@@ -41,6 +54,23 @@ api = HfApi(token=HF_TOKEN)
|
|
| 41 |
st.set_page_config(page_title="Скоринг публикаций", layout="wide")
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
# =========================
|
| 45 |
# Helpers
|
| 46 |
# =========================
|
|
@@ -178,10 +208,111 @@ def topics_line(topics: Any) -> str:
|
|
| 178 |
out.append(str(t))
|
| 179 |
return "; ".join(out) if out else "—"
|
| 180 |
|
| 181 |
-
def
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
def decode_abstract(abstract_inverted_index: Optional[dict]) -> str:
|
| 187 |
if not abstract_inverted_index or not isinstance(abstract_inverted_index, dict):
|
|
@@ -419,7 +550,7 @@ def list_review_files(repo_id: str, dir_id: str, prefix: str) -> List[str]:
|
|
| 419 |
return out
|
| 420 |
|
| 421 |
@st.cache_data(show_spinner=False)
|
| 422 |
-
def load_reviewed_keys(repo_id: str, dir_id: str, prefix: str) -> Set[str]:
|
| 423 |
"""
|
| 424 |
Поддержка миграции:
|
| 425 |
- старые review содержали только work_id -> помечаем как W::<id>
|
|
@@ -441,6 +572,9 @@ def load_reviewed_keys(repo_id: str, dir_id: str, prefix: str) -> Set[str]:
|
|
| 441 |
continue
|
| 442 |
obj = json.loads(line)
|
| 443 |
|
|
|
|
|
|
|
|
|
|
| 444 |
sid = obj.get("source_id")
|
| 445 |
if isinstance(sid, str) and sid.strip():
|
| 446 |
reviewed.add(f"SRC::{sid.strip()}")
|
|
@@ -450,25 +584,30 @@ def load_reviewed_keys(repo_id: str, dir_id: str, prefix: str) -> Set[str]:
|
|
| 450 |
reviewed.add(f"W::{wid.strip()}")
|
| 451 |
except Exception:
|
| 452 |
continue
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
@st.cache_data(show_spinner=False)
|
| 457 |
-
def load_review_index(repo_id: str, dir_id: str, prefix: str) -> Dict[str, Dict[str, Any]]:
|
| 458 |
"""
|
| 459 |
-
Индекс последних оценок по
|
| 460 |
-
Возвращает mapping:
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
Нужен для автоподстановки score/comment при навигации.
|
| 464 |
"""
|
| 465 |
try:
|
| 466 |
files = list_review_files(repo_id, dir_id, prefix)
|
| 467 |
except Exception:
|
| 468 |
return {}
|
| 469 |
|
| 470 |
-
|
| 471 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
for relpath in files:
|
| 474 |
try:
|
|
@@ -479,26 +618,19 @@ def load_review_index(repo_id: str, dir_id: str, prefix: str) -> Dict[str, Dict[
|
|
| 479 |
if not line:
|
| 480 |
continue
|
| 481 |
obj = json.loads(line)
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
if
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
wid = obj.get("work_id")
|
| 492 |
-
if isinstance(wid, str) and wid.strip():
|
| 493 |
-
k = f"W::{wid.strip()}"
|
| 494 |
-
if (k not in ts_by_key) or (ts and ts > ts_by_key.get(k, "")):
|
| 495 |
-
ts_by_key[k] = ts
|
| 496 |
-
idx[k] = obj
|
| 497 |
except Exception:
|
| 498 |
continue
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
|
| 503 |
|
| 504 |
# =========================
|
|
@@ -531,52 +663,66 @@ def push_batch_to_reviews_repo(repo_id: str, dir_id: str, prefix: str, batch: Li
|
|
| 531 |
|
| 532 |
|
| 533 |
# =========================
|
| 534 |
-
# Sidebar
|
| 535 |
-
# =========================
|
| 536 |
-
|
| 537 |
# =========================
|
| 538 |
# MAIN APP (offline-first)
|
| 539 |
# =========================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
with st.sidebar:
|
| 542 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
|
| 544 |
-
|
|
|
|
|
|
|
| 545 |
require_env("REVIEWS_REPO", REVIEWS_REPO)
|
| 546 |
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
st.stop()
|
| 552 |
|
|
|
|
| 553 |
rev_ok, rev_msg = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
|
| 554 |
if not rev_ok:
|
| 555 |
-
|
| 556 |
-
if
|
| 557 |
-
|
| 558 |
-
if
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
else:
|
| 562 |
-
st.
|
|
|
|
| 563 |
else:
|
| 564 |
-
st.error(
|
| 565 |
-
"REVIEWS_REPO недоступен (404/нет доступа).\n\n"
|
| 566 |
-
"Что проверить:\n"
|
| 567 |
-
"1) В Space Variables: REVIEWS_REPO = <owner>/<dataset_name> (именно dataset).\n"
|
| 568 |
-
"2) Репозиторий реально существует на Hub.\n"
|
| 569 |
-
"3) Если repo приватный — в Secrets должен быть HF_TOKEN с правами write на этот dataset.\n"
|
| 570 |
-
"4) Если repo ещё не создан — включи ALLOW_CREATE_REVIEWS_REPO=1 (опционально) или создай вручную.\n\n"
|
| 571 |
-
+ rev_msg
|
| 572 |
-
)
|
| 573 |
st.stop()
|
| 574 |
|
| 575 |
-
#
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
# Load DIR registry + UI (offline-first)
|
| 579 |
-
# =========================
|
| 580 |
dirs = load_dir_registry(PUBLICATIONS_REPO, PUB_DIR_REGISTRY_PATH)
|
| 581 |
dir_ids = [d.get("dir_id") for d in dirs if d.get("dir_id")]
|
| 582 |
if not dir_ids:
|
|
@@ -590,21 +736,13 @@ def _fmt_dir(did: str) -> str:
|
|
| 590 |
name = meta.get("dir_name") or "—"
|
| 591 |
return f"DIR-{str(dir_no(did)).zfill(2)} — {name}"
|
| 592 |
|
|
|
|
| 593 |
|
| 594 |
# Выбор DIR — в основном интерфейсе (не в сайдбаре)
|
| 595 |
-
selected_dir = st.selectbox("
|
| 596 |
dir_meta = dir_map.get(selected_dir) or {}
|
| 597 |
|
| 598 |
# =========================
|
| 599 |
-
# Sidebar (минимум)
|
| 600 |
-
# =========================
|
| 601 |
-
with st.sidebar:
|
| 602 |
-
reviewer = get_reviewer()
|
| 603 |
-
st.caption(f"BATCH_SIZE={BATCH_SIZE} (настройка через переменную окружения)")
|
| 604 |
-
st.divider()
|
| 605 |
-
flush_now = st.button("⬆️ Синхронизировать сейчас", use_container_width=True)
|
| 606 |
-
clear_cache = st.button("🧹 Сбросить кэш", use_container_width=True)
|
| 607 |
-
|
| 608 |
if clear_cache:
|
| 609 |
load_dir_registry.clear()
|
| 610 |
load_candidates.clear()
|
|
@@ -645,21 +783,20 @@ dir_desc = dir_meta.get("dir_description", "—")
|
|
| 645 |
|
| 646 |
st.markdown(
|
| 647 |
f"""
|
| 648 |
-
<div
|
| 649 |
-
<div
|
| 650 |
-
|
| 651 |
-
</div>
|
| 652 |
</div>
|
| 653 |
""",
|
| 654 |
unsafe_allow_html=True,
|
| 655 |
)
|
| 656 |
|
| 657 |
-
with st.expander("
|
| 658 |
st.markdown(f"**Временной интервал:** {year_from} – {year_to}")
|
| 659 |
st.markdown(f"**Якоря:** {anchor_str}")
|
| 660 |
st.markdown(f"**Поддержка:** {support_str}")
|
| 661 |
st.markdown(f"**Шум:** {noise_str}")
|
| 662 |
-
st.markdown(f"**Topics:** {topics_str}")
|
| 663 |
|
| 664 |
st.divider()
|
| 665 |
|
|
@@ -732,9 +869,13 @@ canonical_dir = normalize_dir_id(selected_dir, pad2=False)
|
|
| 732 |
# Загрузка сохранённых оценок (тихо; remote-статусы не показываем)
|
| 733 |
need_reload = (st.session_state["reviewed_remote_dir"] != canonical_dir)
|
| 734 |
if need_reload:
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
st.session_state["reviewed_remote_dir"] = canonical_dir
|
| 739 |
|
| 740 |
reviewed_committed = set(st.session_state["reviewed_remote"]) | set(st.session_state["reviewed_local_committed"])
|
|
@@ -806,20 +947,6 @@ committed_pub_count = int(mask_committed.sum())
|
|
| 806 |
pending_pub_count = int(mask_pending.sum())
|
| 807 |
unreviewed_pub_count = int((~mask_reviewed).sum())
|
| 808 |
|
| 809 |
-
with st.sidebar:
|
| 810 |
-
with st.expander("Статусы", expanded=False):
|
| 811 |
-
st.write(f"✅ сохранено: {committed_pub_count} публикаций")
|
| 812 |
-
st.write(f"🕓 pending: {pending_pub_count} публикаций")
|
| 813 |
-
|
| 814 |
-
if st.session_state.get("batch"):
|
| 815 |
-
pending_jsonl = "".join(json.dumps(x, ensure_ascii=False) + "\n" for x in st.session_state["batch"])
|
| 816 |
-
st.download_button(
|
| 817 |
-
"⬇️ Скачать pending reviews (.jsonl)",
|
| 818 |
-
data=pending_jsonl,
|
| 819 |
-
file_name=f"pending_{canonical_dir}_{dt.datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.jsonl",
|
| 820 |
-
mime="application/jsonl",
|
| 821 |
-
use_container_width=True,
|
| 822 |
-
)
|
| 823 |
|
| 824 |
# =========================
|
| 825 |
# Commit helper
|
|
@@ -875,7 +1002,7 @@ current_idx = int(st.session_state[idx_key])
|
|
| 875 |
current_idx = max(0, min(total - 1, current_idx))
|
| 876 |
st.session_state[idx_key] = current_idx
|
| 877 |
|
| 878 |
-
window_size = 50
|
| 879 |
window_start = int(current_idx // window_size) * int(window_size)
|
| 880 |
window_end = min(window_start + int(window_size), total)
|
| 881 |
page_indices = list(range(window_start, window_end))
|
|
@@ -969,7 +1096,7 @@ with col_left:
|
|
| 969 |
st.rerun()
|
| 970 |
|
| 971 |
with b2:
|
| 972 |
-
if st.button("✅ Сохранить и далее", use_container_width=True):
|
| 973 |
# собираем мета для review
|
| 974 |
authors, abstract = get_authors_and_abstract(row)
|
| 975 |
|
|
@@ -1035,7 +1162,33 @@ with col_left:
|
|
| 1035 |
st.session_state[idx_key] = min(total - 1, current_idx + 1)
|
| 1036 |
st.rerun()
|
| 1037 |
|
| 1038 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
|
| 1040 |
with col_right:
|
| 1041 |
# карточка публикации занимает максимум пространства справа
|
|
@@ -1143,4 +1296,4 @@ with col_right:
|
|
| 1143 |
expl = row.get("dir_score_explanation")
|
| 1144 |
if expl:
|
| 1145 |
st.markdown("**dir_score_explanation:**")
|
| 1146 |
-
st.write(expl)
|
|
|
|
| 5 |
import tempfile
|
| 6 |
import datetime as dt
|
| 7 |
import math
|
| 8 |
+
import hashlib
|
| 9 |
+
import base64
|
| 10 |
+
import hmac
|
| 11 |
from typing import List, Dict, Any, Optional, Tuple, Set
|
| 12 |
+
from pathlib import Path
|
| 13 |
|
| 14 |
import pandas as pd
|
| 15 |
import streamlit as st
|
|
|
|
| 24 |
ALLOW_CREATE_REVIEWS_REPO = (os.environ.get("ALLOW_CREATE_REVIEWS_REPO", "0").strip().lower() in ("1","true","yes","y","on"))
|
| 25 |
REVIEWS_PRIVATE = (os.environ.get("REVIEWS_PRIVATE", "1").strip().lower() in ("1","true","yes","y","on"))
|
| 26 |
|
| 27 |
+
# =========================
|
| 28 |
+
# AUTH (простая роль/логин)
|
| 29 |
+
# =========================
|
| 30 |
+
USERS_REPO = os.environ.get("USERS_REPO", "").strip() # приватный dataset с users.json
|
| 31 |
+
USERS_FILE_PATH = os.environ.get("USERS_FILE_PATH", "users.json").strip()
|
| 32 |
+
DEMO_LOGIN = os.environ.get("DEMO_LOGIN", "demo").strip()
|
| 33 |
+
DEMO_PASSWORD = os.environ.get("DEMO_PASSWORD", "demo")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
# Надёжность по умолчанию: сразу пишем каждый review
|
| 37 |
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "1"))
|
| 38 |
|
|
|
|
| 54 |
st.set_page_config(page_title="Скоринг публикаций", layout="wide")
|
| 55 |
|
| 56 |
|
| 57 |
+
st.markdown(
|
| 58 |
+
"""
|
| 59 |
+
<style>
|
| 60 |
+
/* Global typography tuning (Streamlit) */
|
| 61 |
+
h1 { font-size: 1.75rem; margin-bottom: 0.4rem; }
|
| 62 |
+
h2 { font-size: 1.35rem; margin-top: 1.0rem; margin-bottom: 0.35rem; }
|
| 63 |
+
h3 { font-size: 1.1rem; margin-top: 0.9rem; margin-bottom: 0.25rem; }
|
| 64 |
+
.small-meta { font-size: 0.85rem; opacity: 0.85; }
|
| 65 |
+
.dir-card { padding: 0.75rem 0.9rem; border: 1px solid rgba(49,51,63,0.2); border-radius: 12px; background: rgba(49,51,63,0.04); }
|
| 66 |
+
.kpi-line { font-size: 0.9rem; opacity: 0.9; }
|
| 67 |
+
.kpi-line b { opacity: 1.0; }
|
| 68 |
+
</style>
|
| 69 |
+
""",
|
| 70 |
+
unsafe_allow_html=True,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
# =========================
|
| 75 |
# Helpers
|
| 76 |
# =========================
|
|
|
|
| 208 |
out.append(str(t))
|
| 209 |
return "; ".join(out) if out else "—"
|
| 210 |
|
| 211 |
+
def _b64u(b: bytes) -> str:
|
| 212 |
+
return base64.urlsafe_b64encode(b).decode("utf-8").rstrip("=")
|
| 213 |
+
|
| 214 |
+
def _b64u_dec(s: str) -> bytes:
|
| 215 |
+
pad = "=" * (-len(s) % 4)
|
| 216 |
+
return base64.urlsafe_b64decode((s + pad).encode("utf-8"))
|
| 217 |
+
|
| 218 |
+
def hash_password_pbkdf2(password: str, *, iterations: int = 200_000) -> str:
|
| 219 |
+
salt = os.urandom(16)
|
| 220 |
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
| 221 |
+
return f"pbkdf2_sha256${iterations}${_b64u(salt)}${_b64u(dk)}"
|
| 222 |
+
|
| 223 |
+
def verify_password(record: Dict[str, Any], password: str) -> bool:
|
| 224 |
+
# 1) PBKDF2 string
|
| 225 |
+
ph = record.get("password_hash")
|
| 226 |
+
if isinstance(ph, str) and ph.startswith("pbkdf2_sha256$"):
|
| 227 |
+
try:
|
| 228 |
+
_, it_s, salt_s, hash_s = ph.split("$", 3)
|
| 229 |
+
it = int(it_s)
|
| 230 |
+
salt = _b64u_dec(salt_s)
|
| 231 |
+
expected = _b64u_dec(hash_s)
|
| 232 |
+
got = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, it)
|
| 233 |
+
return hmac.compare_digest(got, expected)
|
| 234 |
+
except Exception:
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
# 2) structured hash
|
| 238 |
+
if isinstance(ph, dict) and ph.get("algo") == "pbkdf2_sha256":
|
| 239 |
+
try:
|
| 240 |
+
it = int(ph.get("iterations") or 200_000)
|
| 241 |
+
salt = _b64u_dec(str(ph.get("salt") or ""))
|
| 242 |
+
expected = _b64u_dec(str(ph.get("hash") or ""))
|
| 243 |
+
got = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, it)
|
| 244 |
+
return hmac.compare_digest(got, expected)
|
| 245 |
+
except Exception:
|
| 246 |
+
return False
|
| 247 |
+
|
| 248 |
+
# 3) plaintext (не рекомендуется, но поддерживаем для простого старта)
|
| 249 |
+
pw = record.get("password")
|
| 250 |
+
if isinstance(pw, str):
|
| 251 |
+
return hmac.compare_digest(pw, password)
|
| 252 |
+
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
@st.cache_data(show_spinner=False)
|
| 256 |
+
def load_users(repo_id: str, relpath: str) -> Dict[str, Dict[str, Any]]:
|
| 257 |
+
if not repo_id:
|
| 258 |
+
return {}
|
| 259 |
+
try:
|
| 260 |
+
path = hf_hub_download(repo_id=repo_id, filename=relpath, repo_type="dataset", token=HF_TOKEN)
|
| 261 |
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 262 |
+
except Exception:
|
| 263 |
+
return {}
|
| 264 |
+
users: Dict[str, Dict[str, Any]] = {}
|
| 265 |
+
items = data.get("users") if isinstance(data, dict) else None
|
| 266 |
+
if isinstance(items, list):
|
| 267 |
+
for u in items:
|
| 268 |
+
if not isinstance(u, dict):
|
| 269 |
+
continue
|
| 270 |
+
login = str(u.get("login") or "").strip()
|
| 271 |
+
if login:
|
| 272 |
+
users[login] = u
|
| 273 |
+
return users
|
| 274 |
+
|
| 275 |
+
def ensure_auth() -> Dict[str, str]:
|
| 276 |
+
"""
|
| 277 |
+
Возвращает dict {login, role}. Если не авторизован — показывает форму входа и останавливает выполнение.
|
| 278 |
+
"""
|
| 279 |
+
if isinstance(st.session_state.get("auth"), dict):
|
| 280 |
+
return st.session_state["auth"]
|
| 281 |
+
|
| 282 |
+
users = load_users(USERS_REPO, USERS_FILE_PATH)
|
| 283 |
+
st.markdown("### Вход")
|
| 284 |
+
|
| 285 |
+
col1, col2 = st.columns([2,1])
|
| 286 |
+
with col1:
|
| 287 |
+
with st.form("login_form", clear_on_submit=False):
|
| 288 |
+
login = st.text_input("Логин").strip()
|
| 289 |
+
password = st.text_input("Пароль", type="password")
|
| 290 |
+
ok = st.form_submit_button("Войти")
|
| 291 |
+
if ok:
|
| 292 |
+
if login == DEMO_LOGIN and hmac.compare_digest(password, str(DEMO_PASSWORD)):
|
| 293 |
+
st.session_state["auth"] = {"login": DEMO_LOGIN, "role": "demo"}
|
| 294 |
+
st.rerun()
|
| 295 |
+
rec = users.get(login)
|
| 296 |
+
if rec and verify_password(rec, password):
|
| 297 |
+
role = str(rec.get("role") or "reviewer").strip().lower()
|
| 298 |
+
if role not in ("admin", "reviewer", "demo"):
|
| 299 |
+
role = "reviewer"
|
| 300 |
+
st.session_state["auth"] = {"login": login, "role": role}
|
| 301 |
+
st.rerun()
|
| 302 |
+
st.error("Неверный логин или пароль.")
|
| 303 |
+
with col2:
|
| 304 |
+
st.write("")
|
| 305 |
+
st.write("")
|
| 306 |
+
if st.button("Войти в Demo", use_container_width=True):
|
| 307 |
+
st.session_state["auth"] = {"login": DEMO_LOGIN, "role": "demo"}
|
| 308 |
+
st.rerun()
|
| 309 |
+
|
| 310 |
+
st.info(
|
| 311 |
+
"Админу: задайте USERS_REPO (приватный dataset) и USERS_FILE_PATH (users.json) в настройках Space. "
|
| 312 |
+
"Для быстрого теста доступен Demo."
|
| 313 |
+
)
|
| 314 |
+
st.stop()
|
| 315 |
+
|
| 316 |
|
| 317 |
def decode_abstract(abstract_inverted_index: Optional[dict]) -> str:
|
| 318 |
if not abstract_inverted_index or not isinstance(abstract_inverted_index, dict):
|
|
|
|
| 550 |
return out
|
| 551 |
|
| 552 |
@st.cache_data(show_spinner=False)
|
| 553 |
+
def load_reviewed_keys(repo_id: str, dir_id: str, prefix: str, reviewer: Optional[str] = None) -> Set[str]:
|
| 554 |
"""
|
| 555 |
Поддержка миграции:
|
| 556 |
- старые review содержали только work_id -> помечаем как W::<id>
|
|
|
|
| 572 |
continue
|
| 573 |
obj = json.loads(line)
|
| 574 |
|
| 575 |
+
if reviewer and str(obj.get('reviewer') or '').strip() != reviewer:
|
| 576 |
+
continue
|
| 577 |
+
|
| 578 |
sid = obj.get("source_id")
|
| 579 |
if isinstance(sid, str) and sid.strip():
|
| 580 |
reviewed.add(f"SRC::{sid.strip()}")
|
|
|
|
| 584 |
reviewed.add(f"W::{wid.strip()}")
|
| 585 |
except Exception:
|
| 586 |
continue
|
| 587 |
+
|
|
|
|
|
|
|
| 588 |
@st.cache_data(show_spinner=False)
|
| 589 |
+
def load_review_index(repo_id: str, dir_id: str, prefix: str, reviewer: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
|
| 590 |
"""
|
| 591 |
+
Индекс последних оценок по публикации (для предзаполнения score/comment).
|
| 592 |
+
Возвращает mapping: key -> last_review_obj, где key — SRC::<source_id> или W::<work_id>.
|
| 593 |
+
Если reviewer задан — берём только его записи (для разделения прав).
|
|
|
|
|
|
|
| 594 |
"""
|
| 595 |
try:
|
| 596 |
files = list_review_files(repo_id, dir_id, prefix)
|
| 597 |
except Exception:
|
| 598 |
return {}
|
| 599 |
|
| 600 |
+
index: Dict[str, Dict[str, Any]] = {}
|
| 601 |
+
ts_index: Dict[str, str] = {}
|
| 602 |
+
|
| 603 |
+
def pick_key(obj: Dict[str, Any]) -> Optional[str]:
|
| 604 |
+
sid = obj.get("source_id")
|
| 605 |
+
if isinstance(sid, str) and sid.strip():
|
| 606 |
+
return f"SRC::{sid.strip()}"
|
| 607 |
+
wid = obj.get("work_id")
|
| 608 |
+
if isinstance(wid, str) and wid.strip():
|
| 609 |
+
return f"W::{wid.strip()}"
|
| 610 |
+
return None
|
| 611 |
|
| 612 |
for relpath in files:
|
| 613 |
try:
|
|
|
|
| 618 |
if not line:
|
| 619 |
continue
|
| 620 |
obj = json.loads(line)
|
| 621 |
+
if reviewer and str(obj.get("reviewer") or "").strip() != reviewer:
|
| 622 |
+
continue
|
| 623 |
+
k = pick_key(obj)
|
| 624 |
+
if not k:
|
| 625 |
+
continue
|
| 626 |
+
t = str(obj.get("ts_utc") or obj.get("ts") or "")
|
| 627 |
+
if t >= ts_index.get(k, ""):
|
| 628 |
+
ts_index[k] = t
|
| 629 |
+
index[k] = obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
except Exception:
|
| 631 |
continue
|
| 632 |
+
return index
|
| 633 |
+
return reviewed
|
|
|
|
| 634 |
|
| 635 |
|
| 636 |
# =========================
|
|
|
|
| 663 |
|
| 664 |
|
| 665 |
# =========================
|
|
|
|
|
|
|
|
|
|
| 666 |
# =========================
|
| 667 |
# MAIN APP (offline-first)
|
| 668 |
# =========================
|
| 669 |
+
auth = ensure_auth()
|
| 670 |
+
login = auth.get("login", "anonymous")
|
| 671 |
+
role = auth.get("role", "reviewer")
|
| 672 |
+
|
| 673 |
+
# Sidebar: только пользователь/админ-инструменты
|
| 674 |
+
flush_now = False
|
| 675 |
+
clear_cache = False
|
| 676 |
|
| 677 |
with st.sidebar:
|
| 678 |
+
st.markdown(f"**Пользователь:** {login} \\n**Роль:** {role}")
|
| 679 |
+
if st.button("Выйти", use_container_width=True):
|
| 680 |
+
if "auth" in st.session_state:
|
| 681 |
+
del st.session_state["auth"]
|
| 682 |
+
st.rerun()
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
if role != "demo":
|
| 686 |
+
with st.expander("Сервис", expanded=False):
|
| 687 |
+
if st.session_state.get("batch"):
|
| 688 |
+
flush_now = st.button("⬆️ Синхронизировать pending", use_container_width=True)
|
| 689 |
+
if role == "admin":
|
| 690 |
+
with st.expander("Админ", expanded=False):
|
| 691 |
+
clear_cache = st.button("🧹 Сбросить кэш", use_container_width=True)
|
| 692 |
|
| 693 |
+
# Preflight: окружение/доступ к репозиториям
|
| 694 |
+
require_env("PUBLICATIONS_REPO", PUBLICATIONS_REPO)
|
| 695 |
+
if role != "demo":
|
| 696 |
require_env("REVIEWS_REPO", REVIEWS_REPO)
|
| 697 |
|
| 698 |
+
pub_ok, pub_msg = check_dataset_repo(PUBLICATIONS_REPO, HF_TOKEN)
|
| 699 |
+
if not pub_ok:
|
| 700 |
+
st.error("PUBLICATIONS_REPO недоступен как dataset repo. Проверь repo_id и доступ.\n\n" + pub_msg)
|
| 701 |
+
st.stop()
|
|
|
|
| 702 |
|
| 703 |
+
if role != "demo":
|
| 704 |
rev_ok, rev_msg = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
|
| 705 |
if not rev_ok:
|
| 706 |
+
# создание допускаем только администратору и только если явно разрешено
|
| 707 |
+
if role == "admin":
|
| 708 |
+
created, _ = maybe_create_reviews_repo(REVIEWS_REPO)
|
| 709 |
+
if created:
|
| 710 |
+
rev_ok2, rev_msg2 = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
|
| 711 |
+
if not rev_ok2:
|
| 712 |
+
st.error("REVIEWS_REPO недоступен после create_repo.\n\n" + rev_msg2)
|
| 713 |
+
st.stop()
|
| 714 |
+
else:
|
| 715 |
+
st.success("REVIEWS_REPO создан/доступен.")
|
| 716 |
else:
|
| 717 |
+
st.error("REVIEWS_REPO недоступен (404/нет доступа).\n\n" + rev_msg)
|
| 718 |
+
st.stop()
|
| 719 |
else:
|
| 720 |
+
st.error("REVIEWS_REPO недоступен (404/нет доступа).\n\n" + rev_msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
st.stop()
|
| 722 |
|
| 723 |
+
# reviewer берём из учётной записи
|
| 724 |
+
reviewer = login
|
| 725 |
+
reviewer_filter = None if role == 'admin' else reviewer
|
|
|
|
|
|
|
| 726 |
dirs = load_dir_registry(PUBLICATIONS_REPO, PUB_DIR_REGISTRY_PATH)
|
| 727 |
dir_ids = [d.get("dir_id") for d in dirs if d.get("dir_id")]
|
| 728 |
if not dir_ids:
|
|
|
|
| 736 |
name = meta.get("dir_name") or "—"
|
| 737 |
return f"DIR-{str(dir_no(did)).zfill(2)} — {name}"
|
| 738 |
|
| 739 |
+
st.title("Скоринг публикаций")
|
| 740 |
|
| 741 |
# Выбор DIR — в основном интерфейсе (не в сайдбаре)
|
| 742 |
+
selected_dir = st.selectbox("DIR", dir_ids, index=0, format_func=_fmt_dir)
|
| 743 |
dir_meta = dir_map.get(selected_dir) or {}
|
| 744 |
|
| 745 |
# =========================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
if clear_cache:
|
| 747 |
load_dir_registry.clear()
|
| 748 |
load_candidates.clear()
|
|
|
|
| 783 |
|
| 784 |
st.markdown(
|
| 785 |
f"""
|
| 786 |
+
<div class="dir-card">
|
| 787 |
+
<div class="small-meta"><b>Краткое описание</b></div>
|
| 788 |
+
<div>{dir_desc}</div>
|
|
|
|
| 789 |
</div>
|
| 790 |
""",
|
| 791 |
unsafe_allow_html=True,
|
| 792 |
)
|
| 793 |
|
| 794 |
+
with st.expander("Детали DIR", expanded=True):
|
| 795 |
st.markdown(f"**Временной интервал:** {year_from} – {year_to}")
|
| 796 |
st.markdown(f"**Якоря:** {anchor_str}")
|
| 797 |
st.markdown(f"**Поддержка:** {support_str}")
|
| 798 |
st.markdown(f"**Шум:** {noise_str}")
|
| 799 |
+
st.markdown(f"**Topics поиска:** {topics_str}")
|
| 800 |
|
| 801 |
st.divider()
|
| 802 |
|
|
|
|
| 869 |
# Загрузка сохранённых оценок (тихо; remote-статусы не показываем)
|
| 870 |
need_reload = (st.session_state["reviewed_remote_dir"] != canonical_dir)
|
| 871 |
if need_reload:
|
| 872 |
+
if role != "demo":
|
| 873 |
+
# Тихо подгружаем только оценки текущего пользователя (или все, если admin)
|
| 874 |
+
st.session_state["reviewed_remote"] = load_reviewed_keys(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX, reviewer=reviewer_filter)
|
| 875 |
+
st.session_state["review_index_remote"] = load_review_index(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX, reviewer=reviewer_filter)
|
| 876 |
+
else:
|
| 877 |
+
st.session_state["reviewed_remote"] = set()
|
| 878 |
+
st.session_state["review_index_remote"] = {}
|
| 879 |
st.session_state["reviewed_remote_dir"] = canonical_dir
|
| 880 |
|
| 881 |
reviewed_committed = set(st.session_state["reviewed_remote"]) | set(st.session_state["reviewed_local_committed"])
|
|
|
|
| 947 |
pending_pub_count = int(mask_pending.sum())
|
| 948 |
unreviewed_pub_count = int((~mask_reviewed).sum())
|
| 949 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
|
| 951 |
# =========================
|
| 952 |
# Commit helper
|
|
|
|
| 1002 |
current_idx = max(0, min(total - 1, current_idx))
|
| 1003 |
st.session_state[idx_key] = current_idx
|
| 1004 |
|
| 1005 |
+
window_size = 50
|
| 1006 |
window_start = int(current_idx // window_size) * int(window_size)
|
| 1007 |
window_end = min(window_start + int(window_size), total)
|
| 1008 |
page_indices = list(range(window_start, window_end))
|
|
|
|
| 1096 |
st.rerun()
|
| 1097 |
|
| 1098 |
with b2:
|
| 1099 |
+
if st.button("✅ Сохранить и далее", use_container_width=True, disabled=(role=="demo")):
|
| 1100 |
# собираем мета для review
|
| 1101 |
authors, abstract = get_authors_and_abstract(row)
|
| 1102 |
|
|
|
|
| 1162 |
st.session_state[idx_key] = min(total - 1, current_idx + 1)
|
| 1163 |
st.rerun()
|
| 1164 |
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
# --- Статусы/прогресс (внизу левого блока) ---
|
| 1168 |
+
total_pub = int(total)
|
| 1169 |
+
prog_eval = int(committed_pub_count + pending_pub_count)
|
| 1170 |
+
prog_pct = int(round(100.0 * prog_eval / total_pub)) if total_pub > 0 else 0
|
| 1171 |
+
st.markdown(
|
| 1172 |
+
f'<div class="kpi-line">'
|
| 1173 |
+
f'<b>Всего</b>: {total_pub} • '
|
| 1174 |
+
f'<b>Оценено</b>: {committed_pub_count} • '
|
| 1175 |
+
f'<b>Pending</b>: {pending_pub_count} • '
|
| 1176 |
+
f'<b>Осталось</b>: {unreviewed_pub_count} • '
|
| 1177 |
+
f'<b>{prog_pct}%</b> • '
|
| 1178 |
+
f'<b>#{int(current_idx)+1}/{total_pub}</b>'
|
| 1179 |
+
f'</div>',
|
| 1180 |
+
unsafe_allow_html=True,
|
| 1181 |
+
)
|
| 1182 |
+
|
| 1183 |
+
if role != "demo" and (st.session_state.get("batch") or []):
|
| 1184 |
+
pending_jsonl = "".join(json.dumps(x, ensure_ascii=False) + "\\n" for x in (st.session_state.get("batch") or []))
|
| 1185 |
+
st.download_button(
|
| 1186 |
+
"⬇️ Скачать pending (.jsonl)",
|
| 1187 |
+
data=pending_jsonl,
|
| 1188 |
+
file_name=f"pending_{canonical_dir}_{dt.datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.jsonl",
|
| 1189 |
+
mime="application/jsonl",
|
| 1190 |
+
use_container_width=True,
|
| 1191 |
+
)
|
| 1192 |
|
| 1193 |
with col_right:
|
| 1194 |
# карточка публикации занимает максимум пространства справа
|
|
|
|
| 1296 |
expl = row.get("dir_score_explanation")
|
| 1297 |
if expl:
|
| 1298 |
st.markdown("**dir_score_explanation:**")
|
| 1299 |
+
st.write(expl)
|