feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish
Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
suspicious_event_threshold), encoach.course.plan (is_personalized,
personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
* exam_security: POST /api/exam/security/event,
GET /api/exam/single-attempt-check,
GET /api/teacher/exam/<id>/security/events,
GET /api/teacher/exam/attempt/<id>/security/events,
GET /api/teacher/exam/<id>/live.
* live_sessions: full CRUD + /notify, /join, /leave, /end,
/recording, /remove-participant, /attendance, /ics
(RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
invite emails so Gmail/Outlook/Apple Mail prompt to add).
* personalized_plan: GET/POST /api/student/personalized-plan
(CEFR / weeks / focus / grammar_focus payloads).
* handwritten: student upload + AI grading (pytesseract OCR +
OpenAI scoring) + teacher review override.
* reports_export: CSV + PDF export for student-performance and
record reports.
* turnitin: per-entity settings (admin), test connection, submit
text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
res.users mapping, and capped rate_completed at 100% (unique
student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
Reports stack.
Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
with host bar — Mute all / Cams off / per-row Ask-to-unmute /
Remove + log audit reason, kickParticipant after backend audit POST),
TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
(six-tone callout with key-based auto-detect — did_you_know,
key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
is_mandatory color-coding + Accredited Core / Supplementary
sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
routes infographic-style keys into colored InfographicBlock cards,
splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
/teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
active pills, dark CTA buttons, bright orange accents,
rounded-2xl cards.
Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
full Phase 2/3/4 rollout for the record.
Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).
Made-with: Cursor
This commit is contained in:
@@ -352,6 +352,95 @@ class CoursePlanController(http.Controller):
|
||||
)
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials/manual
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): teachers asked for a way to add quizzes,
|
||||
# graded tests, supplementary resources, or assignment rows into a
|
||||
# week of the delivery plan WITHOUT going through the AI
|
||||
# generator. This endpoint creates a single material row using the
|
||||
# new ``kind`` enum and the optional ``exam_template_id`` /
|
||||
# ``resource_id`` link fields.
|
||||
@http.route(
|
||||
'/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/manual',
|
||||
type='http', auth='none', methods=['POST'], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def create_manual_material(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', plan.id),
|
||||
('week_number', '=', int(week_number)),
|
||||
], limit=1)
|
||||
if not week:
|
||||
return _error_response('Week not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
kind = (body.get('kind') or 'content').strip().lower()
|
||||
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
|
||||
return _error_response('Unsupported kind', 400)
|
||||
title = (body.get('title') or '').strip()
|
||||
if not title:
|
||||
return _error_response('title is required', 400)
|
||||
skill = (body.get('skill') or 'integrated').strip()
|
||||
material_type = (body.get('material_type') or 'other').strip()
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'title': title,
|
||||
'skill': skill,
|
||||
'material_type': material_type,
|
||||
'kind': kind,
|
||||
'is_static': True,
|
||||
'summary': body.get('summary') or '',
|
||||
}
|
||||
if body.get('exam_template_id'):
|
||||
try:
|
||||
vals['exam_template_id'] = int(body['exam_template_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if body.get('resource_id'):
|
||||
try:
|
||||
vals['resource_id'] = int(body['resource_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if body.get('due_date'):
|
||||
vals['due_date'] = body['due_date']
|
||||
if 'is_graded' in body:
|
||||
vals['is_graded'] = bool(body['is_graded'])
|
||||
else:
|
||||
vals['is_graded'] = kind == 'test'
|
||||
material = request.env['encoach.course.plan.material'].sudo().create(vals)
|
||||
return _json_response(material.to_api_dict())
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_manual_material failed')
|
||||
return _error_response(
|
||||
str(exc), 403 if isinstance(exc, PermissionError) else 500,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/material/<material_id>
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): teachers need a way to remove a manually
|
||||
# added quiz/test/resource row without going through the AI
|
||||
# regenerator. The existing PATCH route below is extended with
|
||||
# the new ``kind`` / exam / resource fields (see line ~770).
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_material(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
material.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_material failed')
|
||||
return _error_response(
|
||||
str(exc), 403 if isinstance(exc, PermissionError) else 500,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
# ------------------------------------------------------------------
|
||||
@@ -612,6 +701,22 @@ class CoursePlanController(http.Controller):
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
# Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
|
||||
if 'kind' in body:
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
|
||||
return _error_response('Unsupported kind', 400)
|
||||
vals['kind'] = kind
|
||||
if 'exam_template_id' in body:
|
||||
v = body.get('exam_template_id')
|
||||
vals['exam_template_id'] = int(v) if v else False
|
||||
if 'resource_id' in body:
|
||||
v = body.get('resource_id')
|
||||
vals['resource_id'] = int(v) if v else False
|
||||
if 'due_date' in body:
|
||||
vals['due_date'] = body.get('due_date') or False
|
||||
if 'is_graded' in body:
|
||||
vals['is_graded'] = bool(body.get('is_graded'))
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
@@ -1005,6 +1110,114 @@ class CoursePlanController(http.Controller):
|
||||
# PHASE E — Student-side endpoints
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/student/learning
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): the merged "My Learning" page in the
|
||||
# student portal asked for a single endpoint that returns BOTH
|
||||
# traditional ``op.course`` enrollments AND AI-generated
|
||||
# ``encoach.course.plan`` assignments, each tagged with the
|
||||
# ``is_mandatory`` flag so the UI can group them under
|
||||
# "Accredited Core" vs "Supplementary". We re-use the existing
|
||||
# visibility rules (op.student.course for courses, the
|
||||
# ``encoach.course.plan.assignment`` graph for plans) so this
|
||||
# endpoint stays a thin aggregator instead of a new code path.
|
||||
@http.route('/api/student/learning',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_learning(self, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
uid = user.id
|
||||
|
||||
# --- AI course plans ---------------------------------------
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
seen_plan_ids = set()
|
||||
plan_items = []
|
||||
for a in assignments:
|
||||
if a.plan_id.id in seen_plan_ids:
|
||||
continue
|
||||
visible = False
|
||||
if a.mode == 'students' and uid in a.student_user_ids.ids:
|
||||
visible = True
|
||||
elif a.mode in ('batch', 'entities') and uid in a.expand_user_ids():
|
||||
visible = True
|
||||
if not visible:
|
||||
continue
|
||||
seen_plan_ids.add(a.plan_id.id)
|
||||
p = a.plan_id
|
||||
plan_items.append({
|
||||
'kind': 'plan',
|
||||
'id': p.id,
|
||||
'name': p.name or '',
|
||||
'description': p.description or '',
|
||||
'is_mandatory': bool(p.is_mandatory),
|
||||
'cefr_level': p.cefr_level or '',
|
||||
'total_weeks': p.total_weeks or 0,
|
||||
'status': p.status or 'draft',
|
||||
'course_id': p.course_id.id if p.course_id else None,
|
||||
'course_name': p.course_id.name if p.course_id else '',
|
||||
'entity_id': p.entity_id.id if p.entity_id else None,
|
||||
'entity_name': p.entity_id.name if p.entity_id else '',
|
||||
'assignment_id': a.id,
|
||||
'assignment_mode': a.mode or '',
|
||||
})
|
||||
|
||||
# --- Traditional op.course enrollments ---------------------
|
||||
Student = request.env['op.student'].sudo()
|
||||
student = Student.search([('user_id', '=', uid)], limit=1)
|
||||
course_items = []
|
||||
if student:
|
||||
StudentCourse = request.env['op.student.course'].sudo()
|
||||
regs = StudentCourse.search([('student_id', '=', student.id)])
|
||||
for cd in regs:
|
||||
c = cd.course_id
|
||||
if not c:
|
||||
continue
|
||||
chapters = request.env['encoach.course.chapter'].sudo().search(
|
||||
[('course_id', '=', c.id)],
|
||||
)
|
||||
progress_recs = request.env['encoach.chapter.progress'].sudo().search([
|
||||
('student_id', '=', student.id),
|
||||
('chapter_id', 'in', chapters.ids),
|
||||
])
|
||||
completed = len(progress_recs.filtered(lambda p: p.status == 'completed'))
|
||||
total = len(chapters) or 0
|
||||
course_items.append({
|
||||
'kind': 'course',
|
||||
'id': c.id,
|
||||
'name': c.name or '',
|
||||
'code': c.code or '',
|
||||
'description': getattr(c, 'description', '') or '',
|
||||
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
|
||||
'cefr_level': getattr(c, 'cefr_level', '') or '',
|
||||
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
|
||||
'batch_id': cd.batch_id.id if cd.batch_id else None,
|
||||
'batch_name': cd.batch_id.name if cd.batch_id else '',
|
||||
'chapter_count': total,
|
||||
'completed_chapters': completed,
|
||||
'progress': int((completed / total * 100) if total else 0),
|
||||
'entity_id': c.entity_id.id if getattr(c, 'entity_id', False) else None,
|
||||
'entity_name': c.entity_id.name if getattr(c, 'entity_id', False) else '',
|
||||
})
|
||||
|
||||
mandatory = [i for i in (course_items + plan_items) if i.get('is_mandatory')]
|
||||
elective = [i for i in (course_items + plan_items) if not i.get('is_mandatory')]
|
||||
mandatory.sort(key=lambda i: i.get('name', '').lower())
|
||||
elective.sort(key=lambda i: i.get('name', '').lower())
|
||||
|
||||
return _json_response({
|
||||
'mandatory': mandatory,
|
||||
'elective': elective,
|
||||
'count_mandatory': len(mandatory),
|
||||
'count_elective': len(elective),
|
||||
'count_total': len(mandatory) + len(elective),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.student_learning failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/student/course-plans',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
|
||||
@@ -49,6 +49,22 @@ MATERIAL_TYPE_SELECTION = [
|
||||
]
|
||||
|
||||
|
||||
# Phase 1 (2026-04-29): teachers asked to be able to drop a quiz, a
|
||||
# test, a supplementary resource, or a graded assignment into any
|
||||
# week of the delivery plan. ``material_type`` keeps describing *what
|
||||
# kind of teaching content this is* (reading text, listening script,
|
||||
# …); the new ``kind`` field describes *how the student interacts
|
||||
# with it* — read, take a test, attempt a quiz, submit work — and is
|
||||
# what the weekly-plan UI surfaces as a top-level filter.
|
||||
MATERIAL_KIND_SELECTION = [
|
||||
('content', 'Reading / Lesson'),
|
||||
('quiz', 'Quiz'),
|
||||
('test', 'Graded Test'),
|
||||
('resource', 'Supplementary Resource'),
|
||||
('assignment', 'Assignment'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlan(models.Model):
|
||||
_name = 'encoach.course.plan'
|
||||
_description = 'AI-generated Course Plan'
|
||||
@@ -114,6 +130,18 @@ class CoursePlan(models.Model):
|
||||
('archived', 'Archived'),
|
||||
], default='draft')
|
||||
|
||||
# Same flag as op.course.is_mandatory so the merged "My Learning"
|
||||
# page can group both kinds of records under the same two
|
||||
# sections (Accredited Core vs Supplementary).
|
||||
is_mandatory = fields.Boolean(
|
||||
string='Mandatory',
|
||||
default=False,
|
||||
index=True,
|
||||
help='Accredited core plan (mandatory). When True the plan is '
|
||||
'grouped under "Accredited Core" on the student learning '
|
||||
'page and tagged as Mandatory.',
|
||||
)
|
||||
|
||||
brief_json = fields.Text(
|
||||
help='Original brief that was sent to the AI — kept for audit and '
|
||||
'so the user can re-generate if the first pass disappoints.',
|
||||
@@ -183,6 +211,7 @@ class CoursePlan(models.Model):
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
'is_mandatory': bool(self.is_mandatory),
|
||||
'total_weeks': self.total_weeks or 0,
|
||||
'contact_hours_per_week': self.contact_hours_per_week or 0,
|
||||
'skills_division': self.skills_division or '',
|
||||
@@ -282,6 +311,44 @@ class CoursePlanMaterial(models.Model):
|
||||
material_type = fields.Selection(
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
# Phase 1 (2026-04-29): top-level interaction kind. Reading-style
|
||||
# materials default to ``content``; teachers can flip a row to
|
||||
# ``quiz`` / ``test`` / ``resource`` / ``assignment`` from the
|
||||
# weekly plan editor and link the appropriate ``exam_template_id``
|
||||
# / ``resource_id`` / ``assignment_id`` on the same row.
|
||||
kind = fields.Selection(
|
||||
MATERIAL_KIND_SELECTION,
|
||||
required=True,
|
||||
default='content',
|
||||
index=True,
|
||||
help='How the student interacts with this material: read it, '
|
||||
'take a graded test, attempt a quiz, download a resource, '
|
||||
'or submit an assignment.',
|
||||
)
|
||||
exam_template_id = fields.Many2one(
|
||||
'encoach.exam.template',
|
||||
string='Linked exam',
|
||||
ondelete='set null',
|
||||
help='Used when kind in (quiz, test). Points at the exam '
|
||||
'template the student should take from this row.',
|
||||
)
|
||||
resource_id = fields.Many2one(
|
||||
'encoach.resource',
|
||||
string='Linked resource',
|
||||
ondelete='set null',
|
||||
help='Used when kind = resource. Points at a library resource '
|
||||
'(PDF, video, link) the student should consult.',
|
||||
)
|
||||
is_graded = fields.Boolean(
|
||||
string='Graded',
|
||||
default=False,
|
||||
help='When enabled, the row counts toward course grades. '
|
||||
'Auto-set to True for kind=test, optional for assignment.',
|
||||
)
|
||||
due_date = fields.Date(
|
||||
string='Due date',
|
||||
help='Optional deadline. Surfaced on the student weekly view.',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
is_static = fields.Boolean(
|
||||
default=False,
|
||||
@@ -338,6 +405,13 @@ class CoursePlanMaterial(models.Model):
|
||||
'week_number': self.week_number or 0,
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'kind': self.kind or 'content',
|
||||
'is_graded': bool(self.is_graded),
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'exam_template_id': self.exam_template_id.id if self.exam_template_id else None,
|
||||
'exam_template_name': self.exam_template_id.name if self.exam_template_id else None,
|
||||
'resource_id': self.resource_id.id if self.resource_id else None,
|
||||
'resource_name': self.resource_id.name if self.resource_id else None,
|
||||
'title': self.title or '',
|
||||
'is_static': bool(self.is_static),
|
||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
||||
|
||||
@@ -143,6 +143,42 @@ def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
|
||||
return '\n\n'.join(pages).strip()
|
||||
|
||||
|
||||
def _sniff_format(payload: bytes) -> str:
|
||||
"""Detect the real file format from magic bytes.
|
||||
|
||||
User-supplied `type` / `mime_type` / filename are unreliable: people
|
||||
pick `type=pdf` in the resource library and then upload a `.docx`,
|
||||
or rename a `.doc` to `.pdf`. We therefore decide by content first,
|
||||
so that even when `res.type='pdf'` is wrong we route a DOCX into
|
||||
`_extract_docx` instead of feeding it to `pypdf` which then dies
|
||||
with "EOF marker not found" — exactly the failure that surfaced in
|
||||
the UI as "indexing failed" without an actionable message.
|
||||
|
||||
Returns one of: ``pdf``, ``docx``, ``doc``, ``html``, ``text``, ``unknown``.
|
||||
"""
|
||||
if not payload or len(payload) < 4:
|
||||
return 'unknown'
|
||||
if payload[:5] == b'%PDF-':
|
||||
return 'pdf'
|
||||
if payload[:4] == b'PK\x03\x04':
|
||||
head = payload[:8192]
|
||||
if b'word/document.xml' in head or b'word/' in head:
|
||||
return 'docx'
|
||||
return 'zip'
|
||||
if payload[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
|
||||
return 'doc'
|
||||
sniff = payload[:512].lstrip().lower()
|
||||
if sniff.startswith(b'<!doctype') or sniff.startswith(b'<html') or b'<html' in sniff:
|
||||
return 'html'
|
||||
try:
|
||||
sample = payload[:1024].decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return 'unknown'
|
||||
if all((ch.isprintable() or ch in '\r\n\t') for ch in sample):
|
||||
return 'text'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _extract_pdf(payload: bytes) -> str:
|
||||
"""Best-effort PDF text extraction with OCR fallback.
|
||||
|
||||
@@ -155,6 +191,11 @@ def _extract_pdf(payload: bytes) -> str:
|
||||
Both raise on encrypted PDFs we can't decrypt — we swallow that and
|
||||
let the caller record the error.
|
||||
"""
|
||||
if payload[:5] != b'%PDF-':
|
||||
raise RuntimeError(
|
||||
'File is not a valid PDF (missing %PDF- header). '
|
||||
f'Detected format: {_sniff_format(payload)!r}.'
|
||||
)
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
@@ -280,17 +321,28 @@ class SourceIndexer:
|
||||
payload = base64.b64decode(res.file)
|
||||
if not payload:
|
||||
raise ValueError('Library resource has an empty file.')
|
||||
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
|
||||
# Route by ACTUAL file content (magic bytes), not by the
|
||||
# user-set `type`. Otherwise an admin who uploaded a DOCX
|
||||
# but classified it as `type='pdf'` causes pypdf to die
|
||||
# with "EOF marker not found" — exactly the failure we
|
||||
# were debugging here.
|
||||
fmt = _sniff_format(payload)
|
||||
if fmt == 'pdf':
|
||||
return _extract_pdf(payload)
|
||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
||||
if fmt == 'docx':
|
||||
return _extract_docx(payload)
|
||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
||||
if file_name.lower().endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
if fmt == 'html':
|
||||
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||
if fmt == 'text':
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
# Best-effort: try PDF first, then DOCX, then UTF-8.
|
||||
if fmt == 'doc':
|
||||
raise RuntimeError(
|
||||
'Legacy .doc (Word 97-2003) is not supported for RAG '
|
||||
'indexing. Re-save the file as .docx or PDF and '
|
||||
're-upload.'
|
||||
)
|
||||
# Last-resort: best-effort try the parsers anyway, in case
|
||||
# someone gave us a weird-but-still-text-bearing payload.
|
||||
for fn in (_extract_pdf, _extract_docx):
|
||||
try:
|
||||
text = fn(payload)
|
||||
@@ -299,11 +351,18 @@ class SourceIndexer:
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
decoded = payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot decode library resource binary: {exc}',
|
||||
) from exc
|
||||
if decoded:
|
||||
return decoded
|
||||
raise RuntimeError(
|
||||
f'Unsupported file format for RAG indexing '
|
||||
f'(detected={fmt!r}, declared type={rtype!r}). '
|
||||
'Supported: PDF, DOCX, HTML, plain text.'
|
||||
)
|
||||
|
||||
if res.url:
|
||||
_, text = _fetch_url(res.url)
|
||||
@@ -326,32 +385,40 @@ class SourceIndexer:
|
||||
payload = source.get_decoded_file()
|
||||
if not payload:
|
||||
raise ValueError('No file payload to index')
|
||||
# Detect actual content first so a mis-typed mime / extension
|
||||
# (e.g. .pdf renamed onto a .docx) still indexes correctly.
|
||||
fmt = _sniff_format(payload)
|
||||
mime = (source.mime_type or '').lower()
|
||||
name = (source.file_name or '').lower()
|
||||
if mime == 'application/pdf' or name.endswith('.pdf'):
|
||||
if fmt == 'pdf':
|
||||
return _extract_pdf(payload)
|
||||
if (mime in (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword',
|
||||
) or name.endswith('.docx') or name.endswith('.doc')):
|
||||
if fmt == 'docx':
|
||||
return _extract_docx(payload)
|
||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
||||
# rather than silently UTF-8-decoded into garbage that we'd
|
||||
# then "successfully" embed and surface as a usable RAG source.
|
||||
if (mime.startswith('text/')
|
||||
or mime in ('application/json', 'application/xml',
|
||||
'application/csv')
|
||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
||||
'.json', '.xml', '.log', '.rst'))):
|
||||
if fmt == 'html':
|
||||
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||
if fmt == 'text':
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
if fmt == 'doc':
|
||||
raise RuntimeError(
|
||||
'Legacy .doc (Word 97-2003) is not supported for RAG '
|
||||
'indexing. Re-save the file as .docx or PDF and '
|
||||
're-upload.'
|
||||
)
|
||||
# Fall back to declared mime/extension for ambiguous content.
|
||||
if mime.startswith('text/') or mime in (
|
||||
'application/json', 'application/xml', 'application/csv',
|
||||
) or name.endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||
raise ValueError(
|
||||
f'Unsupported file type for RAG indexing: '
|
||||
f'mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
||||
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
|
||||
)
|
||||
|
||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||
|
||||
Reference in New Issue
Block a user