short_answer_de/en/se Felder + Distractor-Wörter in Fragen-Sidebar
- Directus: questions.short_answer_de/en/se Text-Felder angelegt - Backend: short_answer_de beim Erstellen speichern - Backend: get_object_questions_list gibt short_answer_de + distractor_words zurück - Frontend: Sidebar zeigt Kurzantwort (blau) + Ablenker-Chips pro Frage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
40
app.py
40
app.py
@@ -937,7 +937,7 @@ def _find_or_create_word(title_de: str, level: int, token: str):
|
|||||||
|
|
||||||
|
|
||||||
def _find_or_create_question(question_de: str, answer_de: str, level: int,
|
def _find_or_create_question(question_de: str, answer_de: str, level: int,
|
||||||
short_answer_id, obj_id: str, token: str):
|
short_answer_id, short_answer_de: str, obj_id: str, token: str):
|
||||||
"""Return (question_id, is_new). Creates question with status=draft if missing."""
|
"""Return (question_id, is_new). Creates question with status=draft if missing."""
|
||||||
enc = urllib.parse.quote(question_de, safe="")
|
enc = urllib.parse.quote(question_de, safe="")
|
||||||
data, status = _directus(
|
data, status = _directus(
|
||||||
@@ -957,6 +957,8 @@ def _find_or_create_question(question_de: str, answer_de: str, level: int,
|
|||||||
}
|
}
|
||||||
if short_answer_id:
|
if short_answer_id:
|
||||||
body["short_answer"] = short_answer_id
|
body["short_answer"] = short_answer_id
|
||||||
|
if short_answer_de:
|
||||||
|
body["short_answer_de"] = short_answer_de
|
||||||
|
|
||||||
data, status = _directus("POST", "/items/questions", token, body)
|
data, status = _directus("POST", "/items/questions", token, body)
|
||||||
if status in (200, 201):
|
if status in (200, 201):
|
||||||
@@ -1098,7 +1100,7 @@ def generate_questions(obj_id: str):
|
|||||||
|
|
||||||
# Frage anlegen / verknüpfen
|
# Frage anlegen / verknüpfen
|
||||||
try:
|
try:
|
||||||
q_id, q_is_new = _find_or_create_question(q_de, a_de, level, short_answer_id, obj_id, token)
|
q_id, q_is_new = _find_or_create_question(q_de, a_de, level, short_answer_id, short_text, obj_id, token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[generate_questions] question error level {level}: {e}")
|
print(f"[generate_questions] question error level {level}: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1180,17 +1182,47 @@ def publish_questions(obj_id: str):
|
|||||||
|
|
||||||
@app.route("/api/object/<obj_id>/questions", methods=["GET"])
|
@app.route("/api/object/<obj_id>/questions", methods=["GET"])
|
||||||
def get_object_questions_list(obj_id: str):
|
def get_object_questions_list(obj_id: str):
|
||||||
"""Gibt alle verknüpften Fragen eines Objekts zurück (2-Schritt-Query)."""
|
"""Gibt alle verknüpften Fragen eines Objekts zurück (mit short_answer_de + distractor_words)."""
|
||||||
token = request.headers.get("Authorization", "")
|
token = request.headers.get("Authorization", "")
|
||||||
|
|
||||||
|
# Schritt 1: Frage-IDs aus Junction
|
||||||
junc, _ = _directus("GET",
|
junc, _ = _directus("GET",
|
||||||
f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200", token)
|
f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200", token)
|
||||||
q_ids = [e["questions_id"] for e in (junc.get("data") or []) if e.get("questions_id")]
|
q_ids = [e["questions_id"] for e in (junc.get("data") or []) if e.get("questions_id")]
|
||||||
if not q_ids:
|
if not q_ids:
|
||||||
return jsonify({"data": []})
|
return jsonify({"data": []})
|
||||||
|
|
||||||
|
# Schritt 2: Fragen laden (inkl. short_answer_de)
|
||||||
ids_param = urllib.parse.quote(",".join(q_ids), safe="")
|
ids_param = urllib.parse.quote(",".join(q_ids), safe="")
|
||||||
q_data, _ = _directus("GET",
|
q_data, _ = _directus("GET",
|
||||||
f"/items/questions?filter[id][_in]={ids_param}&fields=id,question_de,answer_de,level,status&limit=200", token)
|
f"/items/questions?filter[id][_in]={ids_param}&fields=id,question_de,answer_de,short_answer_de,level,status&limit=200", token)
|
||||||
items = sorted(q_data.get("data") or [], key=lambda x: x.get("level") or 0)
|
items = sorted(q_data.get("data") or [], key=lambda x: x.get("level") or 0)
|
||||||
|
|
||||||
|
# Schritt 3: Distractor-Wörter pro Frage (Bulk)
|
||||||
|
dw_junc, _ = _directus("GET",
|
||||||
|
f"/items/questions_distractor_words?filter[questions_id][_in]={ids_param}&fields=questions_id,words_id&limit=5000", token)
|
||||||
|
dw_entries = dw_junc.get("data") or []
|
||||||
|
|
||||||
|
# Wort-IDs sammeln und Titel laden
|
||||||
|
all_word_ids = list({e["words_id"] for e in dw_entries if e.get("words_id")})
|
||||||
|
word_title_map: dict[str, str] = {}
|
||||||
|
if all_word_ids:
|
||||||
|
wids_param = urllib.parse.quote(",".join(all_word_ids), safe="")
|
||||||
|
w_data, _ = _directus("GET",
|
||||||
|
f"/items/words?filter[id][_in]={wids_param}&fields=id,title_de&limit=5000", token)
|
||||||
|
word_title_map = {w["id"]: w["title_de"] for w in (w_data.get("data") or [])}
|
||||||
|
|
||||||
|
# Distractor-Wörter je Frage gruppieren
|
||||||
|
dw_by_question: dict[str, list[str]] = {}
|
||||||
|
for e in dw_entries:
|
||||||
|
qid = e.get("questions_id")
|
||||||
|
wid = e.get("words_id")
|
||||||
|
if qid and wid and wid in word_title_map:
|
||||||
|
dw_by_question.setdefault(qid, []).append(word_title_map[wid])
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item["distractor_words"] = dw_by_question.get(item["id"], [])
|
||||||
|
|
||||||
return jsonify({"data": items})
|
return jsonify({"data": items})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ export interface ObjectQuestion {
|
|||||||
id: string
|
id: string
|
||||||
question_de: string
|
question_de: string
|
||||||
answer_de: string
|
answer_de: string
|
||||||
|
short_answer_de: string | null
|
||||||
|
distractor_words: string[]
|
||||||
level: number
|
level: number
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export default function GenerateIt() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Questions sidebar */}
|
{/* Questions sidebar */}
|
||||||
<aside className="sidebar sidebar--right" style={{ width: 280, minWidth: 220 }}>
|
<aside className="sidebar sidebar--right" style={{ width: 300, minWidth: 240 }}>
|
||||||
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div className="sidebar-panel" style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">
|
||||||
Fragen
|
Fragen
|
||||||
@@ -473,20 +473,43 @@ export default function GenerateIt() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||||
{questions.map(q => (
|
{questions.map(q => (
|
||||||
<div key={q.id} style={{ padding: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
<div key={q.id} style={{ padding: '7px 10px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 4 }}>
|
{/* Header: Level + Status + Delete */}
|
||||||
<span style={{ fontWeight: 600, color: 'var(--muted)', fontSize: 10, flexShrink: 0 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
|
||||||
|
<span style={{ fontWeight: 700, color: 'var(--muted)', fontSize: 10 }}>
|
||||||
L{q.level}
|
L{q.level}
|
||||||
{q.status === 'published' && <span style={{ marginLeft: 4, color: 'var(--success)' }}>↑</span>}
|
{q.status === 'published' && <span style={{ marginLeft: 4, color: 'var(--success)' }}>↑</span>}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteQuestion(q.id)}
|
onClick={() => handleDeleteQuestion(q.id)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, lineHeight: 1, color: 'var(--muted)', fontSize: 11, flexShrink: 0 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--muted)', fontSize: 11 }}
|
||||||
title="Frage löschen"
|
title="Frage löschen"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 2, color: 'var(--fg)' }}>{q.question_de}</div>
|
{/* Frage */}
|
||||||
<div style={{ marginTop: 2, color: 'var(--muted)', fontStyle: 'italic' }}>{q.answer_de}</div>
|
<div style={{ color: 'var(--fg)', marginBottom: 2 }}>{q.question_de}</div>
|
||||||
|
{/* Antwort */}
|
||||||
|
<div style={{ color: 'var(--muted)', fontStyle: 'italic', marginBottom: q.short_answer_de || q.distractor_words?.length ? 4 : 0 }}>{q.answer_de}</div>
|
||||||
|
{/* Short Answer */}
|
||||||
|
{q.short_answer_de && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 3 }}>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>Kurz:</span>
|
||||||
|
<span style={{ background: 'var(--primary-muted, #e8f0fe)', color: 'var(--primary)', borderRadius: 4, padding: '1px 6px', fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{q.short_answer_de}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Distractor Words */}
|
||||||
|
{q.distractor_words?.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--muted)', alignSelf: 'center', flexShrink: 0 }}>Ablenker:</span>
|
||||||
|
{q.distractor_words.map(dw => (
|
||||||
|
<span key={dw} style={{ background: 'var(--bg-muted, #f5f5f5)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 5px', fontSize: 10 }}>
|
||||||
|
{dw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user