KPrashanth commited on
Commit
c8bde74
·
verified ·
1 Parent(s): 12fa522

Deploy Rachana Data Studio

Browse files
data_studio/api.py CHANGED
@@ -19,7 +19,7 @@ from data_studio.review import (
19
  ReviewConflict,
20
  ReviewValidationError,
21
  apply_review_action,
22
- claim_next_sample,
23
  queue_overview,
24
  release_claim,
25
  )
@@ -152,7 +152,7 @@ def overview(user: dict[str, Any] = Depends(current_user)) -> dict[str, Any]:
152
 
153
  @app.post("/api/review/claim/{queue_name}")
154
  def claim(queue_name: str, user: dict[str, Any] = Depends(require_permission("review"))) -> dict[str, Any]:
155
- return {"sample": claim_next_sample(db, queue_name, user["username"])}
156
 
157
 
158
  @app.post("/api/review/{sample_id}/release")
@@ -166,7 +166,7 @@ def review(
166
  sample_id: str,
167
  payload: ReviewRequest,
168
  user: dict[str, Any] = Depends(require_permission("review")),
169
- ) -> dict[str, str]:
170
  try:
171
  resolved_action = apply_review_action(
172
  db=db,
@@ -187,7 +187,7 @@ def review(
187
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
188
  except ReviewValidationError as exc:
189
  raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
190
- return {"action": resolved_action}
191
 
192
 
193
  @app.post("/api/admin/sources/seed")
 
19
  ReviewConflict,
20
  ReviewValidationError,
21
  apply_review_action,
22
+ claim_next_sample_with_enrichment,
23
  queue_overview,
24
  release_claim,
25
  )
 
152
 
153
  @app.post("/api/review/claim/{queue_name}")
154
  def claim(queue_name: str, user: dict[str, Any] = Depends(require_permission("review"))) -> dict[str, Any]:
155
+ return {"sample": claim_next_sample_with_enrichment(db, settings, queue_name, user["username"])}
156
 
157
 
158
  @app.post("/api/review/{sample_id}/release")
 
166
  sample_id: str,
167
  payload: ReviewRequest,
168
  user: dict[str, Any] = Depends(require_permission("review")),
169
+ ) -> dict[str, Any]:
170
  try:
171
  resolved_action = apply_review_action(
172
  db=db,
 
187
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
188
  except ReviewValidationError as exc:
189
  raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
190
+ return resolved_action
191
 
192
 
193
  @app.post("/api/admin/sources/seed")
data_studio/config.py CHANGED
@@ -22,10 +22,12 @@ class StudioSettings:
22
  hf_raw_dataset_repo: str
23
  hf_clean_dataset_repo: str
24
  hf_audit_dataset_repo: str
 
25
  custom_pure_telugu_hf_dataset: str
26
  custom_pure_telugu_source_name: str
27
  active_tokenizer_version: str
28
  active_tokenizer_model_path: str
 
29
  target_clean_tokens: int
30
  studio_env: str
31
  deployment_version: str
@@ -47,6 +49,7 @@ class StudioSettings:
47
  hf_raw_dataset_repo=os.getenv("HF_RAW_DATASET_REPO", "").strip(),
48
  hf_clean_dataset_repo=os.getenv("HF_CLEAN_DATASET_REPO", "").strip(),
49
  hf_audit_dataset_repo=os.getenv("HF_AUDIT_DATASET_REPO", "").strip(),
 
50
  custom_pure_telugu_hf_dataset=os.getenv("CUSTOM_PURE_TELUGU_HF_DATASET", "").strip(),
51
  custom_pure_telugu_source_name=os.getenv(
52
  "CUSTOM_PURE_TELUGU_SOURCE_NAME",
@@ -60,6 +63,7 @@ class StudioSettings:
60
  str(Path("tokenizer") / "rachana_bpe32k.model"),
61
  ).strip()
62
  or str(Path("tokenizer") / "rachana_bpe32k.model"),
 
63
  target_clean_tokens=int(os.getenv("TARGET_CLEAN_TOKENS", "0").strip() or "0"),
64
  studio_env=os.getenv("STUDIO_ENV", "development").strip().lower(),
65
  deployment_version=os.getenv("DEPLOYMENT_VERSION", "local").strip() or "local",
@@ -104,6 +108,7 @@ class StudioSettings:
104
  "legacy_import_export_enabled": self.allow_legacy_import_export,
105
  "custom_pure_telugu_hf_dataset": self.custom_pure_telugu_hf_dataset or None,
106
  "active_tokenizer_version": self.active_tokenizer_version,
 
107
  "target_clean_tokens": self.target_clean_tokens,
108
  "single_user_mode": self.single_user_mode,
109
  "single_user_username": self.single_user_username or None,
 
22
  hf_raw_dataset_repo: str
23
  hf_clean_dataset_repo: str
24
  hf_audit_dataset_repo: str
25
+ hf_live_review_dataset_repo: str
26
  custom_pure_telugu_hf_dataset: str
27
  custom_pure_telugu_source_name: str
28
  active_tokenizer_version: str
29
  active_tokenizer_model_path: str
30
+ active_ner_model_id: str
31
  target_clean_tokens: int
32
  studio_env: str
33
  deployment_version: str
 
49
  hf_raw_dataset_repo=os.getenv("HF_RAW_DATASET_REPO", "").strip(),
50
  hf_clean_dataset_repo=os.getenv("HF_CLEAN_DATASET_REPO", "").strip(),
51
  hf_audit_dataset_repo=os.getenv("HF_AUDIT_DATASET_REPO", "").strip(),
52
+ hf_live_review_dataset_repo=os.getenv("HF_LIVE_REVIEW_DATASET_REPO", "").strip(),
53
  custom_pure_telugu_hf_dataset=os.getenv("CUSTOM_PURE_TELUGU_HF_DATASET", "").strip(),
54
  custom_pure_telugu_source_name=os.getenv(
55
  "CUSTOM_PURE_TELUGU_SOURCE_NAME",
 
63
  str(Path("tokenizer") / "rachana_bpe32k.model"),
64
  ).strip()
65
  or str(Path("tokenizer") / "rachana_bpe32k.model"),
66
+ active_ner_model_id=os.getenv("ACTIVE_NER_MODEL_ID", "ai4bharat/IndicNER").strip() or "ai4bharat/IndicNER",
67
  target_clean_tokens=int(os.getenv("TARGET_CLEAN_TOKENS", "0").strip() or "0"),
68
  studio_env=os.getenv("STUDIO_ENV", "development").strip().lower(),
69
  deployment_version=os.getenv("DEPLOYMENT_VERSION", "local").strip() or "local",
 
108
  "legacy_import_export_enabled": self.allow_legacy_import_export,
109
  "custom_pure_telugu_hf_dataset": self.custom_pure_telugu_hf_dataset or None,
110
  "active_tokenizer_version": self.active_tokenizer_version,
111
+ "active_ner_model_id": self.active_ner_model_id,
112
  "target_clean_tokens": self.target_clean_tokens,
113
  "single_user_mode": self.single_user_mode,
114
  "single_user_username": self.single_user_username or None,
data_studio/importer.py CHANGED
@@ -57,18 +57,19 @@ def _document_sample(source: dict[str, Any], row: dict[str, Any]) -> dict[str, A
57
  tags = [payload["doc_type"]] if payload["doc_type"] and payload["doc_type"] != "unknown" else []
58
  return {
59
  "payload": payload,
60
- "review": {
61
- "current_text": raw_text,
62
- "current_pair": None,
63
- "current_transliteration": None,
64
- "tags": tags,
65
- "quality_tags": [],
66
- "task_tags": [],
67
- "notes": "",
68
- "last_reviewed_by": None,
69
- "last_reviewed_at": None,
70
- "edit_count": 0,
71
- },
 
72
  "identity_parts": [raw_text],
73
  }
74
 
@@ -90,21 +91,22 @@ def _translation_sample(source: dict[str, Any], row: dict[str, Any]) -> dict[str
90
  }
91
  return {
92
  "payload": payload,
93
- "review": {
94
- "current_text": None,
95
- "current_pair": {
96
- "source_text": source_text,
97
- "target_text": target_text,
98
- },
99
- "current_transliteration": None,
100
- "tags": [],
101
- "quality_tags": [],
102
- "task_tags": ["translation"],
103
- "notes": "",
104
- "last_reviewed_by": None,
105
- "last_reviewed_at": None,
106
- "edit_count": 0,
107
- },
 
108
  "identity_parts": [source_text, target_text],
109
  }
110
 
@@ -127,21 +129,22 @@ def _transliteration_sample(source: dict[str, Any], row: dict[str, Any]) -> dict
127
  }
128
  return {
129
  "payload": payload,
130
- "review": {
131
- "current_text": None,
132
- "current_pair": None,
133
- "current_transliteration": {
134
- "native_text": native_text,
135
- "latin_text": latin_text,
136
- },
137
- "tags": [],
138
- "quality_tags": [],
139
- "task_tags": ["transliteration"],
140
- "notes": "",
141
- "last_reviewed_by": None,
142
- "last_reviewed_at": None,
143
- "edit_count": 0,
144
- },
 
145
  "identity_parts": [native_text, latin_text],
146
  }
147
 
@@ -183,14 +186,21 @@ def _sample_template(
183
  "script": _script_for_queue(source["queue_name"]),
184
  "language": _language_for_queue(source["queue_name"]),
185
  },
186
- "export": {
187
- "eligible": False,
188
- "dataset_version": None,
189
- "exported_at": None,
190
- },
191
- "created_at": now,
192
- "updated_at": now,
193
- }
 
 
 
 
 
 
 
194
 
195
 
196
  def build_sample(source: dict[str, Any], row: dict[str, Any]) -> dict[str, Any] | None:
 
57
  tags = [payload["doc_type"]] if payload["doc_type"] and payload["doc_type"] != "unknown" else []
58
  return {
59
  "payload": payload,
60
+ "review": {
61
+ "current_text": raw_text,
62
+ "current_pair": None,
63
+ "current_transliteration": None,
64
+ "tags": tags,
65
+ "quality_tags": [],
66
+ "task_tags": [],
67
+ "ner": None,
68
+ "notes": "",
69
+ "last_reviewed_by": None,
70
+ "last_reviewed_at": None,
71
+ "edit_count": 0,
72
+ },
73
  "identity_parts": [raw_text],
74
  }
75
 
 
91
  }
92
  return {
93
  "payload": payload,
94
+ "review": {
95
+ "current_text": None,
96
+ "current_pair": {
97
+ "source_text": source_text,
98
+ "target_text": target_text,
99
+ },
100
+ "current_transliteration": None,
101
+ "tags": [],
102
+ "quality_tags": [],
103
+ "task_tags": ["translation"],
104
+ "ner": None,
105
+ "notes": "",
106
+ "last_reviewed_by": None,
107
+ "last_reviewed_at": None,
108
+ "edit_count": 0,
109
+ },
110
  "identity_parts": [source_text, target_text],
111
  }
112
 
 
129
  }
130
  return {
131
  "payload": payload,
132
+ "review": {
133
+ "current_text": None,
134
+ "current_pair": None,
135
+ "current_transliteration": {
136
+ "native_text": native_text,
137
+ "latin_text": latin_text,
138
+ },
139
+ "tags": [],
140
+ "quality_tags": [],
141
+ "task_tags": ["transliteration"],
142
+ "ner": None,
143
+ "notes": "",
144
+ "last_reviewed_by": None,
145
+ "last_reviewed_at": None,
146
+ "edit_count": 0,
147
+ },
148
  "identity_parts": [native_text, latin_text],
149
  }
150
 
 
186
  "script": _script_for_queue(source["queue_name"]),
187
  "language": _language_for_queue(source["queue_name"]),
188
  },
189
+ "export": {
190
+ "eligible": False,
191
+ "dataset_version": None,
192
+ "exported_at": None,
193
+ },
194
+ "live_sync": {
195
+ "repo_id": None,
196
+ "path_in_repo": None,
197
+ "status": "pending",
198
+ "last_synced_at": None,
199
+ "last_error": None,
200
+ },
201
+ "created_at": now,
202
+ "updated_at": now,
203
+ }
204
 
205
 
206
  def build_sample(source: dict[str, Any], row: dict[str, Any]) -> dict[str, Any] | None:
data_studio/live_review_sync.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from huggingface_hub import HfApi
9
+
10
+ from data_studio.config import StudioSettings
11
+
12
+
13
+ def target_repo(settings: StudioSettings) -> str:
14
+ repo = settings.hf_live_review_dataset_repo or settings.hf_clean_dataset_repo
15
+ if not repo:
16
+ raise ValueError("HF_LIVE_REVIEW_DATASET_REPO or HF_CLEAN_DATASET_REPO is required for live review sync.")
17
+ return repo
18
+
19
+
20
+ def reviewed_sample_row(sample: dict[str, Any], review_action: str) -> dict[str, Any]:
21
+ return {
22
+ "sample_id": sample["sample_id"],
23
+ "queue_name": sample["queue_name"],
24
+ "sample_type": sample["sample_type"],
25
+ "source": sample["source"],
26
+ "original": sample["payload"],
27
+ "cleaned": sample["review"],
28
+ "status": sample["status"],
29
+ "metrics": sample.get("metrics", {}),
30
+ "review_action": review_action,
31
+ }
32
+
33
+
34
+ def sync_reviewed_sample(settings: StudioSettings, sample: dict[str, Any], review_action: str) -> dict[str, Any]:
35
+ repo_id = target_repo(settings)
36
+ queue_name = sample["queue_name"]
37
+ sample_path = f"live_reviews/{queue_name}/{sample['sample_id']}.json"
38
+ api = HfApi(token=settings.hf_token)
39
+ api.create_repo(repo_id, repo_type="dataset", private=True, exist_ok=True)
40
+ payload = reviewed_sample_row(sample, review_action)
41
+ with tempfile.TemporaryDirectory() as temp_dir:
42
+ temp_file = Path(temp_dir) / "sample.json"
43
+ temp_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
44
+ api.upload_file(
45
+ path_or_fileobj=str(temp_file),
46
+ path_in_repo=sample_path,
47
+ repo_id=repo_id,
48
+ repo_type="dataset",
49
+ commit_message=f"Sync reviewed sample {sample['sample_id']}",
50
+ )
51
+ return {"repo_id": repo_id, "path_in_repo": sample_path, "status": "synced"}
data_studio/ner.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import Any
5
+
6
+ from transformers import AutoModelForTokenClassification, AutoTokenizer, pipeline
7
+
8
+ from data_studio.config import StudioSettings
9
+ from data_studio.utils import utc_now_iso
10
+
11
+
12
+ def _normalize_entity_label(label: str) -> str:
13
+ normalized = label.upper().replace("B-", "").replace("I-", "")
14
+ return normalized
15
+
16
+
17
+ @lru_cache(maxsize=2)
18
+ def _ner_pipeline(model_id: str, token: str):
19
+ tokenizer = AutoTokenizer.from_pretrained(model_id, token=token)
20
+ model = AutoModelForTokenClassification.from_pretrained(model_id, token=token)
21
+ return pipeline(
22
+ "token-classification",
23
+ model=model,
24
+ tokenizer=tokenizer,
25
+ aggregation_strategy="simple",
26
+ device=-1,
27
+ )
28
+
29
+
30
+ def should_run_ner(sample: dict[str, Any]) -> bool:
31
+ return sample.get("queue_name") == "pure_telugu" and sample.get("sample_type") == "document"
32
+
33
+
34
+ def build_ner_suggestions(settings: StudioSettings, text: str) -> dict[str, Any]:
35
+ ner_pipe = _ner_pipeline(settings.active_ner_model_id, settings.hf_token)
36
+ raw_entities = ner_pipe(text)
37
+ entities: list[dict[str, Any]] = []
38
+ suggested_tags: list[str] = []
39
+ seen = set()
40
+ for item in raw_entities:
41
+ label = _normalize_entity_label(str(item.get("entity_group", "")))
42
+ word = str(item.get("word", "")).strip()
43
+ if not word or not label:
44
+ continue
45
+ key = (label, word)
46
+ if key in seen:
47
+ continue
48
+ seen.add(key)
49
+ entities.append(
50
+ {
51
+ "text": word,
52
+ "label": label,
53
+ "score": round(float(item.get("score", 0.0)), 4),
54
+ "start": item.get("start"),
55
+ "end": item.get("end"),
56
+ }
57
+ )
58
+ suggested_tags.append(word)
59
+ return {
60
+ "model_version": settings.active_ner_model_id,
61
+ "generated_at": utc_now_iso(),
62
+ "entities": entities,
63
+ "suggested_tags": suggested_tags[:24],
64
+ "status": "ready",
65
+ "error": None,
66
+ }
data_studio/review.py CHANGED
@@ -10,6 +10,8 @@ from pymongo import ReturnDocument
10
  from pymongo.database import Database
11
 
12
  from data_studio.config import StudioSettings
 
 
13
  from data_studio.token_accounting import token_progress, token_stats_for_review
14
  from data_studio.utils import normalize_multiline_text, normalize_text, utc_now
15
 
@@ -78,6 +80,35 @@ def claim_next_sample(db: Database, queue_name: str, reviewer: str) -> dict[str,
78
  return _serialize_sample(sample)
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def next_sample(db: Database, queue_name: str) -> dict[str, Any] | None:
82
  """Legacy Streamlit compatibility; the API uses claim_next_sample."""
83
  return _serialize_sample(db["samples"].find_one({"queue_name": queue_name, "status": "pending"}, sort=[("created_at", 1)]))
@@ -144,7 +175,7 @@ def apply_review_action(
144
  notes: str,
145
  confidence: int,
146
  reason: str,
147
- ) -> str:
148
  if action not in {"accept", "edit", "reject", "skip"}:
149
  raise ReviewValidationError(f"Unsupported review action: {action}")
150
  if not 1 <= int(confidence) <= 5:
@@ -153,6 +184,9 @@ def apply_review_action(
153
  raise ReviewValidationError("A rejection reason is required.")
154
 
155
  client = db.client
 
 
 
156
  with client.start_session() as session:
157
  with session.start_transaction():
158
  sample = db["samples"].find_one(
@@ -219,4 +253,40 @@ def apply_review_action(
219
  },
220
  session=session,
221
  )
222
- return resolved_action
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  from pymongo.database import Database
11
 
12
  from data_studio.config import StudioSettings
13
+ from data_studio.live_review_sync import sync_reviewed_sample
14
+ from data_studio.ner import build_ner_suggestions, should_run_ner
15
  from data_studio.token_accounting import token_progress, token_stats_for_review
16
  from data_studio.utils import normalize_multiline_text, normalize_text, utc_now
17
 
 
80
  return _serialize_sample(sample)
81
 
82
 
83
+ def claim_next_sample_with_enrichment(
84
+ db: Database,
85
+ settings: StudioSettings,
86
+ queue_name: str,
87
+ reviewer: str,
88
+ ) -> dict[str, Any] | None:
89
+ sample = claim_next_sample(db, queue_name, reviewer)
90
+ if sample is None:
91
+ return None
92
+ if sample.get("review", {}).get("ner") is None and should_run_ner(sample):
93
+ try:
94
+ ner = build_ner_suggestions(settings, str(sample["review"].get("current_text", "")))
95
+ except Exception as exc:
96
+ ner = {
97
+ "model_version": settings.active_ner_model_id,
98
+ "generated_at": None,
99
+ "entities": [],
100
+ "suggested_tags": [],
101
+ "status": "failed",
102
+ "error": str(exc),
103
+ }
104
+ db["samples"].update_one(
105
+ {"sample_id": sample["sample_id"]},
106
+ {"$set": {"review.ner": ner, "updated_at": utc_now()}},
107
+ )
108
+ sample["review"]["ner"] = ner
109
+ return sample
110
+
111
+
112
  def next_sample(db: Database, queue_name: str) -> dict[str, Any] | None:
113
  """Legacy Streamlit compatibility; the API uses claim_next_sample."""
114
  return _serialize_sample(db["samples"].find_one({"queue_name": queue_name, "status": "pending"}, sort=[("created_at", 1)]))
 
175
  notes: str,
176
  confidence: int,
177
  reason: str,
178
+ ) -> dict[str, Any]:
179
  if action not in {"accept", "edit", "reject", "skip"}:
180
  raise ReviewValidationError(f"Unsupported review action: {action}")
181
  if not 1 <= int(confidence) <= 5:
 
184
  raise ReviewValidationError("A rejection reason is required.")
185
 
186
  client = db.client
187
+ updated_sample: dict[str, Any] | None = None
188
+ status = "pending"
189
+ resolved_action = action
190
  with client.start_session() as session:
191
  with session.start_transaction():
192
  sample = db["samples"].find_one(
 
253
  },
254
  session=session,
255
  )
256
+ updated_sample = db["samples"].find_one({"sample_id": sample_id}, session=session)
257
+
258
+ live_sync: dict[str, Any] | None = None
259
+ if updated_sample is not None and status in {"accepted", "edited"}:
260
+ try:
261
+ live_sync = sync_reviewed_sample(settings, updated_sample, resolved_action)
262
+ db["samples"].update_one(
263
+ {"sample_id": sample_id},
264
+ {
265
+ "$set": {
266
+ "live_sync.repo_id": live_sync["repo_id"],
267
+ "live_sync.path_in_repo": live_sync["path_in_repo"],
268
+ "live_sync.status": "synced",
269
+ "live_sync.last_synced_at": utc_now(),
270
+ "live_sync.last_error": None,
271
+ }
272
+ },
273
+ )
274
+ except Exception as exc:
275
+ live_sync = {"status": "failed", "error": str(exc)}
276
+ db["samples"].update_one(
277
+ {"sample_id": sample_id},
278
+ {
279
+ "$set": {
280
+ "live_sync.status": "failed",
281
+ "live_sync.last_synced_at": None,
282
+ "live_sync.last_error": str(exc),
283
+ }
284
+ },
285
+ )
286
+
287
+ return {
288
+ "action": resolved_action,
289
+ "status": status,
290
+ "token_stats": updated_sample["review"]["token_stats"] if updated_sample else None,
291
+ "live_sync": live_sync,
292
+ }
frontend/app/globals.css CHANGED
@@ -1 +1,2 @@
1
  :root{--ink:#17221d;--muted:#637169;--paper:#f4f5f0;--card:#fff;--line:#dfe4dd;--green:#176b4d;--green-soft:#e8f2ed;--gold:#d2a547;--red:#a23d3d;--shadow:0 18px 50px rgba(30,48,39,.08)}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh;display:grid;grid-template-columns:280px 1fr}.sidebar{background:#102a20;color:#f5f8f5;padding:26px 18px;display:flex;flex-direction:column;gap:28px;position:sticky;top:0;height:100vh}.brand{display:flex;gap:12px;align-items:center}.brandMark{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--gold);color:#183427;font-size:25px;font-weight:800}.brand div{display:grid}.brand strong{font-size:18px}.brand small{color:#b8c9c0}.sidebar nav{display:grid;gap:8px}.sidebar nav button{border:0;background:transparent;color:#c7d4cd;text-align:left;padding:12px 14px;border-radius:9px}.sidebar nav button.active,.sidebar nav button:hover{background:#214638;color:#fff}.tokenPanel{border:1px solid #345044;border-radius:14px;padding:16px;background:#163126;display:grid;gap:8px}.tokenPanel strong{font-size:20px}.tokenPanel small{color:#b8c9c0}.tokenPanelStats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px}.tokenPanelStats div{background:#214638;border-radius:10px;padding:10px;display:grid;gap:3px}.tokenPanelStats span{font-size:11px;color:#b8c9c0;text-transform:uppercase;letter-spacing:.06em}.progressBar{height:10px;border-radius:999px;background:#284136;overflow:hidden}.progressBar span{display:block;height:100%;background:linear-gradient(90deg,#d2a547,#47a977)}.profile{margin-top:auto;display:grid;grid-template-columns:34px 1fr;gap:8px;align-items:center;border-top:1px solid #345044;padding-top:18px}.profile>span{width:32px;height:32px;border-radius:50%;background:#dceade;color:#183427;display:grid;place-items:center;font-weight:700}.profile div{display:grid}.profile small{color:#a8bbb1;text-transform:capitalize}.profile button{grid-column:1/-1;border:0;color:#bdcec5;background:transparent;text-align:left;padding:8px 0}main{padding:40px;max-width:1600px;width:100%;margin:auto}.pageHeader{display:flex;justify-content:space-between;align-items:start;margin-bottom:28px;gap:18px}.pageHeader h1{font-family:Georgia,serif;font-size:36px;line-height:1.08;margin:7px 0}.pageHeader p{color:var(--muted);margin:0}.eyebrow{text-transform:uppercase;letter-spacing:.13em;color:var(--green);font-size:12px;font-weight:800}.lease{background:var(--green-soft);color:var(--green);border:1px solid #c9dfd3;padding:9px 13px;border-radius:20px;font-size:13px}.queueTabs{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px}.queueTabs button{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;text-align:left;display:grid;gap:4px;color:var(--ink)}.queueTabs button span{font-size:12px;color:var(--muted)}.queueTabs button.active{border-color:var(--green);box-shadow:inset 0 -3px var(--green)}.reviewGrid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:18px}.workCard,.decisionCard,.tableCard,.operationCard,.metric,.emptyState{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.cardHeader{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px}.cardHeader div{display:grid;grid-template-columns:auto 1fr;gap:2px 8px}.cardHeader small{grid-column:2;color:var(--muted)}.statusDot{width:9px;height:9px;border-radius:50%;background:#47a977;grid-row:1/3;align-self:center}.sampleType{font-size:12px;background:#f0f2ee;border-radius:20px;padding:6px 10px;height:max-content}.lineColumnHeader{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}.lineColumnHeader div{display:grid;gap:3px}.lineColumnHeader small{color:var(--muted);font-size:12px}.decisionCard{padding:20px;height:max-content}.decisionCard h2,.operationCard h2,.tableCard h2{margin:0 0 6px;font-family:Georgia,serif}.decisionCard p,.operationCard p{color:var(--muted);font-size:13px;margin:0 0 18px}.decisionCard label,.operationCard label,.loginCard label{display:grid;gap:6px;font-size:12px;font-weight:700;margin:12px 0}.decisionCard input,.decisionCard textarea,.operationCard input,.operationCard select,.loginCard input,.editorTextarea{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff;color:var(--ink)}.editorPanel{display:grid;grid-template-columns:1fr 1fr;min-height:580px}.editorColumn{padding:18px;min-width:0}.editorColumn+.editorColumn{border-left:1px solid var(--line)}.editableColumn{background:#fafbf9}.editorTextarea{width:100%;min-height:460px;resize:vertical;line-height:1.6}.referenceBlock{border:1px solid var(--line);border-radius:10px;background:#f6f8f5;padding:14px;white-space:pre-wrap;line-height:1.7;min-height:460px;overflow:auto}.statsStrip{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0 6px}.statsStrip div{border:1px solid var(--line);border-radius:10px;padding:12px;background:#f7f8f4;display:grid;gap:4px}.statsStrip span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.statsStrip strong{font-family:Georgia,serif;font-size:24px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:18px}button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:10px 14px;color:var(--ink)}button.primary{background:var(--green);border-color:var(--green);color:#fff;font-weight:700}button.danger{color:var(--red);border-color:#e6caca}button:disabled{opacity:.55;cursor:not-allowed}.emptyState{text-align:center;padding:80px 20px}.emptyState div{width:56px;height:56px;border-radius:50%;background:var(--green-soft);color:var(--green);display:grid;place-items:center;margin:auto;font-size:24px}.emptyState h2{font-family:Georgia,serif;margin-bottom:5px}.emptyState p{color:var(--muted);margin-bottom:22px}.notice{position:fixed;top:18px;right:22px;z-index:5;background:#18372a;color:#fff;padding:12px 14px;border-radius:9px;box-shadow:var(--shadow);display:flex;gap:20px;align-items:center}.notice button{background:transparent;color:#d6e3dc;border:0;padding:0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px}.metric{padding:20px;display:grid;gap:8px}.metric span{text-transform:capitalize;color:var(--muted)}.metric strong{font-size:32px;font-family:Georgia,serif}.tableCard{padding:22px;overflow:auto;margin-bottom:20px}table{border-collapse:collapse;width:100%;margin-top:16px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:13px 10px;font-size:13px;vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}td small{display:block;color:var(--muted);margin-top:3px}.adminGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.operationCard{padding:20px}.operationCard button{margin-top:10px}.result{white-space:pre-wrap;background:#102a20;color:#dce9e2;padding:18px;border-radius:10px;margin-top:20px;overflow:auto}.loginPage{min-height:100vh;display:grid;grid-template-columns:1.2fr .8fr;background:#102a20}.loginStory{color:#fff;padding:10vw;display:flex;flex-direction:column;justify-content:center;background:radial-gradient(circle at 20% 20%,#285c46 0,transparent 35%),#102a20}.loginStory .eyebrow{color:#d9b666}.loginStory h1{font:54px/1.05 Georgia,serif;max-width:680px;margin:18px 0}.loginStory p{color:#bed0c7;font-size:18px;max-width:540px}.teluguSample{font:27px/1.7 Georgia,serif;color:#e6cf98;margin-top:50px}.loginCard{background:#fff;margin:auto;width:min(420px,85%);border-radius:16px;padding:34px;box-shadow:var(--shadow)}.loginCard h2{font:32px Georgia,serif;margin:38px 0 5px}.loginCard p{color:var(--muted);margin:0 0 22px}.loginCard button{width:100%;margin-top:14px}.fieldError{background:#fff0f0;color:var(--red);padding:10px;border-radius:8px;font-size:13px}@media(max-width:1150px){.reviewGrid{grid-template-columns:1fr}.decisionCard{width:100%}.adminGrid{grid-template-columns:1fr}.loginPage{grid-template-columns:1fr}.loginStory{display:none}}@media(max-width:760px){.shell{display:block}.sidebar{position:static;height:auto;padding:16px}.sidebar nav{grid-template-columns:repeat(3,1fr)}.profile{display:none}main{padding:22px 14px}.queueTabs,.metrics,.statsStrip,.tokenPanelStats{grid-template-columns:1fr}.editorPanel{grid-template-columns:1fr}.editorColumn+.editorColumn{border-left:0;border-top:1px solid var(--line)}.pageHeader{display:grid}.pageHeader h1{font-size:30px}}
 
 
1
  :root{--ink:#17221d;--muted:#637169;--paper:#f4f5f0;--card:#fff;--line:#dfe4dd;--green:#176b4d;--green-soft:#e8f2ed;--gold:#d2a547;--red:#a23d3d;--shadow:0 18px 50px rgba(30,48,39,.08)}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh;display:grid;grid-template-columns:280px 1fr}.sidebar{background:#102a20;color:#f5f8f5;padding:26px 18px;display:flex;flex-direction:column;gap:28px;position:sticky;top:0;height:100vh}.brand{display:flex;gap:12px;align-items:center}.brandMark{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--gold);color:#183427;font-size:25px;font-weight:800}.brand div{display:grid}.brand strong{font-size:18px}.brand small{color:#b8c9c0}.sidebar nav{display:grid;gap:8px}.sidebar nav button{border:0;background:transparent;color:#c7d4cd;text-align:left;padding:12px 14px;border-radius:9px}.sidebar nav button.active,.sidebar nav button:hover{background:#214638;color:#fff}.tokenPanel{border:1px solid #345044;border-radius:14px;padding:16px;background:#163126;display:grid;gap:8px}.tokenPanel strong{font-size:20px}.tokenPanel small{color:#b8c9c0}.tokenPanelStats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px}.tokenPanelStats div{background:#214638;border-radius:10px;padding:10px;display:grid;gap:3px}.tokenPanelStats span{font-size:11px;color:#b8c9c0;text-transform:uppercase;letter-spacing:.06em}.progressBar{height:10px;border-radius:999px;background:#284136;overflow:hidden}.progressBar span{display:block;height:100%;background:linear-gradient(90deg,#d2a547,#47a977)}.profile{margin-top:auto;display:grid;grid-template-columns:34px 1fr;gap:8px;align-items:center;border-top:1px solid #345044;padding-top:18px}.profile>span{width:32px;height:32px;border-radius:50%;background:#dceade;color:#183427;display:grid;place-items:center;font-weight:700}.profile div{display:grid}.profile small{color:#a8bbb1;text-transform:capitalize}.profile button{grid-column:1/-1;border:0;color:#bdcec5;background:transparent;text-align:left;padding:8px 0}main{padding:40px;max-width:1600px;width:100%;margin:auto}.pageHeader{display:flex;justify-content:space-between;align-items:start;margin-bottom:28px;gap:18px}.pageHeader h1{font-family:Georgia,serif;font-size:36px;line-height:1.08;margin:7px 0}.pageHeader p{color:var(--muted);margin:0}.eyebrow{text-transform:uppercase;letter-spacing:.13em;color:var(--green);font-size:12px;font-weight:800}.lease{background:var(--green-soft);color:var(--green);border:1px solid #c9dfd3;padding:9px 13px;border-radius:20px;font-size:13px}.queueTabs{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px}.queueTabs button{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;text-align:left;display:grid;gap:4px;color:var(--ink)}.queueTabs button span{font-size:12px;color:var(--muted)}.queueTabs button.active{border-color:var(--green);box-shadow:inset 0 -3px var(--green)}.reviewGrid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:18px}.workCard,.decisionCard,.tableCard,.operationCard,.metric,.emptyState{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.cardHeader{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px}.cardHeader div{display:grid;grid-template-columns:auto 1fr;gap:2px 8px}.cardHeader small{grid-column:2;color:var(--muted)}.statusDot{width:9px;height:9px;border-radius:50%;background:#47a977;grid-row:1/3;align-self:center}.sampleType{font-size:12px;background:#f0f2ee;border-radius:20px;padding:6px 10px;height:max-content}.lineColumnHeader{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}.lineColumnHeader div{display:grid;gap:3px}.lineColumnHeader small{color:var(--muted);font-size:12px}.decisionCard{padding:20px;height:max-content}.decisionCard h2,.operationCard h2,.tableCard h2{margin:0 0 6px;font-family:Georgia,serif}.decisionCard p,.operationCard p{color:var(--muted);font-size:13px;margin:0 0 18px}.decisionCard label,.operationCard label,.loginCard label{display:grid;gap:6px;font-size:12px;font-weight:700;margin:12px 0}.decisionCard input,.decisionCard textarea,.operationCard input,.operationCard select,.loginCard input,.editorTextarea{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff;color:var(--ink)}.editorPanel{display:grid;grid-template-columns:1fr 1fr;min-height:580px}.editorColumn{padding:18px;min-width:0}.editorColumn+.editorColumn{border-left:1px solid var(--line)}.editableColumn{background:#fafbf9}.editorTextarea{width:100%;min-height:460px;resize:vertical;line-height:1.6}.referenceBlock{border:1px solid var(--line);border-radius:10px;background:#f6f8f5;padding:14px;white-space:pre-wrap;line-height:1.7;min-height:460px;overflow:auto}.statsStrip{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0 6px}.statsStrip div{border:1px solid var(--line);border-radius:10px;padding:12px;background:#f7f8f4;display:grid;gap:4px}.statsStrip span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.statsStrip strong{font-family:Georgia,serif;font-size:24px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:18px}button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:10px 14px;color:var(--ink)}button.primary{background:var(--green);border-color:var(--green);color:#fff;font-weight:700}button.danger{color:var(--red);border-color:#e6caca}button:disabled{opacity:.55;cursor:not-allowed}.emptyState{text-align:center;padding:80px 20px}.emptyState div{width:56px;height:56px;border-radius:50%;background:var(--green-soft);color:var(--green);display:grid;place-items:center;margin:auto;font-size:24px}.emptyState h2{font-family:Georgia,serif;margin-bottom:5px}.emptyState p{color:var(--muted);margin-bottom:22px}.notice{position:fixed;top:18px;right:22px;z-index:5;background:#18372a;color:#fff;padding:12px 14px;border-radius:9px;box-shadow:var(--shadow);display:flex;gap:20px;align-items:center}.notice button{background:transparent;color:#d6e3dc;border:0;padding:0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px}.metric{padding:20px;display:grid;gap:8px}.metric span{text-transform:capitalize;color:var(--muted)}.metric strong{font-size:32px;font-family:Georgia,serif}.tableCard{padding:22px;overflow:auto;margin-bottom:20px}table{border-collapse:collapse;width:100%;margin-top:16px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:13px 10px;font-size:13px;vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}td small{display:block;color:var(--muted);margin-top:3px}.adminGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.operationCard{padding:20px}.operationCard button{margin-top:10px}.result{white-space:pre-wrap;background:#102a20;color:#dce9e2;padding:18px;border-radius:10px;margin-top:20px;overflow:auto}.loginPage{min-height:100vh;display:grid;grid-template-columns:1.2fr .8fr;background:#102a20}.loginStory{color:#fff;padding:10vw;display:flex;flex-direction:column;justify-content:center;background:radial-gradient(circle at 20% 20%,#285c46 0,transparent 35%),#102a20}.loginStory .eyebrow{color:#d9b666}.loginStory h1{font:54px/1.05 Georgia,serif;max-width:680px;margin:18px 0}.loginStory p{color:#bed0c7;font-size:18px;max-width:540px}.teluguSample{font:27px/1.7 Georgia,serif;color:#e6cf98;margin-top:50px}.loginCard{background:#fff;margin:auto;width:min(420px,85%);border-radius:16px;padding:34px;box-shadow:var(--shadow)}.loginCard h2{font:32px Georgia,serif;margin:38px 0 5px}.loginCard p{color:var(--muted);margin:0 0 22px}.loginCard button{width:100%;margin-top:14px}.fieldError{background:#fff0f0;color:var(--red);padding:10px;border-radius:8px;font-size:13px}@media(max-width:1150px){.reviewGrid{grid-template-columns:1fr}.decisionCard{width:100%}.adminGrid{grid-template-columns:1fr}.loginPage{grid-template-columns:1fr}.loginStory{display:none}}@media(max-width:760px){.shell{display:block}.sidebar{position:static;height:auto;padding:16px}.sidebar nav{grid-template-columns:repeat(3,1fr)}.profile{display:none}main{padding:22px 14px}.queueTabs,.metrics,.statsStrip,.tokenPanelStats{grid-template-columns:1fr}.editorPanel{grid-template-columns:1fr}.editorColumn+.editorColumn{border-left:0;border-top:1px solid var(--line)}.pageHeader{display:grid}.pageHeader h1{font-size:30px}}
2
+ :root{--ink:#17221d;--muted:#637169;--paper:#f4f5f0;--card:#fff;--line:#dfe4dd;--green:#176b4d;--green-soft:#e8f2ed;--gold:#d2a547;--red:#a23d3d;--shadow:0 18px 50px rgba(30,48,39,.08)}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh;display:grid;grid-template-columns:280px 1fr}.sidebar{background:#102a20;color:#f5f8f5;padding:26px 18px;display:flex;flex-direction:column;gap:28px;position:sticky;top:0;height:100vh}.brand{display:flex;gap:12px;align-items:center}.brandMark{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--gold);color:#183427;font-size:25px;font-weight:800}.brand div{display:grid}.brand strong{font-size:18px}.brand small{color:#b8c9c0}.sidebar nav{display:grid;gap:8px}.sidebar nav button{border:0;background:transparent;color:#c7d4cd;text-align:left;padding:12px 14px;border-radius:9px}.sidebar nav button.active,.sidebar nav button:hover{background:#214638;color:#fff}.tokenPanel{border:1px solid #345044;border-radius:14px;padding:16px;background:#163126;display:grid;gap:8px}.tokenPanel strong{font-size:20px}.tokenPanel small{color:#b8c9c0}.tokenPanelStats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px}.tokenPanelStats div{background:#214638;border-radius:10px;padding:10px;display:grid;gap:3px}.tokenPanelStats span{font-size:11px;color:#b8c9c0;text-transform:uppercase;letter-spacing:.06em}.progressBar{height:10px;border-radius:999px;background:#284136;overflow:hidden}.progressBar span{display:block;height:100%;background:linear-gradient(90deg,#d2a547,#47a977)}.profile{margin-top:auto;display:grid;grid-template-columns:34px 1fr;gap:8px;align-items:center;border-top:1px solid #345044;padding-top:18px}.profile>span{width:32px;height:32px;border-radius:50%;background:#dceade;color:#183427;display:grid;place-items:center;font-weight:700}.profile div{display:grid}.profile small{color:#a8bbb1;text-transform:capitalize}.profile button{grid-column:1/-1;border:0;color:#bdcec5;background:transparent;text-align:left;padding:8px 0}main{padding:40px;max-width:1600px;width:100%;margin:auto}.pageHeader{display:flex;justify-content:space-between;align-items:start;margin-bottom:28px;gap:18px}.pageHeader h1{font-family:Georgia,serif;font-size:36px;line-height:1.08;margin:7px 0}.pageHeader p{color:var(--muted);margin:0}.eyebrow{text-transform:uppercase;letter-spacing:.13em;color:var(--green);font-size:12px;font-weight:800}.lease{background:var(--green-soft);color:var(--green);border:1px solid #c9dfd3;padding:9px 13px;border-radius:20px;font-size:13px}.queueTabs{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px}.queueTabs button{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;text-align:left;display:grid;gap:4px;color:var(--ink)}.queueTabs button span{font-size:12px;color:var(--muted)}.queueTabs button.active{border-color:var(--green);box-shadow:inset 0 -3px var(--green)}.reviewGrid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:18px}.workCard,.decisionCard,.tableCard,.operationCard,.metric,.emptyState{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.cardHeader{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px}.cardHeader div{display:grid;grid-template-columns:auto 1fr;gap:2px 8px}.cardHeader small{grid-column:2;color:var(--muted)}.statusDot{width:9px;height:9px;border-radius:50%;background:#47a977;grid-row:1/3;align-self:center}.sampleType{font-size:12px;background:#f0f2ee;border-radius:20px;padding:6px 10px;height:max-content}.lineColumnHeader{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}.lineColumnHeader div{display:grid;gap:3px}.lineColumnHeader small{color:var(--muted);font-size:12px}.decisionCard{padding:20px;height:max-content}.decisionCard h2,.operationCard h2,.tableCard h2{margin:0 0 6px;font-family:Georgia,serif}.decisionCard p,.operationCard p{color:var(--muted);font-size:13px;margin:0 0 18px}.decisionCard label,.operationCard label,.loginCard label{display:grid;gap:6px;font-size:12px;font-weight:700;margin:12px 0}.decisionCard input,.decisionCard textarea,.operationCard input,.operationCard select,.loginCard input,.editorTextarea,.lineInput{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff;color:var(--ink)}.editorPanel{display:grid;grid-template-columns:1fr 1fr;min-height:580px}.editorColumn{padding:18px;min-width:0}.editorColumn+.editorColumn{border-left:1px solid var(--line)}.editableColumn{background:#fafbf9}.editorTextarea{width:100%;min-height:460px;resize:vertical;line-height:1.6}.referenceBlock{border:1px solid var(--line);border-radius:10px;background:#f6f8f5;padding:14px;white-space:pre-wrap;line-height:1.7;min-height:460px;overflow:auto}.lineEditorPanel{min-height:auto}.lineRowList{display:grid;gap:10px;max-height:640px;overflow:auto;padding-right:4px}.lineRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:10px;align-items:start;border:1px solid var(--line);border-radius:10px;background:#fff;padding:10px}.lineRow>span{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;background:#edf3ef;color:var(--green);font-size:11px;font-weight:700}.lineInput{width:100%;min-height:74px;resize:vertical;line-height:1.5}.referenceInline{border:1px solid var(--line);border-radius:10px;background:#f6f8f5;padding:10px;white-space:pre-wrap;line-height:1.6;min-height:74px}.deleteLine{padding:8px 10px;border-color:#ead0d0;color:var(--red);background:#fff7f7;font-size:12px}.statsStrip{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0 6px}.statsStrip div{border:1px solid var(--line);border-radius:10px;padding:12px;background:#f7f8f4;display:grid;gap:4px}.statsStrip span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.statsStrip strong{font-family:Georgia,serif;font-size:24px}.suggestionBlock{border:1px solid var(--line);border-radius:10px;background:#f7f8f4;padding:12px;margin:10px 0 14px}.suggestionBlock h3{margin:0 0 8px;font-size:14px}.chipRow,.entityList{display:flex;flex-wrap:wrap;gap:8px}.chipButton,.entityChip{border:1px solid #c9dfd3;border-radius:999px;padding:6px 10px;background:#fff;color:var(--green);font-size:12px}.chipButton{cursor:pointer}.syncNote{border:1px solid var(--line);border-radius:10px;background:#f7f8f4;padding:10px 12px;margin:10px 0 14px;font-size:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:18px}button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:10px 14px;color:var(--ink)}button.primary{background:var(--green);border-color:var(--green);color:#fff;font-weight:700}button.danger{color:var(--red);border-color:#e6caca}button:disabled{opacity:.55;cursor:not-allowed}.emptyState{text-align:center;padding:80px 20px}.emptyState div{width:56px;height:56px;border-radius:50%;background:var(--green-soft);color:var(--green);display:grid;place-items:center;margin:auto;font-size:24px}.emptyState h2{font-family:Georgia,serif;margin-bottom:5px}.emptyState p{color:var(--muted);margin-bottom:22px}.notice{position:fixed;top:18px;right:22px;z-index:5;background:#18372a;color:#fff;padding:12px 14px;border-radius:9px;box-shadow:var(--shadow);display:flex;gap:20px;align-items:center}.notice button{background:transparent;color:#d6e3dc;border:0;padding:0}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px}.metric{padding:20px;display:grid;gap:8px}.metric span{text-transform:capitalize;color:var(--muted)}.metric strong{font-size:32px;font-family:Georgia,serif}.tableCard{padding:22px;overflow:auto;margin-bottom:20px}table{border-collapse:collapse;width:100%;margin-top:16px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:13px 10px;font-size:13px;vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}td small{display:block;color:var(--muted);margin-top:3px}.adminGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.operationCard{padding:20px}.operationCard button{margin-top:10px}.result{white-space:pre-wrap;background:#102a20;color:#dce9e2;padding:18px;border-radius:10px;margin-top:20px;overflow:auto}.loginPage{min-height:100vh;display:grid;grid-template-columns:1.2fr .8fr;background:#102a20}.loginStory{color:#fff;padding:10vw;display:flex;flex-direction:column;justify-content:center;background:radial-gradient(circle at 20% 20%,#285c46 0,transparent 35%),#102a20}.loginStory .eyebrow{color:#d9b666}.loginStory h1{font:54px/1.05 Georgia,serif;max-width:680px;margin:18px 0}.loginStory p{color:#bed0c7;font-size:18px;max-width:540px}.teluguSample{font:27px/1.7 Georgia,serif;color:#e6cf98;margin-top:50px}.loginCard{background:#fff;margin:auto;width:min(420px,85%);border-radius:16px;padding:34px;box-shadow:var(--shadow)}.loginCard h2{font:32px Georgia,serif;margin:38px 0 5px}.loginCard p{color:var(--muted);margin:0 0 22px}.loginCard button{width:100%;margin-top:14px}.fieldError{background:#fff0f0;color:var(--red);padding:10px;border-radius:8px;font-size:13px}@media(max-width:1150px){.reviewGrid{grid-template-columns:1fr}.decisionCard{width:100%}.adminGrid{grid-template-columns:1fr}.loginPage{grid-template-columns:1fr}.loginStory{display:none}}@media(max-width:760px){.shell{display:block}.sidebar{position:static;height:auto;padding:16px}.sidebar nav{grid-template-columns:repeat(3,1fr)}.profile{display:none}main{padding:22px 14px}.queueTabs,.metrics,.statsStrip,.tokenPanelStats{grid-template-columns:1fr}.editorPanel{grid-template-columns:1fr}.editorColumn+.editorColumn{border-left:0;border-top:1px solid var(--line)}.pageHeader{display:grid}.pageHeader h1{font-size:30px}.lineRow{grid-template-columns:24px minmax(0,1fr)}.deleteLine{grid-column:2}}
frontend/app/page.tsx CHANGED
@@ -30,8 +30,17 @@ type Sample = {
30
  task_tags: string[];
31
  notes: string;
32
  token_stats?: { tokenizer_version: string; clean_char_count: number; clean_token_count: number };
 
 
 
 
 
 
 
 
33
  };
34
  source: { dataset_key: string; source_record_id?: string; source_title?: string; source_url?: string };
 
35
  lease?: { expires_at: string };
36
  };
37
 
@@ -120,7 +129,7 @@ export default function Home() {
120
  setBusy(true);
121
  setMessage("");
122
  try {
123
- const result = await api<{ action: string }>(`/api/review/${encodeURIComponent(sample.sample_id)}`, token, {
124
  method: "POST",
125
  body: JSON.stringify({
126
  action,
@@ -134,7 +143,8 @@ export default function Home() {
134
  confidence: editor.confidence,
135
  }),
136
  });
137
- setMessage(result.action === "edit" && action === "accept" ? "Changes were safely recorded as an edit." : `Review recorded: ${result.action}.`);
 
138
  setSample(null);
139
  await Promise.all([refreshOverview(token), claim(queue)]);
140
  } catch (error) {
@@ -201,6 +211,8 @@ function Login({ onLogin }: { onLogin: (token: string, user: User) => void }) {
201
 
202
  type EditorState = { cleaned: Record<string, unknown>; tags: string; qualityTags: string; taskTags: string; notes: string; reason: string; confidence: number };
203
 
 
 
204
  function ReviewWorkspace({ queue, queues, sample, busy, onQueue, onClaim, onSubmit }: { queue: string; queues: QueueCounts; sample: Sample | null; busy: boolean; onQueue: (q: string) => void; onClaim: () => void; onSubmit: (action: string, editor: EditorState) => void }) {
205
  return <><header className="pageHeader"><div><span className="eyebrow">Review workspace</span><h1>Make one clear decision at a time.</h1><p>Work through the audit queue, clean the text directly, and keep the token target moving.</p></div>{sample?.lease && <div className="lease">Reserved until {new Date(sample.lease.expires_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>}</header><div className="queueTabs">{QUEUES.map((item) => <button key={item.id} className={queue === item.id ? "active" : ""} onClick={() => onQueue(item.id)}><strong>{item.label}</strong><span>{queues[item.id]?.pending || 0} pending</span></button>)}</div>{sample ? <ReviewEditor key={sample.sample_id} sample={sample} busy={busy} onSubmit={onSubmit} /> : <div className="emptyState"><div>✓</div><h2>Ready for the next item?</h2><p>{QUEUES.find((q) => q.id === queue)?.description}</p><button className="primary" onClick={onClaim} disabled={busy}>{busy ? "Finding an item..." : "Claim next sample"}</button></div>}</>;
206
  }
@@ -218,28 +230,72 @@ function ReviewEditor({ sample, busy, onSubmit }: { sample: Sample; busy: boolea
218
  const cleanedText = reviewText(sample.sample_type, cleaned);
219
  const cleanChars = countChars(cleanedText);
220
  const lastTokens = sample.review.token_stats?.clean_token_count;
 
 
 
 
 
 
221
 
222
- return <div className="reviewGrid"><section className="workCard"><div className="cardHeader"><div><span className="statusDot"></span><strong>{sample.source.dataset_key}</strong><small>{sample.source.source_title || `Record ${sample.source.source_record_id || "unavailable"}`}</small></div><span className="sampleType">{sample.sample_type.replaceAll("_", " ")}</span></div><Editor sample={sample} cleaned={cleaned} setCleaned={setCleaned} /></section><aside className="decisionCard"><h2>Review decision</h2><p>Keep fixes fast. Reject obvious junk, edit when the cleanup is worthwhile, and use the token view to track progress.</p><div className="statsStrip"><div><span>Current chars</span><strong>{cleanChars.toLocaleString()}</strong></div><div><span>Last saved tokens</span><strong>{lastTokens?.toLocaleString() || "n/a"}</strong></div></div><label>Content tags<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="education, science" /></label><label>Quality tags<input value={qualityTags} onChange={(e) => setQualityTags(e.target.value)} placeholder="high_quality, needs_review" /></label><label>Task tags<input value={taskTags} onChange={(e) => setTaskTags(e.target.value)} placeholder="translation, reasoning" /></label><label>Notes<textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes for later filtering or export." /></label><label>Action reason<textarea rows={3} value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Required when rejecting. Useful for skip decisions too." /></label><label>Confidence <strong>{confidence}/5</strong><input type="range" min="1" max="5" value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} /></label><div className="actions"><button className="primary" disabled={busy} onClick={() => onSubmit("accept", state)}>Accept</button><button disabled={busy} onClick={() => onSubmit("edit", state)}>Save edit</button><button className="danger" disabled={busy} onClick={() => onSubmit("reject", state)}>Reject</button><button disabled={busy} onClick={() => onSubmit("skip", state)}>Skip</button></div></aside></div>;
223
  }
224
 
225
  function Editor({ sample, cleaned, setCleaned }: { sample: Sample; cleaned: Record<string, unknown>; setCleaned: (v: Record<string, unknown>) => void }) {
226
  if (sample.sample_type === "document") {
227
- return <DocumentEditor originalText={sample.payload.text || ""} currentText={String(cleaned.current_text || "")} onChange={(value) => setCleaned({ current_text: value })} />;
228
  }
229
  if (sample.sample_type === "translation_pair") {
230
  const pair = cleaned.current_pair as { source_text: string; target_text: string };
231
- return <PairEditor leftTitle={`Source text (${sample.payload.source_lang})`} rightTitle={`Target text (${sample.payload.target_lang})`} leftValue={pair.source_text} rightValue={pair.target_text} editableRight onChange={(leftValue, rightValue) => setCleaned({ current_pair: { source_text: leftValue, target_text: rightValue } })} />;
232
  }
233
  const pair = cleaned.current_transliteration as { native_text: string; latin_text: string };
234
- return <PairEditor leftTitle="Telugu script" rightTitle="Latin script" leftValue={pair.native_text} rightValue={pair.latin_text} editableRight onChange={(leftValue, rightValue) => setCleaned({ current_transliteration: { native_text: leftValue, latin_text: rightValue } })} />;
 
 
 
 
 
 
 
 
 
235
  }
236
 
237
- function DocumentEditor({ originalText, currentText, onChange }: { originalText: string; currentText: string; onChange: (value: string) => void }) {
238
- return <div className="editorPanel"><section className="editorColumn"><div className="lineColumnHeader"><div><strong>Original text</strong><small>Reference only</small></div></div><div className="referenceBlock">{originalText}</div></section><section className="editorColumn editableColumn"><div className="lineColumnHeader"><div><strong>Cleaned text</strong><small>Edit directly. Keep only what should survive export.</small></div></div><textarea className="editorTextarea" value={currentText} onChange={(e) => onChange(e.target.value)} rows={22} /></section></div>;
 
 
 
 
 
 
 
239
  }
240
 
241
- function PairEditor({ leftTitle, rightTitle, leftValue, rightValue, editableRight, onChange }: { leftTitle: string; rightTitle: string; leftValue: string; rightValue: string; editableRight: boolean; onChange: (leftValue: string, rightValue: string) => void }) {
242
- return <div className="editorPanel"><section className="editorColumn"><div className="lineColumnHeader"><div><strong>{leftTitle}</strong><small>Editable reference field</small></div></div><textarea className="editorTextarea" value={leftValue} onChange={(e) => onChange(e.target.value, rightValue)} rows={18} /></section><section className="editorColumn editableColumn"><div className="lineColumnHeader"><div><strong>{rightTitle}</strong><small>{editableRight ? "Edit directly." : "Reference only."}</small></div></div>{editableRight ? <textarea className="editorTextarea" value={rightValue} onChange={(e) => onChange(leftValue, e.target.value)} rows={18} /> : <div className="referenceBlock">{rightValue}</div>}</section></div>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
 
245
  function TokenProgressPanel({ tokenProgress, queue }: { tokenProgress: TokenProgress; queue: string }) {
 
30
  task_tags: string[];
31
  notes: string;
32
  token_stats?: { tokenizer_version: string; clean_char_count: number; clean_token_count: number };
33
+ ner?: {
34
+ model_version: string;
35
+ generated_at: string | null;
36
+ entities: Array<{ text: string; label: string; score: number }>;
37
+ suggested_tags: string[];
38
+ status: string;
39
+ error: string | null;
40
+ } | null;
41
  };
42
  source: { dataset_key: string; source_record_id?: string; source_title?: string; source_url?: string };
43
+ live_sync?: { status: string; repo_id?: string | null; path_in_repo?: string | null; last_error?: string | null };
44
  lease?: { expires_at: string };
45
  };
46
 
 
129
  setBusy(true);
130
  setMessage("");
131
  try {
132
+ const result = await api<{ action: string; live_sync?: { status: string; error?: string } | null }>(`/api/review/${encodeURIComponent(sample.sample_id)}`, token, {
133
  method: "POST",
134
  body: JSON.stringify({
135
  action,
 
143
  confidence: editor.confidence,
144
  }),
145
  });
146
+ const syncSuffix = result.live_sync?.status === "failed" ? " Hugging Face sync failed; MongoDB audit was saved." : result.live_sync?.status === "synced" ? " Saved to Hugging Face." : "";
147
+ setMessage((result.action === "edit" && action === "accept" ? "Changes were safely recorded as an edit." : `Review recorded: ${result.action}.`) + syncSuffix);
148
  setSample(null);
149
  await Promise.all([refreshOverview(token), claim(queue)]);
150
  } catch (error) {
 
211
 
212
  type EditorState = { cleaned: Record<string, unknown>; tags: string; qualityTags: string; taskTags: string; notes: string; reason: string; confidence: number };
213
 
214
+ type ReviewLine = { id: string; left: string; right: string };
215
+
216
  function ReviewWorkspace({ queue, queues, sample, busy, onQueue, onClaim, onSubmit }: { queue: string; queues: QueueCounts; sample: Sample | null; busy: boolean; onQueue: (q: string) => void; onClaim: () => void; onSubmit: (action: string, editor: EditorState) => void }) {
217
  return <><header className="pageHeader"><div><span className="eyebrow">Review workspace</span><h1>Make one clear decision at a time.</h1><p>Work through the audit queue, clean the text directly, and keep the token target moving.</p></div>{sample?.lease && <div className="lease">Reserved until {new Date(sample.lease.expires_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>}</header><div className="queueTabs">{QUEUES.map((item) => <button key={item.id} className={queue === item.id ? "active" : ""} onClick={() => onQueue(item.id)}><strong>{item.label}</strong><span>{queues[item.id]?.pending || 0} pending</span></button>)}</div>{sample ? <ReviewEditor key={sample.sample_id} sample={sample} busy={busy} onSubmit={onSubmit} /> : <div className="emptyState"><div>✓</div><h2>Ready for the next item?</h2><p>{QUEUES.find((q) => q.id === queue)?.description}</p><button className="primary" onClick={onClaim} disabled={busy}>{busy ? "Finding an item..." : "Claim next sample"}</button></div>}</>;
218
  }
 
230
  const cleanedText = reviewText(sample.sample_type, cleaned);
231
  const cleanChars = countChars(cleanedText);
232
  const lastTokens = sample.review.token_stats?.clean_token_count;
233
+ const ner = sample.review.ner;
234
+ const addSuggestedTag = (tag: string) => {
235
+ const current = new Set(csv(tags));
236
+ current.add(tag);
237
+ setTags(Array.from(current).join(", "));
238
+ };
239
 
240
+ return <div className="reviewGrid"><section className="workCard"><div className="cardHeader"><div><span className="statusDot"></span><strong>{sample.source.dataset_key}</strong><small>{sample.source.source_title || `Record ${sample.source.source_record_id || "unavailable"}`}</small></div><span className="sampleType">{sample.sample_type.replaceAll("_", " ")}</span></div><Editor sample={sample} cleaned={cleaned} setCleaned={setCleaned} /></section><aside className="decisionCard"><h2>Review decision</h2><p>Keep fixes fast. Reject obvious junk, edit when the cleanup is worthwhile, and use the token view to track progress.</p><div className="statsStrip"><div><span>Current chars</span><strong>{cleanChars.toLocaleString()}</strong></div><div><span>Last saved tokens</span><strong>{lastTokens?.toLocaleString() || "n/a"}</strong></div></div>{sample.live_sync?.status && <div className="syncNote"><strong>Live sync:</strong> {sample.live_sync.status}{sample.live_sync.last_error ? ` - ${sample.live_sync.last_error}` : ""}</div>}{ner && <section className="suggestionBlock"><h3>NER suggestions</h3><p>{ner.status === "ready" ? "Use suggestions as optional tags. Review decisions remain manual." : ner.error || "NER suggestions unavailable."}</p>{ner.status === "ready" && <><div className="chipRow">{ner.suggested_tags.map((tag) => <button type="button" key={tag} className="chipButton" onClick={() => addSuggestedTag(tag)}>{tag}</button>)}</div><div className="entityList">{ner.entities.slice(0, 12).map((entity) => <span key={`${entity.label}-${entity.text}`} className="entityChip">{entity.label}: {entity.text}</span>)}</div></>}</section>}<label>Content tags<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="education, science" /></label><label>Quality tags<input value={qualityTags} onChange={(e) => setQualityTags(e.target.value)} placeholder="high_quality, needs_review" /></label><label>Task tags<input value={taskTags} onChange={(e) => setTaskTags(e.target.value)} placeholder="translation, reasoning" /></label><label>Notes<textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes for later filtering or export." /></label><label>Action reason<textarea rows={3} value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Required when rejecting. Useful for skip decisions too." /></label><label>Confidence <strong>{confidence}/5</strong><input type="range" min="1" max="5" value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} /></label><div className="actions"><button className="primary" disabled={busy} onClick={() => onSubmit("accept", state)}>Accept</button><button disabled={busy} onClick={() => onSubmit("edit", state)}>Save edit</button><button className="danger" disabled={busy} onClick={() => onSubmit("reject", state)}>Reject</button><button disabled={busy} onClick={() => onSubmit("skip", state)}>Skip</button></div></aside></div>;
241
  }
242
 
243
  function Editor({ sample, cleaned, setCleaned }: { sample: Sample; cleaned: Record<string, unknown>; setCleaned: (v: Record<string, unknown>) => void }) {
244
  if (sample.sample_type === "document") {
245
+ return <LineEditor leftTitle="Original lines" rightTitle="Cleaned lines" leftText={sample.payload.text || ""} rightText={String(cleaned.current_text || "")} lockLeft onChange={(leftValue, rightValue) => setCleaned({ current_text: rightValue })} />;
246
  }
247
  if (sample.sample_type === "translation_pair") {
248
  const pair = cleaned.current_pair as { source_text: string; target_text: string };
249
+ return <LineEditor leftTitle={`Source lines (${sample.payload.source_lang})`} rightTitle={`Target lines (${sample.payload.target_lang})`} leftText={pair.source_text} rightText={pair.target_text} onChange={(leftValue, rightValue) => setCleaned({ current_pair: { source_text: leftValue, target_text: rightValue } })} />;
250
  }
251
  const pair = cleaned.current_transliteration as { native_text: string; latin_text: string };
252
+ return <LineEditor leftTitle="Telugu script lines" rightTitle="Latin script lines" leftText={pair.native_text} rightText={pair.latin_text} onChange={(leftValue, rightValue) => setCleaned({ current_transliteration: { native_text: leftValue, latin_text: rightValue } })} />;
253
+ }
254
+
255
+ function splitReviewLines(value: string) {
256
+ const normalized = value.replace(/\r\n?/g, "\n").trim();
257
+ if (!normalized) return [""];
258
+ return normalized
259
+ .split(/\n+|(?<=[.!?।॥])\s+/u)
260
+ .map((line) => line.trim())
261
+ .filter(Boolean);
262
  }
263
 
264
+ function buildLineRows(leftText: string, rightText: string) {
265
+ const leftLines = splitReviewLines(leftText);
266
+ const rightLines = splitReviewLines(rightText);
267
+ const size = Math.max(leftLines.length, rightLines.length, 1);
268
+ return Array.from({ length: size }, (_, index) => ({
269
+ id: `${index}-${leftLines[index] || ""}-${rightLines[index] || ""}`,
270
+ left: leftLines[index] || "",
271
+ right: rightLines[index] || "",
272
+ }));
273
  }
274
 
275
+ function LineEditor({ leftTitle, rightTitle, leftText, rightText, onChange, lockLeft = false }: { leftTitle: string; rightTitle: string; leftText: string; rightText: string; onChange: (leftValue: string, rightValue: string) => void; lockLeft?: boolean }) {
276
+ const [rows, setRows] = useState<ReviewLine[]>(() => buildLineRows(leftText, rightText));
277
+
278
+ const publish = (nextRows: ReviewLine[]) => {
279
+ setRows(nextRows);
280
+ onChange(
281
+ nextRows.map((row) => row.left).filter((value) => value.trim()).join("\n"),
282
+ nextRows.map((row) => row.right).filter((value) => value.trim()).join("\n"),
283
+ );
284
+ };
285
+
286
+ const updateRow = (index: number, side: "left" | "right", value: string) => {
287
+ const nextRows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, [side]: value } : row);
288
+ publish(nextRows);
289
+ };
290
+
291
+ const removeRow = (index: number) => {
292
+ const nextRows = rows.filter((_, rowIndex) => rowIndex !== index);
293
+ publish(nextRows.length ? nextRows : [{ id: "empty", left: "", right: "" }]);
294
+ };
295
+
296
+ const addRow = () => publish([...rows, { id: `new-${rows.length}`, left: "", right: "" }]);
297
+
298
+ return <div className="editorPanel lineEditorPanel"><section className="editorColumn"><div className="lineColumnHeader"><div><strong>{leftTitle}</strong><small>{lockLeft ? "Reference column" : "Editable column"}</small></div><button type="button" onClick={addRow}>Add row</button></div><div className="lineRowList">{rows.map((row, index) => <div className="lineRow" key={row.id}><span>{index + 1}</span>{lockLeft ? <div className="referenceInline">{row.left}</div> : <textarea className="lineInput" rows={2} value={row.left} onChange={(e) => updateRow(index, "left", e.target.value)} />}</div>)}</div></section><section className="editorColumn editableColumn"><div className="lineColumnHeader"><div><strong>{rightTitle}</strong><small>Inline edit and delete rows directly.</small></div></div><div className="lineRowList">{rows.map((row, index) => <div className="lineRow" key={row.id}><span>{index + 1}</span><textarea className="lineInput" rows={2} value={row.right} onChange={(e) => updateRow(index, "right", e.target.value)} /><button type="button" className="deleteLine" onClick={() => removeRow(index)}>Delete</button></div>)}</div></section></div>;
299
  }
300
 
301
  function TokenProgressPanel({ tokenProgress, queue }: { tokenProgress: TokenProgress; queue: string }) {
requirements-studio.txt CHANGED
@@ -2,6 +2,8 @@ datasets
2
  fastapi==0.115.12
3
  huggingface_hub
4
  sentencepiece
 
 
5
  pydantic==2.11.5
6
  pymongo
7
  python-dotenv
 
2
  fastapi==0.115.12
3
  huggingface_hub
4
  sentencepiece
5
+ torch
6
+ transformers
7
  pydantic==2.11.5
8
  pymongo
9
  python-dotenv