Spaces:
Sleeping
Sleeping
| from pathlib import Path | |
| import gradio as gr | |
| import sbti | |
| IMAGE_DIR = Path(__file__).resolve().parent / "image" | |
| ALL_QUESTIONS = sbti.QUESTIONS + sbti.SPECIAL_QUESTIONS | |
| TOTAL_QUESTIONS = len(ALL_QUESTIONS) | |
| QUESTION_SECTIONS = [ | |
| ("自我模型", sbti.QUESTIONS[0:6]), | |
| ("情感模型", sbti.QUESTIONS[6:12]), | |
| ("态度模型", sbti.QUESTIONS[12:18]), | |
| ("行动模型", sbti.QUESTIONS[18:24]), | |
| ("社交模型", sbti.QUESTIONS[24:30]), | |
| ("特殊触发", sbti.SPECIAL_QUESTIONS), | |
| ] | |
| LEVEL_LABELS = {"L": "低", "M": "中", "H": "高"} | |
| QUESTION_SECTION_BY_ID = { | |
| question["id"]: section_title | |
| for section_title, questions in QUESTION_SECTIONS | |
| for question in questions | |
| } | |
| def resolve_image_path(code: str) -> str: | |
| extension = ".jpg" if code in {"Dior-s", "JOKE-R"} else ".png" | |
| candidate = IMAGE_DIR / f"{code}{extension}" | |
| if candidate.exists(): | |
| return str(candidate) | |
| if code == "WOC!": | |
| fallback = IMAGE_DIR / "WOC!.png" | |
| if fallback.exists(): | |
| return str(fallback) | |
| fallback = IMAGE_DIR / "WOC.png" | |
| if fallback.exists(): | |
| return str(fallback) | |
| raise FileNotFoundError(f"Missing image asset for type: {code}") | |
| def normalize_answers(answers): | |
| if not isinstance(answers, list) or len(answers) != TOTAL_QUESTIONS: | |
| return [None] * TOTAL_QUESTIONS | |
| return answers | |
| def answered_count(answers): | |
| return sum(value is not None for value in answers) | |
| def get_question(index: int) -> dict: | |
| return ALL_QUESTIONS[index] | |
| def get_section_name(question: dict) -> str: | |
| return QUESTION_SECTION_BY_ID.get(question["id"], "未分类") | |
| def render_progress(index: int, answers: list) -> str: | |
| question = get_question(index) | |
| done = answered_count(answers) | |
| progress = round(done / TOTAL_QUESTIONS * 100) | |
| return f""" | |
| <section class="progress-card"> | |
| <div class="progress-top"> | |
| <span>{get_section_name(question)}</span> | |
| <span>第 {index + 1} / {TOTAL_QUESTIONS} 题</span> | |
| </div> | |
| <div class="progress-bar"><span style="width: {progress}%;"></span></div> | |
| <p>已完成 {done} / {TOTAL_QUESTIONS} 题</p> | |
| </section> | |
| """ | |
| def render_question_text(index: int) -> str: | |
| question = get_question(index) | |
| return f""" | |
| <section class="question-card"> | |
| <div class="question-kicker">{get_section_name(question)}</div> | |
| <h2>第 {index + 1} 题</h2> | |
| <p>{question['text']}</p> | |
| </section> | |
| """ | |
| def render_result_card(result: dict) -> str: | |
| final_type = result["final_type"] | |
| secondary = result.get("secondary_type") | |
| secondary_html = "" | |
| if secondary: | |
| secondary_html = ( | |
| "<div class='sub-card'>" | |
| "<span>常规人格参考匹配</span>" | |
| f"<strong>{secondary['code']} / {secondary['cn']}</strong>" | |
| f"<p>相似度 {secondary['similarity']}%,精确命中 {secondary['exact']}/15。</p>" | |
| "</div>" | |
| ) | |
| return f""" | |
| <section class="result-card"> | |
| <div class="eyebrow">{result['mode_kicker']}</div> | |
| <h2>{final_type['code']} / {final_type['cn']}</h2> | |
| <p class="intro">{final_type['intro']}</p> | |
| <div class="badge">{result['badge']}</div> | |
| <p class="desc">{final_type['desc']}</p> | |
| <p class="sub">{result['sub']}</p> | |
| {secondary_html} | |
| </section> | |
| """ | |
| def render_dimension_markdown(result: dict) -> str: | |
| lines = [ | |
| "### 15 维度解析", | |
| "", | |
| "| 维度 | 等级 | 解读 |", | |
| "| --- | --- | --- |", | |
| ] | |
| for dim in sbti.DIMENSION_ORDER: | |
| meta = sbti.DIMENSION_META[dim] | |
| level = result["levels"][dim] | |
| explanation = sbti.DIM_EXPLANATIONS[dim][level] | |
| lines.append(f"| {meta['name']} | {LEVEL_LABELS[level]} | {explanation} |") | |
| return "\n".join(lines) | |
| def build_missing_error(answers: list) -> tuple[int | None, str]: | |
| missing = [] | |
| for index, (question, value) in enumerate(zip(ALL_QUESTIONS, answers)): | |
| if value is None: | |
| section = get_section_name(question) | |
| missing.append((index, f"{index + 1}. [{section}] {question['text']}")) | |
| if not missing: | |
| return None, "" | |
| preview = "\n".join(f"- {item}" for _, item in missing[:8]) | |
| suffix = "" | |
| if len(missing) > 8: | |
| suffix = f"\n- 另外还有 {len(missing) - 8} 道题未完成。" | |
| message = f"你还有 {len(missing)} 道题未作答,请先完成以下题目:\n{preview}{suffix}" | |
| return missing[0][0], message | |
| def build_question_updates(index: int, answers: list, validation_message: str = ""): | |
| answers = normalize_answers(answers) | |
| question = get_question(index) | |
| choices = [(option["label"], option["value"]) for option in question["options"]] | |
| prev_update = gr.update(interactive=index > 0) | |
| next_update = gr.update( | |
| value="保存并下一题" if index < TOTAL_QUESTIONS - 1 else "已是最后一题", | |
| interactive=index < TOTAL_QUESTIONS - 1, | |
| ) | |
| validation_update = gr.update( | |
| value=validation_message, | |
| visible=bool(validation_message), | |
| ) | |
| radio_update = gr.update( | |
| choices=choices, | |
| value=answers[index], | |
| ) | |
| return ( | |
| index, | |
| answers, | |
| render_progress(index, answers), | |
| render_question_text(index), | |
| radio_update, | |
| validation_update, | |
| prev_update, | |
| next_update, | |
| ) | |
| def persist_current_answer(answers: list, index: int, selected_value): | |
| answers = normalize_answers(list(answers)) | |
| if selected_value is not None: | |
| answers[index] = int(selected_value) | |
| return answers | |
| def go_prev(selected_value, index, answers): | |
| answers = persist_current_answer(answers, index, selected_value) | |
| new_index = max(0, index - 1) | |
| return build_question_updates(new_index, answers) | |
| def go_next(selected_value, index, answers): | |
| answers = normalize_answers(list(answers)) | |
| if selected_value is None: | |
| return build_question_updates(index, answers, f"第 {index + 1} 题还没有作答。") | |
| answers[index] = int(selected_value) | |
| new_index = min(TOTAL_QUESTIONS - 1, index + 1) | |
| return build_question_updates(new_index, answers) | |
| def submit_quiz(selected_value, index, answers): | |
| answers = persist_current_answer(answers, index, selected_value) | |
| first_missing, error_message = build_missing_error(answers) | |
| if first_missing is not None: | |
| question_updates = build_question_updates(first_missing, answers, error_message) | |
| return ( | |
| *question_updates, | |
| gr.update(value="", visible=False), | |
| gr.update(value="", visible=False), | |
| gr.update(value=None, visible=False), | |
| ) | |
| answer_map = { | |
| question["id"]: int(value) | |
| for question, value in zip(ALL_QUESTIONS, answers) | |
| if value is not None | |
| } | |
| result = sbti.compute_result(answer_map) | |
| image_path = resolve_image_path(result["final_type"]["code"]) | |
| question_updates = build_question_updates(index, answers) | |
| return ( | |
| *question_updates, | |
| gr.update(value=render_result_card(result), visible=True), | |
| gr.update(value=render_dimension_markdown(result), visible=True), | |
| gr.update(value=image_path, visible=True), | |
| ) | |
| def reset_all(): | |
| answers = [None] * TOTAL_QUESTIONS | |
| question_updates = build_question_updates(0, answers) | |
| return ( | |
| *question_updates, | |
| gr.update(value="", visible=False), | |
| gr.update(value="", visible=False), | |
| gr.update(value=None, visible=False), | |
| ) | |
| INITIAL_ANSWERS = [None] * TOTAL_QUESTIONS | |
| INITIAL_QUESTION = get_question(0) | |
| INITIAL_CHOICES = [(option["label"], option["value"]) for option in INITIAL_QUESTION["options"]] | |
| CSS = """ | |
| :root { | |
| --paper: rgba(255, 251, 245, 0.96); | |
| --paper-strong: #fffdf9; | |
| --ink: #20150f; | |
| --muted: #553c2f; | |
| --accent: #8a401b; | |
| --accent-strong: #6f2f12; | |
| --line: rgba(50, 29, 16, 0.14); | |
| } | |
| body, .gradio-container { | |
| background: | |
| radial-gradient(circle at top, rgba(218, 136, 72, 0.22), transparent 30%), | |
| linear-gradient(180deg, #f7efe3 0%, #f2dfc6 100%); | |
| color: var(--ink); | |
| font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", serif; | |
| } | |
| .block-wrap, .panel { | |
| border-radius: 24px; | |
| } | |
| .hero, | |
| .progress-card, | |
| .question-card, | |
| .validation-box, | |
| .result-panel, | |
| .dimension-panel, | |
| .image-panel { | |
| border: 1px solid var(--line); | |
| background: var(--paper-strong); | |
| box-shadow: 0 12px 32px rgba(108, 69, 36, 0.08); | |
| } | |
| .hero { | |
| padding: 28px 30px; | |
| border-radius: 24px; | |
| background: linear-gradient(135deg, rgba(255, 252, 246, 0.96), rgba(250, 241, 228, 0.94)); | |
| } | |
| .hero h1 { | |
| margin: 0 0 8px; | |
| font-size: 2.6rem; | |
| color: #5a2a15; | |
| } | |
| .hero p { | |
| margin: 0; | |
| color: var(--muted); | |
| line-height: 1.7; | |
| font-size: 1.05rem; | |
| } | |
| .progress-card { | |
| padding: 16px 18px; | |
| border-radius: 18px; | |
| } | |
| .progress-top { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| font-weight: 700; | |
| color: var(--accent-strong); | |
| font-size: 0.98rem; | |
| } | |
| .progress-card p { | |
| margin: 10px 0 0; | |
| color: var(--muted); | |
| } | |
| .progress-bar { | |
| margin-top: 12px; | |
| height: 10px; | |
| border-radius: 999px; | |
| background: #efdfcf; | |
| overflow: hidden; | |
| } | |
| .progress-bar span { | |
| display: block; | |
| height: 100%; | |
| border-radius: inherit; | |
| background: linear-gradient(135deg, #8a401b, #c56a34); | |
| } | |
| .question-card { | |
| padding: 20px 22px; | |
| border-radius: 22px; | |
| } | |
| .question-kicker { | |
| display: inline-block; | |
| margin-bottom: 10px; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: #f3e0cc; | |
| color: #6f2f12; | |
| font-size: 0.9rem; | |
| font-weight: 700; | |
| } | |
| .question-card h2 { | |
| margin: 0 0 10px; | |
| color: #5a2a15; | |
| font-size: 1.4rem; | |
| } | |
| .question-card p { | |
| margin: 0; | |
| color: #2d1b12; | |
| font-size: 1.1rem; | |
| line-height: 1.75; | |
| } | |
| .question-radio { | |
| margin: 4px 0 2px; | |
| padding: 6px 0 0; | |
| } | |
| .question-radio fieldset { | |
| gap: 10px; | |
| } | |
| .question-radio .wrap, | |
| .question-radio .wrap label, | |
| .question-radio fieldset label { | |
| background: #fffaf3 !important; | |
| border: 1px solid #b98a6b !important; | |
| border-radius: 14px !important; | |
| box-shadow: 0 4px 12px rgba(108, 69, 36, 0.06) !important; | |
| } | |
| .question-radio .wrap label, | |
| .question-radio fieldset label { | |
| padding: 10px 16px !important; | |
| } | |
| .question-radio .wrap label span, | |
| .question-radio .wrap label p, | |
| .question-radio .wrap label div, | |
| .question-radio fieldset label span, | |
| .question-radio fieldset label p, | |
| .question-radio fieldset label div { | |
| color: #2f190f !important; | |
| font-weight: 700 !important; | |
| opacity: 1 !important; | |
| } | |
| .question-radio .wrap label input[type="radio"], | |
| .question-radio fieldset label input[type="radio"] { | |
| accent-color: #8a401b !important; | |
| } | |
| .question-radio .wrap label:has(input:checked), | |
| .question-radio fieldset label:has(input:checked) { | |
| background: #7a3416 !important; | |
| border: 1px solid #7a3416 !important; | |
| box-shadow: 0 8px 18px rgba(122, 52, 22, 0.28) !important; | |
| } | |
| .question-radio .wrap label:has(input:checked) span, | |
| .question-radio .wrap label:has(input:checked) p, | |
| .question-radio .wrap label:has(input:checked) div, | |
| .question-radio fieldset label:has(input:checked) span, | |
| .question-radio fieldset label:has(input:checked) p, | |
| .question-radio fieldset label:has(input:checked) div { | |
| color: #ffffff !important; | |
| } | |
| .question-radio .wrap label:has(input:checked) input[type="radio"], | |
| .question-radio fieldset label:has(input:checked) input[type="radio"] { | |
| accent-color: #ffffff !important; | |
| } | |
| .validation-box { | |
| padding: 14px 18px; | |
| border-radius: 18px; | |
| background: #fff5ef; | |
| } | |
| .validation-box, | |
| .validation-box *, | |
| .validation-box .prose, | |
| .validation-box .prose * { | |
| color: #8a2e14 !important; | |
| font-weight: 700 !important; | |
| } | |
| .primary-btn button, | |
| .secondary-btn button, | |
| .ghost-btn button { | |
| border-radius: 999px !important; | |
| font-weight: 700 !important; | |
| } | |
| .primary-btn button { | |
| background: linear-gradient(135deg, #8a401b, #b25826) !important; | |
| color: #fffaf6 !important; | |
| } | |
| .secondary-btn button { | |
| background: var(--paper-strong) !important; | |
| color: var(--accent-strong) !important; | |
| border: 1px solid rgba(111, 47, 18, 0.18) !important; | |
| } | |
| .ghost-btn button { | |
| background: #efe1d3 !important; | |
| color: #5a2a15 !important; | |
| border: 1px solid rgba(111, 47, 18, 0.12) !important; | |
| } | |
| .result-card { | |
| padding: 24px; | |
| border-radius: 24px; | |
| background: linear-gradient(135deg, #2f1f17 0%, #6d3322 55%, #c56a34 100%); | |
| color: #fff7ef; | |
| box-shadow: 0 22px 50px rgba(96, 42, 24, 0.28); | |
| } | |
| .result-card h2 { | |
| margin: 8px 0 10px; | |
| font-size: 2rem; | |
| } | |
| .eyebrow { | |
| display: inline-block; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.16); | |
| font-size: 0.9rem; | |
| } | |
| .badge { | |
| display: inline-block; | |
| margin: 8px 0 14px; | |
| padding: 8px 12px; | |
| border-radius: 12px; | |
| background: rgba(240, 179, 95, 0.22); | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| } | |
| .intro { | |
| margin: 0 0 8px; | |
| font-size: 1.05rem; | |
| } | |
| .desc, .sub { | |
| line-height: 1.8; | |
| } | |
| .sub-card { | |
| margin-top: 18px; | |
| padding: 14px 16px; | |
| border-radius: 16px; | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .sub-card span { | |
| display: block; | |
| font-size: 0.9rem; | |
| opacity: 0.85; | |
| } | |
| .dimension-panel { | |
| padding: 12px 18px; | |
| border-radius: 20px; | |
| color: #2d1b12 !important; | |
| } | |
| .dimension-panel, | |
| .dimension-panel *, | |
| .dimension-panel .prose, | |
| .dimension-panel .prose * { | |
| color: #2d1b12 !important; | |
| } | |
| .dimension-panel h1, | |
| .dimension-panel h2, | |
| .dimension-panel h3, | |
| .dimension-panel h4 { | |
| color: #6f2f12 !important; | |
| font-weight: 800 !important; | |
| } | |
| .dimension-panel table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| background: #fffaf4 !important; | |
| color: #2d1b12 !important; | |
| } | |
| .dimension-panel th { | |
| background: #f3e0cc !important; | |
| color: #6f2f12 !important; | |
| font-weight: 800 !important; | |
| } | |
| .dimension-panel td { | |
| background: #fffdf9 !important; | |
| color: #2d1b12 !important; | |
| } | |
| .dimension-panel th, | |
| .dimension-panel td { | |
| border: 1px solid rgba(111, 47, 18, 0.16) !important; | |
| } | |
| .image-panel { | |
| border-radius: 20px; | |
| } | |
| .image-panel img { | |
| border-radius: 18px; | |
| } | |
| @media (max-width: 768px) { | |
| .hero { | |
| padding: 20px 18px; | |
| } | |
| .hero h1 { | |
| font-size: 2rem; | |
| } | |
| .question-card { | |
| padding: 18px 16px; | |
| } | |
| .question-card h2 { | |
| font-size: 1.2rem; | |
| } | |
| .question-card p { | |
| font-size: 1rem; | |
| } | |
| } | |
| """ | |
| with gr.Blocks(css=CSS, title="SBTI 人格测试") as demo: | |
| current_index = gr.State(0) | |
| answers_state = gr.State(INITIAL_ANSWERS) | |
| gr.HTML( | |
| """ | |
| <section class="hero"> | |
| <h1>SBTI 人格测试</h1> | |
| <p> | |
| 已针对手机端做轻量化优化:改为逐题作答,减少首屏渲染压力,提高打开和交互速度。 | |
| </p> | |
| </section> | |
| """ | |
| ) | |
| progress_html = gr.HTML(render_progress(0, INITIAL_ANSWERS)) | |
| question_html = gr.HTML(render_question_text(0)) | |
| answer_radio = gr.Radio( | |
| choices=INITIAL_CHOICES, | |
| value=None, | |
| type="value", | |
| show_label=False, | |
| elem_classes="question-radio", | |
| ) | |
| validation_md = gr.Markdown(visible=False, elem_classes="validation-box") | |
| with gr.Row(): | |
| prev_btn = gr.Button("上一题", variant="secondary", size="lg", elem_classes="secondary-btn", interactive=False) | |
| next_btn = gr.Button("保存并下一题", variant="secondary", size="lg", elem_classes="ghost-btn") | |
| submit_btn = gr.Button("生成结果", variant="primary", size="lg", elem_classes="primary-btn") | |
| reset_btn = gr.Button("重新开始", variant="secondary", size="lg", elem_classes="secondary-btn") | |
| result_html = gr.HTML(visible=False, elem_classes="result-panel") | |
| dimension_md = gr.Markdown(visible=False, elem_classes="dimension-panel") | |
| result_image = gr.Image(label="结果图片", type="filepath", interactive=False, visible=False, elem_classes="image-panel") | |
| prev_btn.click( | |
| fn=go_prev, | |
| inputs=[answer_radio, current_index, answers_state], | |
| outputs=[current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn], | |
| queue=False, | |
| ) | |
| next_btn.click( | |
| fn=go_next, | |
| inputs=[answer_radio, current_index, answers_state], | |
| outputs=[current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn], | |
| queue=False, | |
| ) | |
| submit_btn.click( | |
| fn=submit_quiz, | |
| inputs=[answer_radio, current_index, answers_state], | |
| outputs=[ | |
| current_index, | |
| answers_state, | |
| progress_html, | |
| question_html, | |
| answer_radio, | |
| validation_md, | |
| prev_btn, | |
| next_btn, | |
| result_html, | |
| dimension_md, | |
| result_image, | |
| ], | |
| ) | |
| reset_btn.click( | |
| fn=reset_all, | |
| outputs=[ | |
| current_index, | |
| answers_state, | |
| progress_html, | |
| question_html, | |
| answer_radio, | |
| validation_md, | |
| prev_btn, | |
| next_btn, | |
| result_html, | |
| dimension_md, | |
| result_image, | |
| ], | |
| queue=False, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |