cz-base1 / app.py
james-d-taboola's picture
feat: add ui password
2071209
'''
Note
- DO NOT create an assistant every time! UPDATE through assistant_id.
'''
'''
References
- https://github.com/openai/openai-cookbook/blob/main/examples/Assistants_API_overview_python.ipynb
'''
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
# from google.colab import auth
from oauth2client.client import GoogleCredentials
from openai import OpenAI
import os
import json
import gradio as gr
from json_repair import repair_json
import json_repair # enable streaming
# Initialize OpenAI client
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
client = OpenAI(api_key=OPENAI_API_KEY)
# View existing assistants
existed_assistants = client.beta.assistants.list(
order="desc",
limit="20",
)
print(len(existed_assistants.data),existed_assistants.data)
# Delete assistant by id
def delete_asst(assistant_id):
response = client.beta.assistants.delete(assistant_id)
print(response)
# Assistant setting (Playground: https://platform.openai.com/playground/assistants)
# Record once created
assistant_id = os.getenv('assistant_id')
google_drive_folder_id = os.getenv('google_drive_folder_id')
ASSISTANT_MODEL = "gpt-4o"
ASSISTANT_NAME = "非認知能力學習模組教案設計"
ASSISTANT_DESCRIPTION = "根據教師提供的教學情境,自動生成有趣且具成效的非認知能力學習模組教案,協助新手與熟手教師輕鬆備課與授課。"
ASSISTANT_INSTRUCTION = """
## 目標
協助教師逐步完成非認知能力教案設計,確保每一步驟生成的內容符合需求,並能根據教師反饋進行動態調整,最終生成完整教案。
---
## 流程
Assistant 應按照以下步驟逐步生成教案內容,並在每一步等待教師確認或補充後,進入下一階段。從一、教學需求開始、二、教案設計、到三、類似教案推薦,每次在用戶回覆前只輸出一個JSON:
### 一、教學需求
1. 確認學生的年級與特徵。
2. 課程時長(如 30 或 45 分鐘)。
3. 明確課程主題(如提升抗挫能力或專注力)。
4. 確認是否有具體活動需求(如角色扮演、小組討論)。
5. 在此階段生成簡要的教學需求描述,供教師確認或補充。
### 二、教案設計
教案設計包含以下部分,需逐步完成:
1. 教案名稱與主題
- 定義課程的核心目標與模組主題。
2. 學習目標
- 明確描述學生應達成的學習成果,幫助教師檢核成效。
3. 課程流程設計
課程流程應包含三個部分:
- (1) 入場券:啟發學生分享經驗,連結課程主題。
- (2) 主活動:課堂核心學習活動,例如角色扮演、小組討論或策略練習。
- (3) 出場券:引導學生反思學習,總結課程重點。
4. 評估與反饋建議
- 設計適合本課程的評估方式(如學習單、行為觀察或小組分享)。
5. 附加資源建議
- 提供與課程相關的資源(如活動模板、案例參考)。
---
### 三、類似教案推薦
- 從內建教案中選擇 2-3 個類似範例進行推薦,並簡要說明其特點。
- 範例格式:
- 《非認知模組|四年級_這就是我.docx》:幫助學生探索自我特點,適合四年級課程。
- 《非認知模組|五年級_成為時間管理大師.docx》:設計以時間管理四象限為核心的活動。
- 《非認知模組|三年級_優點大轟炸.docx》:增進學生之間的正向互動與認同。
---
## 逐步生成流程
1. **逐步生成**:
- 每次只生成當前階段的內容,例如教學需求、教案名稱與主題、入場券、主活動等。
- 確認後才進入下一步,避免一次性生成過多內容。
2. **JSON 格式輸出**:
- 每次回應包含三部分:
- current_lesson_plan:包含從「二、教案設計」開始生成的所有內容。需要根據用戶回饋調整內容。
- suggestion:對生成內容的調整建議與鼓勵。
- next_step_prompt:以清單形式提供引導用戶回覆的選項,例如 [["進入下一步"], ["更詳細一點"]],或依照該步驟生成可以調整的例子。
3. **動態互動與靈活調整**:
- 允許教師插入補充信息,例如新增活動需求或修改課程主題。
- 鼓勵教師參與討論,給予正面回饋。
4. **整合內建範例**:
- 提供相似範例供參考,並建議如何整合到當前教案中。
---
## 輸出格式
### JSON 格式輸出範例
#### 教學需求階段
{
"current_lesson_plan": "",
"suggestion": "請提供您的教學情境描述,讓我能為您設計一份適合的非認知能力教案!例如,請說明:\n 1. 學生的年級與特徵。\n 2. 課程時長。\n 3. 課程主題**。\n 4. 是否有具體的活動需求。",
"next_step_prompt": [["四年級學生面對挫折時容易放棄,課程主題是增強抗挫能力,共1節課(45分鐘),希望包括角色扮演活動。"]]
}
#### 教案設計階段
{
"current_lesson_plan": "1. 教案名稱與主題:專注力探索之旅\n2. 學習目標:\n - 辨識分心來源。\n - 學習兩種專注技巧。\n - 制定個人專注計畫。\n3. 課程流程設計:\n (1) 入場券:專注挑戰(5分鐘)——透過音樂與深呼吸活動,幫助學生進入專注狀態。\n (2) 主活動:分心偵探(10分鐘)——學生辨識分心來源,並進行小組討論。\n (3) 出場券:我的專注計畫(10分鐘)——學生制定專注策略並分享。\n4. 評估與反饋建議:設計學習單,檢核學生的學習成果與參與度。\n5. 附加資源建議:參考類似教案模板如《五年級_成為時間管理大師.docx》。",
"suggestion": "以上為教案初稿,請檢查是否符合需求或有其他補充建議。例如,可新增其他專注力訓練活動。",
"next_step_prompt": [["進入下一步"], ["補充具體活動設計"]]
}
---
## 注意事項
1. **逐步生成**:每次僅生成一個步驟內容,避免一次性輸出過多資訊,每次只輸出一個JSON。
2. **互動確認**:等待教師確認後,根據反饋進行調整或進入下一步。
3. **結構清晰**:確保生成內容條理分明,便於教師理解與應用。
4. **鼓勵性回應**:每次建議中加入正面評價與鼓勵,促進教師參與。
"""
RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "lesson_plan_response",
"schema": {
"type": "object",
"properties": {
"current_lesson_plan": {
"type": "string",
"description": "當前生成的教案內容。如果流程尚未進入「教案設計」,此值應為空字串。"
},
"suggestion": {
"type": "string",
"description": "對當前教案內容的改進建議,或對用戶的鼓勵,引導進入下一步。"
},
"next_step_prompt": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "提供用戶下一步的選項,以嵌套列表形式表示,例如 [['進入下一步'], ['補充更多細節']]。"
}
},
"required": ["current_lesson_plan", "suggestion", "next_step_prompt"],
"additionalProperties": False
},
"strict": True
}
}
VECTOR_STORE_NAME = "lesson-plan"
CONVERSATION_STARTER = "點選此按鈕開始設計教案"
def show_json(obj):
print(json.loads(obj.model_dump_json()))
# spent 4m 50s downloading all 175 files
def embed_from_drive(folder_id):
# auth.authenticate_user()
gauth = GoogleAuth()
gauth.credentials = GoogleCredentials.get_application_default()
drive = GoogleDrive(gauth)
# Get all files in '定稿專案' folder: https://drive.google.com/drive/folders/1dlsf5BNjNczzUYKPZvYXd2mLW21QCLUK?usp=drive_link
file_list = drive.ListFile({'q': f"'{folder_id}' in parents and trashed=false"}).GetList()
# Download files to local (`/content/`), since file_streams don't recieve google docs
local_file_paths = []
for file1 in file_list:
print('Processing file title: %s, id: %s' % (file1['title'], file1['id']))
local_path = f"/content/{file1['title']}.docx"
if 'exportLinks' in file1:
if 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' in file1['exportLinks']:
# update type if needed (application/vnd.openxmlformats-officedocument.wordprocessingml.document == .docx)
export_url = file1['exportLinks']['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
print(f"Downloading as Word document: {file1['title']}")
downloaded_file = drive.CreateFile({'id': file1['id']})
downloaded_file.GetContentFile(local_path, mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document')
local_file_paths.append(local_path)
else:
print(f"No Word export available for: {file1['title']}")
else:
print(f"Skipping non-Google Docs file: {file1['title']}")
for path in local_file_paths:
print(f"Downloaded file: {path}")
file_streams = [open(path, "rb") for path in local_file_paths]
return file_streams
# Embed files (downloaded from drive folder)
def get_vector_store_id(file_streams):
vector_store = client.beta.vector_stores.create(name=VECTOR_STORE_NAME)
# spent 51s batching all 175 files
file_batch = client.beta.vector_stores.file_batches.upload_and_poll(
vector_store_id=vector_store.id, files=file_streams
)
print("file_batch status",file_batch.status)
print("file_counts",file_batch.file_counts)
return vector_store.id
# Create a completely new assistant
def create_assistant(vector_store_id):
assistant = client.beta.assistants.create(
name=ASSISTANT_NAME,
description=ASSISTANT_DESCRIPTION,
instructions=ASSISTANT_INSTRUCTION,
model=ASSISTANT_MODEL,
tools=[{"type": "file_search"}],
tool_resources={'file_search': {'vector_store_ids': [vector_store_id]}},
response_format=RESPONSE_FORMAT
)
show_json(assistant)
return assistant.id
# Update existing assistant through ID (please customize prefered inputs)
def update_assistant(assistant_id):
assistant = client.beta.assistants.update(
assistant_id=assistant_id,
name=ASSISTANT_NAME,
description=ASSISTANT_DESCRIPTION,
instructions=ASSISTANT_INSTRUCTION,
model=ASSISTANT_MODEL,
tools=[{"type": "file_search"}],
# tool_resources={'file_search': {'vector_store_ids': [vector_store_id]}},
response_format=RESPONSE_FORMAT
)
show_json(assistant)
# Create assistant if assistant_id does not exist
try:
assistant = client.beta.assistants.retrieve(assistant_id)
# update_assistant(assistant_id)
except Exception as e:
print(f"Assistant DNE: {e}, create assistant instead.")
file_streams = embed_from_drive(google_drive_folder_id)
vector_store_id = get_vector_store_id(file_streams)
print(vector_store_id)
assistant_id = create_assistant(vector_store_id)
ASSISTANT_ID = assistant_id
"ASSISTANT_ID",ASSISTANT_ID
# Update assistant if needed
assistant = client.beta.assistants.update(
assistant_id=ASSISTANT_ID,
instructions=ASSISTANT_INSTRUCTION,
response_format=RESPONSE_FORMAT
)
show_json(assistant)
# Enable assistant streaming event handler (openai template)
from typing_extensions import override
from openai import AssistantEventHandler, OpenAI
from openai.types.beta.threads import Text, TextDelta
from openai.types.beta.threads.runs import ToolCall, ToolCallDelta
class EventHandler(AssistantEventHandler):
@override
def on_text_created(self, text: Text) -> None:
print(f"\nassistant > ", end="", flush=True)
@override
def on_text_delta(self, delta: TextDelta, snapshot: Text):
print(delta.value, end="", flush=True)
@override
def on_tool_call_created(self, tool_call: ToolCall):
print(f"\nassistant > {tool_call.type}\n", flush=True)
# Components
chatbot = gr.Chatbot(type="messages")
textbox = gr.Textbox( # Canvas
label="教案編輯",
lines=20,
render=False
)
prompt_input = gr.Textbox( # User prompt
submit_btn=True,
render=False
)
quick_response = gr.Dataset( # Suggested user prompt
samples=[[CONVERSATION_STARTER]],
components=[prompt_input],
render=False
)
hidden_list = gr.JSON(
value=[[]],
render=False,
visible=False
)
def handle_response(message, history, textbox_content):
integrated_message = message
if not (message == CONVERSATION_STARTER or textbox_content == ""):
integrated_message = f"""
用戶當前的需求:
{message}
用戶對您生成的教案進行了以下修改:
{textbox_content}
請根據用戶的需求和修改內容,更新教案,並依照步驟生成下一部分內容。
確保您:
1. 完整保留用戶的修改。
2. 提供清晰的建議(`suggestion`)。
3. 提供下一步的行動選項(`next_step_prompt`)。
"""
thread = client.beta.threads.create()
client.beta.threads.messages.create(
thread_id=thread.id,
role="user",
content=integrated_message,
)
full_response = ""
current_lesson_plan = ""
suggestion = ""
next_step_prompt = [[]]
with client.beta.threads.runs.stream(
thread_id=thread.id,
assistant_id=ASSISTANT_ID
) as stream:
for text_delta in stream.text_deltas:
full_response += text_delta
repaired_json = json_repair.loads(full_response)
try:
current_lesson_plan = repaired_json.get('current_lesson_plan', '')
except:
current_lesson_plan = ""
try:
suggestion = repaired_json.get('suggestion', '')
except:
suggestion = ""
yield suggestion, current_lesson_plan, [[]]
try:
repaired_json = json.loads(full_response)
next_step_prompt = repaired_json.get('next_step_prompt', [["進入下一步"]])
except:
next_step_prompt = [["進入下一步"]]
yield suggestion, current_lesson_plan, next_step_prompt
def handle_quick_response_click(selected):
return selected[0]
def handle_quick_response_samples(next_step_prompt):
if len(next_step_prompt) > 0 and len(next_step_prompt[0]) > 0:
return gr.Dataset(samples=next_step_prompt,visible=True)
return gr.Dataset(samples=[['-']],visible=False)
CORRECT_PASSWORD = os.getenv('ui_password')
def check_password(input_password):
if input_password == CORRECT_PASSWORD:
return gr.update(visible=False), gr.update(visible=True), ""
else:
return gr.update(visible=True), gr.update(visible=False), gr.update(value="密码错误,请重试。hint: channel name", visible=True)
with gr.Blocks() as demo:
# password UI popup
with gr.Group(visible=True) as password_popup:
password_input = gr.Textbox(label="請輸入密碼", type="password")
submit_button = gr.Button("提交")
error_message = gr.Textbox(label="", visible=False, interactive=False)
# Main UI
with gr.Group(visible=False) as main_ui:
with gr.Row(equal_height=True):
with gr.Column():
textbox.render()
prompt_input.render()
quick_response.render()
hidden_list.render()
with gr.Column():
gr.ChatInterface(
handle_response,
textbox=prompt_input,
examples=[[CONVERSATION_STARTER, None]],
additional_inputs=[textbox],
additional_outputs=[textbox, hidden_list],
type="messages"
)
# submit button event
submit_button.click(
check_password,
inputs=password_input,
outputs=[password_popup, main_ui, error_message]
)
# password input submit event (click enter)
password_input.submit(
check_password,
inputs=password_input,
outputs=[password_popup, main_ui, error_message]
)
quick_response.click(
handle_quick_response_click,
quick_response,
prompt_input
)
hidden_list.change(handle_quick_response_samples, hidden_list, quick_response)
demo.launch(debug=True)
# delete_asst(ASSISTANT_ID)