Seed: grade 3 rebuilt (every lesson 5+ questions, wrong figures/lesson text fixed), Persian digits and plain fractions across all grades, apply_fixups gains lesson/question_set, ledger save chunked for SQLite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 21:27:22 +03:30
parent 5fbbd371fe
commit f57deb9c1d
9 changed files with 35779 additions and 11725 deletions

View File

@@ -300,7 +300,7 @@ has been done before.
### Content ### Content
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects, - `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 19,819 questions. `deploy.sh` runs `seed_content` on every release. 89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 21,172 questions. `deploy.sh` runs `seed_content` on every release.
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers - `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`, what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path** `steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**

View File

@@ -13,7 +13,7 @@
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` | | **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud | | **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) | | **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۹٬۸۱۹ سوال | | **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۲۱٬۱۷۲ سوال |
| **تست** | ۹۷۳ تستِ خودکار، همه سبز | | **تست** | ۹۷۳ تستِ خودکار، همه سبز |
| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) | | **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,11 @@ class _Ledger:
self.write[key] = (kind, value) self.write[key] = (kind, value)
def save(self) -> None: def save(self) -> None:
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True)) # دسته‌دسته: SQLite بیش از ۳۲٬۷۶۶ متغیر در یک پرس‌وجو نمی‌پذیرد و دفترِ یک seedِ تازه از آن بیشتر است.
keys = list(self.write)
existing = set()
for i in range(0, len(keys), 500):
existing.update(SeedRecord.objects.filter(key__in=keys[i:i + 500]).values_list('key', flat=True))
SeedRecord.objects.bulk_create([ SeedRecord.objects.bulk_create([
SeedRecord(key=k, kind=kind, value=value) SeedRecord(key=k, kind=kind, value=value)
for k, (kind, value) in self.write.items() if k not in existing for k, (kind, value) in self.write.items() if k not in existing

View File

@@ -0,0 +1,61 @@
"""tools/figures/apply_fixups.py --db: اصلاحِ محتوای منتشرشده روی دیتابیس، بدون از دست رفتنِ پاسخ‌های ثبت‌شده."""
import importlib.util
import os
from django.conf import settings
from django.test import TestCase
from content.models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer
_spec = importlib.util.spec_from_file_location(
'apply_fixups', os.path.join(settings.BASE_DIR, 'tools', 'figures', 'apply_fixups.py'))
apply_fixups = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(apply_fixups)
PATH = ['پایه سوم', 'فصل ۱', 'جزیره', 'درس']
class ApplyFixupsDbTests(TestCase):
def setUp(self):
g = Grade.objects.create(title='پایه سوم', icon='', color='')
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
n = Node.objects.create(unit=u, title='جزیره', order=1)
self.lesson = Lesson.objects.create(node=n, title='درس', intro_text='متن', example='مثالِ غلط',
steps=['گام'], figures=[{'where': 'example', 'diagram': {}}])
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='(۱)/(۲) کدام است؟')
self.c = [Choice.objects.create(question=self.q, value=v, is_correct=(i == 0))
for i, v in enumerate(['(۱)/(۲)', '۲', '۳', '۴'])]
self.answer = UserAnswer.objects.create(user_id=7, question=self.q, choice=self.c[0], correct=True)
self.dup = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='تکراری')
def test_question_set_keeps_rows_and_answers(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {
'prompt': '۱/۲ کدام است؟', 'choices': ['۱/۲', '۲', '۳', '۴'], 'correct': 0}]]})
self.assertEqual(problems, [])
self.q.refresh_from_db()
self.assertEqual(self.q.prompt, '۱/۲ کدام است؟')
self.assertEqual([c.value for c in self.q.choices.order_by('id')], ['۱/۲', '۲', '۳', '۴'])
self.assertEqual([c.pk for c in self.q.choices.order_by('id')], [c.pk for c in self.c])
self.assertTrue(UserAnswer.objects.filter(pk=self.answer.pk, choice=self.c[0]).exists())
def test_choice_count_mismatch_is_reported_not_applied(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {'choices': ['۱', '۲']}]]})
self.assertEqual(len(problems), 1)
self.assertEqual(self.c[0].__class__.objects.get(pk=self.c[0].pk).value, '(۱)/(۲)')
def test_lesson_fields_and_delete(self):
problems = apply_fixups.apply_db({
'lesson': [[PATH, {'example': 'مثالِ درست', 'figures': []}]],
'delete': [[PATH, 'تکراری']]})
self.assertEqual(problems, [])
self.lesson.refresh_from_db()
self.assertEqual((self.lesson.example, self.lesson.figures, self.lesson.steps), ('مثالِ درست', [], ['گام']))
self.assertFalse(Question.objects.filter(pk=self.dup.pk).exists())
def test_missing_targets_are_reported(self):
problems = apply_fixups.apply_db({'delete': [[PATH, 'نیست']], 'lesson': [[['x', 'y', 'z', 'w'], {'steps': []}]]})
self.assertEqual(len(problems), 2)
def test_unknown_field_stops(self):
with self.assertRaises(SystemExit):
apply_fixups.apply_db({'lesson': [[PATH, {'title': 'نه'}]]})

View File

@@ -11,7 +11,7 @@
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) | | `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) |
| `questions.json` | ۱۹٬۸۱۹ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) | | `questions.json` | ۲۱٬۱۷۲ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) |
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ (`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ

View File

@@ -20,7 +20,7 @@
|---|---| |---|---|
| پایه‌های سوم تا دوازدهم | **۱۰ پایه** | | پایه‌های سوم تا دوازدهم | **۱۰ پایه** |
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** | | کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
| سؤالِ چهارگزینه‌ای | **۱۹٬۸۱۹** | | سؤالِ چهارگزینه‌ای | **۲۱٬۱۷۲** |
| عملیاتِ API، مستند در Swagger | **۶۱** | | عملیاتِ API، مستند در Swagger | **۶۱** |
| تستِ خودکار، همه سبز | **۹۷۳** | | تستِ خودکار، همه سبز | **۹۷۳** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** | | اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
@@ -55,7 +55,7 @@
### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند) ### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند)
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۹٬۸۱۹ ← گزینه Choice پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۲۱٬۱۷۲ ← گزینه Choice
**«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد. **«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد.
@@ -732,7 +732,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس | | `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس |
| `content/seed/questions.json` | ۱۹٬۸۱۹ سؤالِ چهارگزینه‌ای، بعضی با شکل | | `content/seed/questions.json` | ۲۱٬۱۷۲ سؤالِ چهارگزینه‌ای، بعضی با شکل |
- `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است. - `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است.
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند. - سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند.

View File

@@ -1,13 +1,21 @@
""" """
اصلاحِ سوال‌های شکل‌دارِ از قبل منتشرشده (seed هیچ‌وقت حذف یا بازنویسی نمی‌کند، پس اصلاح یک ابزارِ جدا می‌خواهد). اصلاحِ محتوای از قبل منتشرشده. seed هیچ‌وقت سوال را حذف یا بازنویسی نمی‌کند و مثال، گام‌ها و شکل‌های درس را
فقط وقتی خالی‌اند پر می‌کند؛ پس اصلاحِ چیزی که روی سرور هست این ابزارِ جدا را می‌خواهد.
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump) venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]} fixups.json (هر کلید اختیاری است):
delete سوال را کامل پاک می‌کند (پاسخ‌های ثبت‌شده‌اش هم می‌رود؛ فقط برای سوالِ تازه‌منتشرشده) "delete": [[path, prompt], ...] سوال را کامل پاک می‌کند. پاسخ‌های ثبت‌شده‌اش هم پاک می‌شود (CASCADE)،
blank_title عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جوابِ سوال را لو می‌داد) پس فقط برای سوالِ تکراری یا غلط، و پیش از کاربرِ واقعی.
سوال با (path, prompt) شناخته می‌شود؛ هر کدام پیدا نشود فهرست می‌شود، بی‌صدا رد نمی‌شود. "blank_title": [[path, prompt], ...] عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جواب را لو می‌داد).
"lesson": [[path, {"intro"|"example"|"steps"|"figures": مقدار}], ...]
همان فیلدهای داده‌شده‌ی درس را بازنویسی می‌کند.
"question_set": [[path, prompt, {"prompt"|"choices"|"correct"|"figure": مقدار}], ...]
سوال را در جای خودش اصلاح می‌کند. گزینه‌ها به ترتیبِ id عوض می‌شوند و
ردیفشان می‌ماند، چون پاسخ‌های ثبت‌شده به ردیفِ گزینه وصل‌اند؛ تعدادِ
گزینه‌ها باید با سوالِ فعلی یکی باشد.
path برای سوال و درس [پایه، فصل، جزیره، درس] است. هر موردی پیدا نشود یا نخواند فهرست می‌شود، بی‌صدا رد نمی‌شود.
""" """
import json import json
import os import os
@@ -16,64 +24,148 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
LESSON_FIELDS = {'intro': ('introText', 'intro_text'), 'example': ('example', 'example'),
'steps': ('steps', 'steps'), 'figures': ('figures', 'figures')}
QUESTION_FIELDS = {'prompt', 'choices', 'correct', 'figure'}
def key(path, prompt): def key(path, prompt):
return (tuple(path), prompt.strip()) return (tuple(path), prompt.strip())
def _check_fields(fields, allowed, what):
bad = set(fields) - set(allowed)
if bad:
raise SystemExit(f'{what}: فیلدِ ناشناخته {sorted(bad)}')
def apply_seed(fx): def apply_seed(fx):
p = os.path.join(ROOT, 'content', 'seed', 'questions.json') p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
data = json.load(open(p)) data = json.load(open(p))
dele = {key(*x) for x in fx.get('delete', [])} dele = {key(*x) for x in fx.get('delete', [])}
blank = {key(*x) for x in fx.get('blank_title', [])} blank = {key(*x) for x in fx.get('blank_title', [])}
seen_d, seen_b, out = set(), set(), [] qset = {key(path, prompt): f for path, prompt, f in fx.get('question_set', [])}
seen, out, problems = set(), [], []
for q in data['questions']: for q in data['questions']:
k = key(q['path'], q['prompt']) k = key(q['path'], q['prompt'])
if k in dele: if k in dele:
seen_d.add(k) seen.add(('delete', k))
continue continue
if k in blank and q.get('figure'): if k in blank and q.get('figure'):
q['figure']['title'] = '' q['figure']['title'] = ''
seen_b.add(k) seen.add(('blank_title', k))
if k in qset:
f = qset[k]
_check_fields(f, QUESTION_FIELDS, 'question_set')
if 'choices' in f and len(f['choices']) != len(q['choices']):
problems.append(('question_set: تعدادِ گزینه فرق دارد', k))
else:
q.update(f)
seen.add(('question_set', k))
out.append(q) out.append(q)
data['questions'] = out data['questions'] = out
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1) json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
return dele - seen_d, blank - seen_b
cp = os.path.join(ROOT, 'content', 'seed', 'curriculum.json')
cur = json.load(open(cp))
lessons = {}
for g in cur['grades']:
for u in g['units']:
for n in u['nodes']:
for l in n['lessons']:
lessons[(g['title'], u['title'], n['title'], l['title'])] = l
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
l[LESSON_FIELDS[name][0]] = value
seen.add(('lesson', tuple(path)))
json.dump(cur, open(cp, 'w'), ensure_ascii=False, indent=1)
return _missing(fx, seen) + problems
def _missing(fx, seen):
miss = []
for op in ('delete', 'blank_title'):
miss += [(f'{op}: پیدا نشد', key(*x)) for x in fx.get(op, []) if (op, key(*x)) not in seen]
miss += [('question_set: پیدا نشد', key(p, pr)) for p, pr, _ in fx.get('question_set', [])
if ('question_set', key(p, pr)) not in seen]
return miss
def apply_db(fx): def apply_db(fx):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django import django
django.setup() django.setup()
from content.models import Question from django.db import transaction
from content.models import Lesson, Question
lessons = {} lessons = {}
from content.models import Lesson
for l in Lesson.objects.select_related('node__unit__grade'): for l in Lesson.objects.select_related('node__unit__grade'):
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l
miss_d, miss_b = set(), set() problems = []
for path, prompt in fx.get('delete', []):
lid = lessons.get(tuple(path)) def find(path, prompt):
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none() l = lessons.get(tuple(path))
if not qs.exists(): return list(Question.objects.filter(lesson=l, prompt=prompt.strip())) if l else []
miss_d.add(key(path, prompt))
else: with transaction.atomic():
qs.delete() for path, prompt in fx.get('delete', []):
for path, prompt in fx.get('blank_title', []): qs = find(path, prompt)
lid = lessons.get(tuple(path)) if not qs:
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else [] problems.append(('delete: پیدا نشد', key(path, prompt)))
if not qs: for q in qs:
miss_b.add(key(path, prompt)) q.delete()
for q in qs: for path, prompt in fx.get('blank_title', []):
if q.figure: qs = find(path, prompt)
q.figure = {**q.figure, 'title': ''} if not qs:
q.save(update_fields=['figure']) problems.append(('blank_title: پیدا نشد', key(path, prompt)))
return miss_d, miss_b for q in qs:
if q.figure:
q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure'])
for path, prompt, f in fx.get('question_set', []):
_check_fields(f, QUESTION_FIELDS, 'question_set')
qs = find(path, prompt)
if not qs:
problems.append(('question_set: پیدا نشد', key(path, prompt)))
for q in qs:
choices = list(q.choices.order_by('id'))
if 'choices' in f and len(f['choices']) != len(choices):
problems.append(('question_set: تعدادِ گزینه فرق دارد', key(path, prompt)))
continue
correct = f.get('correct')
for i, c in enumerate(choices):
if 'choices' in f:
c.value = f['choices'][i]
if correct is not None:
c.is_correct = (i == correct)
c.save(update_fields=['value', 'is_correct'])
if 'prompt' in f:
q.prompt = f['prompt']
if 'figure' in f:
q.figure = f['figure']
q.save(update_fields=['prompt', 'figure'])
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
setattr(l, LESSON_FIELDS[name][1], value)
l.save(update_fields=[LESSON_FIELDS[n][1] for n in f])
return problems
if __name__ == '__main__': if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--db'] args = [a for a in sys.argv[1:] if a != '--db']
fx = json.load(open(args[0])) fx = json.load(open(args[0]))
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx) problems = (apply_db if '--db' in sys.argv else apply_seed)(fx)
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}') counts = {k: len(v) for k, v in fx.items()}
for k in list(miss_d)[:5] + list(miss_b)[:5]: print(f'applied {counts}; problems: {len(problems)}')
print(' missing:', k[1][:60]) for what, k in problems[:20]:
print(f' {what}: {" › ".join(k[0])} | {k[1][:60]}')
sys.exit(1 if problems else 0)