Compare commits

..

2 commits
v4 ... master

Author SHA1 Message Date
Daniel
e7f91e5e5b fix: scope quiz logging and bot status 2026-06-06 00:01:44 +02:00
Daniel
95de56d81b Improve quiz note and navigation layout
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m33s
2026-05-14 17:30:08 +02:00
5 changed files with 76 additions and 17 deletions

View file

@ -37,7 +37,7 @@ services:
- chroma_data:/app/chroma_data
networks:
- default
- speech_net
- danvics_speech
depends_on:
postgres:
condition: service_healthy
@ -74,7 +74,7 @@ services:
env_file:
- ./telegram-bot/.env
environment:
- DATABASE_URL=postgresql://pedquiz:<password>@postgres:5432/pedquiz
- DATABASE_URL=postgresql://pedquiz:${POSTGRES_PASSWORD}@postgres:5432/pedquiz
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
depends_on:
postgres:
@ -147,5 +147,5 @@ volumes:
promtail_positions:
networks:
speech_net:
danvics_speech:
external: true

View file

@ -30,6 +30,7 @@ export default function MyNote({ variant = 'tab' }) {
const loadedRef = useRef(false)
const dragRef = useRef(null)
const draggedRef = useRef(false)
const textareaRef = useRef(null)
const loadNote = async () => {
if (loadedRef.current) return
@ -107,6 +108,18 @@ export default function MyNote({ variant = 'tab' }) {
return () => clearTimeout(saveRef.current)
}, [content])
useEffect(() => {
if (!open || loading) return
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
const end = textarea.value.length
textarea.focus()
textarea.setSelectionRange(end, end)
textarea.scrollTop = textarea.scrollHeight
})
}, [open, loading])
const openNote = () => {
setOpen(true)
loadNote()
@ -184,6 +197,7 @@ export default function MyNote({ variant = 'tab' }) {
</div>
{error && <div className="mynote-error">{error}</div>}
<textarea
ref={textareaRef}
value={content}
onChange={e => setContent(e.target.value)}
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}

View file

@ -974,12 +974,12 @@ const timerStarted = timeLeft !== null
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{quizNavigation('top')}
{/* Two-column layout: content + desktop sidebar */}
<div className="quiz-layout">
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{quizNavigation('top')}
{current && (
<div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,

View file

@ -13,10 +13,14 @@ scrape_configs:
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
filters:
- name: label
values: ["com.docker.compose.project=quiz"]
relabel_configs:
# Keep only quiz-* containers
- source_labels: ['__meta_docker_container_name']
regex: '.*quiz.*'
# Keep only this compose project; the Docker API filter above prevents
# Promtail from opening non-readable log streams from other stacks.
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
regex: 'quiz'
action: keep
# Extract container name as label
- source_labels: ['__meta_docker_container_name']

View file

@ -24,7 +24,7 @@ DATABASE_URL = os.environ["DATABASE_URL"]
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
MAX_EXPLANATION_CHARS = int(os.getenv("MAX_EXPLANATION_CHARS", "1800"))
TELEGRAM_MESSAGE_LIMIT = 4096
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
@ -269,15 +269,56 @@ def truncate_text(value: str, limit: int) -> str:
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
def answer_feedback(question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str:
def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str:
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
explanation = truncate_text(question.get("explanation") or "No explanation available.", MAX_EXPLANATION_CHARS)
return "\n".join([
"Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}.",
"",
f"<b>Explanation:</b> {html.escape(explanation)}",
])
return "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}."
def split_plain_text(text: str, limit: int) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n\n", 0, limit)
if split_at < limit // 2:
split_at = remaining.rfind("\n", 0, limit)
if split_at < limit // 2:
split_at = remaining.rfind(" ", 0, limit)
if split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at].rstrip())
remaining = remaining[split_at:].lstrip()
return chunks
async def send_answer_feedback(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
question: dict[str, Any],
chosen_idx: int,
correct_idx: int,
ok: bool,
) -> None:
header = answer_feedback_header(chosen_idx, correct_idx, ok)
explanation = (question.get("explanation") or "No explanation available.").strip()
first_prefix = f"{header}\n\n<b>Explanation:</b> "
next_prefix = "<b>Explanation continued:</b> "
first_limit = TELEGRAM_MESSAGE_LIMIT - len(first_prefix) - 200
next_limit = TELEGRAM_MESSAGE_LIMIT - len(next_prefix) - 200
chunks = split_plain_text(explanation, max(1000, first_limit))
for idx, chunk in enumerate(chunks):
prefix = first_prefix if idx == 0 else next_prefix
if idx > 0 and len(chunk) > next_limit:
# First chunk has a smaller budget because it includes answer feedback.
for subchunk in split_plain_text(chunk, next_limit):
await context.bot.send_message(chat_id, f"{next_prefix}{html.escape(subchunk)}", parse_mode=ParseMode.HTML)
continue
await context.bot.send_message(chat_id, f"{prefix}{html.escape(chunk)}", parse_mode=ParseMode.HTML)
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
@ -527,7 +568,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -
answered_question_text(state, question, chosen_idx, correct_idx),
parse_mode=ParseMode.HTML,
)
await context.bot.send_message(chat_id, answer_feedback(question, chosen_idx, correct_idx, ok), parse_mode=ParseMode.HTML)
await send_answer_feedback(context, chat_id, question, chosen_idx, correct_idx, ok)
else:
await query.edit_message_reply_markup(reply_markup=None)
state.index += 1