feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches
Ported from monorepo v4 commit 3b62075d (backend/* portion). encoach_ai_course: - workbook_attempt model + scoring + REST endpoints for student attempts - dialogue_parser splits scripts by speaker, classifies gender, strips labels - media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation, manual media upload endpoint (audio/image/video) with size validation - source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs, page-streaming to stay under memory limit - exercise_extractor + rag_context for RAG-grounded interactive workbooks - course_plan_pipeline: v2 generator that grounds week material on indexed sources and persists grounded_on_json metadata - security: access rules for new models encoach_lms_api: - branches model + controller (entity-scoped LMS branches) - classroom_ext + course_ext (assignment + section workflow) - classrooms controller: students/teachers/assign-course endpoints Made-with: Cursor
This commit is contained in:
@@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
from .course_plan_pipeline import CoursePlanPipeline
|
||||
from .source_indexer import SourceIndexer
|
||||
from .rag_context import RAGContextBuilder
|
||||
from .exercise_extractor import ExerciseExtractor
|
||||
from .media_service import MediaService
|
||||
from .deliverables import compute_deliverables
|
||||
|
||||
@@ -203,6 +203,165 @@ Only include skills present in the week's items list.
|
||||
"""
|
||||
|
||||
|
||||
# Richer schema used by the RAG-grounded v2 generator. Each skill body now
|
||||
# carries enough material to fill a real teacher's lesson, plus a
|
||||
# self-contained ``interactive_workbook`` block whose ``exercises[]`` are
|
||||
# rendered by ``InteractiveWorkbook.tsx``. The model is instructed to keep
|
||||
# the structure exact — UI components depend on it.
|
||||
_WEEK_JSON_HINT_V2 = """
|
||||
Return JSON with EXACTLY this shape (no extra prose, no surrounding markdown):
|
||||
{
|
||||
"materials": [
|
||||
{
|
||||
"skill": "reading",
|
||||
"material_type": "reading_text",
|
||||
"title": "...",
|
||||
"summary": "1-2 sentence teacher note (purpose + LOs targeted)",
|
||||
"body": {
|
||||
"intro": "lead-in question to activate schema",
|
||||
"text": "<reading passage 600-900 words at the target CEFR level>",
|
||||
"glossary": [{"term": "...", "definition": "..."}],
|
||||
"questions": [
|
||||
{"id":"r1", "type": "multiple_choice", "stem":"...",
|
||||
"options":["A","B","C","D"], "answer":"A",
|
||||
"rationale":"Why this is the correct answer."},
|
||||
{"id":"r2", "type": "true_false", "stem":"...", "answer": true,
|
||||
"rationale":"..."},
|
||||
{"id":"r3", "type": "open", "stem":"...", "model_answer":"..."},
|
||||
{"id":"r4", "type": "inference", "stem":"...", "model_answer":"..."}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "listening",
|
||||
"material_type": "listening_script",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"context": "Where the conversation/monologue is set.",
|
||||
"script": "<300-500 word natural script with speaker labels>",
|
||||
"transcript": "Same as script but cleaned for student handout.",
|
||||
"comprehension_questions": [
|
||||
{"id":"l1","type":"multiple_choice","stem":"...","options":["A","B","C","D"],"answer":"A"},
|
||||
{"id":"l2","type":"open","stem":"...","model_answer":"..."}
|
||||
],
|
||||
"listen_and_fill": {
|
||||
"instructions": "Listen and fill in the missing words.",
|
||||
"transcript_with_gaps": "I ___ (1) to the ___ (2) every morning.",
|
||||
"answers": ["go","gym"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "speaking",
|
||||
"material_type": "speaking_prompt",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"warmup": "30-second pair task to break the ice.",
|
||||
"lead_in": "Discussion question to set the topic.",
|
||||
"paired_prompt": "Student A asks; Student B answers; then swap.",
|
||||
"solo_prompt": "1-minute mini-presentation on the topic.",
|
||||
"useful_language": ["I usually...", "On the other hand...", "..."],
|
||||
"model_answer": "Sample 60-90 second answer the teacher can read aloud."
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "writing",
|
||||
"material_type": "writing_prompt",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"brainstorm": ["3-5 prompts to spark ideas before drafting."],
|
||||
"task": "The actual writing task.",
|
||||
"word_count": 150,
|
||||
"model_paragraph": "<a fully written 120-180 word model>",
|
||||
"annotations": [
|
||||
{"highlight":"phrase from the model","note":"Why it works."}
|
||||
],
|
||||
"checklist": ["Topic sentence", "Linking words", "Range of tenses"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "grammar",
|
||||
"material_type": "grammar_lesson",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"rule": "1-2 paragraph rule explanation.",
|
||||
"form_table": [
|
||||
{"subject":"I/you/we/they","positive":"work","negative":"don't work","question":"do ... work?"},
|
||||
{"subject":"he/she/it","positive":"works","negative":"doesn't work","question":"does ... work?"}
|
||||
],
|
||||
"examples": ["She works in a hospital.","They don't live here."],
|
||||
"common_errors": [{"wrong":"He don't like fish.","right":"He doesn't like fish."}],
|
||||
"interactive_workbook": {
|
||||
"lesson_plan": {
|
||||
"warmup":"...","presentation":"...","controlled_practice":"...",
|
||||
"freer_practice":"...","exit_ticket":"..."
|
||||
},
|
||||
"exercises": [
|
||||
{"id":"g1","type":"gap_fill","stem":"I ___ (be) a student.","answer":"am","alt":["am"],"hint":"present simple of 'be'"},
|
||||
{"id":"g2","type":"multiple_choice","stem":"She ___ coffee every morning.","options":["drink","drinks","drinking","is drink"],"answer":"drinks","rationale":"3rd person singular adds -s."},
|
||||
{"id":"g3","type":"transformation","from":"He plays football.","instruction":"Make the sentence negative.","answer":"He doesn't play football.","alt":["He does not play football."]},
|
||||
{"id":"g4","type":"reorder_words","tokens":["Where","do","you","live","?"],"answer":"Where do you live ?"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "vocabulary",
|
||||
"material_type": "vocabulary_list",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"words": [
|
||||
{"term":"routine","pos":"n.","definition":"a regular sequence of activities","collocations":["daily routine","morning routine"],"example":"My morning routine is busy."}
|
||||
],
|
||||
"interactive_workbook": {
|
||||
"lesson_plan": {"warmup":"...","presentation":"...","controlled_practice":"...","freer_practice":"...","exit_ticket":"..."},
|
||||
"exercises": [
|
||||
{"id":"v1","type":"match_pairs","left":["dog","cat","cow","sheep"],"right":["barks","meows","moos","baas"],"answer":[[0,0],[1,1],[2,2],[3,3]]},
|
||||
{"id":"v2","type":"short_answer","stem":"Give an example of your daily routine.","answer":"I usually …","accepted":["I usually *","I always *","I often *","I sometimes *"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "integrated",
|
||||
"material_type": "interactive_workbook",
|
||||
"title": "Week N — Practice Workbook",
|
||||
"summary": "Mixed-skill consolidation tasks — students solve and submit.",
|
||||
"body": {
|
||||
"lesson_plan": {
|
||||
"warmup":"...","presentation":"...","controlled_practice":"...",
|
||||
"freer_practice":"...","exit_ticket":"..."
|
||||
},
|
||||
"exercises": [
|
||||
{"id":"ex1","type":"gap_fill","stem":"I ___ (live) in London.","answer":"live","alt":["live"]},
|
||||
{"id":"ex2","type":"multiple_choice","stem":"Which is correct?","options":["He don't","He doesn't","He didn't","He do not"],"answer":"He doesn't","rationale":"3rd person singular negative."},
|
||||
{"id":"ex3","type":"match_pairs","left":["bus","train","car","plane"],"right":["station","stop","park","airport"],"answer":[[0,1],[1,0],[2,2],[3,3]]},
|
||||
{"id":"ex4","type":"reorder_words","tokens":["What","time","do","you","get","up","?"],"answer":"What time do you get up ?"},
|
||||
{"id":"ex5","type":"transformation","from":"She is from Spain.","instruction":"Make a yes/no question.","answer":"Is she from Spain?","alt":["Is she from Spain ?"]},
|
||||
{"id":"ex6","type":"short_answer","stem":"What's your name?","answer":"My name is …","accepted":["My name is *","I'm *","I am *"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules
|
||||
-----
|
||||
- Include skills exactly matching the week's items list (do not add or skip).
|
||||
- Plus ONE additional `interactive_workbook` of skill `integrated` for mixed practice.
|
||||
- Word counts and CEFR-appropriate vocabulary are MANDATORY — do not output A1 vocabulary in a B2 lesson.
|
||||
- Every interactive exercise MUST be one of: gap_fill, multiple_choice, match_pairs, reorder_words, transformation, short_answer.
|
||||
- Provide a clean machine-checkable `answer` (and `alt`/`accepted` where applicable). The frontend grades them server-side.
|
||||
- Use the `Reference passages` block (when present) as the primary source of vocabulary, examples, and topic. Cite ideas faithfully — do not invent contradicting facts.
|
||||
- Do not include URLs, copyrighted full pages, or large verbatim quotes. Paraphrase or use only short illustrative phrases.
|
||||
"""
|
||||
|
||||
|
||||
class CoursePlanPipeline:
|
||||
"""Wrap the LLM call, normalise the JSON, persist the result."""
|
||||
|
||||
@@ -384,12 +543,19 @@ class CoursePlanPipeline:
|
||||
# ------------------------------------------------------------------
|
||||
# Week-level material generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_week_materials(self, plan_id, week_number):
|
||||
def generate_week_materials(self, plan_id, week_number, *, use_rag=False):
|
||||
"""Generate teaching materials for one week and persist them.
|
||||
|
||||
Any existing materials for the same plan_id + week_number are
|
||||
replaced — callers that want to keep old versions should copy
|
||||
them before re-running.
|
||||
|
||||
:param use_rag: when True, retrieve top-k passages from each
|
||||
indexed source via :class:`RAGContextBuilder`, inject them
|
||||
into the prompt, switch to ``_WEEK_JSON_HINT_V2`` (richer
|
||||
bodies + interactive workbook), and persist the source
|
||||
citations on each created material under ``grounded_on_json``.
|
||||
Falls back to v1 cleanly if no sources are indexed.
|
||||
"""
|
||||
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
@@ -402,25 +568,72 @@ class CoursePlanPipeline:
|
||||
outcomes = plan._loads(plan.outcomes_json, {})
|
||||
items = week._loads(week.items_json, [])
|
||||
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use classroom materials. Your output MUST be valid JSON "
|
||||
"matching the schema in the user prompt. Keep reading texts "
|
||||
"close to the target word count for the CEFR level. Keep "
|
||||
"listening scripts natural and conversational. All tasks "
|
||||
"must target the outcome codes supplied."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
# Build per-skill RAG context (only when requested AND sources exist).
|
||||
rag_blocks: dict[str, dict] = {}
|
||||
rag_enabled = False
|
||||
if use_rag:
|
||||
from .rag_context import RAGContextBuilder
|
||||
builder = RAGContextBuilder(self.env)
|
||||
if builder.has_indexed_sources(plan):
|
||||
rag_enabled = True
|
||||
# The week may contain duplicates / the integrated workbook
|
||||
# references "integrated"; we always retrieve once per
|
||||
# listed skill plus once for the integrated bucket so the
|
||||
# extra workbook material has its own grounding too.
|
||||
wanted = {(it.get('skill') or '').lower()
|
||||
for it in items if it.get('skill')}
|
||||
wanted.add('integrated')
|
||||
for skill in wanted:
|
||||
if not skill:
|
||||
continue
|
||||
rag_blocks[skill] = builder.for_week(plan, week, skill)
|
||||
|
||||
if rag_enabled:
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use, classroom-ready materials. You ground your "
|
||||
"content on the reference passages provided in the prompt "
|
||||
"(from the teacher's uploaded book). Paraphrase ideas; "
|
||||
"do not quote whole pages verbatim. Output MUST be valid "
|
||||
"JSON matching the schema EXACTLY — no preamble, no "
|
||||
"trailing commentary."
|
||||
)
|
||||
ref_block = self._render_rag_block(rag_blocks)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Reference passages from your uploaded book(s):\n"
|
||||
f"{ref_block}\n\n"
|
||||
+ _WEEK_JSON_HINT_V2
|
||||
)
|
||||
max_tokens = 8000
|
||||
else:
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use classroom materials. Your output MUST be valid JSON "
|
||||
"matching the schema in the user prompt. Keep reading texts "
|
||||
"close to the target word count for the CEFR level. Keep "
|
||||
"listening scripts natural and conversational. All tasks "
|
||||
"must target the outcome codes supplied."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
max_tokens = 6000
|
||||
|
||||
content = self._invoke_agent_or_chat(
|
||||
agent_key="course_week_materials",
|
||||
@@ -430,9 +643,10 @@ class CoursePlanPipeline:
|
||||
"course": plan.name,
|
||||
"cefr_level": (plan.cefr_level or "").lower(),
|
||||
"week_number": week.week_number,
|
||||
"rag_enabled": rag_enabled,
|
||||
},
|
||||
temperature=0.6,
|
||||
max_tokens=6000,
|
||||
max_tokens=max_tokens,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
used_week_fallback = False
|
||||
@@ -474,21 +688,47 @@ class CoursePlanPipeline:
|
||||
summary = (m.get('summary') or '').strip()
|
||||
if used_week_fallback:
|
||||
summary = (summary + "\n\n" + skeleton_note).strip()
|
||||
rec = Material.create({
|
||||
skill = (m.get('skill') or 'integrated').strip().lower()
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'skill': skill,
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
}
|
||||
if rag_enabled:
|
||||
block = rag_blocks.get(skill) or rag_blocks.get('integrated')
|
||||
if block and block.get('sources'):
|
||||
vals['grounded_on_json'] = json.dumps(
|
||||
block['sources'], ensure_ascii=False,
|
||||
)
|
||||
rec = Material.create(vals)
|
||||
created.append(rec)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_logger.warning("Skipping bad material row: %s", exc)
|
||||
return created
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _render_rag_block(rag_blocks: dict) -> str:
|
||||
"""Render the per-skill RAG passages as a single citation block.
|
||||
|
||||
We keep the per-skill grouping so the model knows which passages
|
||||
relate to which skill — a flat dump tends to mix topics and the
|
||||
model loses focus when generating, e.g., a grammar lesson against
|
||||
a reading-passage source.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for skill, block in rag_blocks.items():
|
||||
text = (block or {}).get('as_prompt_block') or ''
|
||||
if not text:
|
||||
continue
|
||||
parts.append(f"### For {skill}:\n{text}")
|
||||
return '\n\n'.join(parts) or '(no reference passages available)'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
247
custom_addons/encoach_ai_course/services/dialogue_parser.py
Normal file
247
custom_addons/encoach_ai_course/services/dialogue_parser.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Parse a listening-script into per-speaker segments with gender hints.
|
||||
|
||||
The course-plan AI emits listening scripts in a "screenplay" form::
|
||||
|
||||
Host: Welcome to Travel Tales. Today we have Sarah ...
|
||||
Sarah: Hi! Yes, I spent two weeks in Italy ...
|
||||
Host: That sounds amazing. What was your favourite city?
|
||||
Sarah: Venice was incredible.
|
||||
|
||||
Naively feeding that whole blob to a TTS engine reads the speaker
|
||||
labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one
|
||||
monotone voice for both characters. That's painful for listening
|
||||
practice and breaks the very pedagogical signal a dialogue is supposed
|
||||
to carry — turn-taking, contrasted voices, gendered roles.
|
||||
|
||||
This module extracts the labels, classifies each speaker by gender, and
|
||||
returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS
|
||||
pipeline can:
|
||||
|
||||
1. Strip the labels from the rendered audio (``Host:`` is never spoken).
|
||||
2. Synthesize each turn with a voice matching the speaker's gender
|
||||
(Host with a male voice, Sarah with a female voice).
|
||||
3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real
|
||||
conversation rather than a single narration.
|
||||
|
||||
For monologues (no ``Name:`` prefix anywhere in the script) the parser
|
||||
returns one segment with ``speaker=None``, so callers can keep the
|
||||
single-voice fast-path without special-casing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
# A "speaker tag" is a 1–3 word capitalized name followed by a colon at
|
||||
# either start-of-string or right after sentence-final punctuation. We
|
||||
# anchor with a positive lookbehind so we don't match colons that appear
|
||||
# inside dialogue ("She said: 'No.'") — only those that actually mark a
|
||||
# turn boundary.
|
||||
_SPEAKER_PREFIX = re.compile(
|
||||
r'(?:^|(?<=[.!?]\s)|(?<=\n))'
|
||||
r'(?P<name>[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})'
|
||||
r'\s*:\s*'
|
||||
)
|
||||
|
||||
# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of
|
||||
# the time when generating English-language listening dialogues. Anything
|
||||
# missing falls back to alternation by encounter order, which always
|
||||
# produces alternating male/female voices for the typical 2-speaker
|
||||
# classroom dialogue.
|
||||
_FEMALE_NAMES = frozenset({
|
||||
# English first names
|
||||
'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane',
|
||||
'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella',
|
||||
'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella',
|
||||
'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica',
|
||||
'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy',
|
||||
'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina',
|
||||
'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve',
|
||||
# Arabic / regional first names common in the platform's audience
|
||||
'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma',
|
||||
'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem',
|
||||
# Generic role / kinship terms that imply female
|
||||
'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister',
|
||||
'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs',
|
||||
'ms', 'queen', 'princess',
|
||||
})
|
||||
|
||||
_MALE_NAMES = frozenset({
|
||||
# English first names
|
||||
'john', 'david', 'michael', 'james', 'robert', 'william', 'richard',
|
||||
'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew',
|
||||
'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george',
|
||||
'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim',
|
||||
'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah',
|
||||
'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb',
|
||||
'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve',
|
||||
# Arabic / regional first names
|
||||
'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled',
|
||||
'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami',
|
||||
'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal',
|
||||
# Generic role / kinship terms
|
||||
'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son',
|
||||
'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince',
|
||||
})
|
||||
|
||||
# Role nouns that frequently appear as speaker labels in listening
|
||||
# scripts but carry no inherent gender — we route these through the
|
||||
# alternation path instead of forcing a default voice on them.
|
||||
_NEUTRAL_ROLES = frozenset({
|
||||
'narrator', 'speaker', 'voice', 'person', 'student', 'teacher',
|
||||
'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer',
|
||||
'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor',
|
||||
'patient', 'manager', 'employee', 'colleague', 'friend',
|
||||
})
|
||||
|
||||
|
||||
def _lookup_gender(name: str) -> str | None:
|
||||
"""Return ``'male'`` / ``'female'`` from the static name lookup, or
|
||||
``None`` if the first token isn't recognised.
|
||||
"""
|
||||
first = (name or '').strip().lower().split()[0] if name else ''
|
||||
if first in _FEMALE_NAMES:
|
||||
return 'female'
|
||||
if first in _MALE_NAMES:
|
||||
return 'male'
|
||||
return None
|
||||
|
||||
|
||||
def parse_dialogue(script: str) -> list[dict]:
|
||||
"""Split ``script`` into per-speaker segments.
|
||||
|
||||
Returns a list of ``{'speaker': str | None, 'gender': str | None,
|
||||
'text': str}``:
|
||||
|
||||
* Multi-speaker dialogue → one entry per turn, with the speaker's
|
||||
name (preserved as written) and a heuristic gender label.
|
||||
* Monologue (no ``Name:`` prefix) → a single segment with
|
||||
``speaker=None`` and ``gender=None`` so callers can short-circuit.
|
||||
* Empty / whitespace-only script → ``[]``.
|
||||
|
||||
The returned ``text`` never includes the speaker label — that's the
|
||||
whole point: the TTS engine must not say "Host colon ..." aloud.
|
||||
|
||||
Gender assignment is two-pass so that unknown / role-only speakers
|
||||
(Host, Interviewer, etc.) end up *contrasting* with the named
|
||||
speakers in the same dialogue. Without this a "Host + Sarah"
|
||||
interview would assign female to both (Sarah by lookup, Host by
|
||||
default), defeating the whole point of multi-voice rendering.
|
||||
"""
|
||||
if not script or not script.strip():
|
||||
return []
|
||||
|
||||
matches = list(_SPEAKER_PREFIX.finditer(script))
|
||||
if not matches:
|
||||
return [{'speaker': None, 'gender': None, 'text': script.strip()}]
|
||||
|
||||
# Build the list of (name, text) turns first so we can do a global
|
||||
# gender assignment over the unique speaker set.
|
||||
turns: list[tuple[str, str]] = []
|
||||
head = script[: matches[0].start()].strip()
|
||||
for i, m in enumerate(matches):
|
||||
name = m.group('name').strip()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(script)
|
||||
text = script[m.end():end].strip()
|
||||
if text:
|
||||
turns.append((name, text))
|
||||
if not turns:
|
||||
# Edge case: matched some "Name:" labels but every body turned
|
||||
# out empty. Return the whole pre-match head as a single
|
||||
# narration, falling back to the script if even that's empty.
|
||||
return [{'speaker': None, 'gender': None,
|
||||
'text': head or script.strip()}]
|
||||
|
||||
# Pass 1: collect unique speakers in order of first appearance, and
|
||||
# apply the static name → gender lookup. ``None`` here means
|
||||
# "we don't know yet — fill in pass 2".
|
||||
unique_order: list[str] = []
|
||||
speaker_gender: dict[str, str | None] = {}
|
||||
for name, _ in turns:
|
||||
key = name.lower()
|
||||
if key not in speaker_gender:
|
||||
unique_order.append(key)
|
||||
speaker_gender[key] = _lookup_gender(name)
|
||||
|
||||
# Pass 2: resolve unknowns. The contract we want is "contrast" —
|
||||
# any 2-speaker dialogue should produce one male and one female
|
||||
# voice. So for each unknown speaker we count how many male vs
|
||||
# female slots are *already* assigned (by lookup or by an earlier
|
||||
# iteration of this pass) and pick the under-represented gender.
|
||||
# Pure parity-by-index would re-introduce the Host/Sarah collision.
|
||||
for key in unique_order:
|
||||
if speaker_gender[key] is not None:
|
||||
continue
|
||||
male_count = sum(1 for v in speaker_gender.values() if v == 'male')
|
||||
female_count = sum(1 for v in speaker_gender.values() if v == 'female')
|
||||
if male_count < female_count:
|
||||
speaker_gender[key] = 'male'
|
||||
elif female_count < male_count:
|
||||
speaker_gender[key] = 'female'
|
||||
else:
|
||||
# Truly tied (or first unknown in a no-lookup dialogue) —
|
||||
# alternate based on how many unknowns we've already filled
|
||||
# so two unknowns in a row get different voices.
|
||||
assigned_unknowns = sum(
|
||||
1 for k in unique_order
|
||||
if speaker_gender[k] is not None and _lookup_gender(k) is None
|
||||
)
|
||||
speaker_gender[key] = (
|
||||
'male' if assigned_unknowns % 2 == 0 else 'female'
|
||||
)
|
||||
|
||||
# Pass 3: emit the segments, including any unattributed pre-match
|
||||
# narration ("It was a sunny day. Anna: Hi!"). The narration takes
|
||||
# whichever gender is *under-represented* among the speakers, so it
|
||||
# contrasts with the dialogue voices instead of duplicating one.
|
||||
segments: list[dict] = []
|
||||
if head:
|
||||
female = sum(1 for v in speaker_gender.values() if v == 'female')
|
||||
male = sum(1 for v in speaker_gender.values() if v == 'male')
|
||||
narrator_gender = 'female' if female <= male else 'male'
|
||||
segments.append(
|
||||
{'speaker': None, 'gender': narrator_gender, 'text': head},
|
||||
)
|
||||
|
||||
for name, text in turns:
|
||||
segments.append({
|
||||
'speaker': name,
|
||||
'gender': speaker_gender[name.lower()],
|
||||
'text': text,
|
||||
})
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def strip_speaker_labels(script: str) -> str:
|
||||
"""Return ``script`` with all ``Name:`` prefixes removed.
|
||||
|
||||
Used when the active TTS provider can't do multi-voice (gTTS only
|
||||
speaks one voice per call) so we still get a clean recording —
|
||||
just one narrator reading both turns instead of an awkward
|
||||
"Host colon ... Sarah colon ...".
|
||||
"""
|
||||
if not script:
|
||||
return ''
|
||||
return _SPEAKER_PREFIX.sub('', script).strip()
|
||||
|
||||
|
||||
def is_dialogue(segments: Iterable[dict]) -> bool:
|
||||
"""True if ``segments`` represents a multi-speaker dialogue.
|
||||
|
||||
The single-speaker fast-path skips concatenation and just calls the
|
||||
underlying TTS once with the full text, so this gate has to be
|
||||
accurate. We require *two distinct named speakers* — a single
|
||||
speaker who happens to talk in multiple turns (rare but possible
|
||||
after an intro paragraph) is still a monologue from a TTS
|
||||
perspective.
|
||||
"""
|
||||
seen = set()
|
||||
for seg in segments:
|
||||
sp = seg.get('speaker')
|
||||
if sp:
|
||||
seen.add(sp.lower())
|
||||
if len(seen) >= 2:
|
||||
return True
|
||||
return False
|
||||
373
custom_addons/encoach_ai_course/services/exercise_extractor.py
Normal file
373
custom_addons/encoach_ai_course/services/exercise_extractor.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""Mine exercises from indexed reference books and persist them as
|
||||
``interactive_workbook`` materials on the plan.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1. Walk all chunks belonging to the plan's indexed sources
|
||||
(``encoach.embedding`` rows with ``content_type='course_plan_source'``
|
||||
and ``entity_id=plan.id``).
|
||||
2. Group chunks into N-chunk batches and ask the LLM to identify
|
||||
exercises in the text — gap-fills, multiple-choice items, matching
|
||||
pairs, word-reorder, transformation, short-answer.
|
||||
3. Validate / clean each exercise with cheap heuristics. Drop anything
|
||||
without a usable answer key. De-dupe against previously-extracted
|
||||
stems (same plan).
|
||||
4. For each accepted batch, attach the workbook to the most relevant
|
||||
week — picked by keyword overlap between the exercise stems and
|
||||
each week's ``unit + focus``. Falls back to the first week.
|
||||
5. Persist as one ``encoach.course.plan.material`` per batch with
|
||||
``material_type='interactive_workbook'`` and an
|
||||
``extracted_from`` provenance record so the UI can show "Extracted
|
||||
from <book>".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per LLM call: how many ~2K-char chunks to feed in one go. 6 keeps the
|
||||
# prompt under ~12K chars, well within a 16K context budget while still
|
||||
# letting the model see enough surrounding context to identify rubrics.
|
||||
CHUNKS_PER_BATCH = 6
|
||||
# Hard cap on total batches per run — protects against runaway cost on a
|
||||
# very large book. Operators can lift this via ``max_batches`` kwarg.
|
||||
DEFAULT_MAX_BATCHES = 8
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'gap_fill', 'multiple_choice', 'match_pairs',
|
||||
'reorder_words', 'transformation', 'short_answer',
|
||||
}
|
||||
|
||||
_EXTRACT_HINT = """
|
||||
You are reading raw text extracted from a printed English exercise book.
|
||||
Identify any drills, controlled-practice exercises, or workbook tasks in
|
||||
the passage and extract them as machine-checkable JSON.
|
||||
|
||||
Return JSON with EXACTLY this shape:
|
||||
{
|
||||
"exercises": [
|
||||
{"id":"e1", "type":"gap_fill", "stem":"I ___ (be) a student.", "answer":"am", "alt":["am"], "hint":"present simple of 'be'"},
|
||||
{"id":"e2", "type":"multiple_choice", "stem":"...", "options":["A","B","C","D"], "answer":"B", "rationale":"..."},
|
||||
{"id":"e3", "type":"match_pairs", "left":["dog","cat"], "right":["barks","meows"], "answer":[[0,0],[1,1]]},
|
||||
{"id":"e4", "type":"reorder_words", "tokens":["I","am","a","student"], "answer":"I am a student"},
|
||||
{"id":"e5", "type":"transformation", "from":"He plays football.", "instruction":"Make it negative.", "answer":"He doesn't play football.", "alt":["He does not play football."]},
|
||||
{"id":"e6", "type":"short_answer", "stem":"What's your name?", "answer":"My name is …", "accepted":["My name is *","I'm *","I am *"]}
|
||||
],
|
||||
"topic_keywords": ["routine","present simple", "..."],
|
||||
"page_hint": "if the source mentions 'p. 12' or 'Unit 1', echo it here"
|
||||
}
|
||||
|
||||
Strict rules
|
||||
------------
|
||||
- Skip narrative prose, scope-and-sequence pages, answer keys.
|
||||
- Skip exercises whose answer cannot be inferred from the source.
|
||||
- Use ONLY the six types listed above; never invent a new type.
|
||||
- Stems must be self-contained (no "look at exercise 3").
|
||||
- Answers must be a string, a boolean, or a list of [int,int] pairs as
|
||||
shown — never a sentence like "answer key on p. 99".
|
||||
- Output MUST be valid JSON. If the passage contains no usable
|
||||
exercises, return ``{"exercises": [], "topic_keywords": [], "page_hint": ""}``.
|
||||
"""
|
||||
|
||||
|
||||
def _normalize_stem(stem: str) -> str:
|
||||
"""Lowercase + collapse whitespace — used for de-dup keys."""
|
||||
return re.sub(r'\s+', ' ', (stem or '').strip().lower())
|
||||
|
||||
|
||||
def _is_clean_exercise(ex: dict) -> bool:
|
||||
"""Cheap structural validation. Drops anything we can't grade."""
|
||||
t = (ex.get('type') or '').lower()
|
||||
if t not in ALLOWED_TYPES:
|
||||
return False
|
||||
if not (ex.get('stem') or ex.get('from') or ex.get('left') or ex.get('tokens')):
|
||||
return False
|
||||
ans = ex.get('answer')
|
||||
if ans is None or ans == '' or ans == []:
|
||||
return False
|
||||
if t == 'multiple_choice':
|
||||
opts = ex.get('options') or []
|
||||
if not isinstance(opts, list) or len(opts) < 2:
|
||||
return False
|
||||
if isinstance(ans, str) and ans not in opts:
|
||||
# Some authors use "B" as the answer; that's fine.
|
||||
if not (len(ans) == 1 and ans.isalpha()):
|
||||
return False
|
||||
if t == 'match_pairs':
|
||||
if not (isinstance(ex.get('left'), list)
|
||||
and isinstance(ex.get('right'), list)
|
||||
and isinstance(ans, list)):
|
||||
return False
|
||||
if not all(isinstance(p, list) and len(p) == 2 for p in ans):
|
||||
return False
|
||||
if t == 'reorder_words':
|
||||
toks = ex.get('tokens')
|
||||
if not (isinstance(toks, list) and len(toks) >= 2):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ExerciseExtractor:
|
||||
"""Walk the plan's indexed corpus and persist interactive workbooks."""
|
||||
|
||||
CONTENT_TYPE = 'course_plan_source'
|
||||
|
||||
def __init__(self, env, *, language: str = 'en'):
|
||||
self.env = env
|
||||
self.language = language
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import (
|
||||
OpenAIService,
|
||||
)
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
self._OpenAIService = OpenAIService
|
||||
self._ai = None # lazy
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _service(self):
|
||||
if self._ai is not None:
|
||||
return self._ai
|
||||
if self._OpenAIService is None:
|
||||
raise RuntimeError('encoach_ai.OpenAIService not available')
|
||||
self._ai = self._OpenAIService(self.env, language=self.language)
|
||||
return self._ai
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _load_corpus(self, plan) -> list[dict]:
|
||||
"""Return all indexed chunks for the plan, oldest-first."""
|
||||
Embedding = self.env['encoach.embedding'].sudo()
|
||||
rows = Embedding.search([
|
||||
('content_type', '=', self.CONTENT_TYPE),
|
||||
('entity_id', '=', plan.id),
|
||||
], order='content_id asc, chunk_index asc')
|
||||
chunks = []
|
||||
for r in rows:
|
||||
metadata = {}
|
||||
try:
|
||||
metadata = json.loads(r.metadata_json or '{}')
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
chunks.append({
|
||||
'source_id': int(metadata.get('source_id') or r.content_id),
|
||||
'title': metadata.get('title') or '',
|
||||
'chunk_index': r.chunk_index,
|
||||
'text': r.content_text or '',
|
||||
})
|
||||
return chunks
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _existing_stems(self, plan) -> set[str]:
|
||||
"""Already-stored normalized stems → avoid duplicate workbooks."""
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
rows = Material.search([
|
||||
('plan_id', '=', plan.id),
|
||||
('material_type', '=', 'interactive_workbook'),
|
||||
])
|
||||
seen: set[str] = set()
|
||||
for m in rows:
|
||||
try:
|
||||
body = json.loads(m.body_json or '{}')
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for ex in body.get('exercises') or []:
|
||||
key = _normalize_stem(ex.get('stem') or ex.get('from') or '')
|
||||
if key:
|
||||
seen.add(key)
|
||||
return seen
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _pick_week(plan, topic_keywords: list[str], stems: list[str]):
|
||||
"""Return the week most relevant to a batch's topic keywords.
|
||||
|
||||
Falls back to week 1 when the plan has no overlap signal — that's
|
||||
better than dropping the workbook entirely.
|
||||
"""
|
||||
weeks = list(plan.week_ids.sorted('week_number'))
|
||||
if not weeks:
|
||||
return None
|
||||
haystack = ' '.join(topic_keywords + stems).lower()
|
||||
if not haystack.strip():
|
||||
return weeks[0]
|
||||
|
||||
def score(week):
|
||||
text = f'{week.unit or ""} {week.focus or ""}'.lower()
|
||||
terms = [t for t in re.split(r'\W+', text) if len(t) > 3]
|
||||
return sum(1 for t in terms if t in haystack)
|
||||
|
||||
ranked = sorted(weeks, key=score, reverse=True)
|
||||
# If nothing scored at all, prefer week 1 for stability.
|
||||
return ranked[0] if score(ranked[0]) > 0 else weeks[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _llm_extract_batch(self, chunks: list[dict]) -> dict:
|
||||
"""Run one extraction LLM call against a batch of chunks."""
|
||||
ai = self._service()
|
||||
joined = '\n\n---\n\n'.join(
|
||||
f'[chunk {c["chunk_index"]} | source #{c["source_id"]}]\n{c["text"]}'
|
||||
for c in chunks
|
||||
)
|
||||
messages = [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': (
|
||||
'You extract original printed exercises from English '
|
||||
'workbook scans. You return STRICT JSON. You never '
|
||||
'invent exercises that are not on the page.'
|
||||
),
|
||||
},
|
||||
{
|
||||
'role': 'user',
|
||||
'content': (
|
||||
f'Extract the exercises from the passage below.\n\n'
|
||||
f'{_EXTRACT_HINT}\n\nPassage:\n{joined}'
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
return ai.chat_json(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
max_tokens=4000,
|
||||
action='course_plan.extract_exercises',
|
||||
) or {}
|
||||
except Exception as exc:
|
||||
_logger.warning('Exercise extraction LLM call failed: %s', exc)
|
||||
return {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def run(self, plan, *, max_batches: int = DEFAULT_MAX_BATCHES) -> dict:
|
||||
"""Extract exercises for ``plan`` and persist workbook materials.
|
||||
|
||||
:returns: ``{materials_created, exercises_total, batches_run, skipped}``.
|
||||
"""
|
||||
chunks = self._load_corpus(plan)
|
||||
if not chunks:
|
||||
return {
|
||||
'materials_created': 0,
|
||||
'exercises_total': 0,
|
||||
'batches_run': 0,
|
||||
'skipped': 0,
|
||||
'reason': 'no indexed chunks for this plan',
|
||||
}
|
||||
|
||||
seen_stems = self._existing_stems(plan)
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
materials_created = 0
|
||||
exercises_total = 0
|
||||
batches_run = 0
|
||||
skipped_dup = 0
|
||||
|
||||
# Group chunks per source so a single workbook stays cohesive.
|
||||
by_source: dict[int, list[dict]] = {}
|
||||
for ch in chunks:
|
||||
by_source.setdefault(ch['source_id'], []).append(ch)
|
||||
|
||||
for source_id, source_chunks in by_source.items():
|
||||
for i in range(0, len(source_chunks), CHUNKS_PER_BATCH):
|
||||
if batches_run >= max_batches:
|
||||
break
|
||||
batch = source_chunks[i:i + CHUNKS_PER_BATCH]
|
||||
batches_run += 1
|
||||
payload = self._llm_extract_batch(batch)
|
||||
if not payload or 'error' in payload:
|
||||
continue
|
||||
raw_exercises = payload.get('exercises') or []
|
||||
clean: list[dict] = []
|
||||
for ex in raw_exercises:
|
||||
if not isinstance(ex, dict):
|
||||
continue
|
||||
if not _is_clean_exercise(ex):
|
||||
continue
|
||||
key = _normalize_stem(
|
||||
ex.get('stem') or ex.get('from') or '',
|
||||
)
|
||||
if not key or key in seen_stems:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
seen_stems.add(key)
|
||||
clean.append(ex)
|
||||
if not clean:
|
||||
continue
|
||||
|
||||
topic_keywords = [
|
||||
str(t).strip() for t in (payload.get('topic_keywords') or [])
|
||||
if str(t).strip()
|
||||
]
|
||||
page_hint = (payload.get('page_hint') or '').strip()
|
||||
stems = [ex.get('stem') or ex.get('from') or '' for ex in clean]
|
||||
week = self._pick_week(plan, topic_keywords, stems)
|
||||
if week is None:
|
||||
continue
|
||||
|
||||
title = self._pick_title(batch, topic_keywords, week)
|
||||
summary = (
|
||||
f'Extracted from {batch[0].get("title") or "uploaded book"}'
|
||||
+ (f' ({page_hint})' if page_hint else '')
|
||||
+ f' — {len(clean)} interactive exercise(s).'
|
||||
)
|
||||
body = {
|
||||
'lesson_plan': {
|
||||
'warmup': '',
|
||||
'presentation': (
|
||||
'Originally printed exercises mined from the '
|
||||
'reference book. Use them as in-class drills '
|
||||
'or homework.'
|
||||
),
|
||||
'controlled_practice': '',
|
||||
'freer_practice': '',
|
||||
'exit_ticket': '',
|
||||
},
|
||||
'exercises': clean,
|
||||
}
|
||||
provenance = {
|
||||
'source_id': int(source_id),
|
||||
'source_title': batch[0].get('title') or '',
|
||||
'page_hint': page_hint,
|
||||
'topic_keywords': topic_keywords,
|
||||
'chunk_indices': [c['chunk_index'] for c in batch],
|
||||
}
|
||||
Material.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': 'integrated',
|
||||
'material_type': 'interactive_workbook',
|
||||
'title': title,
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(body, ensure_ascii=False),
|
||||
'body_text': '',
|
||||
'extracted_from_json': json.dumps(
|
||||
provenance, ensure_ascii=False,
|
||||
),
|
||||
})
|
||||
materials_created += 1
|
||||
exercises_total += len(clean)
|
||||
|
||||
if batches_run >= max_batches:
|
||||
break
|
||||
|
||||
return {
|
||||
'materials_created': materials_created,
|
||||
'exercises_total': exercises_total,
|
||||
'batches_run': batches_run,
|
||||
'skipped': skipped_dup,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _pick_title(batch, topic_keywords, week) -> str:
|
||||
"""Compose a friendly material title for the extracted workbook."""
|
||||
topic = ''
|
||||
if topic_keywords:
|
||||
topic = topic_keywords[0]
|
||||
if not topic and week and (week.unit or week.focus):
|
||||
topic = (week.unit or week.focus or '').strip()
|
||||
if not topic:
|
||||
topic = 'Practice'
|
||||
return f'Workbook — {topic.title()}'
|
||||
@@ -31,17 +31,42 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_bin(name: str) -> str | None:
|
||||
"""Locate a binary, preferring the running Python's sibling ``bin/``.
|
||||
|
||||
Odoo is launched with the conda env's interpreter, so its sibling
|
||||
``bin/`` reliably hosts ``ffmpeg`` even when ``$PATH`` in the
|
||||
daemon's environment doesn't include the conda prefix. We use this
|
||||
same pattern in ``encoach_ai_course.services.source_indexer`` for
|
||||
tesseract / pdftoppm — keeping it duplicated here rather than
|
||||
importing across services to avoid a circular dep on a single
|
||||
helper.
|
||||
"""
|
||||
sibling = os.path.join(os.path.dirname(sys.executable), name)
|
||||
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
|
||||
return sibling
|
||||
return shutil.which(name)
|
||||
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
should_fallback,
|
||||
)
|
||||
from . import dialogue_parser
|
||||
|
||||
|
||||
# Providers that can do per-segment multi-voice TTS. gTTS / silent only
|
||||
# expose a single voice per request, so for those we degrade gracefully
|
||||
# to label-stripped single-voice narration.
|
||||
_MULTI_VOICE_PROVIDERS = frozenset({'polly', 'elevenlabs'})
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
@@ -93,7 +118,16 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
|
||||
|
||||
def _build_audio_script(material) -> str:
|
||||
"""Choose the right text from a material's body for narration."""
|
||||
"""Choose the right text from a material's body for narration.
|
||||
|
||||
NEVER include the structural field labels ("Context:", "Script:",
|
||||
"Transcript:") — only the actual narratable content. The previous
|
||||
implementation already did this for ``listening_script`` (only the
|
||||
``script`` field was returned), but we re-document the contract
|
||||
here because the dialogue-aware path below depends on it: the
|
||||
speaker labels we strip out are ONLY the in-text "Name:" turn
|
||||
markers, never section headers.
|
||||
"""
|
||||
body = material._loads(material.body_json, {}) or {}
|
||||
if material.material_type == 'listening_script':
|
||||
return (body.get('script') or '').strip()
|
||||
@@ -208,16 +242,41 @@ class MediaService:
|
||||
return media
|
||||
media.write({'source_text': text[:3000]})
|
||||
|
||||
# Parse "Host: ... Sarah: ..." style dialogues into per-speaker
|
||||
# segments. For monologues this returns a single segment with
|
||||
# speaker=None and we fall through to the legacy fast-path. For
|
||||
# real dialogues we'll synthesize each turn with a gender-matched
|
||||
# voice and concatenate them with ffmpeg.
|
||||
segments = dialogue_parser.parse_dialogue(text)
|
||||
is_dialogue = dialogue_parser.is_dialogue(segments)
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'audio', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
result = self._call_audio_provider(
|
||||
prov, text=text, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
if is_dialogue and prov in _MULTI_VOICE_PROVIDERS:
|
||||
# The good path: per-speaker, gender-matched voices,
|
||||
# stitched into a single MP3 by ffmpeg. The user asked
|
||||
# for "if conversation between two persons, person if
|
||||
# man voice must be man and if woman voice is for
|
||||
# woman" — this is where that contract lives.
|
||||
result = self._synthesize_dialogue(
|
||||
prov, segments=segments, language=language,
|
||||
)
|
||||
else:
|
||||
# Single-voice path. Even here we must NOT speak the
|
||||
# speaker labels aloud, so for dialogue scripts hitting
|
||||
# a free fallback we feed the label-stripped form.
|
||||
spoken = (
|
||||
dialogue_parser.strip_speaker_labels(text)
|
||||
if is_dialogue else text
|
||||
)
|
||||
result = self._call_audio_provider(
|
||||
prov, text=spoken, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
content_type = result.get('content_type', 'audio/mpeg')
|
||||
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
|
||||
attach = _attach_bytes(
|
||||
@@ -294,6 +353,132 @@ class MediaService:
|
||||
return synthesize_silent(duration_seconds=seconds)
|
||||
raise RuntimeError(f'Unknown audio provider: {provider!r}')
|
||||
|
||||
# -- Multi-voice dialogue --------------------------------------------
|
||||
def _synthesize_dialogue(self, provider, *, segments, language):
|
||||
"""Render a dialogue script with per-speaker, gendered voices.
|
||||
|
||||
Each turn is synthesized independently — Polly/ElevenLabs both
|
||||
let us pick a voice per call — and the resulting MP3 / WAV blobs
|
||||
are concatenated via ffmpeg's concat filter into a single audio
|
||||
track. We re-encode through libmp3lame so the output is uniform
|
||||
regardless of which provider was used (bitrate, sample-rate
|
||||
differences would otherwise corrupt the join).
|
||||
|
||||
If ffmpeg is not available we still return *some* audio — a
|
||||
single-voice rendering of the label-stripped text — rather than
|
||||
failing the whole request. Listening practice without a real
|
||||
dialogue is preferable to no audio at all.
|
||||
"""
|
||||
if not segments:
|
||||
raise RuntimeError('No dialogue segments to synthesize')
|
||||
|
||||
ffmpeg_bin = _resolve_bin('ffmpeg')
|
||||
if not ffmpeg_bin:
|
||||
# Graceful degradation: produce a single-voice rendition of
|
||||
# the stripped script. Surface a soft warning but do not
|
||||
# raise — the caller's fallback chain would just route us to
|
||||
# gtts/silent, which is identical behaviour but slower.
|
||||
_logger.warning(
|
||||
'ffmpeg missing — falling back to single-voice dialogue; '
|
||||
'install ffmpeg for true per-speaker rendering.'
|
||||
)
|
||||
joined = ' '.join(seg['text'] for seg in segments)
|
||||
return self._call_audio_provider(
|
||||
provider, text=joined, voice=None,
|
||||
language=language, gender='female',
|
||||
)
|
||||
|
||||
clip_paths: list[str] = []
|
||||
first_voice: Optional[str] = None
|
||||
# Polly bills per character; cap each turn at ~3000 chars (5x the
|
||||
# ~600-char average so a stray giant turn from the LLM doesn't
|
||||
# blow the budget but a typical 3-minute conversation passes
|
||||
# untouched).
|
||||
MAX_CHARS_PER_TURN = 3000
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='encoach_dialogue_') as tmp:
|
||||
for idx, seg in enumerate(segments):
|
||||
seg_text = (seg.get('text') or '').strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
seg_text = seg_text[:MAX_CHARS_PER_TURN]
|
||||
seg_gender = seg.get('gender') or 'female'
|
||||
try:
|
||||
# Pin the per-segment voice via gender. Letting
|
||||
# ``voice=None`` flow through means each provider
|
||||
# picks its default gendered voice (Amy/Brian for
|
||||
# Polly en-GB, Rachel/Arnold for ElevenLabs).
|
||||
res = self._call_audio_provider(
|
||||
provider, text=seg_text, voice=None,
|
||||
language=language, gender=seg_gender,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'segment {idx} ({seg.get("speaker") or "narrator"}) '
|
||||
f'failed: {exc}'
|
||||
) from exc
|
||||
if not first_voice:
|
||||
first_voice = res.get('voice') or seg_gender
|
||||
ext = 'wav' if res.get(
|
||||
'content_type') == 'audio/wav' else 'mp3'
|
||||
clip_path = os.path.join(tmp, f'seg_{idx:03d}.{ext}')
|
||||
with open(clip_path, 'wb') as fh:
|
||||
fh.write(res['audio'])
|
||||
clip_paths.append(clip_path)
|
||||
|
||||
if not clip_paths:
|
||||
raise RuntimeError('All dialogue segments were empty')
|
||||
if len(clip_paths) == 1:
|
||||
# Edge case: 2 speakers but only one had non-empty text.
|
||||
# Skip the concat dance and return the single clip raw.
|
||||
with open(clip_paths[0], 'rb') as fh:
|
||||
return {
|
||||
'audio': fh.read(),
|
||||
'content_type': 'audio/mpeg' if clip_paths[0].endswith('.mp3') else 'audio/wav',
|
||||
'voice': first_voice or 'dialogue',
|
||||
'characters': sum(len(s.get('text') or '') for s in segments),
|
||||
}
|
||||
|
||||
# Build the ffmpeg concat-filter call. We re-encode to a
|
||||
# uniform 22050 Hz mono MP3 so bitrate/sample-rate mismatches
|
||||
# between providers (or even between Polly's neural and
|
||||
# standard engines) don't corrupt the join.
|
||||
out_path = os.path.join(tmp, 'dialogue.mp3')
|
||||
cmd = [ffmpeg_bin, '-y']
|
||||
for p in clip_paths:
|
||||
cmd += ['-i', p]
|
||||
n = len(clip_paths)
|
||||
filter_inputs = ''.join(f'[{i}:a]' for i in range(n))
|
||||
cmd += [
|
||||
'-filter_complex',
|
||||
f'{filter_inputs}concat=n={n}:v=0:a=1[outa]',
|
||||
'-map', '[outa]',
|
||||
'-ac', '1',
|
||||
'-ar', '22050',
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '96k',
|
||||
out_path,
|
||||
]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, check=False, timeout=180,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b'').decode(
|
||||
'utf-8', errors='replace')[-500:]
|
||||
raise RuntimeError(
|
||||
f'ffmpeg concat failed in {elapsed:.1f}s: {err}',
|
||||
)
|
||||
with open(out_path, 'rb') as fh:
|
||||
audio_bytes = fh.read()
|
||||
return {
|
||||
'audio': audio_bytes,
|
||||
'content_type': 'audio/mpeg',
|
||||
'voice': f'dialogue-{n}-turns',
|
||||
'characters': sum(len(s.get('text') or '') for s in segments),
|
||||
}
|
||||
|
||||
# -- Image -----------------------------------------------------------
|
||||
def generate_image(self, material, *,
|
||||
custom_prompt: Optional[str] = None,
|
||||
@@ -497,8 +682,9 @@ class MediaService:
|
||||
# ── Concrete video providers ────────────────────────────────────────
|
||||
|
||||
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
|
||||
if shutil.which('ffmpeg') is None:
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available."""
|
||||
ffmpeg_bin = _resolve_bin('ffmpeg')
|
||||
if ffmpeg_bin is None:
|
||||
raise RuntimeError('ffmpeg not found on PATH')
|
||||
|
||||
plan = material.plan_id
|
||||
@@ -537,7 +723,7 @@ class MediaService:
|
||||
with open(i_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
ffmpeg_bin, '-y',
|
||||
'-loop', '1', '-i', i_path,
|
||||
'-i', a_path,
|
||||
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
||||
|
||||
281
custom_addons/encoach_ai_course/services/rag_context.py
Normal file
281
custom_addons/encoach_ai_course/services/rag_context.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Build RAG context for course-plan generation.
|
||||
|
||||
Wraps :class:`encoach_vector.services.embedding_service.EmbeddingService` to
|
||||
return the top-k chunks from a plan's indexed reference sources, scoped to
|
||||
the canonical ``content_type='course_plan_source'`` and trimmed to a
|
||||
character budget so the LLM prompt stays within context limits.
|
||||
|
||||
Indexer convention
|
||||
------------------
|
||||
:class:`SourceIndexer` stores each ``encoach.course.plan.source`` chunk
|
||||
with metadata ``{plan_id, source_id, kind, title, entity_id=plan_id}``
|
||||
(see :mod:`source_indexer`). This builder uses that convention to scope
|
||||
retrieval to a single plan: it filters embeddings on ``entity_id=plan.id``
|
||||
and ``content_type='course_plan_source'``.
|
||||
|
||||
Usage
|
||||
-----
|
||||
.. code-block:: python
|
||||
|
||||
builder = RAGContextBuilder(env)
|
||||
ctx = builder.for_week(plan, week, skill='reading', k=6)
|
||||
if ctx['passages']:
|
||||
prompt += "\\n\\nReference passages from your uploaded book(s):\\n"
|
||||
prompt += ctx['as_prompt_block']
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Soft char budget for the assembled passage block. ~3500 chars is roughly
|
||||
# 800-1000 tokens, leaving room for the rest of the prompt (~2-3 K tokens)
|
||||
# under a 16 K context window.
|
||||
DEFAULT_CHAR_BUDGET = 3500
|
||||
DEFAULT_K = 6
|
||||
|
||||
|
||||
class RAGContextBuilder:
|
||||
"""Produce RAG passages for a (plan, week, skill) tuple."""
|
||||
|
||||
CONTENT_TYPE = 'course_plan_source'
|
||||
|
||||
def __init__(self, env, *, char_budget: int = DEFAULT_CHAR_BUDGET):
|
||||
self.env = env
|
||||
self.char_budget = int(char_budget)
|
||||
self._svc = None # lazily created on first call
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _service(self):
|
||||
"""Lazy-load the embedding service so import never fails fatally."""
|
||||
if self._svc is not None:
|
||||
return self._svc
|
||||
try:
|
||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||
EmbeddingService,
|
||||
)
|
||||
except ImportError:
|
||||
_logger.warning(
|
||||
'encoach_vector not installed; RAG context disabled.'
|
||||
)
|
||||
return None
|
||||
self._svc = EmbeddingService(self.env)
|
||||
return self._svc
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def has_indexed_sources(self, plan) -> bool:
|
||||
"""Return True iff the plan has at least one indexed (status='indexed') source."""
|
||||
try:
|
||||
count = self.env['encoach.course.plan.source'].sudo().search_count([
|
||||
('plan_id', '=', plan.id),
|
||||
('status', '=', 'indexed'),
|
||||
])
|
||||
except Exception:
|
||||
return False
|
||||
return bool(count)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _build_query(self, plan, week, skill: str) -> str:
|
||||
"""Synthesize a focused retrieval query for (week, skill).
|
||||
|
||||
The query is intentionally dense: skill, week unit + focus, and the
|
||||
descriptions of the outcomes targeted by this week's items. We feed
|
||||
the descriptions (not just codes) so semantic search can match book
|
||||
passages that talk about, say, "personal introductions" even when
|
||||
no outcome code appears in the source text.
|
||||
"""
|
||||
outcomes = plan._loads(plan.outcomes_json, {})
|
||||
items = week._loads(week.items_json, []) if week else []
|
||||
codes_for_skill: list[str] = []
|
||||
for it in items:
|
||||
if (it.get('skill') or '').lower() == (skill or '').lower():
|
||||
codes_for_skill.extend(it.get('outcome_codes') or [])
|
||||
|
||||
outcome_descs: list[str] = []
|
||||
skill_outcomes = outcomes.get((skill or '').lower()) or []
|
||||
for o in skill_outcomes:
|
||||
if o.get('code') in set(codes_for_skill):
|
||||
outcome_descs.append((o.get('description') or '').strip())
|
||||
|
||||
parts = [
|
||||
(skill or '').strip(),
|
||||
(week.unit or '').strip() if week else '',
|
||||
(week.focus or '').strip() if week else '',
|
||||
' '.join(d for d in outcome_descs if d),
|
||||
]
|
||||
query = ' — '.join(p for p in parts if p)
|
||||
return query or (plan.name or 'general english')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _trim_to_budget(self, results: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Cut chunks until total chars <= self.char_budget.
|
||||
|
||||
Returns the truncated list and the actual char count used.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
used = 0
|
||||
for r in results:
|
||||
text = (r.get('text') or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
# Per-chunk soft cap so one giant page can't eat the whole budget.
|
||||
if len(text) > 1200:
|
||||
text = text[:1200].rsplit(' ', 1)[0] + '…'
|
||||
if used + len(text) > self.char_budget:
|
||||
# If we have nothing yet, take a hard slice so the prompt is
|
||||
# never empty when the budget is tiny.
|
||||
if not out:
|
||||
text = text[: max(self.char_budget // 2, 200)]
|
||||
else:
|
||||
break
|
||||
out.append({**r, 'text': text})
|
||||
used += len(text)
|
||||
return out, used
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def for_week(self, plan, week, skill: str, *, k: int = DEFAULT_K) -> dict:
|
||||
"""Return RAG context for a (plan, week, skill).
|
||||
|
||||
:returns: ``{passages, sources, char_budget_used, query, as_prompt_block}``.
|
||||
Empty dict-shape when the plan has no indexed sources.
|
||||
"""
|
||||
empty = {
|
||||
'passages': [],
|
||||
'sources': [],
|
||||
'char_budget_used': 0,
|
||||
'query': '',
|
||||
'as_prompt_block': '',
|
||||
}
|
||||
svc = self._service()
|
||||
if svc is None:
|
||||
return empty
|
||||
if not self.has_indexed_sources(plan):
|
||||
return empty
|
||||
|
||||
query = self._build_query(plan, week, skill)
|
||||
try:
|
||||
raw_results = svc.search(
|
||||
query,
|
||||
content_type=self.CONTENT_TYPE,
|
||||
limit=int(k),
|
||||
entity_id=plan.id, # SourceIndexer stores plan_id under entity_id
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception('RAG search failed for plan=%s skill=%s', plan.id, skill)
|
||||
return empty
|
||||
|
||||
# Decorate with source titles for citation.
|
||||
Source = self.env['encoach.course.plan.source'].sudo()
|
||||
passages: list[dict] = []
|
||||
for r in raw_results or []:
|
||||
md = r.get('metadata') or {}
|
||||
source_id = int(md.get('source_id') or 0) or None
|
||||
title = md.get('title') or ''
|
||||
if source_id and not title:
|
||||
rec = Source.browse(source_id)
|
||||
if rec.exists():
|
||||
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
|
||||
passages.append({
|
||||
'text': r.get('text') or '',
|
||||
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
|
||||
'source_id': source_id,
|
||||
'source_title': title,
|
||||
'chunk_index': md.get('chunk_index'),
|
||||
})
|
||||
|
||||
passages, used = self._trim_to_budget(passages)
|
||||
sources = self._collect_sources(passages)
|
||||
block = self._render_prompt_block(passages)
|
||||
return {
|
||||
'passages': passages,
|
||||
'sources': sources,
|
||||
'char_budget_used': used,
|
||||
'query': query,
|
||||
'as_prompt_block': block,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def for_plan(self, plan, *, k: int = 12, query: str | None = None) -> dict:
|
||||
"""Plan-wide retrieval — used by ExerciseExtractor and the workbook
|
||||
cross-week pass. Falls back to ``plan.name`` when no query is given.
|
||||
"""
|
||||
empty = {
|
||||
'passages': [],
|
||||
'sources': [],
|
||||
'char_budget_used': 0,
|
||||
'query': '',
|
||||
'as_prompt_block': '',
|
||||
}
|
||||
svc = self._service()
|
||||
if svc is None or not self.has_indexed_sources(plan):
|
||||
return empty
|
||||
q = (query or plan.name or 'exercises').strip()
|
||||
try:
|
||||
raw_results = svc.search(
|
||||
q,
|
||||
content_type=self.CONTENT_TYPE,
|
||||
limit=int(k),
|
||||
entity_id=plan.id,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception('RAG plan-wide search failed for plan=%s', plan.id)
|
||||
return empty
|
||||
Source = self.env['encoach.course.plan.source'].sudo()
|
||||
passages: list[dict] = []
|
||||
for r in raw_results or []:
|
||||
md = r.get('metadata') or {}
|
||||
source_id = int(md.get('source_id') or 0) or None
|
||||
title = md.get('title') or ''
|
||||
if source_id and not title:
|
||||
rec = Source.browse(source_id)
|
||||
if rec.exists():
|
||||
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
|
||||
passages.append({
|
||||
'text': r.get('text') or '',
|
||||
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
|
||||
'source_id': source_id,
|
||||
'source_title': title,
|
||||
'chunk_index': md.get('chunk_index'),
|
||||
})
|
||||
passages, used = self._trim_to_budget(passages)
|
||||
sources = self._collect_sources(passages)
|
||||
block = self._render_prompt_block(passages)
|
||||
return {
|
||||
'passages': passages,
|
||||
'sources': sources,
|
||||
'char_budget_used': used,
|
||||
'query': q,
|
||||
'as_prompt_block': block,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _collect_sources(passages: list[dict]) -> list[dict]:
|
||||
"""Group passages by source_id → ``[{source_id, title, chunks_used}]``."""
|
||||
agg: dict[int, dict] = {}
|
||||
for p in passages:
|
||||
sid = p.get('source_id') or 0
|
||||
entry = agg.setdefault(sid, {
|
||||
'source_id': sid,
|
||||
'title': p.get('source_title') or '',
|
||||
'chunks_used': 0,
|
||||
})
|
||||
entry['chunks_used'] += 1
|
||||
if not entry['title']:
|
||||
entry['title'] = p.get('source_title') or ''
|
||||
return [v for v in agg.values() if v['source_id']]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _render_prompt_block(passages: list[dict]) -> str:
|
||||
"""Format passages as a citation-ready prompt block."""
|
||||
if not passages:
|
||||
return ''
|
||||
lines: list[str] = []
|
||||
for i, p in enumerate(passages, start=1):
|
||||
title = p.get('source_title') or 'Reference'
|
||||
lines.append(f'[{i}] ({title})\n{p.get("text", "")}\n')
|
||||
return '\n'.join(lines).strip()
|
||||
@@ -20,17 +20,140 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# A doc with fewer than this many extractable characters is treated as
|
||||
# image-only and routed to OCR. Most legitimate PDFs cross 200 chars in
|
||||
# their first few pages; below that we're almost certainly looking at a
|
||||
# scan whose pypdf/pdfminer output is junk metadata only.
|
||||
_OCR_TRIGGER_CHARS = 200
|
||||
|
||||
# Cap how much we OCR in a single indexing pass so a 500-page scanned
|
||||
# tome doesn't tie up the request thread for 20 minutes. Editable via
|
||||
# env var so an operator can dial it up for big workbooks.
|
||||
_OCR_MAX_PAGES = int(os.environ.get('ENCOACH_OCR_MAX_PAGES', '300'))
|
||||
_OCR_DPI = int(os.environ.get('ENCOACH_OCR_DPI', '200'))
|
||||
_OCR_LANGS = os.environ.get('ENCOACH_OCR_LANGS', 'eng')
|
||||
|
||||
|
||||
def _resolve_bin(name: str) -> str | None:
|
||||
"""Locate a binary by checking the running Python's bin/ first.
|
||||
|
||||
Odoo is launched with the conda env's interpreter, so its sibling
|
||||
``bin/`` reliably hosts ``tesseract`` / ``pdftoppm`` even when
|
||||
``$PATH`` in the daemon's environment is sparse. Fall back to
|
||||
``shutil.which`` for systems where they're installed globally.
|
||||
"""
|
||||
sibling = os.path.join(os.path.dirname(sys.executable), name)
|
||||
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
|
||||
return sibling
|
||||
import shutil
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
|
||||
"""OCR a scanned/image-only PDF using tesseract + poppler.
|
||||
|
||||
Streams page-by-page so peak memory stays bounded even on a 100+
|
||||
page workbook: rasterizing all pages eagerly into PIL.Image objects
|
||||
blows past Odoo's ~2 GB ``limit_memory_soft`` and forces a worker
|
||||
reload mid-request. We instead call ``pdftoppm`` for one page at a
|
||||
time, OCR the image, drop it, and move on.
|
||||
|
||||
Raises a ``RuntimeError`` with an actionable message when the OCR
|
||||
stack isn't available, so the UI can surface "install OCR" rather
|
||||
than the cryptic "Extracted no text from source".
|
||||
"""
|
||||
try:
|
||||
from pdf2image import convert_from_bytes, pdfinfo_from_bytes # type: ignore
|
||||
import pytesseract # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'This PDF appears to be scanned/image-only and the OCR stack '
|
||||
'is not installed. Install the deps: '
|
||||
'`micromamba install -n odoo19 -c conda-forge tesseract poppler` '
|
||||
'and `pip install pytesseract pdf2image`. '
|
||||
f'(missing: {exc.name})',
|
||||
) from exc
|
||||
|
||||
tesseract_bin = _resolve_bin('tesseract')
|
||||
poppler_bin = _resolve_bin('pdftoppm')
|
||||
if not tesseract_bin or not poppler_bin:
|
||||
raise RuntimeError(
|
||||
'OCR is required for this PDF (no embedded text layer) but '
|
||||
f'tesseract={tesseract_bin or "missing"} / '
|
||||
f'pdftoppm={poppler_bin or "missing"} could not be located. '
|
||||
'Install with `micromamba install -n odoo19 -c conda-forge '
|
||||
'tesseract poppler`.',
|
||||
)
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_bin
|
||||
poppler_path = os.path.dirname(poppler_bin)
|
||||
|
||||
try:
|
||||
info = pdfinfo_from_bytes(payload, poppler_path=poppler_path)
|
||||
total_pages = int(info.get('Pages') or 0)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Could not read PDF page count: {exc}') from exc
|
||||
|
||||
page_count = min(total_pages, max_pages)
|
||||
if page_count <= 0:
|
||||
return ''
|
||||
|
||||
_logger.info(
|
||||
'OCR streaming %s page(s) at %sdpi (lang=%s)',
|
||||
page_count, _OCR_DPI, _OCR_LANGS,
|
||||
)
|
||||
|
||||
pages: list[str] = []
|
||||
for page_num in range(1, page_count + 1):
|
||||
try:
|
||||
images = convert_from_bytes(
|
||||
payload,
|
||||
dpi=_OCR_DPI,
|
||||
first_page=page_num,
|
||||
last_page=page_num,
|
||||
poppler_path=poppler_path,
|
||||
fmt='png',
|
||||
thread_count=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning('OCR rasterize page %s failed: %s', page_num, exc)
|
||||
continue
|
||||
if not images:
|
||||
continue
|
||||
img = images[0]
|
||||
try:
|
||||
txt = pytesseract.image_to_string(img, lang=_OCR_LANGS) or ''
|
||||
except Exception as exc:
|
||||
_logger.warning('OCR page %s failed: %s', page_num, exc)
|
||||
txt = ''
|
||||
finally:
|
||||
try:
|
||||
img.close()
|
||||
except Exception:
|
||||
pass
|
||||
del img, images
|
||||
if txt.strip():
|
||||
pages.append(txt)
|
||||
|
||||
return '\n\n'.join(pages).strip()
|
||||
|
||||
|
||||
def _extract_pdf(payload: bytes) -> str:
|
||||
"""Best-effort PDF text extraction.
|
||||
"""Best-effort PDF text extraction with OCR fallback.
|
||||
|
||||
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
|
||||
still common). Both raise on encrypted PDFs we can't decrypt — we
|
||||
swallow that and let the caller record the error.
|
||||
1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the
|
||||
embedded text layer — fast, deterministic, lossless.
|
||||
2. If that yields virtually nothing (< ``_OCR_TRIGGER_CHARS``), the
|
||||
PDF is almost certainly a scan; rasterize the first
|
||||
``_OCR_MAX_PAGES`` pages and run them through tesseract.
|
||||
|
||||
Both raise on encrypted PDFs we can't decrypt — we swallow that and
|
||||
let the caller record the error.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
@@ -48,7 +171,22 @@ def _extract_pdf(payload: bytes) -> str:
|
||||
pages.append(page.extract_text() or '')
|
||||
except Exception:
|
||||
pages.append('')
|
||||
return '\n\n'.join(p for p in pages if p).strip()
|
||||
text = '\n\n'.join(p for p in pages if p).strip()
|
||||
|
||||
if len(text) >= _OCR_TRIGGER_CHARS:
|
||||
return text
|
||||
|
||||
# Looks image-only — try OCR. If OCR is unavailable, propagate a
|
||||
# clear error rather than silently returning the empty/near-empty
|
||||
# text-layer output.
|
||||
_logger.info(
|
||||
'PDF text layer empty (%s chars over %s pages); attempting OCR',
|
||||
len(text), len(reader.pages),
|
||||
)
|
||||
ocr_text = _ocr_pdf(payload)
|
||||
if ocr_text:
|
||||
return ocr_text
|
||||
return text
|
||||
|
||||
|
||||
def _extract_docx(payload: bytes) -> str:
|
||||
@@ -238,13 +376,26 @@ class SourceIndexer:
|
||||
return {'status': 'failed', 'error': str(exc)}
|
||||
|
||||
if not text or not text.strip():
|
||||
kind = (source.kind or '').lower()
|
||||
if kind in ('file', 'resource') and (
|
||||
(source.file_name or '').lower().endswith('.pdf')
|
||||
or (source.mime_type or '').lower() == 'application/pdf'
|
||||
):
|
||||
err = (
|
||||
'No text could be extracted from this PDF. It looks '
|
||||
'image-only (a scan) and OCR returned nothing. Try a '
|
||||
'higher-quality scan, a publisher PDF with an '
|
||||
'embedded text layer, or raise ENCOACH_OCR_DPI.'
|
||||
)
|
||||
else:
|
||||
err = 'Extracted no text from source'
|
||||
source.write({
|
||||
'status': 'failed',
|
||||
'error': 'Extracted no text from source',
|
||||
'error': err,
|
||||
'chunks_count': 0,
|
||||
'extracted_chars': 0,
|
||||
})
|
||||
return {'status': 'failed', 'error': 'empty'}
|
||||
return {'status': 'failed', 'error': err}
|
||||
|
||||
try:
|
||||
svc = EmbeddingService(self.env)
|
||||
|
||||
Reference in New Issue
Block a user