yogl commited on
Commit
43b6b2a
·
verified ·
1 Parent(s): fc34f8b

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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 get_reviewer() -> str:
182
- default = os.environ.get("REVIEWER", "")
183
- v = st.sidebar.text_input("Рецензент (имя/ник)", value=default).strip()
184
- return v or "anonymous"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return reviewed
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
- key (SRC::<source_id> / W::<work_id>) -> review_obj (последняя по ts_utc/ts)
462
-
463
- Нужен для автоподстановки score/comment при навигации.
464
  """
465
  try:
466
  files = list_review_files(repo_id, dir_id, prefix)
467
  except Exception:
468
  return {}
469
 
470
- idx: Dict[str, Dict[str, Any]] = {}
471
- ts_by_key: Dict[str, str] = {}
 
 
 
 
 
 
 
 
 
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
- ts = str(obj.get("ts_utc") or obj.get("ts") or "")
483
-
484
- sid = obj.get("source_id")
485
- if isinstance(sid, str) and sid.strip():
486
- k = f"SRC::{sid.strip()}"
487
- if (k not in ts_by_key) or (ts and ts > ts_by_key.get(k, "")):
488
- ts_by_key[k] = ts
489
- idx[k] = obj
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
- return idx
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.title("Скоринг публикаций")
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
- require_env("PUBLICATIONS_REPO", PUBLICATIONS_REPO)
 
 
545
  require_env("REVIEWS_REPO", REVIEWS_REPO)
546
 
547
- # Проверяем наличие репозиториев (чтобы не ловить 404 при сохранении)
548
- pub_ok, pub_msg = check_dataset_repo(PUBLICATIONS_REPO, HF_TOKEN)
549
- if not pub_ok:
550
- st.error("PUBLICATIONS_REPO недоступен как dataset repo. Проверь repo_id и доступ.\n\n" + pub_msg)
551
- st.stop()
552
 
 
553
  rev_ok, rev_msg = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
554
  if not rev_ok:
555
- created, cmsg = maybe_create_reviews_repo(REVIEWS_REPO)
556
- if created:
557
- rev_ok2, rev_msg2 = check_dataset_repo(REVIEWS_REPO, HF_TOKEN)
558
- if not rev_ok2:
559
- st.error("REVIEWS_REPO недоступен после create_repo.\n\n" + rev_msg2)
560
- st.stop()
 
 
 
 
561
  else:
562
- st.success("REVIEWS_REPO создан/доступен.")
 
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("Направление исследований", dir_ids, index=0, format_func=_fmt_dir)
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 style="padding: 14px 16px; border: 1px solid rgba(49,51,63,0.2); border-radius: 12px; background: rgba(49,51,63,0.04);">
649
- <div style="margin-top: 0px;">
650
- <div style="font-size: 12px; font-weight: 700; text-transform: uppercase; opacity: 0.7;">{dir_desc}</div>
651
- </div>
652
  </div>
653
  """,
654
  unsafe_allow_html=True,
655
  )
656
 
657
- with st.expander("Параметры поиска", expanded=True):
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
- # Без спиннера и без UI-вывода: просто подготавливаем пропуск/предзаполнение
736
- st.session_state["reviewed_remote"] = load_reviewed_keys(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX)
737
- st.session_state["review_index_remote"] = load_review_index(REVIEWS_REPO, selected_dir, REVIEWS_LOG_PREFIX)
 
 
 
 
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 # фиксированный размер окна списка (��астройка убрана из UI)
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
- st.caption(f"Pending: {len(st.session_state.get('batch') or [])}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)