Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ assets/

# Uploaded catalogs (user-specific)
catalog/uploaded_*.json
catalog/textbook_*.json

# Test & Coverage
.pytest_cache/
Expand Down
59 changes: 58 additions & 1 deletion api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from queue import Queue
from threading import Thread

from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Header
from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Header, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
Expand All @@ -26,6 +26,7 @@
from src import __version__
from src.pdf_processor import PDFSlideProcessor
from src.ADDIE_optimize import ADDIEOptimizer
from src.textbook_reference import TextbookReferenceBuilder
import tempfile
import shutil

Expand Down Expand Up @@ -67,6 +68,7 @@ class OptimizeRequest(BaseModel):
model_name: str = Field(default="gpt-4o-mini", description="OpenAI model to use")
exp_name: str = Field(default="default", description="Experiment name for output")
chapter_name: Optional[str] = Field(default=None, description="Specific chapter to optimize (None = all)")
mode: str = Field(default="regenerate", description="Improvement strategy: 'regenerate' (per-slide full rewrite) or 'refine' (localized frame-level rewrite)")

class TaskStatus(BaseModel):
task_id: str
Expand Down Expand Up @@ -391,6 +393,58 @@ async def upload_catalog(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error uploading catalog: {str(e)}")

@app.post("/api/textbook/catalog")
async def build_textbook_catalog(
file: UploadFile = File(...),
course_name: str = Form(default="")
):
"""
Build catalog-compatible reference data from an uploaded textbook file.

This proof-of-concept extracts a bounded amount of text from PDF/TXT/MD
input and converts it into the existing catalog schema. It does not call
an LLM, so it can be used before starting a paid generation task.
"""
allowed_suffixes = {".pdf", ".txt", ".md"}
original_name = file.filename or "textbook.pdf"
suffix = Path(original_name).suffix.lower()
if suffix not in allowed_suffixes:
raise HTTPException(status_code=400, detail="Only PDF, TXT, and Markdown textbook files are supported")

MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="Textbook file is too large (max 50 MB)")

try:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir) / original_name
temp_path.write_bytes(content)

builder = TextbookReferenceBuilder()
catalog_data = builder.build_catalog(temp_path, course_name=course_name)

catalog_dir = Path("catalog")
catalog_dir.mkdir(exist_ok=True)
catalog_name = f"textbook_{uuid.uuid4().hex[:8]}"
catalog_filename = f"{catalog_name}.json"
catalog_path = catalog_dir / catalog_filename
with open(catalog_path, "w", encoding="utf-8") as f:
json.dump(catalog_data, f, indent=2, ensure_ascii=False)

return {
"catalog_name": catalog_name,
"filename": catalog_filename,
"saved_path": str(catalog_path),
"catalog_data": catalog_data,
"message": "Textbook reference catalog generated successfully"
}

except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error processing textbook: {str(e)}")

@app.get("/api/tasks/list")
async def list_tasks():
"""
Expand Down Expand Up @@ -900,6 +954,8 @@ async def run_optimization_task(task_id: str, request: OptimizeRequest, api_key:
sys.stdout.flush()
print(f"Experiment: {request.exp_name}")
sys.stdout.flush()
print(f"Mode: {request.mode}")
sys.stdout.flush()
if request.chapter_name:
print(f"Chapter: {request.chapter_name}")
sys.stdout.flush()
Expand All @@ -916,6 +972,7 @@ async def run_optimization_task(task_id: str, request: OptimizeRequest, api_key:
model_name=request.model_name,
exp_name=request.exp_name,
chapter_name=request.chapter_name,
mode=request.mode,
)

print("\n" + "=" * 60)
Expand Down
131 changes: 104 additions & 27 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ const translations = {
modeGenerate: '生成新课程',
modeOptimize: '优化已有材料',
pptxLabel: '同时生成 PPTX 幻灯片',
optimizeModeLabel: '优化方式',
optimizeModeRegenerate: '逐张重新生成(整章重写)',
optimizeModeRefine: '局部精修(仅改相关帧)',
optimizeModeTip: '重新生成:每张幻灯片整体重写。局部精修:只定位并改写与需求相关的帧,其余保持不变。',
optimizeSectionTitle: '优化配置',
optimizeSubmitButton: '<span>🔧</span><span>开始优化</span>',
optimizeProgressTitle: '优化进度',
Expand Down Expand Up @@ -165,6 +169,8 @@ const translations = {
modelOptionGpt4Turbo: 'GPT-4 Turbo',
expNameLabel: 'Experiment Name',
expNamePlaceholder: 'Default: default',
textbookFileLabel: 'Textbook Reference File (optional)',
textbookFileTip: 'Supports PDF/TXT/MD. The system extracts a bounded excerpt and saves a textbook catalog JSON for review.',
copilotLabel: 'Enable Copilot Mode (Interactive Feedback)',
catalogModeLabel: 'Catalog Mode',
catalogOptionNone: 'Do not use',
Expand Down Expand Up @@ -234,9 +240,15 @@ const translations = {
catalogListFailed: 'Failed to load catalog list',
catalogSelectDefault: 'Select Catalog...',
uploadCatalogFailed: 'Failed to upload catalog file',
uploadTextbookFailed: 'Failed to process textbook reference file',
textbookCatalogReady: 'Textbook catalog generated: {filename}',
modeGenerate: 'Generate Course',
modeOptimize: 'Optimize Materials',
pptxLabel: 'Also generate PPTX slides',
optimizeModeLabel: 'Optimization Method',
optimizeModeRegenerate: 'Regenerate (rewrite every slide)',
optimizeModeRefine: 'Localized Refine (edit only relevant frames)',
optimizeModeTip: 'Regenerate: rewrite each slide wholesale. Localized Refine: locate and rewrite only the frames relevant to your requirements, leaving the rest untouched.',
optimizeSectionTitle: 'Optimization Settings',
optimizeSubmitButton: '<span>🔧</span><span>Start Optimization</span>',
optimizeProgressTitle: 'Optimization Progress',
Expand Down Expand Up @@ -503,6 +515,13 @@ function setupEventListeners() {
const catalogMode = document.getElementById('catalog-mode');
catalogMode.addEventListener('change', handleCatalogModeChange);

// Textbook file input: when a file is selected, disable catalog mode dropdown
// since the textbook will generate its own catalog automatically.
const textbookFile = document.getElementById('textbook-file');
if (textbookFile) {
textbookFile.addEventListener('change', handleTextbookFileChange);
}

// API Key management
document.getElementById('save-api-key').addEventListener('click', saveApiKey);
document.getElementById('toggle-api-key').addEventListener('click', toggleApiKeyVisibility);
Expand Down Expand Up @@ -648,6 +667,27 @@ function handleCatalogModeChange(e) {
}
}

function handleTextbookFileChange() {
const textbookInput = document.getElementById('textbook-file');
const catalogMode = document.getElementById('catalog-mode');
const note = document.getElementById('textbook-catalog-note');
const hasFile = textbookInput && textbookInput.files.length > 0;

if (hasFile) {
catalogMode.value = 'none';
catalogMode.disabled = true;
if (note) note.style.display = 'block';
// Hide any open catalog sub-groups
['catalog-upload-group', 'catalog-select-group', 'catalog-json-group'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
} else {
catalogMode.disabled = false;
if (note) note.style.display = 'none';
}
}

async function handleFormSubmit(e) {
e.preventDefault();

Expand Down Expand Up @@ -675,32 +715,48 @@ async function handleFormSubmit(e) {
generate_pptx: document.getElementById('pptx-mode').checked
};

// Handle catalog
const catalogMode = document.getElementById('catalog-mode').value;
if (catalogMode === 'default') {
formData.catalog = 'default_catalog';
} else if (catalogMode === 'select') {
const selected = document.getElementById('catalog-select').value;
if (selected) {
formData.catalog = selected;
}
} else if (catalogMode === 'upload') {
// Handle file upload or JSON input
const fileInput = document.getElementById('catalog-file');
const jsonInput = document.getElementById('catalog-json').value;

if (fileInput.files.length > 0) {
// Upload file first
const uploadResponse = await uploadCatalogFile(fileInput.files[0]);
formData.catalog = uploadResponse.filename.replace('.json', '');
} else if (jsonInput.trim()) {
// Use JSON input directly
try {
formData.catalog_data = JSON.parse(jsonInput);
} catch (err) {
alert(t('invalidCatalogJson'));
setSubmitButtonLoading(false);
return;
// Handle optional textbook reference upload first.
// When a textbook file is provided, it takes priority — the catalog mode
// dropdown is ignored because the textbook generates its own catalog.
const textbookInput = document.getElementById('textbook-file');
if (textbookInput && textbookInput.files.length > 0) {
const textbookResponse = await uploadTextbookCatalog(
textbookInput.files[0],
formData.course_name
);

formData.catalog = textbookResponse.catalog_name;

console.log(t('textbookCatalogReady', { filename: textbookResponse.filename }));
await loadCatalogs();
} else {
// Handle catalog mode (only when no textbook is uploaded)
const catalogMode = document.getElementById('catalog-mode').value;
if (catalogMode === 'default') {
formData.catalog = 'default_catalog';
} else if (catalogMode === 'select') {
const selected = document.getElementById('catalog-select').value;
if (selected) {
formData.catalog = selected;
}
} else if (catalogMode === 'upload') {
// Handle file upload or JSON input
const fileInput = document.getElementById('catalog-file');
const jsonInput = document.getElementById('catalog-json').value;

if (fileInput.files.length > 0) {
// Upload file first
const uploadResponse = await uploadCatalogFile(fileInput.files[0]);
formData.catalog = uploadResponse.filename.replace('.json', '');
} else if (jsonInput.trim()) {
// Use JSON input directly
try {
formData.catalog_data = JSON.parse(jsonInput);
} catch (err) {
alert(t('invalidCatalogJson'));
setSubmitButtonLoading(false);
return;
}
}
}
}
Expand Down Expand Up @@ -794,6 +850,24 @@ async function uploadCatalogFile(file) {
return await response.json();
}

async function uploadTextbookCatalog(file, courseName) {
const formData = new FormData();
formData.append('file', file);
formData.append('course_name', courseName || '');

const response = await fetch(`${API_BASE_URL}/api/textbook/catalog`, {
method: 'POST',
body: formData
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`${t('uploadTextbookFailed')}: ${errorText}`);
}

return await response.json();
}

function startStatusPolling() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
Expand Down Expand Up @@ -1414,6 +1488,8 @@ async function handleOptimizeSubmit(e) {
const modelName = document.getElementById('optimize-model-name').value;
const expName = document.getElementById('optimize-exp-name').value.trim() || 'default';
const chapterName = document.getElementById('chapter-name').value.trim() || null;
const modeSelect = document.getElementById('optimize-mode-select');
const mode = modeSelect ? modeSelect.value : 'regenerate';

const submitBtn = document.getElementById('optimize-submit-btn');
submitBtn.disabled = true;
Expand All @@ -1439,7 +1515,8 @@ async function handleOptimizeSubmit(e) {
user_requirements: userRequirements,
model_name: modelName,
exp_name: expName,
chapter_name: chapterName
chapter_name: chapterName,
mode: mode
})
});

Expand Down
19 changes: 19 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ <h2 data-i18n="configSectionTitle">课程配置</h2>
<input type="text" id="exp-name" name="exp_name"
placeholder="默认:default" value="default" data-i18n-placeholder="expNamePlaceholder">
</div>

<div class="form-group">
<label for="textbook-file" data-i18n="textbookFileLabel">上传教材参考文件(可选)</label>
<input type="file" id="textbook-file" accept=".pdf,.txt,.md">
<small data-i18n="textbookFileTip">支持 PDF/TXT/MD;系统会先提取前若干页并保存为 textbook catalog JSON 供检查。</small>
</div>


<div class="form-group checkbox-group">
<label>
Expand All @@ -101,6 +108,9 @@ <h2 data-i18n="configSectionTitle">课程配置</h2>
<option value="upload" data-i18n="catalogOptionUpload">上传 Catalog 文件</option>
<option value="select" data-i18n="catalogOptionSelect">选择已有 Catalog</option>
</select>
<small id="textbook-catalog-note" style="display:none;color:#856404;background:#fff3cd;padding:4px 8px;border-radius:4px;margin-top:4px;display:none;">
Textbook file uploaded above — the extracted catalog will be used automatically. Catalog mode selection is ignored.
</small>
</div>

<div class="form-group" id="catalog-upload-group" style="display: none;">
Expand Down Expand Up @@ -179,6 +189,15 @@ <h2 data-i18n="optimizeSectionTitle">优化配置</h2>
</select>
</div>

<div class="form-group">
<label for="optimize-mode-select" data-i18n="optimizeModeLabel">优化方式</label>
<select id="optimize-mode-select" name="mode">
<option value="regenerate" selected data-i18n="optimizeModeRegenerate">逐张重新生成(整章重写)</option>
<option value="refine" data-i18n="optimizeModeRefine">局部精修(仅改相关帧)</option>
</select>
<small data-i18n="optimizeModeTip">重新生成:每张幻灯片整体重写。局部精修:只定位并改写与需求相关的帧,其余保持不变。</small>
</div>

<div class="form-group">
<label for="optimize-exp-name" data-i18n="expNameLabel">实验名称</label>
<input type="text" id="optimize-exp-name" name="exp_name"
Expand Down
6 changes: 5 additions & 1 deletion run.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ def run_instructional_design(course_name: str, copilot = None, catalog = None, m


def run_optimization(storage_id: str, user_requirements: str, model_name: str = "gpt-4o-mini",
exp_name: str = "optimize", chapter_name: str = None):
exp_name: str = "optimize", chapter_name: str = None,
mode: str = "regenerate"):
"""
Run the optimization workflow on existing slide materials.

Expand All @@ -126,6 +127,8 @@ def run_optimization(storage_id: str, user_requirements: str, model_name: str =
model_name: Name of the LLM model to use
exp_name: Experiment name for output directory
chapter_name: Specific chapter to optimize (None = all chapters)
mode: "regenerate" (per-slide full rewrite) or "refine" (localized
frame-level rewrite via SlideRefiner)
"""
# Ensure the OPENAI_API_KEY is set
if not os.environ.get("OPENAI_API_KEY"):
Expand All @@ -146,6 +149,7 @@ def run_optimization(storage_id: str, user_requirements: str, model_name: str =
output_dir=output_dir,
exp_name=exp_name,
chapter_name=chapter_name,
mode=mode,
)


Expand Down
Loading