Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f91e5e5b | ||
|
|
95de56d81b |
5 changed files with 76 additions and 17 deletions
|
|
@ -37,7 +37,7 @@ services:
|
||||||
- chroma_data:/app/chroma_data
|
- chroma_data:/app/chroma_data
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- speech_net
|
- danvics_speech
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -74,7 +74,7 @@ services:
|
||||||
env_file:
|
env_file:
|
||||||
- ./telegram-bot/.env
|
- ./telegram-bot/.env
|
||||||
environment:
|
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}
|
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|
@ -147,5 +147,5 @@ volumes:
|
||||||
promtail_positions:
|
promtail_positions:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
speech_net:
|
danvics_speech:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
const loadedRef = useRef(false)
|
const loadedRef = useRef(false)
|
||||||
const dragRef = useRef(null)
|
const dragRef = useRef(null)
|
||||||
const draggedRef = useRef(false)
|
const draggedRef = useRef(false)
|
||||||
|
const textareaRef = useRef(null)
|
||||||
|
|
||||||
const loadNote = async () => {
|
const loadNote = async () => {
|
||||||
if (loadedRef.current) return
|
if (loadedRef.current) return
|
||||||
|
|
@ -107,6 +108,18 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
return () => clearTimeout(saveRef.current)
|
return () => clearTimeout(saveRef.current)
|
||||||
}, [content])
|
}, [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 = () => {
|
const openNote = () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
loadNote()
|
loadNote()
|
||||||
|
|
@ -184,6 +197,7 @@ export default function MyNote({ variant = 'tab' }) {
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="mynote-error">{error}</div>}
|
{error && <div className="mynote-error">{error}</div>}
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={e => setContent(e.target.value)}
|
onChange={e => setContent(e.target.value)}
|
||||||
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}
|
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}
|
||||||
|
|
|
||||||
|
|
@ -974,12 +974,12 @@ const timerStarted = timeLeft !== null
|
||||||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quizNavigation('top')}
|
|
||||||
|
|
||||||
{/* Two-column layout: content + desktop sidebar */}
|
{/* Two-column layout: content + desktop sidebar */}
|
||||||
<div className="quiz-layout">
|
<div className="quiz-layout">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{quizNavigation('top')}
|
||||||
|
|
||||||
{current && (
|
{current && (
|
||||||
<div className="question-card" style={{
|
<div className="question-card" style={{
|
||||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,14 @@ scrape_configs:
|
||||||
docker_sd_configs:
|
docker_sd_configs:
|
||||||
- host: unix:///var/run/docker.sock
|
- host: unix:///var/run/docker.sock
|
||||||
refresh_interval: 5s
|
refresh_interval: 5s
|
||||||
|
filters:
|
||||||
|
- name: label
|
||||||
|
values: ["com.docker.compose.project=quiz"]
|
||||||
relabel_configs:
|
relabel_configs:
|
||||||
# Keep only quiz-* containers
|
# Keep only this compose project; the Docker API filter above prevents
|
||||||
- source_labels: ['__meta_docker_container_name']
|
# Promtail from opening non-readable log streams from other stacks.
|
||||||
regex: '.*quiz.*'
|
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
|
||||||
|
regex: 'quiz'
|
||||||
action: keep
|
action: keep
|
||||||
# Extract container name as label
|
# Extract container name as label
|
||||||
- source_labels: ['__meta_docker_container_name']
|
- source_labels: ['__meta_docker_container_name']
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
||||||
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
||||||
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
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"
|
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
|
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() + "..."
|
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 "?"
|
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||||
correct = LETTERS[correct_idx] if correct_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 "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}."
|
||||||
return "\n".join([
|
|
||||||
"Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}.",
|
|
||||||
"",
|
def split_plain_text(text: str, limit: int) -> list[str]:
|
||||||
f"<b>Explanation:</b> {html.escape(explanation)}",
|
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:
|
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),
|
answered_question_text(state, question, chosen_idx, correct_idx),
|
||||||
parse_mode=ParseMode.HTML,
|
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:
|
else:
|
||||||
await query.edit_message_reply_markup(reply_markup=None)
|
await query.edit_message_reply_markup(reply_markup=None)
|
||||||
state.index += 1
|
state.index += 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue