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}")