pranshu dhiman
Initial commit with Docker and Streamlit
46b701f
Raw
History Blame Contribute Delete
11.7 kB
from __future__ import annotations
import json
import re
from collections import Counter
from .models import Flashcard
from .text_processing import clean_text
try:
from langchain_core.prompts import PromptTemplate
except Exception: # pragma: no cover - only used when langchain-core is unavailable.
PromptTemplate = None
CONCEPT_PROMPT = """Extract the top {count} academic concepts from this summary.
Return JSON only as a list of objects with keys "concept" and "definition".
Summary:
{summary}
"""
FLASHCARD_PROMPT = """Create exam revision flashcards from these concepts.
Return JSON only as a list of objects with keys "question", "short_answer", "long_answer", "concept", "difficulty", and "bloom_level".
Concepts:
{concepts_json}
"""
BLOOM_PROMPT = """Rewrite easy recall questions into harder Bloom's Taxonomy questions.
Prefer apply, analyze, evaluate, or create levels. Return JSON only.
Flashcards:
{cards_json}
"""
def build_prompt(template: str, **values: object) -> str:
if PromptTemplate is None:
return template.format(**values)
prompt = PromptTemplate.from_template(template)
return prompt.format(**values)
def extract_key_concepts(summary: str, count: int = 5) -> list[dict[str, str]]:
"""Extract concepts locally while preserving the JSON contract required by the brief."""
build_prompt(CONCEPT_PROMPT, count=count, summary=summary)
cleaned = clean_text(summary)
if not cleaned:
return []
candidate_phrases = _candidate_phrases(cleaned)
concepts: list[dict[str, str]] = []
seen: set[str] = set()
for phrase in candidate_phrases:
key = phrase.lower()
if key in seen:
continue
seen.add(key)
concepts.append({"concept": phrase, "definition": _definition_for(cleaned, phrase)})
if count > 0 and len(concepts) >= count:
break
if not concepts:
concepts.append({"concept": cleaned.split(".")[0][:80], "definition": cleaned[:240]})
return _json_round_trip(concepts)
def dedupe_concepts(concepts: list[dict[str, str]]) -> list[dict[str, str]]:
unique: list[dict[str, str]] = []
seen: set[str] = set()
for item in concepts:
concept = clean_text(str(item.get("concept", "")))
definition = clean_text(str(item.get("definition", "")))
key = _normalize_key(concept)
if not concept or not definition or key in seen:
continue
seen.add(key)
unique.append({"concept": concept, "definition": definition})
return unique
def generate_flashcards(concepts: list[dict[str, str]], cards_per_concept: int = 1) -> list[Flashcard]:
build_prompt(FLASHCARD_PROMPT, concepts_json=json.dumps(concepts, indent=2))
cards: list[Flashcard] = []
question_styles = [
(
"explain",
"medium",
"understand",
"What role does {concept} play in this topic?",
),
(
"compare",
"hard",
"analyze",
"How does {concept} connect to the surrounding ideas in the notes?",
),
(
"apply",
"medium",
"apply",
"How could {concept} be applied in an exam-style problem?",
),
(
"cause",
"hard",
"analyze",
"Why would a change in {concept} affect the outcome being studied?",
),
(
"evidence",
"medium",
"evaluate",
"What evidence or reasoning from the notes supports {concept}?",
),
(
"misconception",
"hard",
"evaluate",
"What common misconception about {concept} should a student avoid?",
),
(
"define",
"easy",
"remember",
"What does {concept} mean?",
),
(
"importance",
"medium",
"understand",
"Why is {concept} important for exam revision?",
),
(
"difference",
"hard",
"analyze",
"How can {concept} be distinguished from related ideas?",
),
(
"limitation",
"hard",
"evaluate",
"What limitation or condition should be considered when using {concept}?",
),
]
for concept_index, item in enumerate(dedupe_concepts(concepts)):
concept = clean_text(item.get("concept", ""))
long_answer = _polish_long_answer(item.get("definition", ""), concept)
short_answer = _short_answer(long_answer, concept)
if not concept or not short_answer or not long_answer:
continue
for offset in range(cards_per_concept):
style = question_styles[(concept_index + offset) % len(question_styles)]
_, difficulty, bloom_level, template = style
cards.append(
Flashcard(
question=_one_line_question(template.format(concept=concept)),
short_answer=short_answer,
long_answer=_style_long_answer(long_answer, concept, bloom_level),
concept=concept,
difficulty=difficulty,
bloom_level=bloom_level,
)
)
return dedupe_flashcards(cards)
def harden_flashcards(cards: list[Flashcard]) -> list[Flashcard]:
build_prompt(BLOOM_PROMPT, cards_json=json.dumps([card.__dict__ for card in cards], indent=2))
hard_templates = [
("How does {concept} influence the larger process described in the notes?", "analyze"),
("Why is {concept} important for solving exam problems on this topic?", "evaluate"),
("What might happen if {concept} were missing, incorrect, or changed?", "evaluate"),
("What short example would demonstrate {concept} in action?", "create"),
]
hardened: list[Flashcard] = []
for index, card in enumerate(cards):
if card.difficulty != "easy":
hardened.append(card)
continue
template, bloom_level = hard_templates[index % len(hard_templates)]
hardened.append(
Flashcard(
question=template.format(concept=card.concept),
short_answer=card.short_answer,
long_answer=card.long_answer,
concept=card.concept,
difficulty="hard",
bloom_level=bloom_level,
)
)
return dedupe_flashcards(hardened)
def dedupe_flashcards(cards: list[Flashcard]) -> list[Flashcard]:
unique: list[Flashcard] = []
seen_questions: set[str] = set()
seen_pairs: set[tuple[str, str]] = set()
for card in cards:
question_key = _normalize_key(card.question)
pair_key = (question_key, _normalize_key(card.short_answer), _normalize_key(card.long_answer))
if not card.question.strip() or not card.short_answer.strip() or not card.long_answer.strip():
continue
if question_key in seen_questions or pair_key in seen_pairs:
continue
seen_questions.add(question_key)
seen_pairs.add(pair_key)
unique.append(card)
return unique
def parse_json_list(raw_text: str) -> list[dict[str, object]]:
"""Parse a JSON list from plain or fenced model output."""
match = re.search(r"\[[\s\S]*\]", raw_text)
if not match:
return []
try:
value = json.loads(match.group(0))
except json.JSONDecodeError:
return []
return value if isinstance(value, list) else []
def _candidate_phrases(text: str) -> list[str]:
words = re.findall(r"[A-Za-z][A-Za-z-]{3,}", text)
stopwords = {
"about",
"after",
"also",
"because",
"between",
"could",
"absorbs",
"convert",
"converts",
"drive",
"drives",
"during",
"example",
"from",
"have",
"important",
"into",
"lecture",
"more",
"notes",
"other",
"produce",
"produces",
"should",
"their",
"there",
"these",
"this",
"through",
"using",
"were",
"when",
"which",
"with",
"would",
}
filtered = [word.lower() for word in words if word.lower() not in stopwords]
counts = Counter(filtered)
ranked = [word for word, _ in counts.most_common(20)]
phrases = _technical_phrases(text, stopwords)
noun_like = re.findall(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b", text)
phrases.extend(phrase for phrase in noun_like if phrase.lower() not in stopwords)
phrases.extend(word.title() for word in ranked if counts[word] > 1)
return phrases
def _technical_phrases(text: str, stopwords: set[str]) -> list[str]:
phrases: list[str] = []
sentences = re.split(r"(?<=[.!?])\s+", text)
for size in (2, 3):
counts: Counter[str] = Counter()
for sentence in sentences:
words = [word.lower() for word in re.findall(r"[A-Za-z][A-Za-z-]*", sentence)]
for index in range(0, len(words) - size + 1):
window = words[index : index + size]
if any(len(word) < 4 for word in window):
continue
if any(word in stopwords for word in window):
continue
if len(set(window)) < size:
continue
counts[" ".join(window)] += 1
phrases.extend(phrase.title() for phrase, amount in counts.most_common(12) if amount >= 1)
return phrases
def _definition_for(text: str, phrase: str) -> str:
sentences = re.split(r"(?<=[.!?])\s+", text)
phrase_lower = phrase.lower()
for sentence in sentences:
if phrase_lower in sentence.lower():
return sentence.strip()
return text[:260].strip()
def _json_round_trip(concepts: list[dict[str, str]]) -> list[dict[str, str]]:
return json.loads(json.dumps(concepts))
def _normalize_key(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
def _one_line_question(question: str) -> str:
cleaned = clean_text(question)
if not cleaned.endswith("?"):
cleaned = f"{cleaned.rstrip('.') }?"
return cleaned
def _short_answer(long_answer: str, concept: str) -> str:
first_sentence = re.split(r"(?<=[.!?])\s+", clean_text(long_answer))[0]
if not first_sentence:
return concept
words = first_sentence.split()
if len(words) <= 22:
return first_sentence
return " ".join(words[:22]).rstrip(",;:") + "."
def _style_long_answer(answer: str, concept: str, bloom_level: str) -> str:
cleaned = clean_text(answer)
if bloom_level in {"analyze", "evaluate", "create"} and concept.lower() not in cleaned.lower():
return f"{concept} should be understood in context: {cleaned}"
return cleaned
def _polish_long_answer(answer: str, concept: str) -> str:
cleaned = clean_text(answer)
if not cleaned:
return ""
if len(cleaned.split()) < 8:
return f"{concept} refers to {cleaned.lower()}. It is important because it helps explain the main process, relationship, or outcome described in the notes."
if len(cleaned.split()) < 18:
return (
f"{cleaned} In exam terms, focus on what {concept} does, why it matters, "
"and how it connects to the cause, effect, or process described in the notes."
)
if not cleaned.endswith((".", "!", "?")):
cleaned = f"{cleaned}."
return cleaned