Spaces:
Running
Running
| import streamlit as st | |
| import json | |
| from io import StringIO | |
| import requests | |
| from PyPDF2 import PdfReader | |
| from datetime import datetime, timezone, timedelta | |
| st.set_page_config(page_title="資料上傳與檢查工具", layout="wide") | |
| BACKEND_URL = st.secrets.get("BACKEND_URL", None) | |
| st.title("🌟 協助貢獻繁體中文資料") | |
| st.markdown("專案路徑: [tw-sharegpt](https://huggingface.co/datasets/lianghsun/tw-sharegpt)") | |
| st.markdown(""" | |
| 歡迎加入我們,一起建立高品質的 **繁體中文語言資料集**!你提供的每一份資料,都能幫助未來的繁中模型更準確、更理解本地語境。我們非常感謝你的協助,你的貢獻將直接推動繁體中文 AI 生態的發展!🌱 | |
| """) | |
| st.info("⚠️ 請勿上傳真實個資或敏感商業資料。") | |
| # ---- 本次上傳的共同設定(兩個 tab 共用) ---- | |
| st.markdown("### 本次上傳設定") | |
| contributor_email = st.text_input("聯絡 email(選填)", placeholder="example@email.com") | |
| share_permission = st.checkbox( | |
| "我同意將本次上傳的資料,未來在去識別化後以開源形式提供研究與模型訓練使用。", | |
| value=True, | |
| ) | |
| # 產生 UTC+8 的上傳時間(每次互動當下) | |
| tz_utc8 = timezone(timedelta(hours=8)) | |
| uploaded_at = datetime.now(tz_utc8).isoformat() | |
| tab_jsonl, tab_pdf = st.tabs(["對話資料 (.jsonl)", "預訓練 PDF"]) | |
| # ---------- Tab 1: JSONL ---------- | |
| # ---------- Tab 1: JSONL ---------- | |
| with tab_jsonl: | |
| st.subheader("上傳對話資料") | |
| sample_prompt = """ | |
| 請將我們上述對話的內容(但不包含本問題),整理成 OpenAI Messages Format,輸出格式必須是 .jsonl。 | |
| 格式要求: | |
| - 每一行是一個獨立的 JSON 物件。 | |
| - 每個 JSON 物件必須包含一個 messages 欄位。 | |
| - 不要在檔案中加入註解或說明文字,每一行只能是 JSON。 | |
| 範例(僅供格式參考): | |
| [{"messages": [ | |
| {"role": "system", "content": "你是一個友善的客服人員。"}, | |
| {"role": "user", "content": "請問我要如何申請退貨?"}, | |
| {"role": "assistant", "content": "您好,若您要申請退貨,請先登入會員中心,在「訂單管理」中選擇欲退貨的訂單,點選「申請退貨」,依指示填寫原因並送出。"}, {"role": "user", "content": "..."}, | |
| {"role": "assistant", "content": "..."},] | |
| 請依照以上規格輸出一筆 .jsonl 對話資料(保持為一列,好讓我可以方便貼上),並用 markdown 表示。 | |
| """ | |
| st.markdown("##### 請將以下的 prompt 貼到你的對話生成模型中,產生符合格式的對話資料:") | |
| st.code(sample_prompt, language="markdown") | |
| st.markdown("#### 選擇輸入方式") | |
| input_mode = st.radio( | |
| "選擇要如何提供 `.jsonl` 內容", | |
| ["上傳檔案", "貼上文字"], | |
| horizontal=True, | |
| ) | |
| # 共用的檢查函式:給「檔案模式」和「貼上模式」共用 | |
| def validate_jsonl_lines(lines): | |
| """lines: list[str] (每一行一個 JSON) → 回傳 (parsed_objs, errors)""" | |
| parsed = [] | |
| errors = [] | |
| allowed_roles = {"system", "user", "assistant"} | |
| for idx, line in enumerate(lines, start=1): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| # 如果使用者是從 ChatGPT 貼出來的,有可能含 ``` 之類的標記,先跳過 | |
| if line.startswith("```") and line.endswith("```"): | |
| continue | |
| if line.startswith("```") or line == "```": | |
| continue | |
| try: | |
| obj = json.loads(line) | |
| except json.JSONDecodeError as e: | |
| errors.append(f"第 {idx} 行不是合法 JSON:{e}") | |
| continue | |
| if "messages" not in obj or not isinstance(obj["messages"], list): | |
| errors.append(f"第 {idx} 行缺少 messages 欄位或型態錯誤。") | |
| continue | |
| for m_idx, msg in enumerate(obj["messages"]): | |
| if not isinstance(msg, dict): | |
| errors.append(f"第 {idx} 行第 {m_idx+1} 則訊息不是物件。") | |
| continue | |
| role = msg.get("role") | |
| msg_content = msg.get("content") | |
| if role not in allowed_roles: | |
| errors.append(f"第 {idx} 行第 {m_idx+1} 則 role 非預期:{role}") | |
| if not isinstance(msg_content, str): | |
| errors.append(f"第 {idx} 行第 {m_idx+1} 則 content 需為字串。") | |
| parsed.append(obj) | |
| return parsed, errors | |
| # ---------- 模式 A:上傳檔案 ---------- | |
| if input_mode == "上傳檔案": | |
| jsonl_file = st.file_uploader( | |
| "上傳對話資料 `.jsonl` 檔", | |
| type=["jsonl"], | |
| accept_multiple_files=False | |
| ) | |
| file_jsonl_valid = False | |
| file_parsed_lines = [] | |
| if jsonl_file is not None: | |
| st.markdown("#### 檔案檢查結果") | |
| content = jsonl_file.read().decode("utf-8") | |
| lines = content.splitlines() | |
| file_parsed_lines, errors = validate_jsonl_lines(lines) | |
| if errors: | |
| st.error("格式檢查失敗,請修正後重新上傳:") | |
| for e in errors[:20]: | |
| st.write("- " + e) | |
| if len(errors) > 20: | |
| st.write(f"... 還有 {len(errors) - 20} 筆錯誤未顯示") | |
| st.info("若多次調整仍無法通過檢查,建議先在本地編輯好 `.jsonl` 檔,再重新上傳。") | |
| else: | |
| file_jsonl_valid = True | |
| st.success(f"檢查通過!共 {len(file_parsed_lines)} 筆對話。") | |
| st.markdown("#### 範例預覽(前 2 筆)") | |
| for i, obj in enumerate(file_parsed_lines[:2], start=1): | |
| st.json(obj) | |
| if st.button("上傳對話資料(檔案)", disabled=not (BACKEND_URL and file_jsonl_valid)): | |
| if BACKEND_URL is None: | |
| st.warning("尚未設定 BACKEND_URL,無法實際送出,請在 `st.secrets` 中配置。") | |
| else: | |
| with st.spinner("正在上傳對話資料並檢查,請稍候…"): | |
| meta = { | |
| "uploaded_at": uploaded_at, # UTC+8 ISO 字串 | |
| "contributor_email": contributor_email if contributor_email.strip() else None, | |
| "share_permission": bool(share_permission), | |
| } | |
| enriched_lines = [] | |
| for obj in file_parsed_lines: | |
| obj_with_meta = {**obj, "metadata": meta} | |
| enriched_lines.append(json.dumps(obj_with_meta, ensure_ascii=False)) | |
| payload = "\n".join(enriched_lines).encode("utf-8") | |
| files = {"file": ("contrib.jsonl", payload, "application/jsonl")} | |
| try: | |
| resp = requests.post(f"{BACKEND_URL}/upload-jsonl", files=files) | |
| if resp.ok: | |
| st.success("已成功送交後端伺服器,等待後端進一步檢查與處理。") | |
| else: | |
| st.error(f"後端回傳錯誤:{resp.status_code} {resp.text}") | |
| except Exception as e: | |
| st.error(f"送出時發生錯誤:{e}") | |
| # ---------- 模式 B:貼上文字 ---------- | |
| else: | |
| st.markdown("請將 `.jsonl` 內容貼在下方,每一行必須是一個 JSON 物件:") | |
| pasted_text = st.text_area( | |
| "貼上 `.jsonl` 內容", | |
| placeholder='例如:\n{"messages": [...]}', | |
| height=240, | |
| ) | |
| pasted_jsonl_valid = False | |
| pasted_parsed_lines = [] | |
| if pasted_text.strip(): | |
| st.markdown("#### 貼上內容檢查結果") | |
| lines = pasted_text.splitlines() | |
| pasted_parsed_lines, errors = validate_jsonl_lines(lines) | |
| if errors: | |
| st.error("格式檢查失敗,請依錯誤訊息調整貼上的內容:") | |
| for e in errors[:20]: | |
| st.write("- " + e) | |
| if len(errors) > 20: | |
| st.write(f"... 還有 {len(errors) - 20} 筆錯誤未顯示") | |
| st.info("若多次調整仍無法通過檢查,建議先在本地編輯好 `.jsonl` 檔案,再使用「上傳檔案」模式上傳。") | |
| else: | |
| pasted_jsonl_valid = True | |
| st.success(f"檢查通過!共 {len(pasted_parsed_lines)} 筆對話。") | |
| st.markdown("#### 範例預覽(前 2 筆)") | |
| for i, obj in enumerate(pasted_parsed_lines[:2], start=1): | |
| st.json(obj) | |
| if st.button("上傳對話資料(貼上內容)", disabled=not (BACKEND_URL and pasted_jsonl_valid)): | |
| if BACKEND_URL is None: | |
| st.warning("尚未設定 BACKEND_URL,無法實際送出,請在 `st.secrets` 中配置。") | |
| else: | |
| with st.spinner("正在上傳貼上內容並檢查,請稍候…"): | |
| meta = { | |
| "uploaded_at": uploaded_at, # UTC+8 ISO 字串 | |
| "contributor_email": contributor_email if contributor_email.strip() else None, | |
| "share_permission": bool(share_permission), | |
| } | |
| enriched_lines = [] | |
| for obj in pasted_parsed_lines: | |
| obj_with_meta = {**obj, "metadata": meta} | |
| enriched_lines.append(json.dumps(obj_with_meta, ensure_ascii=False)) | |
| payload = "\n".join(enriched_lines).encode("utf-8") | |
| files = {"file": ("contrib_pasted.jsonl", payload, "application/jsonl")} | |
| try: | |
| resp = requests.post(f"{BACKEND_URL}/upload-jsonl", files=files) | |
| if resp.ok: | |
| st.success("已成功送交後端伺服器,等待後端進一步檢查與處理。") | |
| else: | |
| st.error(f"後端回傳錯誤:{resp.status_code} {resp.text}") | |
| except Exception as e: | |
| st.error(f"送出時發生錯誤:{e}") | |
| # ---------- Tab 2: PDF ---------- | |
| with tab_pdf: | |
| st.subheader("上傳預訓練 PDF(純文字型)") | |
| st.markdown(""" | |
| **格式說明** | |
| - 檔案副檔名:`.pdf` | |
| - 內容須為可擷取文字的 PDF(非掃描圖片)。 | |
| - 系統會抽樣頁面檢查是否能讀取到足夠文字內容。 | |
| """) | |
| pdf_files = st.file_uploader( | |
| "上傳一個或多個 PDF 檔", | |
| type=["pdf"], | |
| accept_multiple_files=True | |
| ) | |
| pdf_results = [] | |
| if pdf_files: | |
| for pdf in pdf_files: | |
| st.markdown(f"#### 檢查檔案:`{pdf.name}`") | |
| try: | |
| reader = PdfReader(pdf) | |
| num_pages = len(reader.pages) | |
| sample_pages = [0, 2, 4] # 第 1,3,5 頁(若存在) | |
| text_snippets = [] | |
| for p in sample_pages: | |
| if p < num_pages: | |
| page = reader.pages[p] | |
| text = page.extract_text() or "" | |
| text_snippets.append(text) | |
| total_text = "".join(text_snippets) | |
| text_len = len(total_text) | |
| if text_len < 100: | |
| st.warning(f"未擷取到足夠文字內容(抽樣字數 {text_len})。此 PDF 可能是掃描型,建議先做 OCR。") | |
| else: | |
| st.success(f"檢查通過!頁數:{num_pages},抽樣字數:{text_len}") | |
| st.markdown("**文字預覽(前 300 字)**") | |
| st.text(total_text[:300]) | |
| pdf_results.append((pdf, text_len)) | |
| except Exception as e: | |
| st.error(f"讀取 PDF 時發生錯誤:{e}") | |
| any_valid_pdf = any(tlen >= 100 for _, tlen in pdf_results) if pdf_results else False | |
| if st.button("上傳 PDF 檔案", disabled=not (pdf_files and any_valid_pdf or BACKEND_URL is None)): | |
| if BACKEND_URL is None: | |
| st.warning("尚未設定 BACKEND_URL,無法實際送出,請在 `st.secrets` 中配置。") | |
| else: | |
| if not pdf_results: | |
| st.warning("沒有可上傳的 PDF 檔案。") | |
| else: | |
| with st.spinner("正在上傳 PDF 並進行檢查,請稍候…"): | |
| files = [] | |
| for pdf, text_len in pdf_results: | |
| if text_len < 100: | |
| continue # 跳過疑似掃描檔 | |
| pdf.seek(0) | |
| files.append(("files", (pdf.name, pdf.getvalue(), "application/pdf"))) | |
| if not files: | |
| st.warning("沒有通過文字檢查的 PDF 檔案可送出。") | |
| else: | |
| # PDF 部分的 metadata 用 form data 一起送出,讓後端可以記錄 | |
| meta = { | |
| "uploaded_at": uploaded_at, | |
| "contributor_email": contributor_email if contributor_email.strip() else "", | |
| "share_permission": json.dumps(bool(share_permission)), | |
| } | |
| try: | |
| resp = requests.post(f"{BACKEND_URL}/upload-pdf", files=files, data=meta) | |
| if resp.ok: | |
| st.success("已成功送交後端伺服器,等待後端進一步檢查與處理。") | |
| else: | |
| st.error(f"後端回傳錯誤:{resp.status_code} {resp.text}") | |
| except Exception as e: | |
| st.error(f"送出時發生錯誤:{e}") |