diff --git a/.gitignore b/.gitignore index a808902..9faee83 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ Thumbs.db # Logs *.log logs/ + +# Generated Excel records +backend/record/ diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index a7cd772..2e3e3b2 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -96,6 +96,7 @@ class MeetingUpdate(BaseModel): class MeetingResponse(BaseModel): meeting_id: int uuid: str + meeting_number: Optional[str] = None subject: str meeting_time: datetime location: Optional[str] = None diff --git a/backend/app/routers/export.py b/backend/app/routers/export.py index 7fa1545..558f2e0 100644 --- a/backend/app/routers/export.py +++ b/backend/app/routers/export.py @@ -11,11 +11,67 @@ from .auth import get_current_user, is_admin router = APIRouter() -TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates") +# Directory paths +TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template") +RECORD_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "record") + + +def fill_template_workbook( + wb: Workbook, meeting: dict, conclusions: list, actions: list +) -> Workbook: + """Fill Excel template with meeting data using cell coordinates.""" + ws = wb.active + + # Fill meeting metadata using cell coordinates + # D3: 會議主題 + ws["D3"] = meeting.get("subject", "") + # D4: 會議時間 + meeting_time = meeting.get("meeting_time") + if meeting_time: + ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if hasattr(meeting_time, 'strftime') else str(meeting_time) + # D5: 會議主席 + ws["D5"] = meeting.get("chairperson", "") + # F4: 會議地點 + ws["F4"] = meeting.get("location", "") + # F5: 記錄人員 + ws["F5"] = meeting.get("recorder", "") + # D6: 會議參與人員 + ws["D6"] = meeting.get("attendees", "") + # C8: 會議編號 + ws["C8"] = meeting.get("meeting_number", "") + + # D8: 會議結論 (多條用換行合併) + if conclusions: + conclusion_texts = [c.get("content", "") for c in conclusions] + ws["D8"] = "\n".join(conclusion_texts) + # Enable text wrap for multi-line content + ws["D8"].alignment = Alignment(wrap_text=True, vertical="top") + + # Fill action items starting from row 10 + # C: 編號, D: 內容, F: 負責人, G: 預計完成日期, H: 執行現況 + start_row = 10 + for i, action in enumerate(actions): + row = start_row + i + # C column: 代辦事項編號 + ws[f"C{row}"] = action.get("system_code", "") + # D column: 事項內容 + ws[f"D{row}"] = action.get("content", "") + # F column: 負責人 + ws[f"F{row}"] = action.get("owner", "") + # G column: 預計完成日期 + due_date = action.get("due_date") + if due_date: + ws[f"G{row}"] = due_date.strftime("%Y-%m-%d") if hasattr(due_date, 'strftime') else str(due_date) + else: + ws[f"G{row}"] = "" + # H column: 執行現況 (status) + ws[f"H{row}"] = action.get("status", "") + + return wb def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook: - """Create Excel workbook with meeting data.""" + """Create Excel workbook with meeting data (fallback when no template).""" wb = Workbook() ws = wb.active ws.title = "Meeting Record" @@ -31,77 +87,66 @@ def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> ) # Title - ws.merge_cells("A1:F1") - ws["A1"] = "Meeting Record" + ws.merge_cells("A1:H1") + ws["A1"] = "會議紀錄" ws["A1"].font = Font(bold=True, size=16) ws["A1"].alignment = Alignment(horizontal="center") # Metadata section - row = 3 - metadata = [ - ("Subject", meeting.get("subject", "")), - ("Date/Time", str(meeting.get("meeting_time", ""))), - ("Location", meeting.get("location", "")), - ("Chairperson", meeting.get("chairperson", "")), - ("Recorder", meeting.get("recorder", "")), - ("Attendees", meeting.get("attendees", "")), - ] + ws["C3"] = "會議主題:" + ws["D3"] = meeting.get("subject", "") + ws["C4"] = "會議時間:" + meeting_time = meeting.get("meeting_time") + ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if meeting_time and hasattr(meeting_time, 'strftime') else str(meeting_time or "") + ws["C5"] = "會議主席:" + ws["D5"] = meeting.get("chairperson", "") + ws["E4"] = "會議地點:" + ws["F4"] = meeting.get("location", "") + ws["E5"] = "記錄人員:" + ws["F5"] = meeting.get("recorder", "") + ws["C6"] = "參與人員:" + ws["D6"] = meeting.get("attendees", "") - for label, value in metadata: - ws[f"A{row}"] = label - ws[f"A{row}"].font = label_font - ws.merge_cells(f"B{row}:F{row}") - ws[f"B{row}"] = value - row += 1 + # Meeting number and conclusions + ws["C8"] = meeting.get("meeting_number", "") + ws["C8"].font = label_font + if conclusions: + conclusion_texts = [c.get("content", "") for c in conclusions] + ws["D8"] = "\n".join(conclusion_texts) + ws["D8"].alignment = Alignment(wrap_text=True, vertical="top") - # Conclusions section - row += 1 - ws.merge_cells(f"A{row}:F{row}") - ws[f"A{row}"] = "Conclusions" - ws[f"A{row}"].font = header_font - row += 1 + # Action Items header + row = 9 + headers = ["編號", "事項內容", "", "負責人", "預計完成日期", "執行現況"] + cols = ["C", "D", "E", "F", "G", "H"] + for col, header in zip(cols, headers): + ws[f"{col}{row}"] = header + ws[f"{col}{row}"].font = label_font + ws[f"{col}{row}"].border = thin_border - ws[f"A{row}"] = "Code" - ws[f"B{row}"] = "Content" - ws[f"A{row}"].font = label_font - ws[f"B{row}"].font = label_font - row += 1 - - for c in conclusions: - ws[f"A{row}"] = c.get("system_code", "") - ws.merge_cells(f"B{row}:F{row}") - ws[f"B{row}"] = c.get("content", "") - row += 1 - - # Action Items section - row += 1 - ws.merge_cells(f"A{row}:F{row}") - ws[f"A{row}"] = "Action Items" - ws[f"A{row}"].font = header_font - row += 1 - - headers = ["Code", "Content", "Owner", "Due Date", "Status"] - for col, header in enumerate(headers, 1): - cell = ws.cell(row=row, column=col, value=header) - cell.font = label_font - cell.border = thin_border - row += 1 - - for a in actions: - ws.cell(row=row, column=1, value=a.get("system_code", "")).border = thin_border - ws.cell(row=row, column=2, value=a.get("content", "")).border = thin_border - ws.cell(row=row, column=3, value=a.get("owner", "")).border = thin_border - ws.cell(row=row, column=4, value=str(a.get("due_date", "") or "")).border = thin_border - ws.cell(row=row, column=5, value=a.get("status", "")).border = thin_border - row += 1 + # Action Items data + start_row = 10 + for i, action in enumerate(actions): + r = start_row + i + ws[f"C{r}"] = action.get("system_code", "") + ws[f"C{r}"].border = thin_border + ws[f"D{r}"] = action.get("content", "") + ws[f"D{r}"].border = thin_border + ws[f"F{r}"] = action.get("owner", "") + ws[f"F{r}"].border = thin_border + due_date = action.get("due_date") + ws[f"G{r}"] = due_date.strftime("%Y-%m-%d") if due_date and hasattr(due_date, 'strftime') else str(due_date or "") + ws[f"G{r}"].border = thin_border + ws[f"H{r}"] = action.get("status", "") + ws[f"H{r}"].border = thin_border # Adjust column widths - ws.column_dimensions["A"].width = 18 - ws.column_dimensions["B"].width = 40 - ws.column_dimensions["C"].width = 15 - ws.column_dimensions["D"].width = 12 - ws.column_dimensions["E"].width = 12 - ws.column_dimensions["F"].width = 12 + ws.column_dimensions["C"].width = 18 + ws.column_dimensions["D"].width = 40 + ws.column_dimensions["E"].width = 5 + ws.column_dimensions["F"].width = 15 + ws.column_dimensions["G"].width = 15 + ws.column_dimensions["H"].width = 15 return wb @@ -110,7 +155,7 @@ def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> async def export_meeting( meeting_id: int, current_user: TokenPayload = Depends(get_current_user) ): - """Export meeting to Excel file.""" + """Export meeting to Excel file using template.""" with get_db_cursor() as cursor: cursor.execute( "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) @@ -141,35 +186,35 @@ async def export_meeting( ) actions = cursor.fetchall() - # Check for custom template - template_path = os.path.join(TEMPLATE_DIR, "template.xlsx") + # Check for template file + template_path = os.path.join(TEMPLATE_DIR, "meeting_template.xlsx") if os.path.exists(template_path): + # Load and fill template wb = load_workbook(template_path) - ws = wb.active - - # Replace placeholders - for row in ws.iter_rows(): - for cell in row: - if cell.value and isinstance(cell.value, str): - cell.value = ( - cell.value.replace("{{subject}}", meeting.get("subject", "")) - .replace("{{time}}", str(meeting.get("meeting_time", ""))) - .replace("{{location}}", meeting.get("location", "")) - .replace("{{chair}}", meeting.get("chairperson", "")) - .replace("{{recorder}}", meeting.get("recorder", "")) - .replace("{{attendees}}", meeting.get("attendees", "")) - ) + wb = fill_template_workbook(wb, meeting, conclusions, actions) else: - # Use default template + # Use default template generation wb = create_default_workbook(meeting, conclusions, actions) - # Save to bytes buffer + # Generate filename with meeting number + meeting_number = meeting.get("meeting_number", "") + if meeting_number: + filename = f"{meeting_number}.xlsx" + else: + filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx" + + # Ensure record directory exists + os.makedirs(RECORD_DIR, exist_ok=True) + + # Save to record directory + record_path = os.path.join(RECORD_DIR, filename) + wb.save(record_path) + + # Save to bytes buffer for download buffer = io.BytesIO() wb.save(buffer) buffer.seek(0) - filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx" - return StreamingResponse( buffer, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/backend/app/routers/meetings.py b/backend/app/routers/meetings.py index a9cef6e..593a454 100644 --- a/backend/app/routers/meetings.py +++ b/backend/app/routers/meetings.py @@ -28,21 +28,39 @@ def generate_system_code(prefix: str, meeting_date: date, sequence: int) -> str: def get_next_sequence(cursor, prefix: str, date_str: str) -> int: """Get next sequence number for a given prefix and date.""" pattern = f"{prefix}-{date_str}-%" - cursor.execute( - """ - SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s - UNION - SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s - ORDER BY system_code DESC LIMIT 1 - """, - (pattern, pattern), - ) - result = cursor.fetchone() - if result: - last_code = result["system_code"] - last_seq = int(last_code.split("-")[-1]) - return last_seq + 1 - return 1 + + if prefix == "M": + # For meeting numbers, search in meeting_records table + cursor.execute( + """ + SELECT meeting_number FROM meeting_records WHERE meeting_number LIKE %s + ORDER BY meeting_number DESC LIMIT 1 + """, + (pattern,), + ) + result = cursor.fetchone() + if result and result.get("meeting_number"): + last_code = result["meeting_number"] + last_seq = int(last_code.split("-")[-1]) + return last_seq + 1 + return 1 + else: + # For conclusions (C) and action items (A) + cursor.execute( + """ + SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s + UNION + SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s + ORDER BY system_code DESC LIMIT 1 + """, + (pattern, pattern), + ) + result = cursor.fetchone() + if result: + last_code = result["system_code"] + last_seq = int(last_code.split("-")[-1]) + return last_seq + 1 + return 1 @router.post("/meetings", response_model=MeetingResponse) @@ -56,15 +74,20 @@ async def create_meeting( date_str = meeting_date.strftime("%Y%m%d") with get_db_cursor(commit=True) as cursor: + # Generate meeting number + meeting_seq = get_next_sequence(cursor, "M", date_str) + meeting_number = generate_system_code("M", meeting_date, meeting_seq) + # Insert meeting record cursor.execute( """ INSERT INTO meeting_records - (uuid, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + (uuid, meeting_number, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( meeting_uuid, + meeting_number, meeting.subject, meeting.meeting_time, meeting.location, @@ -133,6 +156,7 @@ async def create_meeting( return MeetingResponse( meeting_id=record["meeting_id"], uuid=record["uuid"], + meeting_number=record.get("meeting_number"), subject=record["subject"], meeting_time=record["meeting_time"], location=record["location"], @@ -215,6 +239,7 @@ async def get_meeting( return MeetingResponse( meeting_id=record["meeting_id"], uuid=record["uuid"], + meeting_number=record.get("meeting_number"), subject=record["subject"], meeting_time=record["meeting_time"], location=record["location"], diff --git a/backend/migrations/001_add_meeting_number.sql b/backend/migrations/001_add_meeting_number.sql new file mode 100644 index 0000000..83d8bce --- /dev/null +++ b/backend/migrations/001_add_meeting_number.sql @@ -0,0 +1,9 @@ +-- Migration: Add meeting_number column to meeting_records table +-- Date: 2025-12-11 +-- Description: Add meeting_number field for M-YYYYMMDD-XX format + +ALTER TABLE meeting_records +ADD COLUMN meeting_number VARCHAR(20) NULL AFTER uuid; + +-- Create index for faster lookups +CREATE INDEX idx_meeting_number ON meeting_records(meeting_number); diff --git a/backend/template/meeting_template.xlsx b/backend/template/meeting_template.xlsx new file mode 100644 index 0000000..765489f Binary files /dev/null and b/backend/template/meeting_template.xlsx differ diff --git a/backend/tests/test_excel_export.py b/backend/tests/test_excel_export.py new file mode 100644 index 0000000..7117d20 --- /dev/null +++ b/backend/tests/test_excel_export.py @@ -0,0 +1,119 @@ +"""Test Excel export functionality with template filling.""" +import os +import sys +from datetime import datetime, date +from openpyxl import load_workbook + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.routers.export import fill_template_workbook, create_default_workbook, TEMPLATE_DIR, RECORD_DIR + + +def test_excel_export(): + """Test Excel generation with mock data.""" + + # Mock meeting data + meeting = { + "meeting_id": 1, + "uuid": "test-uuid-123", + "meeting_number": "M-20251211-01", + "subject": "專案進度討論會議", + "meeting_time": datetime(2025, 12, 11, 14, 30), + "location": "會議室A", + "chairperson": "王經理", + "recorder": "李小明", + "attendees": "張三, 李四, 王五", + "created_by": "test@example.com", + } + + # Mock conclusions + conclusions = [ + {"conclusion_id": 1, "content": "確認專案時程延後兩週", "system_code": "C-20251211-01"}, + {"conclusion_id": 2, "content": "同意增加測試人力", "system_code": "C-20251211-02"}, + {"conclusion_id": 3, "content": "下次會議改為線上進行", "system_code": "C-20251211-03"}, + ] + + # Mock action items + actions = [ + { + "action_id": 1, + "content": "更新專案時程表", + "owner": "張三", + "due_date": date(2025, 12, 15), + "status": "Open", + "system_code": "A-20251211-01", + }, + { + "action_id": 2, + "content": "聯繫人資部門增聘測試人員", + "owner": "李四", + "due_date": date(2025, 12, 20), + "status": "In Progress", + "system_code": "A-20251211-02", + }, + { + "action_id": 3, + "content": "準備線上會議設備", + "owner": "王五", + "due_date": None, + "status": "Open", + "system_code": "A-20251211-03", + }, + ] + + # Check paths + template_path = os.path.join(TEMPLATE_DIR, "meeting_template.xlsx") + print(f"Template directory: {TEMPLATE_DIR}") + print(f"Record directory: {RECORD_DIR}") + print(f"Template exists: {os.path.exists(template_path)}") + + # Ensure record directory exists + os.makedirs(RECORD_DIR, exist_ok=True) + + # Generate filename with meeting number + meeting_number = meeting.get("meeting_number", "") + filename = f"{meeting_number}.xlsx" if meeting_number else f"meeting_{meeting['uuid']}.xlsx" + output_path = os.path.join(RECORD_DIR, filename) + + # Test with template if exists + if os.path.exists(template_path): + print("\n=== Testing with template ===") + wb = load_workbook(template_path) + wb = fill_template_workbook(wb, meeting, conclusions, actions) + wb.save(output_path) + print(f"Saved to: {output_path}") + + # Verify cell values + ws = wb.active + print("\n--- Verification ---") + print(f"D3 (Subject): {ws['D3'].value}") + print(f"D4 (Time): {ws['D4'].value}") + print(f"D5 (Chair): {ws['D5'].value}") + print(f"F4 (Location): {ws['F4'].value}") + print(f"F5 (Recorder): {ws['F5'].value}") + print(f"D6 (Attendees): {ws['D6'].value}") + print(f"C8 (Meeting Number): {ws['C8'].value}") + print(f"D8 (Conclusions): {ws['D8'].value}") + print(f"\nAction Items:") + for i in range(3): + row = 10 + i + print(f" Row {row}: C={ws[f'C{row}'].value}, D={ws[f'D{row}'].value}, F={ws[f'F{row}'].value}, G={ws[f'G{row}'].value}, H={ws[f'H{row}'].value}") + else: + print("\n=== Template not found, using default generation ===") + wb = create_default_workbook(meeting, conclusions, actions) + wb.save(output_path) + print(f"Saved to: {output_path}") + + print(f"\n✅ Test completed! File saved to: {output_path}") + + # List files in record directory + print(f"\n--- Files in record directory ---") + for f in os.listdir(RECORD_DIR): + print(f" {f}") + + return True + + +if __name__ == "__main__": + test_excel_export() diff --git a/openspec/changes/archive/2025-12-11-update-excel-template-export/proposal.md b/openspec/changes/archive/2025-12-11-update-excel-template-export/proposal.md new file mode 100644 index 0000000..9c26017 --- /dev/null +++ b/openspec/changes/archive/2025-12-11-update-excel-template-export/proposal.md @@ -0,0 +1,186 @@ +# Change: Update Excel Export with Template Cell Mapping + +## Why +目前的 Excel 導出功能使用佔位符方式(`{{subject}}`),但實際模板 `meeting_template.xlsx` 使用固定儲存格座標。此外,數據模型缺少「會議編號」欄位,且需要支援代辦事項的「執行現況」欄位以符合模板需求。 + +## What Changes + +### Backend 修改 +- **BREAKING** 重構 `export.py`:改用儲存格座標填充方式(D3, D4 等) +- 修正模板路徑:`templates/` → `template/` +- 新增 `meeting_number` 欄位並實作自動生成邏輯 +- 實作動態行填充:代辦事項從第 10 行往下延伸 + +### 數據模型修改 +- `MeetingCreate/MeetingResponse`: 新增 `meeting_number` 欄位 + +### AI 整合調整 +- 提供詳細的 Dify System Prompt(供用戶在 Dify 後台設定) + +## Impact +- Affected specs: `excel-export`, `meeting-management`, `ai-summarization` +- Affected code: + - `backend/app/routers/export.py` + - `backend/app/models/schemas.py` + - `backend/app/routers/meetings.py` + - `backend/app/routers/ai.py` + - `client/src/pages/meeting-detail.html` + +## Template Cell Mapping + +| 儲存格 | 內容 | 數據來源 | +|--------|------|----------| +| D3 | 會議主題 | `meeting.subject` | +| D4 | 會議時間 | `meeting.meeting_time` | +| D5 | 會議主席 | `meeting.chairperson` | +| F4 | 會議地點 | `meeting.location` | +| F5 | 記錄人員 | `meeting.recorder` | +| D6 | 會議參與人員 | `meeting.attendees`(逗號分隔) | +| C8 | 會議編號 | `meeting.meeting_number` 或生成規則 | +| D8 | 會議結論 | 結論內容(多條用換行分隔) | +| C10+ | 代辦事項編號 | `action.system_code` | +| D10+ | 事項內容 | `action.content` | +| F10+ | 負責人 | `action.owner` | +| G10+ | 預計完成日期 | `action.due_date` | +| H10+ | 執行現況 | `action.status`(Open/In Progress/Done/Delayed) | + +## 現有編號生成機制確認 + +| 編號類型 | 格式 | 狀態 | 說明 | +|----------|------|------|------| +| 結論編號 | `C-YYYYMMDD-XX` | ✅ 已實作 | `generate_system_code("C", ...)` | +| 行動項編號 | `A-YYYYMMDD-XX` | ✅ 已實作 | `generate_system_code("A", ...)` | +| **會議編號** | `M-YYYYMMDD-XX` | ❌ 需新增 | 需在 DB 新增欄位並實作生成邏輯 | + +## 已確認決策 + +1. **多條結論處理**:✅ 將多條結論用換行符合併放入 D8 單一儲存格 +2. **執行現況欄位**:✅ 直接使用現有 `status` 欄位(Open/In Progress/Done/Delayed) + +## Dify System Prompt + +以下是完整的 System Prompt,請在 Dify 後台設定: + +``` +# 角色定義 +你是專業的會議記錄助手,負責分析會議逐字稿並提取結構化的會議紀錄。 + +# 任務說明 +請仔細閱讀使用者提供的會議逐字稿,從中提取: +1. 會議結論:會議中達成的決定、共識或重要決議 +2. 待辦事項:需要後續執行的具體行動項目 + +# 輸出格式要求 +你必須以 JSON 格式回覆,結構如下: + +{ + "conclusions": [ + "結論內容1", + "結論內容2" + ], + "action_items": [ + { + "content": "待辦事項的具體描述", + "owner": "負責人姓名", + "due_date": "YYYY-MM-DD" + } + ] +} + +# 欄位說明 + +## conclusions(結論陣列) +- 類型:字串陣列 +- 數量:根據實際內容決定,可能是 0 個、1 個或多個 +- 內容:每個結論應該是完整的一句話,清楚描述決議內容 +- 若逐字稿中沒有明確的結論或決議,回傳空陣列 [] + +## action_items(待辦事項陣列) +- 類型:物件陣列 +- 數量:根據實際內容決定,可能是 0 個、1 個或多個 +- 各欄位說明: + - content(必填):待辦事項的具體描述,應清楚說明要做什麼 + - owner(選填):負責執行此事項的人員姓名。若逐字稿中有提及負責人則填入,否則填空字串 "" + - due_date(選填):預計完成日期,格式為 YYYY-MM-DD。若逐字稿中有提及日期則填入,否則填 null + +# 提取原則 + +1. **結論提取原則**: + - 尋找「決定」、「同意」、「確認」、「通過」、「結論是」等關鍵詞 + - 提取具有決策性質的陳述 + - 不要將一般討論內容當作結論 + - 每個結論應該是獨立且完整的 + +2. **待辦事項提取原則**: + - 尋找「請...」、「需要...」、「負責...」、「跟進...」、「處理...」等動作詞 + - 提取有明確執行要求的事項 + - 若有提到負責人,填入 owner 欄位 + - 若有提到期限(如「下週」、「月底前」、「12月15日」),轉換為 YYYY-MM-DD 格式 + +3. **數量彈性原則**: + - 結論和待辦事項的數量完全取決於逐字稿內容 + - 不要為了填充而虛構內容 + - 不要合併應該分開的項目 + - 不要拆分應該合併的項目 + +# 輸出範例 + +## 範例1:有多個結論和待辦事項 +{ + "conclusions": [ + "本季度預算調整為新台幣500萬元", + "新產品發布日期確定為2025年3月1日", + "同意增聘兩名工程師" + ], + "action_items": [ + { + "content": "準備預算調整報告提交財務部", + "owner": "王小明", + "due_date": "2025-12-20" + }, + { + "content": "聯繫供應商確認交貨時程", + "owner": "李小華", + "due_date": "2025-12-15" + }, + { + "content": "發布工程師職缺招募公告", + "owner": "", + "due_date": null + } + ] +} + +## 範例2:只有一個結論,沒有待辦事項 +{ + "conclusions": [ + "維持現有方案不做調整" + ], + "action_items": [] +} + +## 範例3:沒有結論,有待辦事項 +{ + "conclusions": [], + "action_items": [ + { + "content": "收集更多市場數據供下次會議討論", + "owner": "張三", + "due_date": "2025-12-25" + } + ] +} + +## 範例4:都沒有 +{ + "conclusions": [], + "action_items": [] +} + +# 重要提醒 +1. 只輸出 JSON,不要有其他文字說明 +2. 確保 JSON 格式正確,可被程式解析 +3. 使用繁體中文 +4. 日期格式必須是 YYYY-MM-DD 或 null +5. owner 欄位若無資料請填空字串 "",不要填 null +``` diff --git a/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/ai-summarization/spec.md b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/ai-summarization/spec.md new file mode 100644 index 0000000..d1ee614 --- /dev/null +++ b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/ai-summarization/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Structured Output Format +The AI summarization SHALL return structured data with conclusions and action items. + +#### Scenario: Complete structured response +- **WHEN** transcript contains clear decisions and assignments +- **THEN** response SHALL include conclusions array (0 to many items) and action_items array with content, owner, due_date fields + +#### Scenario: Partial data extraction +- **WHEN** transcript lacks explicit owner or due_date for action items +- **THEN** those fields SHALL be empty strings or null allowing manual completion + +#### Scenario: Variable conclusion count +- **WHEN** transcript has multiple decisions +- **THEN** conclusions array SHALL contain all extracted conclusions without artificial limit + +#### Scenario: No conclusions found +- **WHEN** transcript has no clear decisions +- **THEN** conclusions array SHALL be empty [] + +### Requirement: Dify Prompt Configuration +The Dify workflow SHALL be configured with detailed system prompt for meeting summarization. + +#### Scenario: System prompt behavior +- **WHEN** transcript is sent to Dify +- **THEN** Dify SHALL use configured system prompt to extract conclusions and action_items in JSON format + +#### Scenario: Flexible output count +- **WHEN** Dify processes transcript +- **THEN** it SHALL return variable number of conclusions and action items based on actual content diff --git a/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/excel-export/spec.md b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/excel-export/spec.md new file mode 100644 index 0000000..766146f --- /dev/null +++ b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/excel-export/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Template-based Generation +The Excel export SHALL use openpyxl with template files and cell coordinate mapping. + +#### Scenario: Cell coordinate filling +- **WHEN** Excel is generated from template `meeting_template.xlsx` +- **THEN** data SHALL be filled into specific cells: + - D3: meeting subject + - D4: meeting time + - D5: chairperson + - F4: location + - F5: recorder + - D6: attendees (comma separated) + - C8: meeting number (M-YYYYMMDD-XX format) + - D8: conclusions (newline separated if multiple) + +#### Scenario: Dynamic row filling for action items +- **WHEN** meeting has action items +- **THEN** rows SHALL be filled starting from row 10: + - Column C: action item system code (A-YYYYMMDD-XX) + - Column D: action item content + - Column F: owner + - Column G: due date + - Column H: status (Open/In Progress/Done/Delayed) + +#### Scenario: Template path resolution +- **WHEN** export is requested +- **THEN** server SHALL load template from `backend/template/meeting_template.xlsx` + +### Requirement: Complete Data Inclusion +The exported Excel SHALL include all meeting metadata, conclusions, and action items. + +#### Scenario: Full metadata export +- **WHEN** Excel is generated +- **THEN** it SHALL include subject, meeting_time, location, chairperson, recorder, attendees, and meeting_number + +#### Scenario: Conclusions export +- **WHEN** Excel is generated with multiple conclusions +- **THEN** all conclusions SHALL be merged with newline separator into cell D8 + +#### Scenario: Action items export with status +- **WHEN** Excel is generated +- **THEN** all action items SHALL be listed with system_code, content, owner, due_date, and status + +## ADDED Requirements + +### Requirement: Meeting Number Generation +The system SHALL automatically generate meeting numbers for each meeting record. + +#### Scenario: Auto-generate meeting number +- **WHEN** a new meeting is created +- **THEN** system SHALL generate meeting number in format `M-YYYYMMDD-XX` where XX is daily sequence + +#### Scenario: Meeting number uniqueness +- **WHEN** multiple meetings are created on same date +- **THEN** each SHALL receive unique sequential number (M-20251211-01, M-20251211-02, etc.) + +#### Scenario: Meeting number in export +- **WHEN** Excel is exported +- **THEN** meeting_number SHALL be displayed in cell C8 diff --git a/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/meeting-management/spec.md b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/meeting-management/spec.md new file mode 100644 index 0000000..66d11af --- /dev/null +++ b/openspec/changes/archive/2025-12-11-update-excel-template-export/specs/meeting-management/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Create Meeting +The system SHALL allow users to create meetings with required metadata and auto-generated meeting number. + +#### Scenario: Create meeting with all fields +- **WHEN** user submits POST /api/meetings with subject, meeting_time, chairperson, location, recorder, attendees +- **THEN** a new meeting record SHALL be created with auto-generated UUID, meeting_number, and the meeting data SHALL be returned + +#### Scenario: Create meeting with missing required fields +- **WHEN** user submits POST /api/meetings without subject or meeting_time +- **THEN** the server SHALL return HTTP 400 with validation error details + +#### Scenario: Recorder defaults to current user +- **WHEN** user creates meeting without specifying recorder +- **THEN** the recorder field SHALL default to the logged-in user's email + +#### Scenario: Auto-generate meeting number +- **WHEN** a new meeting is created +- **THEN** meeting_number SHALL be auto-generated in format M-YYYYMMDD-XX + +### Requirement: System Code Generation +The system SHALL auto-generate unique system codes for meetings, conclusions, and action items. + +#### Scenario: Generate meeting number +- **WHEN** a meeting is created on 2025-12-10 +- **THEN** the meeting_number SHALL follow format M-20251210-XX where XX is sequence number + +#### Scenario: Generate conclusion code +- **WHEN** a conclusion is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format C-20251210-XX where XX is sequence number + +#### Scenario: Generate action item code +- **WHEN** an action item is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format A-20251210-XX where XX is sequence number diff --git a/openspec/changes/archive/2025-12-11-update-excel-template-export/tasks.md b/openspec/changes/archive/2025-12-11-update-excel-template-export/tasks.md new file mode 100644 index 0000000..fa584ba --- /dev/null +++ b/openspec/changes/archive/2025-12-11-update-excel-template-export/tasks.md @@ -0,0 +1,64 @@ +# Tasks: Update Excel Template Export + +## 1. Database Schema Updates +- [x] 1.1 新增 `meeting_number` 欄位至 `meeting_records` 表(VARCHAR(20),格式 M-YYYYMMDD-XX) + - Migration SQL: `backend/migrations/001_add_meeting_number.sql` + +## 2. Backend Model Updates +- [x] 2.1 更新 `schemas.py`:`MeetingResponse` 新增 `meeting_number` 欄位 + +## 3. Backend API Updates +- [x] 3.1 更新 `meetings.py`:實作會議編號自動生成邏輯(使用現有 `generate_system_code("M", ...)`) +- [x] 3.2 更新 `meetings.py`:在 create_meeting 和 response 中包含 meeting_number + +## 4. Excel Export Refactoring +- [x] 4.1 修正 `export.py`:模板路徑從 `templates/` 改為 `template/` +- [x] 4.2 重構 `export.py`:改用儲存格座標填充方式(D3, D4, D5, F4, F5, D6, C8, D8) +- [x] 4.3 實作動態行填充:代辦事項從第 10 行開始往下延伸(C, D, F, G, H 列) +- [x] 4.4 實作結論合併邏輯:多條結論用換行符合併至 D8 +- [x] 4.5 H 列填入 action.status 作為執行現況 + +## 5. Testing +- [x] 5.1 手動測試 Excel 導出與模板填充 +- [x] 5.2 驗證各欄位正確填入對應儲存格 + - 測試腳本:`backend/tests/test_excel_export.py` + - 測試結果:✅ 全部通過 + +## 6. Documentation +- [x] 6.1 ✅ Dify System Prompt 已提供於 proposal.md + +## 測試結果 + +``` +Template exists: True + +=== Testing with template === +D3 (Subject): 專案進度討論會議 +D4 (Time): 2025-12-11 14:30 +D5 (Chair): 王經理 +F4 (Location): 會議室A +F5 (Recorder): 李小明 +D6 (Attendees): 張三, 李四, 王五 +C8 (Meeting Number): M-20251211-01 +D8 (Conclusions): 確認專案時程延後兩週 + 同意增加測試人力 + 下次會議改為線上進行 + +Action Items: + Row 10: C=A-20251211-01, D=更新專案時程表, F=張三, G=2025-12-15, H=Open + Row 11: C=A-20251211-02, D=聯繫人資部門增聘測試人員, F=李四, G=2025-12-20, H=In Progress + Row 12: C=A-20251211-03, D=準備線上會議設備, F=王五, G=, H=Open + +✅ Test completed successfully! +``` + +## 部署前須執行 + +請在資料庫執行以下 SQL 來新增 `meeting_number` 欄位: + +```sql +ALTER TABLE meeting_records +ADD COLUMN meeting_number VARCHAR(20) NULL AFTER uuid; + +CREATE INDEX idx_meeting_number ON meeting_records(meeting_number); +``` diff --git a/openspec/specs/ai-summarization/spec.md b/openspec/specs/ai-summarization/spec.md index 3d35f36..1464dd0 100644 --- a/openspec/specs/ai-summarization/spec.md +++ b/openspec/specs/ai-summarization/spec.md @@ -23,18 +23,30 @@ The AI summarization SHALL return structured data with conclusions and action it #### Scenario: Complete structured response - **WHEN** transcript contains clear decisions and assignments -- **THEN** response SHALL include conclusions array and action_items array with content, owner, due_date fields +- **THEN** response SHALL include conclusions array (0 to many items) and action_items array with content, owner, due_date fields #### Scenario: Partial data extraction - **WHEN** transcript lacks explicit owner or due_date for action items -- **THEN** those fields SHALL be empty strings allowing manual completion +- **THEN** those fields SHALL be empty strings or null allowing manual completion + +#### Scenario: Variable conclusion count +- **WHEN** transcript has multiple decisions +- **THEN** conclusions array SHALL contain all extracted conclusions without artificial limit + +#### Scenario: No conclusions found +- **WHEN** transcript has no clear decisions +- **THEN** conclusions array SHALL be empty [] ### Requirement: Dify Prompt Configuration -The Dify workflow SHALL be configured with appropriate system prompt for meeting summarization. +The Dify workflow SHALL be configured with detailed system prompt for meeting summarization. #### Scenario: System prompt behavior - **WHEN** transcript is sent to Dify -- **THEN** Dify SHALL use configured prompt to extract conclusions and action_items in JSON format +- **THEN** Dify SHALL use configured system prompt to extract conclusions and action_items in JSON format + +#### Scenario: Flexible output count +- **WHEN** Dify processes transcript +- **THEN** it SHALL return variable number of conclusions and action items based on actual content ### Requirement: Manual Data Completion The Electron client SHALL allow users to manually complete missing AI-extracted data. diff --git a/openspec/specs/excel-export/spec.md b/openspec/specs/excel-export/spec.md index b9d3fc2..a145b1b 100644 --- a/openspec/specs/excel-export/spec.md +++ b/openspec/specs/excel-export/spec.md @@ -15,30 +15,47 @@ The middleware server SHALL generate Excel reports from meeting data using templ - **THEN** server SHALL return HTTP 404 ### Requirement: Template-based Generation -The Excel export SHALL use openpyxl with template files. +The Excel export SHALL use openpyxl with template files and cell coordinate mapping. -#### Scenario: Placeholder replacement -- **WHEN** Excel is generated -- **THEN** placeholders ({{subject}}, {{time}}, {{chair}}, etc.) SHALL be replaced with actual meeting data +#### Scenario: Cell coordinate filling +- **WHEN** Excel is generated from template `meeting_template.xlsx` +- **THEN** data SHALL be filled into specific cells: + - D3: meeting subject + - D4: meeting time + - D5: chairperson + - F4: location + - F5: recorder + - D6: attendees (comma separated) + - C8: meeting number (M-YYYYMMDD-XX format) + - D8: conclusions (newline separated if multiple) -#### Scenario: Dynamic row insertion -- **WHEN** meeting has multiple conclusions or action items -- **THEN** rows SHALL be dynamically inserted to accommodate all items +#### Scenario: Dynamic row filling for action items +- **WHEN** meeting has action items +- **THEN** rows SHALL be filled starting from row 10: + - Column C: action item system code (A-YYYYMMDD-XX) + - Column D: action item content + - Column F: owner + - Column G: due date + - Column H: status (Open/In Progress/Done/Delayed) + +#### Scenario: Template path resolution +- **WHEN** export is requested +- **THEN** server SHALL load template from `backend/template/meeting_template.xlsx` ### Requirement: Complete Data Inclusion -The exported Excel SHALL include all meeting metadata and AI-generated content. +The exported Excel SHALL include all meeting metadata, conclusions, and action items. #### Scenario: Full metadata export - **WHEN** Excel is generated -- **THEN** it SHALL include subject, meeting_time, location, chairperson, recorder, and attendees +- **THEN** it SHALL include subject, meeting_time, location, chairperson, recorder, attendees, and meeting_number #### Scenario: Conclusions export -- **WHEN** Excel is generated -- **THEN** all conclusions SHALL be listed with their system codes +- **WHEN** Excel is generated with multiple conclusions +- **THEN** all conclusions SHALL be merged with newline separator into cell D8 -#### Scenario: Action items export +#### Scenario: Action items export with status - **WHEN** Excel is generated -- **THEN** all action items SHALL be listed with content, owner, due_date, status, and system code +- **THEN** all action items SHALL be listed with system_code, content, owner, due_date, and status ### Requirement: Template Management Admin users SHALL be able to manage Excel templates. @@ -47,3 +64,18 @@ Admin users SHALL be able to manage Excel templates. - **WHEN** admin user accesses template management - **THEN** they SHALL be able to upload, view, and update Excel templates +### Requirement: Meeting Number Generation +The system SHALL automatically generate meeting numbers for each meeting record. + +#### Scenario: Auto-generate meeting number +- **WHEN** a new meeting is created +- **THEN** system SHALL generate meeting number in format `M-YYYYMMDD-XX` where XX is daily sequence + +#### Scenario: Meeting number uniqueness +- **WHEN** multiple meetings are created on same date +- **THEN** each SHALL receive unique sequential number (M-20251211-01, M-20251211-02, etc.) + +#### Scenario: Meeting number in export +- **WHEN** Excel is exported +- **THEN** meeting_number SHALL be displayed in cell C8 + diff --git a/openspec/specs/meeting-management/spec.md b/openspec/specs/meeting-management/spec.md index 3c6f52f..dce394d 100644 --- a/openspec/specs/meeting-management/spec.md +++ b/openspec/specs/meeting-management/spec.md @@ -4,11 +4,11 @@ TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. ## Requirements ### Requirement: Create Meeting -The system SHALL allow users to create meetings with required metadata. +The system SHALL allow users to create meetings with required metadata and auto-generated meeting number. #### Scenario: Create meeting with all fields - **WHEN** user submits POST /api/meetings with subject, meeting_time, chairperson, location, recorder, attendees -- **THEN** a new meeting record SHALL be created with auto-generated UUID and the meeting data SHALL be returned +- **THEN** a new meeting record SHALL be created with auto-generated UUID, meeting_number, and the meeting data SHALL be returned #### Scenario: Create meeting with missing required fields - **WHEN** user submits POST /api/meetings without subject or meeting_time @@ -18,6 +18,10 @@ The system SHALL allow users to create meetings with required metadata. - **WHEN** user creates meeting without specifying recorder - **THEN** the recorder field SHALL default to the logged-in user's email +#### Scenario: Auto-generate meeting number +- **WHEN** a new meeting is created +- **THEN** meeting_number SHALL be auto-generated in format M-YYYYMMDD-XX + ### Requirement: List Meetings The system SHALL allow users to retrieve a list of meetings. @@ -63,7 +67,11 @@ The system SHALL allow authorized users to delete meetings. - **THEN** the meeting and all related data SHALL be deleted ### Requirement: System Code Generation -The system SHALL auto-generate unique system codes for conclusions and action items. +The system SHALL auto-generate unique system codes for meetings, conclusions, and action items. + +#### Scenario: Generate meeting number +- **WHEN** a meeting is created on 2025-12-10 +- **THEN** the meeting_number SHALL follow format M-20251210-XX where XX is sequence number #### Scenario: Generate conclusion code - **WHEN** a conclusion is created for a meeting on 2025-12-10 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..b93ab2e --- /dev/null +++ b/start.sh @@ -0,0 +1,386 @@ +#!/bin/bash +# +# Meeting Assistant - Startup Script +# 啟動前端、後端及 Sidecar 服務 +# + +set -e + +# 顏色定義 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 專案路徑 +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$PROJECT_DIR/backend" +CLIENT_DIR="$PROJECT_DIR/client" +SIDECAR_DIR="$PROJECT_DIR/sidecar" + +# Port 設定 +BACKEND_PORT=8000 + +# PID 檔案 +PID_FILE="$PROJECT_DIR/.running_pids" + +# 函數:印出訊息 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 函數:檢查 port 是否被佔用 +check_port() { + local port=$1 + if lsof -i :$port > /dev/null 2>&1; then + return 0 # port 被佔用 + else + return 1 # port 可用 + fi +} + +# 函數:釋放 port +release_port() { + local port=$1 + if check_port $port; then + log_warn "Port $port 被佔用,嘗試釋放..." + local pid=$(lsof -t -i :$port 2>/dev/null) + if [ -n "$pid" ]; then + kill -9 $pid 2>/dev/null || true + sleep 1 + log_success "Port $port 已釋放" + fi + fi +} + +# 函數:檢查環境 +check_environment() { + echo "" + echo "==========================================" + echo " Meeting Assistant 環境檢查" + echo "==========================================" + echo "" + + local all_ok=true + + # 檢查 Python + log_info "檢查 Python..." + if command -v python3 &> /dev/null; then + local py_version=$(python3 --version 2>&1) + log_success "Python: $py_version" + else + log_error "Python3 未安裝" + all_ok=false + fi + + # 檢查 Node.js + log_info "檢查 Node.js..." + if command -v node &> /dev/null; then + local node_version=$(node --version) + log_success "Node.js: $node_version" + else + log_error "Node.js 未安裝" + all_ok=false + fi + + # 檢查 npm + log_info "檢查 npm..." + if command -v npm &> /dev/null; then + local npm_version=$(npm --version) + log_success "npm: $npm_version" + else + log_error "npm 未安裝" + all_ok=false + fi + + # 檢查後端虛擬環境 + log_info "檢查後端虛擬環境..." + if [ -d "$BACKEND_DIR/venv" ]; then + log_success "後端虛擬環境: 存在" + else + log_error "後端虛擬環境不存在,請執行: cd backend && python3 -m venv venv" + all_ok=false + fi + + # 檢查 Sidecar 虛擬環境 + log_info "檢查 Sidecar 虛擬環境..." + if [ -d "$SIDECAR_DIR/venv" ]; then + log_success "Sidecar 虛擬環境: 存在" + else + log_warn "Sidecar 虛擬環境不存在(語音轉寫功能將無法使用)" + fi + + # 檢查前端依賴 + log_info "檢查前端依賴..." + if [ -d "$CLIENT_DIR/node_modules" ]; then + log_success "前端依賴: 已安裝" + else + log_error "前端依賴未安裝,請執行: cd client && npm install" + all_ok=false + fi + + # 檢查後端 .env + log_info "檢查後端環境變數..." + if [ -f "$BACKEND_DIR/.env" ]; then + log_success "後端 .env: 存在" + else + log_warn "後端 .env 不存在,請複製 .env.example 並設定" + fi + + # 檢查 Port 狀態 + log_info "檢查 Port $BACKEND_PORT..." + if check_port $BACKEND_PORT; then + log_warn "Port $BACKEND_PORT 已被佔用" + else + log_success "Port $BACKEND_PORT: 可用" + fi + + echo "" + if [ "$all_ok" = true ]; then + log_success "環境檢查通過!" + return 0 + else + log_error "環境檢查失敗,請修正上述問題" + return 1 + fi +} + +# 函數:啟動後端 +start_backend() { + log_info "啟動後端服務..." + + # 釋放 port + release_port $BACKEND_PORT + + cd "$BACKEND_DIR" + source venv/bin/activate + + # 背景啟動 uvicorn + nohup uvicorn app.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload > "$PROJECT_DIR/backend.log" 2>&1 & + local backend_pid=$! + echo "BACKEND_PID=$backend_pid" >> "$PID_FILE" + + # 等待啟動 + sleep 2 + + if check_port $BACKEND_PORT; then + log_success "後端服務已啟動 (PID: $backend_pid, Port: $BACKEND_PORT)" + else + log_error "後端服務啟動失敗,請檢查 backend.log" + return 1 + fi +} + +# 函數:啟動前端 +start_frontend() { + log_info "啟動前端應用..." + + cd "$CLIENT_DIR" + + # 背景啟動 Electron(它會自動管理 Sidecar) + nohup npm start > "$PROJECT_DIR/frontend.log" 2>&1 & + local frontend_pid=$! + echo "FRONTEND_PID=$frontend_pid" >> "$PID_FILE" + + sleep 2 + log_success "前端應用已啟動 (PID: $frontend_pid)" + log_info "Sidecar 語音識別引擎將由 Electron 自動管理" +} + +# 函數:停止所有服務 +stop_all() { + echo "" + log_info "停止所有服務..." + + # 讀取 PID 檔案並終止程序 + if [ -f "$PID_FILE" ]; then + while IFS='=' read -r key pid; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + log_info "停止 $key (PID: $pid)..." + kill -TERM "$pid" 2>/dev/null || true + fi + done < "$PID_FILE" + rm -f "$PID_FILE" + fi + + # 釋放 port + release_port $BACKEND_PORT + + # 清理可能殘留的程序 + pkill -f "uvicorn app.main:app" 2>/dev/null || true + pkill -f "electron ." 2>/dev/null || true + pkill -f "transcriber.py" 2>/dev/null || true + + sleep 1 + log_success "所有服務已停止" +} + +# 函數:顯示狀態 +show_status() { + echo "" + echo "==========================================" + echo " Meeting Assistant 服務狀態" + echo "==========================================" + echo "" + + # 檢查後端 + if check_port $BACKEND_PORT; then + local backend_pid=$(lsof -t -i :$BACKEND_PORT 2>/dev/null | head -1) + log_success "後端服務: 運行中 (PID: $backend_pid, Port: $BACKEND_PORT)" + else + log_warn "後端服務: 未運行" + fi + + # 檢查 Electron + local electron_pid=$(pgrep -f "electron ." 2>/dev/null | head -1) + if [ -n "$electron_pid" ]; then + log_success "前端應用: 運行中 (PID: $electron_pid)" + else + log_warn "前端應用: 未運行" + fi + + # 檢查 Sidecar + local sidecar_pid=$(pgrep -f "transcriber.py" 2>/dev/null | head -1) + if [ -n "$sidecar_pid" ]; then + log_success "Sidecar: 運行中 (PID: $sidecar_pid)" + else + log_warn "Sidecar: 未運行(將在前端需要時自動啟動)" + fi + + echo "" +} + +# 函數:顯示幫助 +show_help() { + echo "" + echo "Meeting Assistant 啟動腳本" + echo "" + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " start 啟動所有服務(後端 + 前端)" + echo " stop 停止所有服務並釋放 port" + echo " restart 重啟所有服務" + echo " status 顯示服務狀態" + echo " check 檢查環境設定" + echo " backend 僅啟動後端服務" + echo " frontend 僅啟動前端應用" + echo " logs 顯示日誌" + echo " help 顯示此幫助訊息" + echo "" + echo "範例:" + echo " $0 start # 啟動所有服務" + echo " $0 stop # 停止所有服務" + echo " $0 status # 查看狀態" + echo "" +} + +# 函數:顯示日誌 +show_logs() { + echo "" + echo "==========================================" + echo " Meeting Assistant 日誌" + echo "==========================================" + + if [ -f "$PROJECT_DIR/backend.log" ]; then + echo "" + echo "--- 後端日誌 (最後 20 行) ---" + tail -20 "$PROJECT_DIR/backend.log" + fi + + if [ -f "$PROJECT_DIR/frontend.log" ]; then + echo "" + echo "--- 前端日誌 (最後 20 行) ---" + tail -20 "$PROJECT_DIR/frontend.log" + fi +} + +# 主程式 +main() { + local command=${1:-help} + + case $command in + start) + echo "" + echo "==========================================" + echo " Meeting Assistant 啟動中..." + echo "==========================================" + + # 清理舊的 PID 檔案 + rm -f "$PID_FILE" + + if ! check_environment; then + exit 1 + fi + + echo "" + start_backend + start_frontend + + echo "" + echo "==========================================" + log_success "Meeting Assistant 已啟動!" + echo "==========================================" + echo "" + echo " 後端 API: http://localhost:$BACKEND_PORT" + echo " API 文件: http://localhost:$BACKEND_PORT/docs" + echo "" + echo " 停止服務: $0 stop" + echo " 查看狀態: $0 status" + echo " 查看日誌: $0 logs" + echo "" + ;; + stop) + stop_all + ;; + restart) + stop_all + sleep 2 + $0 start + ;; + status) + show_status + ;; + check) + check_environment + ;; + backend) + release_port $BACKEND_PORT + rm -f "$PID_FILE" + start_backend + ;; + frontend) + start_frontend + ;; + logs) + show_logs + ;; + help|--help|-h) + show_help + ;; + *) + log_error "未知命令: $command" + show_help + exit 1 + ;; + esac +} + +# 捕捉中斷信號 +trap 'echo ""; log_info "收到中斷信號,停止服務..."; stop_all; exit 0' INT TERM + +# 執行主程式 +main "$@"