Spaces:
Running
Running
Sync from GitHub f548297062c1b82c3ebf9d03b3ebc9992ba65cf7
Browse files- cert_study_app/services/quiz_service.py +59 -2
- streamlit_app.py +92 -2
cert_study_app/services/quiz_service.py
CHANGED
|
@@ -132,6 +132,29 @@ def normalize_choice_answer(value: str, ordered: bool = False) -> str:
|
|
| 132 |
|
| 133 |
|
| 134 |
def yes_no_labels(value: str) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
text = str(value or "").strip().lower()
|
| 136 |
if not text:
|
| 137 |
return []
|
|
@@ -145,6 +168,28 @@ def yes_no_labels(value: str) -> list[str]:
|
|
| 145 |
return labels if len(labels) >= 2 else []
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def option_text(options: list[str], label: str) -> str:
|
| 149 |
label = option_label(label)
|
| 150 |
for index, option in enumerate(options, 1):
|
|
@@ -203,7 +248,19 @@ def effective_answer(question) -> str:
|
|
| 203 |
answer = question.answer or ""
|
| 204 |
question_type = (question.question_type or "").lower()
|
| 205 |
options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
analysis = visual_analysis(question)
|
| 208 |
statements = analysis.get("statements")
|
| 209 |
if isinstance(statements, list):
|
|
@@ -228,7 +285,7 @@ def effective_answer(question) -> str:
|
|
| 228 |
labels = option_labels_from_texts(options, selected, allow_duplicates=True)
|
| 229 |
if labels:
|
| 230 |
return ",".join(labels)
|
| 231 |
-
if
|
| 232 |
explanation_labels = yes_no_labels(question.explanation or "")
|
| 233 |
if explanation_labels:
|
| 234 |
return ",".join(explanation_labels)
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
def yes_no_labels(value: str) -> list[str]:
|
| 135 |
+
if isinstance(value, (list, dict)):
|
| 136 |
+
raw_value = value
|
| 137 |
+
else:
|
| 138 |
+
raw_value = None
|
| 139 |
+
try:
|
| 140 |
+
raw_value = json.loads(value) if isinstance(value, str) else None
|
| 141 |
+
except Exception:
|
| 142 |
+
raw_value = None
|
| 143 |
+
|
| 144 |
+
if isinstance(raw_value, list):
|
| 145 |
+
values = [
|
| 146 |
+
item.get("value") or item.get("answer") or item.get("selected_answer")
|
| 147 |
+
for item in raw_value
|
| 148 |
+
if isinstance(item, dict)
|
| 149 |
+
]
|
| 150 |
+
labels = yes_no_labels(",".join(str(item) for item in values if item))
|
| 151 |
+
if labels:
|
| 152 |
+
return labels
|
| 153 |
+
elif isinstance(raw_value, dict):
|
| 154 |
+
labels = yes_no_labels(",".join(str(item) for item in raw_value.values()))
|
| 155 |
+
if labels:
|
| 156 |
+
return labels
|
| 157 |
+
|
| 158 |
text = str(value or "").strip().lower()
|
| 159 |
if not text:
|
| 160 |
return []
|
|
|
|
| 168 |
return labels if len(labels) >= 2 else []
|
| 169 |
|
| 170 |
|
| 171 |
+
def structured_answer_values(value) -> list[str]:
|
| 172 |
+
if isinstance(value, (list, dict)):
|
| 173 |
+
raw_value = value
|
| 174 |
+
else:
|
| 175 |
+
try:
|
| 176 |
+
raw_value = json.loads(value) if isinstance(value, str) else None
|
| 177 |
+
except Exception:
|
| 178 |
+
raw_value = None
|
| 179 |
+
|
| 180 |
+
if isinstance(raw_value, list):
|
| 181 |
+
values = [
|
| 182 |
+
item.get("value") or item.get("answer") or item.get("selected_answer")
|
| 183 |
+
for item in raw_value
|
| 184 |
+
if isinstance(item, dict)
|
| 185 |
+
]
|
| 186 |
+
elif isinstance(raw_value, dict):
|
| 187 |
+
values = list(raw_value.values())
|
| 188 |
+
else:
|
| 189 |
+
return []
|
| 190 |
+
return [str(item).strip() for item in values if str(item).strip()]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
def option_text(options: list[str], label: str) -> str:
|
| 194 |
label = option_label(label)
|
| 195 |
for index, option in enumerate(options, 1):
|
|
|
|
| 248 |
answer = question.answer or ""
|
| 249 |
question_type = (question.question_type or "").lower()
|
| 250 |
options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
|
| 251 |
+
structured_values = structured_answer_values(answer)
|
| 252 |
+
if structured_values:
|
| 253 |
+
structured_yn = yes_no_labels(",".join(structured_values))
|
| 254 |
+
return ",".join(structured_yn or structured_values)
|
| 255 |
+
|
| 256 |
+
is_structured_visual = any(
|
| 257 |
+
keyword in question_type
|
| 258 |
+
for keyword in ["hotspot", "table_choice", "matching", "ordering", "yes_no", "true/false"]
|
| 259 |
+
) and "in-context" not in question_type
|
| 260 |
+
if is_structured_visual:
|
| 261 |
+
answer_labels = yes_no_labels(answer)
|
| 262 |
+
if answer_labels and "true/false" in question_type:
|
| 263 |
+
return ",".join(answer_labels)
|
| 264 |
analysis = visual_analysis(question)
|
| 265 |
statements = analysis.get("statements")
|
| 266 |
if isinstance(statements, list):
|
|
|
|
| 285 |
labels = option_labels_from_texts(options, selected, allow_duplicates=True)
|
| 286 |
if labels:
|
| 287 |
return ",".join(labels)
|
| 288 |
+
if is_structured_visual and not yes_no_labels(answer):
|
| 289 |
explanation_labels = yes_no_labels(question.explanation or "")
|
| 290 |
if explanation_labels:
|
| 291 |
return ",".join(explanation_labels)
|
streamlit_app.py
CHANGED
|
@@ -680,6 +680,29 @@ def box_choice_labels(question, count: int) -> list[str]:
|
|
| 680 |
|
| 681 |
|
| 682 |
def yes_no_answer_labels(value: str) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
text = str(value or "")
|
| 684 |
if re.fullmatch(r"\s*[YNyn](?:\s*,\s*[YNyn])+\s*", text):
|
| 685 |
return [token.upper() for token in re.findall(r"[YNyn]", text)]
|
|
@@ -691,8 +714,12 @@ def yes_no_answer_labels(value: str) -> list[str]:
|
|
| 691 |
|
| 692 |
def is_yes_no_hotspot(question) -> bool:
|
| 693 |
question_type = (question.get("question_type") or "").lower()
|
| 694 |
-
if
|
|
|
|
|
|
|
| 695 |
return False
|
|
|
|
|
|
|
| 696 |
text = " ".join(
|
| 697 |
[
|
| 698 |
question.get("question") or "",
|
|
@@ -706,6 +733,65 @@ def is_yes_no_hotspot(question) -> bool:
|
|
| 706 |
)
|
| 707 |
|
| 708 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
def visual_statements(question) -> list[dict]:
|
| 710 |
try:
|
| 711 |
analysis = json.loads(question.get("visual_analysis_json") or "{}")
|
|
@@ -761,12 +847,16 @@ def render_answer_input(question, key_prefix):
|
|
| 761 |
if statement_rows:
|
| 762 |
return render_yes_no_matrix(question, f"{key_prefix}_statements", statement_rows)
|
| 763 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
if is_yes_no_hotspot(question):
|
| 765 |
answer_count = len(yes_no_answer_labels(question.get("answer") or "")) or len(
|
| 766 |
yes_no_answer_labels(question.get("explanation") or "")
|
| 767 |
)
|
| 768 |
row_count = max(3, answer_count, len(statement_rows))
|
| 769 |
-
rows = yes_no_lines(question.get("question") or "")
|
| 770 |
if statement_rows:
|
| 771 |
rows = statement_rows
|
| 772 |
if len(rows) < row_count:
|
|
|
|
| 680 |
|
| 681 |
|
| 682 |
def yes_no_answer_labels(value: str) -> list[str]:
|
| 683 |
+
if isinstance(value, (list, dict)):
|
| 684 |
+
raw_value = value
|
| 685 |
+
else:
|
| 686 |
+
raw_value = None
|
| 687 |
+
try:
|
| 688 |
+
raw_value = json.loads(value) if isinstance(value, str) else None
|
| 689 |
+
except Exception:
|
| 690 |
+
raw_value = None
|
| 691 |
+
|
| 692 |
+
if isinstance(raw_value, list):
|
| 693 |
+
values = [
|
| 694 |
+
item.get("value") or item.get("answer") or item.get("selected_answer")
|
| 695 |
+
for item in raw_value
|
| 696 |
+
if isinstance(item, dict)
|
| 697 |
+
]
|
| 698 |
+
labels = yes_no_answer_labels(",".join(str(item) for item in values if item))
|
| 699 |
+
if labels:
|
| 700 |
+
return labels
|
| 701 |
+
elif isinstance(raw_value, dict):
|
| 702 |
+
labels = yes_no_answer_labels(",".join(str(item) for item in raw_value.values()))
|
| 703 |
+
if labels:
|
| 704 |
+
return labels
|
| 705 |
+
|
| 706 |
text = str(value or "")
|
| 707 |
if re.fullmatch(r"\s*[YNyn](?:\s*,\s*[YNyn])+\s*", text):
|
| 708 |
return [token.upper() for token in re.findall(r"[YNyn]", text)]
|
|
|
|
| 714 |
|
| 715 |
def is_yes_no_hotspot(question) -> bool:
|
| 716 |
question_type = (question.get("question_type") or "").lower()
|
| 717 |
+
if "in-context" in question_type:
|
| 718 |
+
return False
|
| 719 |
+
if not any(keyword in question_type for keyword in ["hotspot", "table_choice", "yes_no", "true/false"]):
|
| 720 |
return False
|
| 721 |
+
if "true/false" in question_type:
|
| 722 |
+
return True
|
| 723 |
text = " ".join(
|
| 724 |
[
|
| 725 |
question.get("question") or "",
|
|
|
|
| 733 |
)
|
| 734 |
|
| 735 |
|
| 736 |
+
def statement_option_rows(options: list[str], expected_count: int) -> list[str]:
|
| 737 |
+
rows = []
|
| 738 |
+
for option in options or []:
|
| 739 |
+
text = str(option or "").strip()
|
| 740 |
+
if not text:
|
| 741 |
+
continue
|
| 742 |
+
cleaned = re.sub(r"^\s*(?:[0-9]+|[A-Z])[-.)]\s*", "", text).strip()
|
| 743 |
+
if re.fullmatch(r"μ|μλμ€|μλμ|yes|no", cleaned, re.I):
|
| 744 |
+
continue
|
| 745 |
+
rows.append(cleaned)
|
| 746 |
+
if expected_count and len(rows) >= expected_count:
|
| 747 |
+
return rows[:expected_count]
|
| 748 |
+
return rows
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def grouped_option_rows(options: list[str]) -> list[dict]:
|
| 752 |
+
grouped = {}
|
| 753 |
+
order = []
|
| 754 |
+
for option in options or []:
|
| 755 |
+
text = str(option or "").strip()
|
| 756 |
+
match = re.match(r"^\s*(\d+)-([A-Z])[\.)]?\s*(.+)$", text, re.I)
|
| 757 |
+
if not match:
|
| 758 |
+
continue
|
| 759 |
+
group_key = match.group(1)
|
| 760 |
+
body = match.group(3).strip()
|
| 761 |
+
row_label = f"νλͺ© {group_key}"
|
| 762 |
+
value = body
|
| 763 |
+
if ":" in body:
|
| 764 |
+
row_label, value = [part.strip() for part in body.split(":", 1)]
|
| 765 |
+
if group_key not in grouped:
|
| 766 |
+
grouped[group_key] = {"label": row_label, "options": []}
|
| 767 |
+
order.append(group_key)
|
| 768 |
+
grouped[group_key]["options"].append(value)
|
| 769 |
+
rows = [grouped[key] for key in order]
|
| 770 |
+
return rows if len(rows) >= 2 and all(row["options"] for row in rows) else []
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
def render_grouped_option_selects(question, key_prefix, rows):
|
| 774 |
+
selections = []
|
| 775 |
+
all_yes_no = all(
|
| 776 |
+
all(str(option).strip().lower() in {"yes", "no", "μ", "μλμ€", "μλμ"} for option in row["options"])
|
| 777 |
+
for row in rows
|
| 778 |
+
)
|
| 779 |
+
st.markdown("#### νλͺ©λ³ λ΅μ")
|
| 780 |
+
for index, row in enumerate(rows, 1):
|
| 781 |
+
options = [str(option).strip() for option in row["options"] if str(option).strip()]
|
| 782 |
+
selected = st.selectbox(
|
| 783 |
+
row["label"] or f"νλͺ© {index}",
|
| 784 |
+
["μ ν μ ν¨"] + options,
|
| 785 |
+
key=f"{key_prefix}_grouped_options_{question['id']}_{index}",
|
| 786 |
+
)
|
| 787 |
+
if selected != "μ ν μ ν¨":
|
| 788 |
+
if all_yes_no:
|
| 789 |
+
selections.append("Y" if selected.lower() in {"yes", "μ"} else "N")
|
| 790 |
+
else:
|
| 791 |
+
selections.append(selected)
|
| 792 |
+
return ",".join(selections) if len(selections) == len(rows) else None
|
| 793 |
+
|
| 794 |
+
|
| 795 |
def visual_statements(question) -> list[dict]:
|
| 796 |
try:
|
| 797 |
analysis = json.loads(question.get("visual_analysis_json") or "{}")
|
|
|
|
| 847 |
if statement_rows:
|
| 848 |
return render_yes_no_matrix(question, f"{key_prefix}_statements", statement_rows)
|
| 849 |
|
| 850 |
+
grouped_rows = grouped_option_rows(options)
|
| 851 |
+
if grouped_rows:
|
| 852 |
+
return render_grouped_option_selects(question, key_prefix, grouped_rows)
|
| 853 |
+
|
| 854 |
if is_yes_no_hotspot(question):
|
| 855 |
answer_count = len(yes_no_answer_labels(question.get("answer") or "")) or len(
|
| 856 |
yes_no_answer_labels(question.get("explanation") or "")
|
| 857 |
)
|
| 858 |
row_count = max(3, answer_count, len(statement_rows))
|
| 859 |
+
rows = statement_option_rows(options, row_count) or yes_no_lines(question.get("question") or "")
|
| 860 |
if statement_rows:
|
| 861 |
rows = statement_rows
|
| 862 |
if len(rows) < row_count:
|