Upload folder using huggingface_hub
Browse files- ankigen_core/card_generator.py +61 -4
- app.py +7 -0
- tests/integration/test_app_interactions.py +1 -0
- tests/unit/test_card_generator.py +36 -0
ankigen_core/card_generator.py
CHANGED
|
@@ -176,6 +176,50 @@ async def generate_cards_batch(
|
|
| 176 |
raise # Re-raise for the main function to handle
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
async def orchestrate_card_generation( # MODIFIED: Added async
|
| 180 |
client_manager: OpenAIClientManager, # Expect the manager
|
| 181 |
cache: ResponseCache, # Expect the cache instance
|
|
@@ -190,6 +234,7 @@ async def orchestrate_card_generation( # MODIFIED: Added async
|
|
| 190 |
cards_per_topic: int,
|
| 191 |
preference_prompt: str,
|
| 192 |
generate_cloze: bool,
|
|
|
|
| 193 |
):
|
| 194 |
"""Orchestrates the card generation process based on UI inputs."""
|
| 195 |
|
|
@@ -490,6 +535,10 @@ async def orchestrate_card_generation( # MODIFIED: Added async
|
|
| 490 |
"structured_output_completion returned None, defaulting to empty card list for text mode."
|
| 491 |
)
|
| 492 |
processed_cards = process_raw_cards_data(raw_cards)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
formatted_cards = format_cards_for_dataframe(
|
| 494 |
processed_cards, topic_name=source_text_display_name, start_index=1
|
| 495 |
)
|
|
@@ -529,7 +578,9 @@ async def orchestrate_card_generation( # MODIFIED: Added async
|
|
| 529 |
# progress_total_batches = len(topics_for_generation)
|
| 530 |
# current_batch_num = 0
|
| 531 |
|
| 532 |
-
for
|
|
|
|
|
|
|
| 533 |
topics_for_generation
|
| 534 |
): # This loop will be skipped if text_mode populated flattened_data directly
|
| 535 |
# current_batch_num += 1
|
|
@@ -551,6 +602,10 @@ async def orchestrate_card_generation( # MODIFIED: Added async
|
|
| 551 |
system_prompt, # System prompt defined above based on mode
|
| 552 |
generate_cloze,
|
| 553 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
# Assign topic name to cards before formatting for DataFrame
|
| 555 |
formatted_batch = format_cards_for_dataframe(
|
| 556 |
batch_cards,
|
|
@@ -758,9 +813,11 @@ def format_cards_for_dataframe(
|
|
| 758 |
difficulty_str = strip_html_tags(str(difficulty))
|
| 759 |
|
| 760 |
formatted_card = {
|
| 761 |
-
"Index":
|
| 762 |
-
|
| 763 |
-
|
|
|
|
|
|
|
| 764 |
"Topic": strip_html_tags(topic_name), # Ensure topic is also plain
|
| 765 |
"Card_Type": strip_html_tags(card_type),
|
| 766 |
"Question": question, # Already stripped during Card object creation
|
|
|
|
| 176 |
raise # Re-raise for the main function to handle
|
| 177 |
|
| 178 |
|
| 179 |
+
async def judge_card(
|
| 180 |
+
openai_client,
|
| 181 |
+
cache: ResponseCache,
|
| 182 |
+
model: str,
|
| 183 |
+
card: Card,
|
| 184 |
+
) -> bool:
|
| 185 |
+
"""Use an LLM to validate a single card."""
|
| 186 |
+
system_prompt = (
|
| 187 |
+
"You review flashcards and decide if the question is clear and useful. "
|
| 188 |
+
'Respond with a JSON object like {"is_valid": true}.'
|
| 189 |
+
)
|
| 190 |
+
user_prompt = f"Question: {card.front.question}\nAnswer: {card.back.answer}"
|
| 191 |
+
try:
|
| 192 |
+
result = await structured_output_completion(
|
| 193 |
+
openai_client=openai_client,
|
| 194 |
+
model=model,
|
| 195 |
+
response_format={"type": "json_object"},
|
| 196 |
+
system_prompt=system_prompt,
|
| 197 |
+
user_prompt=user_prompt,
|
| 198 |
+
cache=cache,
|
| 199 |
+
)
|
| 200 |
+
if isinstance(result, dict):
|
| 201 |
+
return bool(result.get("is_valid", True))
|
| 202 |
+
except Exception as e: # pragma: no cover - network or parse errors
|
| 203 |
+
logger.warning(f"LLM judge failed for card '{card.front.question}': {e}")
|
| 204 |
+
return True
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def judge_cards(
|
| 208 |
+
openai_client,
|
| 209 |
+
cache: ResponseCache,
|
| 210 |
+
model: str,
|
| 211 |
+
cards: List[Card],
|
| 212 |
+
) -> List[Card]:
|
| 213 |
+
"""Filter cards using the LLM judge."""
|
| 214 |
+
validated: List[Card] = []
|
| 215 |
+
for card in cards:
|
| 216 |
+
if await judge_card(openai_client, cache, model, card):
|
| 217 |
+
validated.append(card)
|
| 218 |
+
else:
|
| 219 |
+
logger.info(f"Card rejected by judge: {card.front.question}")
|
| 220 |
+
return validated
|
| 221 |
+
|
| 222 |
+
|
| 223 |
async def orchestrate_card_generation( # MODIFIED: Added async
|
| 224 |
client_manager: OpenAIClientManager, # Expect the manager
|
| 225 |
cache: ResponseCache, # Expect the cache instance
|
|
|
|
| 234 |
cards_per_topic: int,
|
| 235 |
preference_prompt: str,
|
| 236 |
generate_cloze: bool,
|
| 237 |
+
use_llm_judge: bool = False,
|
| 238 |
):
|
| 239 |
"""Orchestrates the card generation process based on UI inputs."""
|
| 240 |
|
|
|
|
| 535 |
"structured_output_completion returned None, defaulting to empty card list for text mode."
|
| 536 |
)
|
| 537 |
processed_cards = process_raw_cards_data(raw_cards)
|
| 538 |
+
if use_llm_judge and processed_cards:
|
| 539 |
+
processed_cards = await judge_cards(
|
| 540 |
+
openai_client, cache, model, processed_cards
|
| 541 |
+
)
|
| 542 |
formatted_cards = format_cards_for_dataframe(
|
| 543 |
processed_cards, topic_name=source_text_display_name, start_index=1
|
| 544 |
)
|
|
|
|
| 578 |
# progress_total_batches = len(topics_for_generation)
|
| 579 |
# current_batch_num = 0
|
| 580 |
|
| 581 |
+
for (
|
| 582 |
+
topic_info
|
| 583 |
+
) in (
|
| 584 |
topics_for_generation
|
| 585 |
): # This loop will be skipped if text_mode populated flattened_data directly
|
| 586 |
# current_batch_num += 1
|
|
|
|
| 602 |
system_prompt, # System prompt defined above based on mode
|
| 603 |
generate_cloze,
|
| 604 |
)
|
| 605 |
+
if use_llm_judge and batch_cards:
|
| 606 |
+
batch_cards = await judge_cards(
|
| 607 |
+
openai_client, cache, model, batch_cards
|
| 608 |
+
)
|
| 609 |
# Assign topic name to cards before formatting for DataFrame
|
| 610 |
formatted_batch = format_cards_for_dataframe(
|
| 611 |
batch_cards,
|
|
|
|
| 813 |
difficulty_str = strip_html_tags(str(difficulty))
|
| 814 |
|
| 815 |
formatted_card = {
|
| 816 |
+
"Index": (
|
| 817 |
+
f"{topic_index}.{actual_index}"
|
| 818 |
+
if topic_index > 0
|
| 819 |
+
else str(actual_index)
|
| 820 |
+
),
|
| 821 |
"Topic": strip_html_tags(topic_name), # Ensure topic is also plain
|
| 822 |
"Card_Type": strip_html_tags(card_type),
|
| 823 |
"Question": question, # Already stripped during Card object creation
|
app.py
CHANGED
|
@@ -295,6 +295,10 @@ def create_ankigen_interface():
|
|
| 295 |
label="Generate Cloze Cards (Experimental)",
|
| 296 |
value=False,
|
| 297 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
generate_button = gr.Button("Generate Cards", variant="primary")
|
| 300 |
|
|
@@ -490,6 +494,7 @@ def create_ankigen_interface():
|
|
| 490 |
cards_per_topic_val,
|
| 491 |
preference_prompt_val,
|
| 492 |
generate_cloze_checkbox_val,
|
|
|
|
| 493 |
progress=gr.Progress(track_tqdm=True), # Added progress tracker
|
| 494 |
):
|
| 495 |
# Recreate the partial function call, but now it can be awaited
|
|
@@ -509,6 +514,7 @@ def create_ankigen_interface():
|
|
| 509 |
cards_per_topic_val,
|
| 510 |
preference_prompt_val,
|
| 511 |
generate_cloze_checkbox_val,
|
|
|
|
| 512 |
)
|
| 513 |
|
| 514 |
generate_button.click(
|
|
@@ -524,6 +530,7 @@ def create_ankigen_interface():
|
|
| 524 |
cards_per_topic,
|
| 525 |
preference_prompt,
|
| 526 |
generate_cloze_checkbox,
|
|
|
|
| 527 |
],
|
| 528 |
outputs=[output, total_cards_html],
|
| 529 |
show_progress="full",
|
|
|
|
| 295 |
label="Generate Cloze Cards (Experimental)",
|
| 296 |
value=False,
|
| 297 |
)
|
| 298 |
+
llm_judge_checkbox = gr.Checkbox(
|
| 299 |
+
label="Use LLM Judge",
|
| 300 |
+
value=False,
|
| 301 |
+
)
|
| 302 |
|
| 303 |
generate_button = gr.Button("Generate Cards", variant="primary")
|
| 304 |
|
|
|
|
| 494 |
cards_per_topic_val,
|
| 495 |
preference_prompt_val,
|
| 496 |
generate_cloze_checkbox_val,
|
| 497 |
+
llm_judge_checkbox_val,
|
| 498 |
progress=gr.Progress(track_tqdm=True), # Added progress tracker
|
| 499 |
):
|
| 500 |
# Recreate the partial function call, but now it can be awaited
|
|
|
|
| 514 |
cards_per_topic_val,
|
| 515 |
preference_prompt_val,
|
| 516 |
generate_cloze_checkbox_val,
|
| 517 |
+
llm_judge_checkbox_val,
|
| 518 |
)
|
| 519 |
|
| 520 |
generate_button.click(
|
|
|
|
| 530 |
cards_per_topic,
|
| 531 |
preference_prompt,
|
| 532 |
generate_cloze_checkbox,
|
| 533 |
+
llm_judge_checkbox,
|
| 534 |
],
|
| 535 |
outputs=[output, total_cards_html],
|
| 536 |
show_progress="full",
|
tests/integration/test_app_interactions.py
CHANGED
|
@@ -393,6 +393,7 @@ def get_orchestrator_mock_inputs(generation_mode="subject", api_key="sk-test"):
|
|
| 393 |
"cards_per_topic": 3, # For subject mode / text mode / web mode
|
| 394 |
"preference_prompt": "Test preferences",
|
| 395 |
"generate_cloze": False,
|
|
|
|
| 396 |
}
|
| 397 |
|
| 398 |
|
|
|
|
| 393 |
"cards_per_topic": 3, # For subject mode / text mode / web mode
|
| 394 |
"preference_prompt": "Test preferences",
|
| 395 |
"generate_cloze": False,
|
| 396 |
+
"use_llm_judge": False,
|
| 397 |
}
|
| 398 |
|
| 399 |
|
tests/unit/test_card_generator.py
CHANGED
|
@@ -203,6 +203,7 @@ def base_orchestrator_args(api_key="valid_key", **kwargs):
|
|
| 203 |
"cards_per_topic": 5, # Corresponds to num_cards in generate_cards_batch
|
| 204 |
"preference_prompt": "Pref prompt", # Corresponds to system_prompt
|
| 205 |
"generate_cloze": False,
|
|
|
|
| 206 |
}
|
| 207 |
base_args.update(kwargs) # Update with any provided kwargs
|
| 208 |
return base_args
|
|
@@ -276,6 +277,41 @@ async def test_orchestrate_subject_mode(
|
|
| 276 |
# assert status.strip() == expected_html_status.strip()
|
| 277 |
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
@patch("ankigen_core.card_generator.structured_output_completion")
|
| 280 |
@patch("ankigen_core.card_generator.generate_cards_batch")
|
| 281 |
async def test_orchestrate_text_mode(
|
|
|
|
| 203 |
"cards_per_topic": 5, # Corresponds to num_cards in generate_cards_batch
|
| 204 |
"preference_prompt": "Pref prompt", # Corresponds to system_prompt
|
| 205 |
"generate_cloze": False,
|
| 206 |
+
"use_llm_judge": False,
|
| 207 |
}
|
| 208 |
base_args.update(kwargs) # Update with any provided kwargs
|
| 209 |
return base_args
|
|
|
|
| 277 |
# assert status.strip() == expected_html_status.strip()
|
| 278 |
|
| 279 |
|
| 280 |
+
@patch("ankigen_core.card_generator.judge_cards")
|
| 281 |
+
@patch("ankigen_core.card_generator.structured_output_completion")
|
| 282 |
+
@patch("ankigen_core.card_generator.generate_cards_batch")
|
| 283 |
+
async def test_orchestrate_subject_mode_with_judge(
|
| 284 |
+
mock_gcb,
|
| 285 |
+
mock_soc,
|
| 286 |
+
mock_judge,
|
| 287 |
+
mock_client_manager_fixture,
|
| 288 |
+
mock_response_cache_fixture,
|
| 289 |
+
):
|
| 290 |
+
"""Test orchestrate_card_generation calls judge_cards when enabled."""
|
| 291 |
+
manager, client = mock_client_manager_fixture
|
| 292 |
+
cache = mock_response_cache_fixture
|
| 293 |
+
args = base_orchestrator_args(generation_mode="subject", use_llm_judge=True)
|
| 294 |
+
|
| 295 |
+
mock_soc.return_value = {
|
| 296 |
+
"topics": [{"name": "T1", "difficulty": "d", "description": "d"}]
|
| 297 |
+
}
|
| 298 |
+
sample_card = Card(
|
| 299 |
+
front=CardFront(question="Q1"),
|
| 300 |
+
back=CardBack(answer="A1", explanation="E1", example="Ex1"),
|
| 301 |
+
)
|
| 302 |
+
mock_gcb.return_value = [sample_card]
|
| 303 |
+
mock_judge.return_value = [sample_card]
|
| 304 |
+
|
| 305 |
+
with patch("gradio.Info"), patch("gradio.Warning"):
|
| 306 |
+
await card_generator.orchestrate_card_generation(
|
| 307 |
+
client_manager=manager,
|
| 308 |
+
cache=cache,
|
| 309 |
+
**args,
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
mock_judge.assert_called_once_with(client, cache, args["model_name"], [sample_card])
|
| 313 |
+
|
| 314 |
+
|
| 315 |
@patch("ankigen_core.card_generator.structured_output_completion")
|
| 316 |
@patch("ankigen_core.card_generator.generate_cards_batch")
|
| 317 |
async def test_orchestrate_text_mode(
|