Spaces:
Running on Zero
Running on Zero
Commit ·
3998cca
1
Parent(s): d896946
add advanced file name detector
Browse files- app/bot/handlers/admin.py +188 -64
- app/bot/handlers/library.py +1 -1
- app/core/seed_courses.py +21 -36
- app/db/migrate.py +7 -0
- app/db/models/material.py +6 -0
- app/db/repos/library.py +77 -8
- app/services/classifier.py +251 -0
- app/services/doc_analyzer.py +69 -0
- app/services/smart_ingest.py +151 -0
- requirements.txt +1 -0
- tests/test_logic.py +97 -0
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,
|
| 31 |
-
return bool(
|
| 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 <2026W41> : close a finished week now\n"
|
| 49 |
"/give <user_id> <xp> : reward a real contribution\n"
|
| 50 |
"/trust <user_id> <delta> : lower/raise a trust score (below 70 = no prizes)\n\n"
|
| 51 |
-
"<b>
|
| 52 |
-
"
|
| 53 |
-
"
|
| 54 |
-
"
|
|
|
|
|
|
|
|
|
|
| 55 |
"<code><course_id> <slide|exam|note|book> [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"📥
|
| 228 |
-
"
|
| 229 |
-
"
|
| 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("
|
| 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
|
| 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 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
await message.answer(
|
| 259 |
-
"
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
| 261 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
return
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
return
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
return
|
|
|
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
)
|
| 298 |
-
if
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 <2026W41> : close a finished week now\n"
|
| 51 |
"/give <user_id> <xp> : reward a real contribution\n"
|
| 52 |
"/trust <user_id> <delta> : 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 <id> <course_id> [kind] · /rename <id> <title> · /del <id>\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><course_id> <slide|exam|note|book> [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} <course_id></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 <id> <course_id> or /del <id>")
|
| 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 <file_id> <course_id> [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 <file_id> <new title></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 <file_id></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 |
-
*
|
| 5 |
-
|
| 6 |
-
*
|
| 7 |
-
|
| 8 |
-
|
| 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 |
-
# ----
|
| 16 |
-
("
|
| 17 |
("natural", 1, 1, "Math1003", "Mathematics for Natural Sciences"),
|
| 18 |
-
("
|
| 19 |
-
("
|
| 20 |
("natural", 1, 1, "Phys1009", "General Physics"),
|
| 21 |
-
("
|
| 22 |
-
("
|
| 23 |
-
|
| 24 |
-
("
|
|
|
|
|
|
|
| 25 |
("natural", 1, 2, "Math1004", "Applied Mathematics I"),
|
| 26 |
-
("
|
| 27 |
-
("
|
| 28 |
-
("
|
| 29 |
("natural", 1, 2, "ECEg1012", "Computer Programming"),
|
| 30 |
-
("
|
| 31 |
-
("
|
| 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 & 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_"):
|