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"""
{get_section_name(question)}
第 {index + 1} / {TOTAL_QUESTIONS} 题
已完成 {done} / {TOTAL_QUESTIONS} 题
"""
def render_question_text(index: int) -> str:
question = get_question(index)
return f"""
{get_section_name(question)}
第 {index + 1} 题
{question['text']}
"""
def render_result_card(result: dict) -> str:
final_type = result["final_type"]
secondary = result.get("secondary_type")
secondary_html = ""
if secondary:
secondary_html = (
"
"
"
常规人格参考匹配"
f"
{secondary['code']} / {secondary['cn']}"
f"
相似度 {secondary['similarity']}%,精确命中 {secondary['exact']}/15。
"
"
"
)
return f"""
{result['mode_kicker']}
{final_type['code']} / {final_type['cn']}
{final_type['intro']}
{result['badge']}
{final_type['desc']}
{result['sub']}
{secondary_html}
"""
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(
"""
SBTI 人格测试
已针对手机端做轻量化优化:改为逐题作答,减少首屏渲染压力,提高打开和交互速度。
"""
)
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)