cipher-attacks commited on
Commit
3998cca
·
1 Parent(s): d896946

add advanced file name detector

Browse files
app/bot/handlers/admin.py CHANGED
@@ -6,7 +6,7 @@ from typing import Optional
6
 
7
  from aiogram import F, Router
8
  from aiogram.filters import BaseFilter, Command, CommandObject
9
- from aiogram.types import Message, ReactionTypeEmoji
10
  from sqlalchemy import text
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
 
@@ -19,6 +19,7 @@ from app.db.repos import stats as stats_repo
19
  from app.core import season as clock
20
  from app.db.repos import users as users_repo
21
  from app.services import library as library_service
 
22
  from app.services import season as season_service
23
  from app.services import xp as xp_service
24
  from app.services.ingest import KIND_ALIASES, clean_title, parse_caption
@@ -27,12 +28,13 @@ log = logging.getLogger(__name__)
27
 
28
 
29
  class IsAdmin(BaseFilter):
30
- async def __call__(self, message: Message) -> bool:
31
- return bool(message.from_user and message.from_user.id in get_settings().admin_id_set)
32
 
33
 
34
  router = Router()
35
  router.message.filter(IsAdmin())
 
36
 
37
  HELP = (
38
  "<b>Admin tools</b>\n"
@@ -48,10 +50,13 @@ HELP = (
48
  "/closeweek &lt;2026W41&gt; : close a finished week now\n"
49
  "/give &lt;user_id&gt; &lt;xp&gt; : reward a real contribution\n"
50
  "/trust &lt;user_id&gt; &lt;delta&gt; : lower/raise a trust score (below 70 = no prizes)\n\n"
51
- "<b>Bulk upload (easiest):</b>\n"
52
- "<code>/upload 3 exam</code> then send or FORWARD many files (e.g. from your channel).\n"
53
- "👍 = saved. Send /done when finished.\n\n"
54
- "<b>Single file:</b> send it with caption\n"
 
 
 
55
  "<code>&lt;course_id&gt; &lt;slide|exam|note|book&gt; [title]</code>\n"
56
  "e.g. <code>3 exam Midterm 2017</code>"
57
  )
@@ -150,6 +155,7 @@ async def add_course(message: Message, command: CommandObject, session: AsyncSes
150
  async def seed_courses(message: Message, session: AsyncSession) -> None:
151
  added = skipped = 0
152
  for stream, year, semester, code, name in FRESHMAN_COURSES:
 
153
  if await repo.find_course(session, name, stream, year):
154
  skipped += 1
155
  continue
@@ -190,20 +196,20 @@ async def stats(message: Message, session: AsyncSession) -> None:
190
  )
191
 
192
 
193
- def _extract(message: Message) -> Optional[tuple[str, str, str, Optional[str]]]:
194
- """-> (file_type, file_id, file_unique_id, file_name)"""
195
  if message.document:
196
  d = message.document
197
- return "document", d.file_id, d.file_unique_id, d.file_name
198
  if message.video:
199
  v = message.video
200
- return "video", v.file_id, v.file_unique_id, v.file_name
201
  if message.audio:
202
  a = message.audio
203
- return "audio", a.file_id, a.file_unique_id, a.file_name or a.title
204
  if message.photo:
205
  p = message.photo[-1]
206
- return "photo", p.file_id, p.file_unique_id, None
207
  return None
208
 
209
 
@@ -224,9 +230,9 @@ async def upload_on(message: Message, command: CommandObject, session: AsyncSess
224
  return
225
  _upload_mode[message.from_user.id] = {"course_id": course.id, "kind": kind, "saved": 0, "dupes": 0}
226
  await message.answer(
227
- f"📥 Upload mode <b>ON</b>: {course.name} · {kind}\n"
228
- "Send or forward files now (many at once is fine). 👍 = saved.\n"
229
- "Change course: send /upload again. Finish: /done"
230
  )
231
 
232
 
@@ -234,7 +240,7 @@ async def upload_on(message: Message, command: CommandObject, session: AsyncSess
234
  async def upload_off(message: Message) -> None:
235
  mode = _upload_mode.pop(message.from_user.id, None)
236
  if not mode:
237
- await message.answer("Upload mode was not on.")
238
  return
239
  await message.answer(f"✅ Done. Saved <b>{mode['saved']}</b> files, skipped {mode['dupes']} duplicates.")
240
 
@@ -242,62 +248,180 @@ async def upload_off(message: Message) -> None:
242
  async def _ack(message: Message) -> None:
243
  try:
244
  await message.react([ReactionTypeEmoji(emoji="👍")])
245
- except Exception: # reactions unavailable in this chat/client: stay quiet
246
  pass
247
 
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  @router.message(F.chat.type == "private", F.document | F.video | F.audio | F.photo)
250
  async def ingest(message: Message, user: User, session: AsyncSession) -> None:
251
- parsed = parse_caption(message.caption) # explicit caption wins
252
  mode = _upload_mode.get(user.id)
253
- if parsed:
254
- course_id, kind, title = parsed
255
- elif mode:
256
- course_id, kind, title = mode["course_id"], mode["kind"], None
257
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  await message.answer(
259
- "❗ Use <code>/upload &lt;course_id&gt; &lt;kind&gt;</code> for bulk upload, or send the file with caption\n"
260
- "<code>&lt;course_id&gt; &lt;" + "|".join(KINDS) + "&gt; [title]</code>\nSee /courses for ids."
 
 
 
261
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  return
263
- course = await repo.get_course(session, course_id)
264
- if not course:
265
- await message.answer(f"❗ No course with id {course_id}. See /courses")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  return
267
- file_type, file_id, unique_id, file_name = _extract(message)
268
- existing = await repo.find_by_unique_id(session, unique_id)
269
- if existing:
270
- if mode and not parsed:
271
- mode["dupes"] += 1
272
- await _ack(message)
273
- else:
274
- await message.answer(f"⚠️ Already saved as file #{existing.id}: {existing.title}")
 
 
 
 
 
 
275
  return
 
276
 
277
- backup_chat = backup_msg = None
278
- channel = get_settings().library_channel_id
279
- if channel:
280
- try:
281
- copied = await message.copy_to(channel)
282
- backup_chat, backup_msg = channel, copied.message_id
283
- except Exception as exc: # backup is optional; never block the upload
284
- log.warning("Library backup copy failed: %s", exc)
285
-
286
- material = await repo.add_material(
287
- session,
288
- course_id=course.id,
289
- kind=kind,
290
- title=(title or clean_title(file_name, message.caption, f"{course.name} {kind}"))[:250],
291
- file_id=file_id,
292
- file_type=file_type,
293
- file_unique_id=unique_id,
294
- uploader_id=user.id,
295
- library_chat_id=backup_chat,
296
- library_message_id=backup_msg,
297
- )
298
- if mode and not parsed:
299
- mode["saved"] += 1
300
- await _ack(message)
301
- else:
302
- note = "" if backup_msg else " (no backup copy)"
303
- await message.answer(f"✅ Saved #{material.id}: {material.title}\n📘 {course.name} · {kind}{note}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from aiogram import F, Router
8
  from aiogram.filters import BaseFilter, Command, CommandObject
9
+ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, ReactionTypeEmoji
10
  from sqlalchemy import text
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
 
 
19
  from app.core import season as clock
20
  from app.db.repos import users as users_repo
21
  from app.services import library as library_service
22
+ from app.services import smart_ingest
23
  from app.services import season as season_service
24
  from app.services import xp as xp_service
25
  from app.services.ingest import KIND_ALIASES, clean_title, parse_caption
 
28
 
29
 
30
  class IsAdmin(BaseFilter):
31
+ async def __call__(self, event: Message | CallbackQuery) -> bool:
32
+ return bool(event.from_user and event.from_user.id in get_settings().admin_id_set)
33
 
34
 
35
  router = Router()
36
  router.message.filter(IsAdmin())
37
+ router.callback_query.filter(IsAdmin())
38
 
39
  HELP = (
40
  "<b>Admin tools</b>\n"
 
50
  "/closeweek &lt;2026W41&gt; : close a finished week now\n"
51
  "/give &lt;user_id&gt; &lt;xp&gt; : reward a real contribution\n"
52
  "/trust &lt;user_id&gt; &lt;delta&gt; : lower/raise a trust score (below 70 = no prizes)\n\n"
53
+ "<b>Smart upload (default):</b> just send or FORWARD files. The bot finds the course, type, "
54
+ "title and skips duplicates. If unsure it asks you (tap a button).\n"
55
+ "/recent : last 15 files · /review : files waiting for you\n"
56
+ "/move &lt;id&gt; &lt;course_id&gt; [kind] · /rename &lt;id&gt; &lt;title&gt; · /del &lt;id&gt;\n"
57
+ "/mergecourses : merge courses shared by both streams (run once, then /seedcourses)\n\n"
58
+ "<b>Force a course:</b> <code>/upload 3 exam</code>, then send files, /done when finished.\n"
59
+ "<b>Or</b> send a file with caption\n"
60
  "<code>&lt;course_id&gt; &lt;slide|exam|note|book&gt; [title]</code>\n"
61
  "e.g. <code>3 exam Midterm 2017</code>"
62
  )
 
155
  async def seed_courses(message: Message, session: AsyncSession) -> None:
156
  added = skipped = 0
157
  for stream, year, semester, code, name in FRESHMAN_COURSES:
158
+ stream = None if stream == "all" else stream
159
  if await repo.find_course(session, name, stream, year):
160
  skipped += 1
161
  continue
 
196
  )
197
 
198
 
199
+ def _extract(message: Message) -> Optional[tuple[str, str, str, Optional[str], Optional[int]]]:
200
+ """-> (file_type, file_id, file_unique_id, file_name, size)"""
201
  if message.document:
202
  d = message.document
203
+ return "document", d.file_id, d.file_unique_id, d.file_name, d.file_size
204
  if message.video:
205
  v = message.video
206
+ return "video", v.file_id, v.file_unique_id, v.file_name, v.file_size
207
  if message.audio:
208
  a = message.audio
209
+ return "audio", a.file_id, a.file_unique_id, a.file_name or a.title, a.file_size
210
  if message.photo:
211
  p = message.photo[-1]
212
+ return "photo", p.file_id, p.file_unique_id, None, p.file_size
213
  return None
214
 
215
 
 
230
  return
231
  _upload_mode[message.from_user.id] = {"course_id": course.id, "kind": kind, "saved": 0, "dupes": 0}
232
  await message.answer(
233
+ f"📥 Forced mode <b>ON</b>: {course.name} · {kind}\n"
234
+ "Every file goes to this course. 👍 = saved. Finish: /done\n"
235
+ "(Normally you do NOT need this: just send files and the bot sorts them.)"
236
  )
237
 
238
 
 
240
  async def upload_off(message: Message) -> None:
241
  mode = _upload_mode.pop(message.from_user.id, None)
242
  if not mode:
243
+ await message.answer("Forced mode was not on.")
244
  return
245
  await message.answer(f"✅ Done. Saved <b>{mode['saved']}</b> files, skipped {mode['dupes']} duplicates.")
246
 
 
248
  async def _ack(message: Message) -> None:
249
  try:
250
  await message.react([ReactionTypeEmoji(emoji="👍")])
251
+ except Exception: # reactions unavailable: stay quiet
252
  pass
253
 
254
 
255
+ def _line(o: smart_ingest.Outcome) -> str:
256
+ m = o.material
257
+ course = f"{o.course.code + ' ' if o.course and o.course.code else ''}{o.course.name}" if o.course else "❓ unsorted"
258
+ text = f"✅ <b>#{m.id}</b> · {course} · <b>{o.kind}</b>\n“{o.title}”"
259
+ if m.pages:
260
+ text += f" · {m.pages} p"
261
+ for note in o.notes:
262
+ text += f"\n⚠️ {note}"
263
+ return text
264
+
265
+
266
+ def _kb_review(o: smart_ingest.Outcome) -> InlineKeyboardMarkup:
267
+ mid = o.material.id
268
+ rows = [
269
+ [InlineKeyboardButton(text=f"📘 {(c.code + ' ') if c.code else ''}{c.name}"[:60], callback_data=f"rv:c:{mid}:{c.id}")]
270
+ for c in o.suggestions
271
+ ]
272
+ rows.append([InlineKeyboardButton(text="🗑 Delete", callback_data=f"rv:x:{mid}")])
273
+ return InlineKeyboardMarkup(inline_keyboard=rows)
274
+
275
+
276
  @router.message(F.chat.type == "private", F.document | F.video | F.audio | F.photo)
277
  async def ingest(message: Message, user: User, session: AsyncSession) -> None:
278
+ parsed = parse_caption(message.caption) # explicit "<id> <kind> [title]" caption wins
279
  mode = _upload_mode.get(user.id)
280
+ forced_course = forced_kind = forced_title = None
281
+ if parsed or mode:
282
+ course_id, forced_kind, forced_title = parsed if parsed else (mode["course_id"], mode["kind"], None)
283
+ forced_course = await repo.get_course(session, course_id)
284
+ if not forced_course:
285
+ await message.answer(f"❗ No course with id {course_id}. See /courses")
286
+ return
287
+ file_type, file_id, unique_id, file_name, size = _extract(message)
288
+ out = await smart_ingest.ingest_file(
289
+ message.bot, session,
290
+ uploader_id=user.id, file_type=file_type, file_id=file_id, unique_id=unique_id,
291
+ file_name=file_name, size=size, caption=message.caption,
292
+ forced_course=forced_course, forced_kind=forced_kind, forced_title=forced_title,
293
+ backup_channel=get_settings().library_channel_id, message=message,
294
+ )
295
+ quiet = bool(mode and not parsed) # forced bulk mode: one 👍 per file instead of a message
296
+
297
+ if out.status == "duplicate":
298
+ if quiet:
299
+ mode["dupes"] += 1
300
+ await _ack(message)
301
+ else:
302
+ d = out.duplicate_of
303
+ await message.answer(f"♻️ Skipped: {out.why}. Already saved as <b>#{d.id}</b> “{d.title}”.")
304
+ elif out.status == "saved":
305
+ if quiet:
306
+ mode["saved"] += 1
307
+ await _ack(message)
308
+ else:
309
+ await message.answer(_line(out))
310
+ elif out.status == "near_duplicate":
311
+ d = out.duplicate_of
312
+ mid = out.material.id
313
  await message.answer(
314
+ f"🔍 <b>#{mid}</b> “{out.title}” has almost the same text as <b>#{d.id}</b> “{d.title}”.\nKeep both?",
315
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
316
+ InlineKeyboardButton(text="✅ Keep both", callback_data=f"rv:k:{mid}"),
317
+ InlineKeyboardButton(text="🗑 Delete new", callback_data=f"rv:x:{mid}"),
318
+ ]]),
319
  )
320
+ else: # review
321
+ hint = "Pick the course:" if out.suggestions else f"No idea which course. Use <code>/move {out.material.id} &lt;course_id&gt;</code>"
322
+ await message.answer(
323
+ f"🤔 <b>#{out.material.id}</b> “{out.title}” · {out.kind}\nNot sure about the course. {hint}",
324
+ reply_markup=_kb_review(out) if out.suggestions else None,
325
+ )
326
+
327
+
328
+ # ---------- review buttons ----------
329
+ @router.callback_query(F.data.startswith("rv:"))
330
+ async def review_button(call: CallbackQuery, session: AsyncSession) -> None:
331
+ parts = call.data.split(":")
332
+ action, material_id = parts[1], int(parts[2])
333
+ material = await repo.get_material(session, material_id)
334
+ if not material:
335
+ await call.answer("Already gone.", show_alert=True)
336
  return
337
+ if action == "x":
338
+ await repo.delete_material(session, material_id)
339
+ session.sync_session.info["wrote"] = True
340
+ text = f"🗑 Deleted #{material_id}."
341
+ elif action == "k":
342
+ material.status = "ok"
343
+ text = f"✅ Kept #{material_id} “{material.title}”."
344
+ else: # "c": assign course
345
+ course = await repo.get_course(session, int(parts[3]))
346
+ if not course:
347
+ await call.answer("No such course.", show_alert=True)
348
+ return
349
+ material.course_id, material.status = course.id, "ok"
350
+ text = f"✅ #{material_id} → {course.name}"
351
+ library_service.invalidate_courses()
352
+ await call.answer()
353
+ if call.message:
354
+ try:
355
+ await call.message.edit_text(text)
356
+ except Exception:
357
+ await call.message.answer(text)
358
+
359
+
360
+ # ---------- corrections ----------
361
+ @router.message(Command("recent"))
362
+ async def recent(message: Message, session: AsyncSession) -> None:
363
+ items = await repo.recent_materials(session, 15)
364
+ if not items:
365
+ await message.answer("No files yet.")
366
  return
367
+ courses = await repo.get_courses_map(session, {m.course_id for m in items if m.course_id})
368
+ lines = []
369
+ for m in items:
370
+ c = courses.get(m.course_id)
371
+ flag = "" if m.status == "ok" else " 🔸review"
372
+ lines.append(f"<b>#{m.id}</b> {c.name if c else '❓'} · {m.kind} · {m.title[:40]}{flag}")
373
+ await message.answer("\n".join(lines))
374
+
375
+
376
+ @router.message(Command("review"))
377
+ async def review_list(message: Message, session: AsyncSession) -> None:
378
+ items = await repo.review_materials(session)
379
+ if not items:
380
+ await message.answer("Nothing waiting for review 🎉")
381
  return
382
+ await message.answer("\n".join(f"<b>#{m.id}</b> {m.kind} · {m.title[:50]}" for m in items) + "\n\nFix with /move &lt;id&gt; &lt;course_id&gt; or /del &lt;id&gt;")
383
 
384
+
385
+ @router.message(Command("move"))
386
+ async def move(message: Message, command: CommandObject, session: AsyncSession) -> None:
387
+ parts = (command.args or "").split()
388
+ if len(parts) not in (2, 3) or not parts[0].isdigit() or not parts[1].isdigit():
389
+ await message.answer("Usage: <code>/move &lt;file_id&gt; &lt;course_id&gt; [slide|exam|note|book]</code>")
390
+ return
391
+ material, course = await repo.get_material(session, int(parts[0])), await repo.get_course(session, int(parts[1]))
392
+ if not material or not course:
393
+ await message.answer("File or course not found.")
394
+ return
395
+ material.course_id, material.status = course.id, "ok"
396
+ if len(parts) == 3 and KIND_ALIASES.get(parts[2].lower()):
397
+ material.kind = KIND_ALIASES[parts[2].lower()]
398
+ await message.answer(f"✅ #{material.id} → {course.name} · {material.kind}")
399
+
400
+
401
+ @router.message(Command("rename"))
402
+ async def rename(message: Message, command: CommandObject, session: AsyncSession) -> None:
403
+ parts = (command.args or "").split(maxsplit=1)
404
+ material = await repo.get_material(session, int(parts[0])) if len(parts) == 2 and parts[0].isdigit() else None
405
+ if not material:
406
+ await message.answer("Usage: <code>/rename &lt;file_id&gt; &lt;new title&gt;</code>")
407
+ return
408
+ material.title = parts[1].strip()[:250]
409
+ await message.answer(f"✅ #{material.id} renamed: “{material.title}”")
410
+
411
+
412
+ @router.message(Command("del"))
413
+ async def delete_cmd(message: Message, command: CommandObject, session: AsyncSession) -> None:
414
+ arg = (command.args or "").strip()
415
+ if not arg.isdigit() or not await repo.delete_material(session, int(arg)):
416
+ await message.answer("Usage: <code>/del &lt;file_id&gt;</code> (must exist)")
417
+ return
418
+ session.sync_session.info["wrote"] = True
419
+ await message.answer(f"🗑 Deleted #{arg}.")
420
+
421
+
422
+ @router.message(Command("mergecourses"))
423
+ async def merge_courses(message: Message, session: AsyncSession) -> None:
424
+ n = await repo.merge_duplicate_courses(session)
425
+ session.sync_session.info["wrote"] = True
426
+ library_service.invalidate_courses()
427
+ await message.answer(f"✅ Merged {n} duplicate courses. Now run /seedcourses to add any missing ones, then /courses.")
app/bot/handlers/library.py CHANGED
@@ -86,7 +86,7 @@ async def on_materials(call: CallbackQuery, user: User, session: AsyncSession) -
86
  @router.callback_query(F.data.startswith("lib:f:"))
87
  async def on_file(call: CallbackQuery, user: User, session: AsyncSession) -> None:
88
  material = await repo.get_material(session, int(call.data.split(":")[2]))
89
- if not material or not material.file_id:
90
  await call.answer(t(user.lang, "file_missing"), show_alert=True)
91
  return
92
  course = await repo.get_course(session, material.course_id) if material.course_id else None
 
86
  @router.callback_query(F.data.startswith("lib:f:"))
87
  async def on_file(call: CallbackQuery, user: User, session: AsyncSession) -> None:
88
  material = await repo.get_material(session, int(call.data.split(":")[2]))
89
+ if not material or not material.file_id or material.status != "ok":
90
  await call.answer(t(user.lang, "file_missing"), show_alert=True)
91
  return
92
  course = await repo.get_course(session, material.course_id) if material.course_id else None
app/core/seed_courses.py CHANGED
@@ -1,52 +1,37 @@
1
  """Year-1 (freshman) course catalog used by /seedcourses.
2
 
3
  Sources (checked 14 Oct 2026):
4
- * NATURAL stream, courses WITH a code: official DBU course catalog (dbu.edu.et, College of
5
- Engineering, Year I). Codes are DBU's own.
6
- * NATURAL stream, General Biology / General Chemistry: common national natural-science
7
- freshman list (Jimma / Debark University). DBU code not verified -> shown without a code.
8
- * SOCIAL stream: national common social-science freshman list (University of Gondar listing).
9
- No DBU-specific source found -> shown without codes. Please verify against your registrar list.
10
 
11
- Tuple: (stream, year, semester, code or None, name)
12
  """
13
 
14
  FRESHMAN_COURSES: list[tuple[str, int, int, str | None, str]] = [
15
- # ---- NATURAL, semester I (DBU catalog) ----
16
- ("natural", 1, 1, "FLEn1001", "Communicative English Language Skills I"),
17
  ("natural", 1, 1, "Math1003", "Mathematics for Natural Sciences"),
18
- ("natural", 1, 1, "LoCT1005", "Critical Thinking"),
19
- ("natural", 1, 1, "Psys1007", "General Psychology"),
20
  ("natural", 1, 1, "Phys1009", "General Physics"),
21
- ("natural", 1, 1, "SpSc1011", "Physical Fitness"),
22
- ("natural", 1, 1, "GeES1013", "Geography of Ethiopia and the Horn"),
23
- # ---- NATURAL, semester II (DBU catalog) ----
24
- ("natural", 1, 2, "FLEn1002", "Communicative English Language Skills II"),
 
 
25
  ("natural", 1, 2, "Math1004", "Applied Mathematics I"),
26
- ("natural", 1, 2, "Anth1006", "Social Anthropology"),
27
- ("natural", 1, 2, "MGMT1008", "Entrepreneurship"),
28
- ("natural", 1, 2, "EmTe1010", "Introduction to Emerging Technologies"),
29
  ("natural", 1, 2, "ECEg1012", "Computer Programming"),
30
- ("natural", 1, 2, "MCiE1014", "Moral and Civic Education"),
31
- ("natural", 1, 2, "Hist1016", "History of Ethiopia and the Horn"),
32
- # ---- NATURAL science (non-engineering), semester II: code not verified for DBU ----
33
  ("natural", 1, 2, None, "General Biology"),
34
  ("natural", 1, 2, None, "General Chemistry"),
35
- # ---- SOCIAL, semester I (national common list; codes not verified for DBU) ----
36
- ("social", 1, 1, None, "Communicative English Language Skills I"),
37
- ("social", 1, 1, None, "General Psychology"),
38
- ("social", 1, 1, None, "Geography of Ethiopia and the Horn"),
39
- ("social", 1, 1, None, "Introduction to Economics"),
40
- ("social", 1, 1, None, "Logic and Critical Thinking"),
41
- ("social", 1, 1, None, "Mathematics for Social Sciences"),
42
- ("social", 1, 1, None, "Physical Fitness"),
43
- # ---- SOCIAL, semester II ----
44
- ("social", 1, 2, None, "Communicative English Language Skills II"),
45
- ("social", 1, 2, None, "Entrepreneurship"),
46
  ("social", 1, 2, None, "Global Affairs"),
47
  ("social", 1, 2, None, "Introduction to Law and the Ethiopian Legal System"),
48
- ("social", 1, 2, None, "Social Anthropology"),
49
- ("social", 1, 2, None, "History of Ethiopia and the Horn"),
50
- ("social", 1, 2, None, "Introduction to Emerging Technologies"),
51
- ("social", 1, 2, None, "Moral and Civic Education"),
52
  ]
 
1
  """Year-1 (freshman) course catalog used by /seedcourses.
2
 
3
  Sources (checked 14 Oct 2026):
4
+ * Courses WITH a code: official DBU course catalog (dbu.edu.et, College of Engineering, Year I).
5
+ * General Biology / General Chemistry: national natural-science freshman list (Jimma / Debark).
6
+ * Social-science-only courses: national common list (University of Gondar). No DBU source found.
7
+ * Courses that natural AND social students both take are stored ONCE (stream "all").
8
+ A file for "English" therefore never needs to be copied into two courses.
 
9
 
10
+ Tuple: (stream natural|social|all, year, semester, code or None, name)
11
  """
12
 
13
  FRESHMAN_COURSES: list[tuple[str, int, int, str | None, str]] = [
14
+ # ---- semester I ----
15
+ ("all", 1, 1, "FLEn1001", "Communicative English Language Skills I"),
16
  ("natural", 1, 1, "Math1003", "Mathematics for Natural Sciences"),
17
+ ("all", 1, 1, "LoCT1005", "Critical Thinking"),
18
+ ("all", 1, 1, "Psys1007", "General Psychology"),
19
  ("natural", 1, 1, "Phys1009", "General Physics"),
20
+ ("all", 1, 1, "SpSc1011", "Physical Fitness"),
21
+ ("all", 1, 1, "GeES1013", "Geography of Ethiopia and the Horn"),
22
+ ("social", 1, 1, None, "Introduction to Economics"),
23
+ ("social", 1, 1, None, "Mathematics for Social Sciences"),
24
+ # ---- semester II ----
25
+ ("all", 1, 2, "FLEn1002", "Communicative English Language Skills II"),
26
  ("natural", 1, 2, "Math1004", "Applied Mathematics I"),
27
+ ("all", 1, 2, "Anth1006", "Social Anthropology"),
28
+ ("all", 1, 2, "MGMT1008", "Entrepreneurship"),
29
+ ("all", 1, 2, "EmTe1010", "Introduction to Emerging Technologies"),
30
  ("natural", 1, 2, "ECEg1012", "Computer Programming"),
31
+ ("all", 1, 2, "MCiE1014", "Moral and Civic Education"),
32
+ ("all", 1, 2, "Hist1016", "History of Ethiopia and the Horn"),
 
33
  ("natural", 1, 2, None, "General Biology"),
34
  ("natural", 1, 2, None, "General Chemistry"),
 
 
 
 
 
 
 
 
 
 
 
35
  ("social", 1, 2, None, "Global Affairs"),
36
  ("social", 1, 2, None, "Introduction to Law and the Ethiopian Legal System"),
 
 
 
 
37
  ]
app/db/migrate.py CHANGED
@@ -27,6 +27,13 @@ POSTGRES_UPGRADES = [
27
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS day_key VARCHAR(10)",
28
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS day_xp INTEGER NOT NULL DEFAULT 0",
29
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS trust INTEGER NOT NULL DEFAULT 100",
 
 
 
 
 
 
 
30
  "ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
31
  ]
32
 
 
27
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS day_key VARCHAR(10)",
28
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS day_xp INTEGER NOT NULL DEFAULT 0",
29
  "ALTER TABLE users ADD COLUMN IF NOT EXISTS trust INTEGER NOT NULL DEFAULT 100",
30
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS content_hash VARCHAR(64)",
31
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS text_sig BIGINT",
32
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS pages INTEGER",
33
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS size INTEGER",
34
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS source_name VARCHAR(256)",
35
+ "ALTER TABLE materials ADD COLUMN IF NOT EXISTS status VARCHAR(8) NOT NULL DEFAULT 'ok'",
36
+ "CREATE INDEX IF NOT EXISTS ix_materials_content_hash ON materials (content_hash)",
37
  "ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
38
  ]
39
 
app/db/models/material.py CHANGED
@@ -23,6 +23,12 @@ class Material(Base):
23
  file_id: Mapped[Optional[str]] = mapped_column(String(512))
24
  file_type: Mapped[str] = mapped_column(String(16), default="document", server_default="document")
25
  file_unique_id: Mapped[Optional[str]] = mapped_column(String(128), unique=True)
 
 
 
 
 
 
26
  downloads: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
27
  uploader_id: Mapped[Optional[int]] = mapped_column(BigInteger)
28
  library_chat_id: Mapped[Optional[int]] = mapped_column(BigInteger)
 
23
  file_id: Mapped[Optional[str]] = mapped_column(String(512))
24
  file_type: Mapped[str] = mapped_column(String(16), default="document", server_default="document")
25
  file_unique_id: Mapped[Optional[str]] = mapped_column(String(128), unique=True)
26
+ content_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True) # sha256 of the bytes
27
+ text_sig: Mapped[Optional[int]] = mapped_column(BigInteger) # SimHash of the text (near-duplicates)
28
+ pages: Mapped[Optional[int]] = mapped_column(Integer)
29
+ size: Mapped[Optional[int]] = mapped_column(Integer)
30
+ source_name: Mapped[Optional[str]] = mapped_column(String(256)) # original file name
31
+ status: Mapped[str] = mapped_column(String(8), default="ok", server_default="ok") # ok | review
32
  downloads: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
33
  uploader_id: Mapped[Optional[int]] = mapped_column(BigInteger)
34
  library_chat_id: Mapped[Optional[int]] = mapped_column(BigInteger)
app/db/repos/library.py CHANGED
@@ -1,6 +1,6 @@
1
  from typing import Optional
2
 
3
- from sqlalchemy import and_, func, or_, select
4
  from sqlalchemy.ext.asyncio import AsyncSession
5
 
6
  from app.db.models import Course, Material
@@ -78,7 +78,7 @@ async def add_course(
78
  async def kind_counts(session: AsyncSession, course_id: int) -> dict[str, int]:
79
  res = await session.execute(
80
  select(Material.kind, func.count())
81
- .where(Material.course_id == course_id)
82
  .group_by(Material.kind)
83
  )
84
  return {kind: int(n) for kind, n in res.all()}
@@ -88,7 +88,7 @@ async def count_materials(session: AsyncSession, course_id: int, kind: str) -> i
88
  res = await session.execute(
89
  select(func.count())
90
  .select_from(Material)
91
- .where(Material.course_id == course_id, Material.kind == kind)
92
  )
93
  return int(res.scalar_one())
94
 
@@ -98,7 +98,7 @@ async def list_materials(
98
  ) -> list[Material]:
99
  res = await session.execute(
100
  select(Material)
101
- .where(Material.course_id == course_id, Material.kind == kind)
102
  .order_by(Material.downloads.desc(), Material.id.desc())
103
  .offset(offset)
104
  .limit(limit)
@@ -125,7 +125,7 @@ async def add_material(session: AsyncSession, **fields) -> Material:
125
  async def top_materials(session: AsyncSession, limit: int) -> list[Material]:
126
  res = await session.execute(
127
  select(Material)
128
- .where(Material.file_id.is_not(None))
129
  .order_by(Material.downloads.desc(), Material.id.desc())
130
  .limit(limit)
131
  )
@@ -153,7 +153,7 @@ async def search_materials(session: AsyncSession, tokens: list[str], limit: int)
153
  res = await session.execute(
154
  select(Material)
155
  .outerjoin(Course, Material.course_id == Course.id)
156
- .where(cond, Material.file_id.is_not(None))
157
  .order_by(Material.downloads.desc(), Material.id.desc())
158
  .limit(limit)
159
  )
@@ -163,7 +163,7 @@ async def search_materials(session: AsyncSession, tokens: list[str], limit: int)
163
  async def file_counts(session: AsyncSession) -> dict[int, int]:
164
  res = await session.execute(
165
  select(Material.course_id, func.count())
166
- .where(Material.file_id.is_not(None), Material.course_id.is_not(None))
167
  .group_by(Material.course_id)
168
  )
169
  return {int(cid): int(n) for cid, n in res.all()}
@@ -174,8 +174,77 @@ async def materials_for_courses(session: AsyncSession, course_ids: list[int], li
174
  return []
175
  res = await session.execute(
176
  select(Material)
177
- .where(Material.course_id.in_(course_ids), Material.file_id.is_not(None))
178
  .order_by(Material.downloads.desc(), Material.id.desc())
179
  .limit(limit)
180
  )
181
  return list(res.scalars())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from typing import Optional
2
 
3
+ from sqlalchemy import and_, delete, func, or_, select, update
4
  from sqlalchemy.ext.asyncio import AsyncSession
5
 
6
  from app.db.models import Course, Material
 
78
  async def kind_counts(session: AsyncSession, course_id: int) -> dict[str, int]:
79
  res = await session.execute(
80
  select(Material.kind, func.count())
81
+ .where(Material.course_id == course_id, Material.status == "ok")
82
  .group_by(Material.kind)
83
  )
84
  return {kind: int(n) for kind, n in res.all()}
 
88
  res = await session.execute(
89
  select(func.count())
90
  .select_from(Material)
91
+ .where(Material.course_id == course_id, Material.kind == kind, Material.status == "ok")
92
  )
93
  return int(res.scalar_one())
94
 
 
98
  ) -> list[Material]:
99
  res = await session.execute(
100
  select(Material)
101
+ .where(Material.course_id == course_id, Material.kind == kind, Material.status == "ok")
102
  .order_by(Material.downloads.desc(), Material.id.desc())
103
  .offset(offset)
104
  .limit(limit)
 
125
  async def top_materials(session: AsyncSession, limit: int) -> list[Material]:
126
  res = await session.execute(
127
  select(Material)
128
+ .where(Material.file_id.is_not(None), Material.status == "ok")
129
  .order_by(Material.downloads.desc(), Material.id.desc())
130
  .limit(limit)
131
  )
 
153
  res = await session.execute(
154
  select(Material)
155
  .outerjoin(Course, Material.course_id == Course.id)
156
+ .where(cond, Material.file_id.is_not(None), Material.status == "ok")
157
  .order_by(Material.downloads.desc(), Material.id.desc())
158
  .limit(limit)
159
  )
 
163
  async def file_counts(session: AsyncSession) -> dict[int, int]:
164
  res = await session.execute(
165
  select(Material.course_id, func.count())
166
+ .where(Material.file_id.is_not(None), Material.status == "ok", Material.course_id.is_not(None))
167
  .group_by(Material.course_id)
168
  )
169
  return {int(cid): int(n) for cid, n in res.all()}
 
174
  return []
175
  res = await session.execute(
176
  select(Material)
177
+ .where(Material.course_id.in_(course_ids), Material.file_id.is_not(None), Material.status == "ok")
178
  .order_by(Material.downloads.desc(), Material.id.desc())
179
  .limit(limit)
180
  )
181
  return list(res.scalars())
182
+
183
+
184
+ # ----- smart-ingest helpers -----
185
+ async def find_by_hash(session: AsyncSession, content_hash: str) -> Optional[Material]:
186
+ res = await session.execute(select(Material).where(Material.content_hash == content_hash).limit(1))
187
+ return res.scalar_one_or_none()
188
+
189
+
190
+ async def all_sigs(session: AsyncSession) -> list[tuple[int, int, str]]:
191
+ res = await session.execute(
192
+ select(Material.id, Material.text_sig, Material.title).where(Material.text_sig.is_not(None))
193
+ )
194
+ return [(int(i), int(sig), title) for i, sig, title in res.all()]
195
+
196
+
197
+ async def titles_in_course(session: AsyncSession, course_id: int, kind: str) -> list[str]:
198
+ res = await session.execute(
199
+ select(Material.title).where(Material.course_id == course_id, Material.kind == kind)
200
+ )
201
+ return list(res.scalars())
202
+
203
+
204
+ async def recent_materials(session: AsyncSession, limit: int = 15) -> list[Material]:
205
+ res = await session.execute(select(Material).order_by(Material.id.desc()).limit(limit))
206
+ return list(res.scalars())
207
+
208
+
209
+ async def review_materials(session: AsyncSession, limit: int = 30) -> list[Material]:
210
+ res = await session.execute(
211
+ select(Material).where(Material.status == "review").order_by(Material.id).limit(limit)
212
+ )
213
+ return list(res.scalars())
214
+
215
+
216
+ async def delete_material(session: AsyncSession, material_id: int) -> bool:
217
+ res = await session.execute(delete(Material).where(Material.id == material_id))
218
+ return bool(res.rowcount)
219
+
220
+
221
+ async def merge_duplicate_courses(session: AsyncSession) -> int:
222
+ """Courses that natural and social students both take were seeded twice (once per stream).
223
+ Merge each pair into ONE course visible to everybody and move its files. Safe to run twice."""
224
+ import re
225
+
226
+ def key(name: str) -> str:
227
+ k = re.sub(r"[^a-z0-9]+", "", name.lower())
228
+ return {"logicandcriticalthinking": "criticalthinking"}.get(k, k)
229
+
230
+ groups: dict[str, list[Course]] = {}
231
+ for course in await all_courses(session):
232
+ groups.setdefault(key(course.name), []).append(course)
233
+ merged = 0
234
+ for members in groups.values():
235
+ if len(members) < 2:
236
+ continue
237
+ keep = sorted(members, key=lambda c: (c.code is None, c.id))[0]
238
+ if len({m.stream for m in members}) > 1:
239
+ keep.stream = None
240
+ for other in members:
241
+ if other.id == keep.id:
242
+ continue
243
+ await session.execute(
244
+ update(Material).where(Material.course_id == other.id).values(course_id=keep.id)
245
+ .execution_options(synchronize_session=False)
246
+ )
247
+ await session.execute(delete(Course).where(Course.id == other.id))
248
+ merged += 1
249
+ await session.flush()
250
+ return merged
app/services/classifier.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smart file understanding: which course, which kind, a clean title, duplicate fingerprints.
2
+ Pure functions (no I/O) so everything here is unit-tested."""
3
+ import hashlib
4
+ import re
5
+ from typing import Optional, Sequence
6
+
7
+ from app.services.fuzzy import words
8
+
9
+ # ---------------------------------------------------------------- helpers
10
+ _STOP = {"for", "of", "the", "and", "to", "in", "on", "a", "an"}
11
+ _GENERIC = {"general", "introduction", "skills", "language", "sciences", "science", "education", "technologies", "communicative"}
12
+
13
+
14
+ def collapse(text: str) -> str:
15
+ return re.sub(r"[^a-z0-9\u1200-\u137f]+", "", (text or "").lower())
16
+
17
+
18
+ def norm_title_key(title: str) -> str:
19
+ return collapse(title)
20
+
21
+
22
+ # Short, hand-written synonyms (collapsed course name -> aliases). Cheap to extend.
23
+ ALIASES: dict[str, list[str]] = {
24
+ "mathematicsfornaturalsciences": ["calculus", "natural math", "math natural"],
25
+ "appliedmathematicsi": ["applied math", "applied maths", "applied mathematics"],
26
+ "mathematicsforsocialsciences": ["social math", "math social", "maths social"],
27
+ "generalphysics": ["physics", "phys", "mechanics", "ፊዚክስ"],
28
+ "generalchemistry": ["chemistry", "chem", "ኬሚስትሪ"],
29
+ "generalbiology": ["biology", "bio", "ባዮሎጂ"],
30
+ "communicativeenglishlanguageskillsi": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
31
+ "communicativeenglishlanguageskillsii": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
32
+ "generalpsychology": ["psychology", "psych", "psys", "ሳይኮሎጂ"],
33
+ "criticalthinking": ["logic", "loct", "logic and critical thinking"],
34
+ "geographyofethiopiaandthehorn": ["geography", "geo", "gees", "ጂኦግራፊ"],
35
+ "historyofethiopiaandthehorn": ["history", "hist", "ታሪክ"],
36
+ "socialanthropology": ["anthropology", "anth"],
37
+ "entrepreneurship": ["entrepreneur", "entre", "mgmt"],
38
+ "introductiontoemergingtechnologies": ["emerging", "emte", "emerging technology"],
39
+ "computerprogramming": ["programming", "coding", "eceg", "python", "c++"],
40
+ "moralandciviceducation": ["civic", "civics", "moral", "mcie", "ethics"],
41
+ "physicalfitness": ["fitness", "sports", "spsc"],
42
+ "introductiontoeconomics": ["economics", "econ"],
43
+ "globalaffairs": ["global", "global trends"],
44
+ "introductiontolawandtheethiopianlegalsystem": ["law", "legal"],
45
+ }
46
+
47
+
48
+ def _hit(word: str, target: str) -> bool:
49
+ """word (from a file name) refers to target (a course-name word)."""
50
+ if word == target:
51
+ return True
52
+ if len(word) >= 4 and (target.startswith(word) or word.startswith(target)):
53
+ return True
54
+ return len(word) >= 5 and len(target) >= 5 and _ratio(word, target) >= 0.85
55
+
56
+
57
+ def _ratio(a: str, b: str) -> float:
58
+ import difflib
59
+
60
+ return difflib.SequenceMatcher(None, a, b).ratio()
61
+
62
+
63
+ def _alias_hit(alias: str, ws: Sequence[str]) -> bool:
64
+ parts = words(alias)
65
+ if not parts:
66
+ return False
67
+ if len(parts) > 1:
68
+ return all(any(w == p for w in ws) for p in parts)
69
+ p = parts[0]
70
+ for w in ws:
71
+ base = re.sub(r"\d+$", "", w)
72
+ if w == p or base == p or (len(p) >= 4 and w.startswith(p)):
73
+ return True
74
+ return False
75
+
76
+
77
+ def _level(ws: Sequence[str]) -> Optional[int]:
78
+ if any(w in ("ii", "2") for w in ws) or any(re.fullmatch(r"[a-z]*(1002|ii)", w) for w in ws):
79
+ return 2
80
+ if any(w in ("i", "1") for w in ws):
81
+ return 1
82
+ return None
83
+
84
+
85
+ # ---------------------------------------------------------------- course matching
86
+ def rank_courses(courses: Sequence, sources: Sequence[tuple[str, str]]) -> list[tuple[float, object]]:
87
+ """sources: [(text, 'name'|'text')]. 'name' = file name/caption (strong), 'text' = content (weak).
88
+ Returns [(score, course)] best first, only courses with score > 0."""
89
+ prepared = []
90
+ for text, kind in sources:
91
+ if text and text.strip():
92
+ prepared.append((collapse(text), words(text), 1.0 if kind == "name" else 0.5))
93
+ if not prepared:
94
+ return []
95
+ out = []
96
+ for c in courses:
97
+ cname_key = collapse(c.name)
98
+ code_key = collapse(c.code) if c.code else ""
99
+ sig_words = [w for w in words(c.name) if len(w) >= 4 and w not in _STOP and w not in _GENERIC]
100
+ score = 0.0
101
+ for compact, ws, weight in prepared:
102
+ if code_key and code_key in compact:
103
+ score += 10 * weight
104
+ if cname_key and cname_key in compact:
105
+ score += 8 * weight
106
+ for alias in ALIASES.get(cname_key, ()):
107
+ if _alias_hit(alias, ws):
108
+ score += 5 * weight
109
+ break
110
+ hits = sum(1 for nw in sig_words if any(_hit(w, nw) for w in ws))
111
+ score += min(hits * 2.0 * weight, 4.0 if weight < 1 else 8.0)
112
+ # "English I" vs "English II": use the level written in the file name
113
+ name_words = words(c.name)
114
+ if name_words and name_words[-1] in ("i", "ii") and score > 0:
115
+ lvl = _level([w for _, ws, wt in prepared if wt == 1.0 for w in ws])
116
+ if lvl:
117
+ is_two = name_words[-1] == "ii"
118
+ score += 3 if (lvl == 2) == is_two else -3
119
+ if score > 0:
120
+ out.append((score, c))
121
+ out.sort(key=lambda item: (-item[0], item[1].id))
122
+ return out
123
+
124
+
125
+ def decide(ranked: Sequence[tuple[float, object]]) -> tuple[Optional[object], bool, list]:
126
+ """-> (best course or None, confident?, top-3 suggestions)."""
127
+ if not ranked:
128
+ return None, False, []
129
+ top = ranked[0][0]
130
+ second = ranked[1][0] if len(ranked) > 1 else 0.0
131
+ confident = top >= 6 and (top - second >= 3 or second <= 0)
132
+ return ranked[0][1], confident, [c for _, c in ranked[:3]]
133
+
134
+
135
+ # ---------------------------------------------------------------- kind
136
+ _EXAM = ("exam", "midterm", "mid exam", "mid-exam", "final", "quiz", "test", "question", "past paper", "previous", "ፈተና")
137
+ _SLIDE = ("slide", "lecture", "ppt", "presentation", "chapter")
138
+ _NOTE = ("note", "handout", "summary", "worksheet", "assignment", "tutorial", "module", "ማስታወሻ")
139
+ _BOOK = ("textbook", "book", "edition", "isbn", "volume")
140
+ _EXAM_TEXT = ("time allowed", "answer all questions", "choose the best answer", "instruction:", "id no", "name:")
141
+
142
+
143
+ def detect_kind(name: str, caption: str, text: str, pages: Optional[int], size: Optional[int], ext: str) -> tuple[str, str]:
144
+ label = f" {(name or '').lower()} {(caption or '').lower()} ".replace("_", " ").replace("-", " ")
145
+ if ext in (".ppt", ".pptx"):
146
+ return "slide", "presentation file"
147
+ if any(k in label for k in _EXAM):
148
+ return "exam", "name says exam"
149
+ if any(k in label for k in _SLIDE):
150
+ return "slide", "name says slides/chapter"
151
+ if any(k in label for k in _BOOK):
152
+ return "book", "name says book"
153
+ if sum(1 for k in _EXAM_TEXT if k in (text or "").lower()) >= 2:
154
+ return "exam", "content looks like an exam"
155
+ if any(k in label for k in _NOTE):
156
+ return "note" if not pages or pages < 150 else "book", "name says module/notes"
157
+ if pages and pages >= 200:
158
+ return "book", f"{pages} pages"
159
+ if size and size > 30 * 1024 * 1024:
160
+ return "book", "very large file"
161
+ return "note", "default"
162
+
163
+
164
+ # ---------------------------------------------------------------- title
165
+ _JUNK = re.compile(r"(https?://\S+|t\.me/\S+|@\w+|www\.\S+|\(\d+\)|\bcopy\b|\bscanned\b)", re.I)
166
+
167
+
168
+ def detect_year(*texts: str) -> Optional[str]:
169
+ for t in texts:
170
+ m = re.search(r"\b(20[0-3]\d)\b", t or "")
171
+ if m:
172
+ return m.group(1)
173
+ return None
174
+
175
+
176
+ def exam_stage(*texts: str) -> Optional[str]:
177
+ blob = " ".join(texts).lower()
178
+ if "mid" in blob:
179
+ return "Mid"
180
+ if "final" in blob:
181
+ return "Final"
182
+ if "quiz" in blob:
183
+ return "Quiz"
184
+ return None
185
+
186
+
187
+ def clean_title(file_name: Optional[str], caption: Optional[str], course_name: str, kind: str, year: Optional[str], stage: Optional[str]) -> str:
188
+ base = re.sub(r"\.[A-Za-z0-9]{2,5}$", "", file_name or "")
189
+ base = _JUNK.sub(" ", base) # junk first: "@some_channel" must go before underscores become spaces
190
+ base = base.replace("_", " ").replace("-", " ").replace(".", " ")
191
+ parts = []
192
+ for tok in base.split():
193
+ if re.fullmatch(r"\d{8,}", tok) or (len(tok) >= 12 and re.search(r"\d", tok) and re.search(r"[a-z]", tok, re.I)):
194
+ continue # phone-number / hash-like junk
195
+ parts.append(tok)
196
+ title = " ".join(parts).strip()
197
+ if not title and caption:
198
+ title = _JUNK.sub(" ", caption.strip().split("\n")[0]).strip()
199
+ if not title:
200
+ title = f"{course_name} {kind}"
201
+ if title.isupper() or title.islower():
202
+ title = title.title()
203
+ if kind == "exam":
204
+ if stage and stage.lower() not in title.lower():
205
+ title += f" {stage}"
206
+ if year and year not in title:
207
+ title += f" {year}"
208
+ return re.sub(r"\s+", " ", title)[:80]
209
+
210
+
211
+ def disambiguate(title: str, taken: set, *, year: Optional[str], pages: Optional[int], size: Optional[int]) -> str:
212
+ """Same title already used for a DIFFERENT file in this course/kind -> add what tells them apart."""
213
+ options = []
214
+ if year and year not in title:
215
+ options.append(f"{title} ({year})")
216
+ if pages:
217
+ options.append(f"{title} ({pages} p)")
218
+ if size:
219
+ options.append(f"{title} ({size / 1048576:.1f} MB)")
220
+ for candidate in options:
221
+ if norm_title_key(candidate) not in taken:
222
+ return candidate
223
+ n = 2
224
+ while norm_title_key(f"{title} ({n})") in taken:
225
+ n += 1
226
+ return f"{title} ({n})"
227
+
228
+
229
+ # ---------------------------------------------------------------- near-duplicate fingerprint
230
+ def simhash64(text: str) -> Optional[int]:
231
+ """64-bit SimHash of word 3-grams (signed, fits a BIGINT). None if there is too little text."""
232
+ ws = words(text)
233
+ if len(ws) < 40:
234
+ return None
235
+ vec = [0] * 64
236
+ for i in range(len(ws) - 2):
237
+ h = int.from_bytes(hashlib.blake2b(" ".join(ws[i : i + 3]).encode(), digest_size=8).digest(), "big")
238
+ for b in range(64):
239
+ vec[b] += 1 if (h >> b) & 1 else -1
240
+ out = 0
241
+ for b in range(64):
242
+ if vec[b] > 0:
243
+ out |= 1 << b
244
+ return out - (1 << 64) if out >= (1 << 63) else out
245
+
246
+
247
+ def hamming(a: int, b: int) -> int:
248
+ return ((a ^ b) & ((1 << 64) - 1)).bit_count()
249
+
250
+
251
+ NEAR_DUPLICATE_BITS = 3
app/services/doc_analyzer.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read the first pages of a file to learn its text and page count. Sync: run in a thread."""
2
+ import html
3
+ import io
4
+ import logging
5
+ import os
6
+ import re
7
+ import zipfile
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class DocInfo:
16
+ text: str = ""
17
+ pages: Optional[int] = None
18
+
19
+
20
+ def _pdf(data: bytes) -> DocInfo:
21
+ try:
22
+ from pypdf import PdfReader
23
+ except ImportError:
24
+ return DocInfo()
25
+ logging.getLogger("pypdf").setLevel(logging.ERROR)
26
+ reader = PdfReader(io.BytesIO(data))
27
+ pages = len(reader.pages)
28
+ chunks = []
29
+ for i in range(min(3, pages)):
30
+ chunks.append(reader.pages[i].extract_text() or "")
31
+ return DocInfo(text=" ".join(chunks), pages=pages)
32
+
33
+
34
+ def _pptx(data: bytes) -> DocInfo:
35
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
36
+ slides = sorted(
37
+ (n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)),
38
+ key=lambda n: int(re.findall(r"\d+", n)[0]),
39
+ )
40
+ chunks = []
41
+ for name in slides[:3]:
42
+ xml = z.read(name).decode("utf-8", "ignore")
43
+ chunks.append(" ".join(html.unescape(t) for t in re.findall(r"<a:t>([^<]*)</a:t>", xml)))
44
+ return DocInfo(text=" ".join(chunks), pages=len(slides))
45
+
46
+
47
+ def _docx(data: bytes) -> DocInfo:
48
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
49
+ xml = z.read("word/document.xml").decode("utf-8", "ignore")
50
+ text = " ".join(html.unescape(t) for t in re.findall(r"<w:t[^>]*>([^<]*)</w:t>", xml))
51
+ return DocInfo(text=text)
52
+
53
+
54
+ def analyze(file_name: Optional[str], data: bytes) -> DocInfo:
55
+ ext = os.path.splitext(file_name or "")[1].lower()
56
+ try:
57
+ if ext == ".pdf":
58
+ info = _pdf(data)
59
+ elif ext == ".pptx":
60
+ info = _pptx(data)
61
+ elif ext == ".docx":
62
+ info = _docx(data)
63
+ else:
64
+ return DocInfo()
65
+ except Exception as exc: # encrypted / scanned / corrupt files must never break an upload
66
+ log.info("Could not read %s: %r", file_name, exc)
67
+ return DocInfo()
68
+ info.text = re.sub(r"\s+", " ", info.text)[:6000]
69
+ return info
app/services/smart_ingest.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One upload -> course, kind, clean title, duplicate checks, stored. Used by the admin upload handler."""
2
+ import asyncio
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+
9
+ from aiogram import Bot
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from app.db.models import Course, Material
13
+ from app.db.repos import library as repo
14
+ from app.services import classifier as C
15
+ from app.services import library as lib
16
+ from app.services.doc_analyzer import DocInfo, analyze
17
+
18
+ log = logging.getLogger(__name__)
19
+ BOT_API_DOWNLOAD_LIMIT = 20 * 1024 * 1024 # a bot can only download files up to 20 MB
20
+
21
+
22
+ @dataclass
23
+ class Outcome:
24
+ status: str # saved | review | near_duplicate | duplicate
25
+ material: Optional[Material] = None
26
+ course: Optional[Course] = None
27
+ kind: str = "note"
28
+ title: str = ""
29
+ why: str = ""
30
+ notes: list[str] = field(default_factory=list)
31
+ suggestions: list = field(default_factory=list)
32
+ duplicate_of: Optional[Material] = None
33
+
34
+
35
+ async def _download(bot: Bot, file_id: str) -> Optional[bytes]:
36
+ try:
37
+ buf = await asyncio.wait_for(bot.download(file_id), timeout=45)
38
+ return buf.read() if buf else None
39
+ except Exception as exc:
40
+ log.info("Download for analysis failed: %r", exc)
41
+ return None
42
+
43
+
44
+ async def ingest_file(
45
+ bot: Bot,
46
+ session: AsyncSession,
47
+ *,
48
+ uploader_id: int,
49
+ file_type: str,
50
+ file_id: str,
51
+ unique_id: str,
52
+ file_name: Optional[str],
53
+ size: Optional[int],
54
+ caption: Optional[str],
55
+ forced_course: Optional[Course] = None,
56
+ forced_kind: Optional[str] = None,
57
+ forced_title: Optional[str] = None,
58
+ backup_channel: int = 0,
59
+ message=None,
60
+ ) -> Outcome:
61
+ # 1) exact same Telegram file
62
+ existing = await repo.find_by_unique_id(session, unique_id)
63
+ if existing:
64
+ return Outcome("duplicate", duplicate_of=existing, why="same Telegram file")
65
+
66
+ # 2) read the content (only possible up to 20 MB) -> hash, text, pages
67
+ content_hash, info = None, DocInfo()
68
+ if file_type == "document" and size is not None and size <= BOT_API_DOWNLOAD_LIMIT:
69
+ data = await _download(bot, file_id)
70
+ if data:
71
+ content_hash = hashlib.sha256(data).hexdigest()
72
+ twin = await repo.find_by_hash(session, content_hash)
73
+ if twin:
74
+ return Outcome("duplicate", duplicate_of=twin, why="identical content, different name")
75
+ info = await asyncio.to_thread(analyze, file_name, data)
76
+
77
+ ext = os.path.splitext(file_name or "")[1].lower()
78
+ notes: list[str] = []
79
+
80
+ # 3) which course?
81
+ suggestions: list = []
82
+ confident = True
83
+ course = forced_course
84
+ if course is None:
85
+ courses = await lib.all_courses_cached(session)
86
+ sources = [(file_name or "", "name"), (caption or "", "name"), (info.text[:3000], "text")]
87
+ best, confident, suggestions = C.decide(C.rank_courses(courses, sources))
88
+ course = best if confident else None
89
+
90
+ # 4) which kind?
91
+ if forced_kind:
92
+ kind, why = forced_kind, "set by you"
93
+ else:
94
+ kind, why = C.detect_kind(file_name or "", caption or "", info.text, info.pages, size, ext)
95
+
96
+ # 5) a clean title (+ year / Mid / Final for exams)
97
+ year = C.detect_year(file_name or "", caption or "", info.text[:1500])
98
+ stage = C.exam_stage(file_name or "", caption or "") if kind == "exam" else None
99
+ course_name = course.name if course else "Unsorted"
100
+ title = forced_title or C.clean_title(file_name, caption, course_name, kind, year, stage)
101
+
102
+ # 6) near-duplicate: same text, different bytes (re-scan, re-export, different compression)
103
+ sig = C.simhash64(info.text)
104
+ near = None
105
+ if sig is not None:
106
+ for mid, other_sig, other_title in await repo.all_sigs(session):
107
+ if C.hamming(sig, other_sig) <= C.NEAR_DUPLICATE_BITS:
108
+ near = await repo.get_material(session, mid)
109
+ break
110
+
111
+ # 7) same title, DIFFERENT file in this course/kind -> keep both, tell them apart
112
+ if course is not None:
113
+ taken = {C.norm_title_key(t) for t in await repo.titles_in_course(session, course.id, kind)}
114
+ if C.norm_title_key(title) in taken:
115
+ new_title = C.disambiguate(title, taken, year=year, pages=info.pages, size=size)
116
+ notes.append(f"same title as an existing different file -> saved as “{new_title}”")
117
+ title = new_title
118
+
119
+ status = "saved"
120
+ if near is not None:
121
+ status = "near_duplicate"
122
+ elif course is None:
123
+ status = "review"
124
+
125
+ material = await repo.add_material(
126
+ session,
127
+ course_id=course.id if course else None,
128
+ kind=kind,
129
+ title=title[:250],
130
+ file_id=file_id,
131
+ file_type=file_type,
132
+ file_unique_id=unique_id,
133
+ uploader_id=uploader_id,
134
+ content_hash=content_hash,
135
+ text_sig=sig,
136
+ pages=info.pages,
137
+ size=size,
138
+ source_name=(file_name or "")[:250] or None,
139
+ status="ok" if status == "saved" else "review",
140
+ )
141
+ if backup_channel and message is not None:
142
+ try:
143
+ copied = await message.copy_to(backup_channel)
144
+ material.library_chat_id, material.library_message_id = backup_channel, copied.message_id
145
+ except Exception as exc: # the backup copy is optional
146
+ log.warning("Library backup copy failed: %s", exc)
147
+
148
+ return Outcome(
149
+ status, material=material, course=course, kind=kind, title=material.title, why=why,
150
+ notes=notes, suggestions=suggestions, duplicate_of=near,
151
+ )
requirements.txt CHANGED
@@ -3,3 +3,4 @@ sqlalchemy[asyncio]>=2.0
3
  asyncpg>=0.29
4
  aiosqlite>=0.20
5
  pydantic-settings>=2.2
 
 
3
  asyncpg>=0.29
4
  aiosqlite>=0.20
5
  pydantic-settings>=2.2
6
+ pypdf>=4
tests/test_logic.py CHANGED
@@ -80,6 +80,9 @@ import random # noqa: E402
80
  from app.core import season # noqa: E402
81
  from app.services import xp_rules as R # noqa: E402
82
  from app.services.fuzzy import match_score # noqa: E402
 
 
 
83
  from app.services.season_logic import Candidate, draw, flags, rank_candidates # noqa: E402
84
  from datetime import datetime, timedelta # noqa: E402
85
 
@@ -152,6 +155,100 @@ def test_fuzzy_search_tolerates_typos():
152
  assert match_score(["physics"], physics) > match_score(["phyy"], physics)
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  if __name__ == "__main__":
156
  for name, fn in list(globals().items()):
157
  if name.startswith("test_"):
 
80
  from app.core import season # noqa: E402
81
  from app.services import xp_rules as R # noqa: E402
82
  from app.services.fuzzy import match_score # noqa: E402
83
+ from types import SimpleNamespace as NS # noqa: E402
84
+ from app.core.seed_courses import FRESHMAN_COURSES # noqa: E402
85
+ from app.services import classifier as C # noqa: E402
86
  from app.services.season_logic import Candidate, draw, flags, rank_candidates # noqa: E402
87
  from datetime import datetime, timedelta # noqa: E402
88
 
 
155
  assert match_score(["physics"], physics) > match_score(["phyy"], physics)
156
 
157
 
158
+ COURSES = [
159
+ NS(id=i, stream=None if st == "all" else st, year=y, code=code, name=name)
160
+ for i, (st, y, _sem, code, name) in enumerate(FRESHMAN_COURSES, start=1)
161
+ ]
162
+
163
+
164
+ def _pick(file_name, caption="", text=""):
165
+ sources = [(file_name, "name"), (caption, "name"), (text, "text")]
166
+ best, confident, sugg = C.decide(C.rank_courses(COURSES, sources))
167
+ return (best.name if best else None), confident
168
+
169
+
170
+ def test_classifier_courses():
171
+ assert _pick("Math_1003_Module.pdf") == ("Mathematics for Natural Sciences", True)
172
+ assert _pick("General Physics Final Exam 2016.pdf") == ("General Physics", True)
173
+ assert _pick("Entrepreneurship ppt chapter 1.pptx") == ("Entrepreneurship", True)
174
+ assert _pick("Eng 1002 - Communicative English II notes.pdf")[0] == "Communicative English Language Skills II"
175
+ assert _pick("english skills I handout.pdf")[0] == "Communicative English Language Skills I"
176
+ assert _pick("chem module.pdf")[0] == "General Chemistry"
177
+ assert _pick("calculus chapter 3.pdf")[0] == "Mathematics for Natural Sciences"
178
+ assert _pick("ታሪክ ማስታወሻ.pdf")[0] == "History of Ethiopia and the Horn"
179
+
180
+
181
+ def test_classifier_unsure_goes_to_review():
182
+ assert _pick("IMG_20261014_final.pdf")[1] is False
183
+ assert _pick("random_file123.pdf") == (None, False)
184
+ assert _pick("Mathematics module.pdf")[1] is False # three math courses tie: ask the admin
185
+
186
+
187
+ def test_classifier_uses_pdf_text_when_name_is_useless():
188
+ text = "Debre Berhan University General Chemistry module atoms and molecules chapter one"
189
+ assert _pick("scan0001.pdf", text=text)[0] == "General Chemistry"
190
+
191
+
192
+ def test_kind_detection():
193
+ assert C.detect_kind("Phys final exam.pdf", "", "", None, None, ".pdf")[0] == "exam"
194
+ assert C.detect_kind("lecture1.pptx", "", "", None, None, ".pptx")[0] == "slide"
195
+ assert C.detect_kind("Math module.pdf", "", "", 120, None, ".pdf")[0] == "note"
196
+ assert C.detect_kind("Math module.pdf", "", "", 260, None, ".pdf")[0] == "book"
197
+ assert C.detect_kind("scan.pdf", "", "Time allowed: 2 hours. Answer all questions", None, None, ".pdf")[0] == "exam"
198
+ assert C.detect_kind("whatever.pdf", "", "", None, None, ".pdf")[0] == "note"
199
+
200
+
201
+ def test_titles():
202
+ assert C.clean_title("Phys_1009_FINAL_EXAM_(1).pdf", None, "General Physics", "exam", "2016", "Final") == "Phys 1009 FINAL EXAM 2016"
203
+ assert C.clean_title("@some_channel 123456789012 notes.pdf", None, "X", "note", None, None) == "Notes"
204
+ assert C.clean_title(None, None, "General Physics", "note", None, None) == "General Physics note"
205
+
206
+
207
+ def test_same_title_different_files_are_told_apart():
208
+ taken = {C.norm_title_key("Math Module")}
209
+ assert C.disambiguate("Math Module", taken, year=None, pages=245, size=5_000_000) == "Math Module (245 p)"
210
+ taken.add(C.norm_title_key("Math Module (245 p)"))
211
+ assert C.disambiguate("Math Module", taken, year=None, pages=245, size=5_000_000) == "Math Module (4.8 MB)"
212
+ taken.add(C.norm_title_key("Math Module (4.8 MB)"))
213
+ assert C.disambiguate("Math Module", taken, year=None, pages=245, size=5_000_000) == "Math Module (2)"
214
+
215
+
216
+ def test_simhash_near_duplicates():
217
+ base = " ".join(f"word{i % 37} term{i % 11}" for i in range(400))
218
+ edited = base + " extra footer line added by a scanner app"
219
+ other = " ".join(f"zeta{i % 53} omega{i % 7}" for i in range(400))
220
+ a, b, c = C.simhash64(base), C.simhash64(edited), C.simhash64(other)
221
+ assert C.hamming(a, b) <= C.NEAR_DUPLICATE_BITS
222
+ assert C.hamming(a, c) > 10
223
+ assert C.simhash64("too short") is None
224
+
225
+
226
+ def test_doc_analyzer_reads_pptx_and_docx():
227
+ import io
228
+ import zipfile
229
+
230
+ from app.services.doc_analyzer import analyze
231
+
232
+ def make(files: dict) -> bytes:
233
+ buf = io.BytesIO()
234
+ with zipfile.ZipFile(buf, "w") as z:
235
+ for name, content in files.items():
236
+ z.writestr(name, content)
237
+ return buf.getvalue()
238
+
239
+ pptx = make({
240
+ "ppt/slides/slide1.xml": "<a:t>General Chemistry</a:t><a:t>Atoms &amp; molecules</a:t>",
241
+ "ppt/slides/slide2.xml": "<a:t>Chapter one</a:t>",
242
+ "ppt/slides/slide10.xml": "<a:t>Last</a:t>",
243
+ })
244
+ info = analyze("chem.pptx", pptx)
245
+ assert info.pages == 3 and "General Chemistry" in info.text and "Atoms & molecules" in info.text
246
+ docx = make({"word/document.xml": "<w:p><w:t>Entrepreneurship</w:t><w:t xml:space='preserve'> module</w:t></w:p>"})
247
+ assert "Entrepreneurship module" in analyze("m.docx", docx).text
248
+ assert analyze("broken.pdf", b"not a pdf").text == "" # never raises
249
+ assert analyze("pic.png", b"x").pages is None
250
+
251
+
252
  if __name__ == "__main__":
253
  for name, fn in list(globals().items()):
254
  if name.startswith("test_"):