Spaces:
Running
Running
Deploy Rachana Data Studio
Browse files- data_studio/api.py +4 -4
- data_studio/config.py +5 -0
- data_studio/importer.py +60 -50
- data_studio/live_review_sync.py +51 -0
- data_studio/ner.py +66 -0
- data_studio/review.py +72 -2
- frontend/app/globals.css +1 -0
- frontend/app/page.tsx +66 -10
- requirements-studio.txt +2 -0
data_studio/api.py
CHANGED
|
@@ -19,7 +19,7 @@ from data_studio.review import (
|
|
| 19 |
ReviewConflict,
|
| 20 |
ReviewValidationError,
|
| 21 |
apply_review_action,
|
| 22 |
-
|
| 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":
|
| 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,
|
| 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
|
| 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 |
-
"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
| 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 |
-
"
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
"
|
| 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 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 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 |
-
"
|
| 192 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 <
|
| 228 |
}
|
| 229 |
if (sample.sample_type === "translation_pair") {
|
| 230 |
const pair = cleaned.current_pair as { source_text: string; target_text: string };
|
| 231 |
-
return <
|
| 232 |
}
|
| 233 |
const pair = cleaned.current_transliteration as { native_text: string; latin_text: string };
|
| 234 |
-
return <
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
-
function
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
|
| 241 |
-
function
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|