From 9b6ca107722dbb430744355b701057374bcb2502 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 01:27:11 +0900 Subject: [PATCH 01/24] main.tf dev space remove --- infra/environments/dev/main.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/environments/dev/main.tf b/infra/environments/dev/main.tf index 08dbc7dd..938e8338 100644 --- a/infra/environments/dev/main.tf +++ b/infra/environments/dev/main.tf @@ -38,7 +38,6 @@ module "cloud_tasks" { module "cloud_run" { source = "../../modules/cloud_run" - project_id = var.project_id region = local.region stack_name = local.stack_name From 0a47fe8199f188bd7b97e37ea23d56c7e7bf7189 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 07:58:03 +0900 Subject: [PATCH 02/24] main.tf fmt fix --- infra/environments/dev/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/environments/dev/main.tf b/infra/environments/dev/main.tf index 938e8338..adaa1e0b 100644 --- a/infra/environments/dev/main.tf +++ b/infra/environments/dev/main.tf @@ -37,7 +37,7 @@ module "cloud_tasks" { } module "cloud_run" { - source = "../../modules/cloud_run" + source = "../../modules/cloud_run" project_id = var.project_id region = local.region stack_name = local.stack_name From 444fcb7492ba547a594148b94b3147acb398ca6b Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 13:44:19 +0900 Subject: [PATCH 03/24] BE/FE refactaring --- .vscode/settings.json | 3 +- backend/app/db/seed.py | 555 +--------- backend/app/db/seeds/qualifications.json | 998 ++++++++++++++++++ backend/app/db/seeds/technology_stacks.json | 642 +++++++++++ backend/app/routers/auth/endpoints.py | 20 +- backend/app/routers/blog.py | 66 +- backend/app/routers/career_analysis.py | 38 +- backend/app/routers/intelligence.py | 44 +- backend/app/schemas/intelligence.py | 2 +- backend/app/services/blog/collector.py | 18 +- .../intelligence/career_paths/__init__.py | 23 - .../intelligence/career_paths/definitions.py | 334 ------ .../intelligence/career_paths/matcher.py | 57 - .../services/intelligence/career_predictor.py | 216 ---- .../services/intelligence/career_simulator.py | 149 --- .../intelligence/confidence_scorer.py | 103 -- .../intelligence/github_analysis_service.py | 121 +++ .../services/intelligence/llm_summarizer.py | 18 +- backend/app/services/intelligence/pipeline.py | 78 +- .../services/intelligence/position_scorer.py | 177 +--- .../intelligence/position_weights.json | 12 + .../services/intelligence/skill_extractor.py | 99 +- .../intelligence/skill_growth_analyzer.py | 157 --- .../intelligence/skill_taxonomy/__init__.py | 20 +- .../skill_taxonomy/ownership_map.py | 133 +++ .../intelligence/skill_timeline_builder.py | 128 --- backend/app/services/progress_service.py | 2 +- backend/app/services/tasks/__init__.py | 15 +- backend/app/services/tasks/base.py | 19 + .../app/services/tasks/dispatch_service.py | 98 ++ .../app/services/tasks/handlers/__init__.py | 30 + backend/app/services/tasks/handlers/base.py | 26 + .../services/tasks/handlers/blog_summarize.py | 89 ++ .../tasks/handlers/career_analysis.py | 65 ++ .../tasks/handlers/github_analysis.py | 22 + backend/app/services/tasks/worker.py | 304 +----- backend/tests/test_career_analysis_api.py | 18 +- backend/tests/test_intelligence.py | 461 +------- backend/tests/test_llm_summarizer.py | 35 +- backend/tests/test_retry_flow.py | 27 +- backend/tests/test_worker_extended.py | 100 +- backend/tests/test_worker_timeout.py | 82 +- frontend/src/api/client.test.ts | 26 + .../src/components/blog/BlogArticleList.tsx | 135 +++ .../src/components/blog/BlogPlatformList.tsx | 115 ++ .../career-analysis/CareerAnalysisPage.tsx | 262 +---- .../result/CareerAnalysisResultView.tsx | 255 +++++ .../components/forms/ProjectModal.test.tsx | 95 +- .../src/components/forms/ProjectModal.tsx | 2 +- .../sections/CareerExperienceSection.tsx | 208 +--- .../analysis/useAsyncAnalysisPage.test.ts | 17 + .../src/hooks/useBlogAccountManager.test.ts | 40 +- .../src/hooks/useCareerExperienceMutators.ts | 208 ++++ frontend/src/hooks/useNotifications.test.ts | 74 ++ .../src/hooks/useProjectModalState.test.ts | 82 +- .../useProjectModalState.validation.test.ts | 45 - frontend/src/payloadBuilders.test.ts | 27 + 57 files changed, 3615 insertions(+), 3580 deletions(-) create mode 100644 backend/app/db/seeds/qualifications.json create mode 100644 backend/app/db/seeds/technology_stacks.json delete mode 100644 backend/app/services/intelligence/career_paths/__init__.py delete mode 100644 backend/app/services/intelligence/career_paths/definitions.py delete mode 100644 backend/app/services/intelligence/career_paths/matcher.py delete mode 100644 backend/app/services/intelligence/career_predictor.py delete mode 100644 backend/app/services/intelligence/career_simulator.py delete mode 100644 backend/app/services/intelligence/confidence_scorer.py create mode 100644 backend/app/services/intelligence/github_analysis_service.py delete mode 100644 backend/app/services/intelligence/skill_growth_analyzer.py create mode 100644 backend/app/services/intelligence/skill_taxonomy/ownership_map.py delete mode 100644 backend/app/services/intelligence/skill_timeline_builder.py create mode 100644 backend/app/services/tasks/dispatch_service.py create mode 100644 backend/app/services/tasks/handlers/__init__.py create mode 100644 backend/app/services/tasks/handlers/base.py create mode 100644 backend/app/services/tasks/handlers/blog_summarize.py create mode 100644 backend/app/services/tasks/handlers/career_analysis.py create mode 100644 backend/app/services/tasks/handlers/github_analysis.py create mode 100644 frontend/src/components/blog/BlogArticleList.tsx create mode 100644 frontend/src/components/blog/BlogPlatformList.tsx create mode 100644 frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx create mode 100644 frontend/src/hooks/useCareerExperienceMutators.ts create mode 100644 frontend/src/hooks/useNotifications.test.ts delete mode 100644 frontend/src/hooks/useProjectModalState.validation.test.ts create mode 100644 frontend/src/payloadBuilders.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index b7c5551d..244fe4e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -174,7 +174,8 @@ "dyld", "DYLD", "turso", - "libsql" + "libsql", + "Qiita" ], "flake8.args": [ "--max-line-length=100" diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py index 97884ad5..db59f89e 100644 --- a/backend/app/db/seed.py +++ b/backend/app/db/seed.py @@ -1,4 +1,14 @@ -"""マスタデータの初期シードを投入するモジュール。""" +"""マスタデータの初期シードを投入するモジュール。 + +実データは ``backend/app/db/seeds/`` 配下の JSON ファイルに格納されている: + - ``qualifications.json``: 資格マスタ + - ``technology_stacks.json``: 技術スタックマスタ(カテゴリ付き) + +本モジュールはローダーとシード投入オーケストレーションのみを担う。 +""" + +import json +from pathlib import Path from sqlalchemy.orm import Session @@ -7,539 +17,18 @@ MTechnologyStackRepository, ) -# 資格マスタ -_QUALIFICATIONS = [ - {"name": "ITストラテジスト(ST)", "sort_order": 1}, - {"name": "システム監査技術者(AU)", "sort_order": 2}, - {"name": "プロジェクトマネージャ(PM)", "sort_order": 3}, - {"name": "システムアーキテクト(SA)", "sort_order": 4}, - {"name": "ITサービスマネージャ(SM)", "sort_order": 5}, - {"name": "ネットワークスペシャリスト(NW)", "sort_order": 6}, - {"name": "データベーススペシャリスト(DB)", "sort_order": 7}, - {"name": "エンベデッドシステムスペシャリスト(ES)", "sort_order": 8}, - {"name": "情報処理安全確保支援士試験(SC)", "sort_order": 9}, - {"name": "応用情報技術者試験(AP)", "sort_order": 10}, - {"name": "基本情報処理技術者(FE)", "sort_order": 11}, - {"name": "ITパスポート試験", "sort_order": 12}, - {"name": "情報セキュリティマネジメント試験", "sort_order": 13}, - {"name": "ウェブデザイン技能検定 1級", "sort_order": 14}, - {"name": "ウェブデザイン技能検定 2級", "sort_order": 15}, - {"name": "ウェブデザイン技能検定 3級", "sort_order": 16}, - {"name": "第1種衛生管理者", "sort_order": 17}, - {"name": "第2種衛生管理者", "sort_order": 18}, - {"name": "Certified Kubernetes Application Developer(CKAD)", "sort_order": 19}, - {"name": "Certified Kubernetes Administrator (CKA)", "sort_order": 20}, - {"name": "Google Cloud認定資格 Cloud Digital Leader", "sort_order": 21}, - {"name": "Google Cloud認定資格 Associate Cloud Engineer", "sort_order": 22}, - {"name": "Google Cloud認定資格 Professional Cloud Architects", "sort_order": 23}, - {"name": "Google Cloud認定資格 Professional Cloud Database Engineer", "sort_order": 24}, - {"name": "Google Cloud認定資格 Professional Cloud Developer", "sort_order": 25}, - {"name": "Google Cloud認定資格 Professional Data Engineer", "sort_order": 26}, - {"name": "Google Cloud認定資格 Professional Cloud DevOps Engineer", "sort_order": 27}, - {"name": "Google Cloud認定資格 Professional Cloud Security Engineer", "sort_order": 28}, - {"name": "Google Cloud認定資格 Professional Cloud Network Engineer", "sort_order": 29}, - {"name": "Google Cloud認定資格 Professional Google Workspace Administrator", "sort_order": 30}, - { - "name": "Google Cloud認定資格 Professional Google Professional Machine Learning Engineer", - "sort_order": 31, - }, - {"name": "ITIL認定資格[ITIL]_Master", "sort_order": 32}, - {"name": "ITIL認定資格[ITIL]_Expert", "sort_order": 33}, - {"name": "ITIL認定資格[ITIL]_Intermediate", "sort_order": 34}, - {"name": "ITIL認定資格[ITIL]_Foundation", "sort_order": 35}, - {"name": "OMG認定UML技術者_アドバンスト", "sort_order": 36}, - {"name": "OMG認定UML技術者_インターメディエイト", "sort_order": 37}, - {"name": "OMG認定UML技術者_ファンダメンタル", "sort_order": 38}, - {"name": "UMTP認定UMLモデリング技能認定_L3", "sort_order": 39}, - {"name": "UMTP認定UMLモデリング技能認定_L2", "sort_order": 40}, - {"name": "UMTP認定UMLモデリング技能認定_L1", "sort_order": 41}, - {"name": "XML技術者認定_プロフェッショナル(データベース)", "sort_order": 42}, - {"name": "XML技術者認定_プロフェッショナル(アプリケーション開発)", "sort_order": 43}, - {"name": "XML技術者認定_ベーシック", "sort_order": 44}, - {"name": "CompTIA_IT Fundamentals", "sort_order": 45}, - {"name": "CompTIA_A+", "sort_order": 46}, - {"name": "CompTIA_NetWork+", "sort_order": 47}, - {"name": "CompTIA_Security+", "sort_order": 48}, - {"name": "CompTIA_Cloud+", "sort_order": 49}, - {"name": "CompTIA_Linux+", "sort_order": 50}, - {"name": "CompTIA_Server+", "sort_order": 51}, - {"name": "CompTIA_Advanced Security Practitioner (CASP+)", "sort_order": 52}, - {"name": "CompTIA_CyberSecurity Analyst+ (CySA+)", "sort_order": 53}, - {"name": "CompTIA_PenTest+", "sort_order": 54}, - {"name": "CompTIA_Data+", "sort_order": 55}, - {"name": "CompTIA_DataSys+", "sort_order": 56}, - {"name": "CompTIA_Project+", "sort_order": 57}, - {"name": "CompTIA_Cloud Essentials+", "sort_order": 58}, - {"name": "PHP技術者認定試験_上級", "sort_order": 59}, - {"name": "PHP技術者認定試験_初級", "sort_order": 60}, - {"name": "Androidプラットフォーム技術者認定試験_ベーシック", "sort_order": 61}, - {"name": "Androidアプリケーション技術者認定試験_ベーシック", "sort_order": 62}, - {"name": "アジャイルソフトウエア開発技術者検定試験_Lv1試験", "sort_order": 63}, - {"name": "アジャイルソフトウエア開発技術者検定試験_Lv2試験", "sort_order": 64}, - {"name": "Cisco認定資格_CCIE", "sort_order": 65}, - {"name": "Cisco認定資格_CCNP", "sort_order": 66}, - {"name": "Cisco認定資格_CCDA", "sort_order": 67}, - {"name": "Cisco認定資格_CCNA", "sort_order": 68}, - {"name": "LPIC(Linux Professional Institute Certification)_Level3(上級)", "sort_order": 69}, - {"name": "LPIC(Linux Professional Institute Certification)_Level2(中級)", "sort_order": 70}, - {"name": "LPIC(Linux Professional Institute Certification)_Level1(基礎)", "sort_order": 71}, - {"name": "Red Hat認定エンジニア", "sort_order": 72}, - {"name": "JSTQB認定テスト技術者資格_Advanced Level", "sort_order": 73}, - {"name": "JSTQB認定テスト技術者資格_Foundation Level", "sort_order": 74}, - {"name": "Vmware_VMware Certified Implementation Expert", "sort_order": 75}, - {"name": "Vmware_VMware Certified Professional", "sort_order": 76}, - {"name": "Vmware_VMware Certified Associate", "sort_order": 77}, - {"name": "LinuC(Linux Professional Certification)_Level3(上級)", "sort_order": 78}, - {"name": "LinuC(Linux Professional Certification)_Level2(中級)", "sort_order": 79}, - {"name": "LinuC(Linux Professional Certification)_Level1(基礎)", "sort_order": 80}, - {"name": "JDLA Deep Learning_G検定(JDLA Deep Learning for GENERAL)", "sort_order": 81}, - {"name": "JDLA Deep Learning_E資格(JDLA Deep Learning for ENGINEER)", "sort_order": 82}, - {"name": "VBAエキスパート_Excel VBAベーシック", "sort_order": 83}, - {"name": "VBAエキスパート_Excel VBAスタンダード", "sort_order": 84}, - {"name": "VBAエキスパート_Access VBAベーシック", "sort_order": 85}, - {"name": "VBAエキスパート_Access VBAスタンダード", "sort_order": 86}, - {"name": "AWS認定試験_ソリューションアーキテクト – アソシエイト", "sort_order": 87}, - {"name": "AWS認定試験_ソリューションアーキテクト – プロフェッショナル", "sort_order": 88}, - {"name": "AWS認定試験_デベロッパー – アソシエイト", "sort_order": 89}, - {"name": "AWS認定試験_SysOpsアドミニストレーター – アソシエイト", "sort_order": 90}, - {"name": "AWS認定試験_DevOps エンジニア – プロフェッショナル", "sort_order": 91}, - {"name": "AWS認定試験_AWS 認定クラウドプラクティショナー", "sort_order": 92}, - {"name": "HTML5_プロフェッショナル 認定試験 レベル1", "sort_order": 93}, - {"name": "HTML5_プロフェッショナル 認定試験 レベル2", "sort_order": 94}, - {"name": "OSS-DB_Silver", "sort_order": 95}, - {"name": "OSS-DB_Gold", "sort_order": 96}, - {"name": "OSS-DB_Platinum", "sort_order": 97}, - {"name": "RPA技術者検定_アソシエイト", "sort_order": 98}, - {"name": "RPA技術者検定_エキスパート", "sort_order": 99}, - {"name": "PythonED_Python3エンジニア認定基礎試験", "sort_order": 100}, - {"name": "PythonED_Python3エンジニア認定データ分析試験", "sort_order": 101}, - {"name": "Ruby技術者認定資格_Silver", "sort_order": 102}, - {"name": "Ruby技術者認定資格_Gold", "sort_order": 103}, - {"name": "Professional Scrum Master™_レベルⅢ(PSMⅢ)", "sort_order": 104}, - {"name": "Professional Scrum Master™_レベルⅡ(PSMⅡ)", "sort_order": 105}, - {"name": "Professional Scrum Master™_レベルI(PSM I)", "sort_order": 106}, - {"name": "モバイルシステム技術検定_シニアモバイルシステムコンサルタント", "sort_order": 107}, - {"name": "モバイルシステム技術検定_モバイルシステム技術検定試験 1級", "sort_order": 108}, - {"name": "モバイルシステム技術検定_モバイルシステム技術検定試験 2級", "sort_order": 109}, - {"name": "モバイルシステム技術検定_モバイル技術基礎検定", "sort_order": 110}, - {"name": "Microsoft認定資格_Azure Fundamentals", "sort_order": 111}, - {"name": "Microsoft認定資格_Azure AI Fundamentals", "sort_order": 112}, - {"name": "Microsoft認定資格_Azure Data Fundamentals", "sort_order": 113}, - {"name": "Microsoft認定資格_Microsoft 365 Fundamentals", "sort_order": 114}, - {"name": "Microsoft認定資格_Dynamics 365 Fundamentals(CRM)", "sort_order": 115}, - {"name": "Microsoft認定資格_Dynamics 365 Fundamentals(ERP)", "sort_order": 116}, - {"name": "Microsoft認定資格_Power Platform Fundamentals", "sort_order": 117}, - { - "name": "Microsoft認定資格_Security, Compliance, and Identity Fundamentals", - "sort_order": 118, - }, - {"name": "Microsoft認定資格_Azure Administrator Associate", "sort_order": 119}, - {"name": "Microsoft認定資格_Azure Solutions Architect Expert", "sort_order": 120}, - {"name": "Microsoft認定資格_Azure Network Engineer Associate", "sort_order": 121}, - {"name": "Microsoft認定資格_Windows Server Hybrid Administrator Associate", "sort_order": 122}, - {"name": "Microsoft認定資格_Azure AI Engineer Associate", "sort_order": 123}, - {"name": "Microsoft認定資格_Power BI Data Analyst Associate", "sort_order": 124}, - {"name": "Microsoft認定資格_Azure Data Scientist Associate", "sort_order": 125}, - {"name": "Microsoft認定資格_Azure Data Engineer Associate", "sort_order": 126}, - {"name": "Microsoft認定資格_Azure Database Administrator Associate", "sort_order": 127}, - {"name": "Microsoft認定資格_Fabric Analytics Engineer Associate", "sort_order": 128}, - {"name": "Microsoft認定資格_Azure Developer Associate", "sort_order": 129}, - {"name": "Microsoft認定資格_DevOps Engineer Expert", "sort_order": 130}, - {"name": "Microsoft認定資格_Endpoint Administrator Associate", "sort_order": 131}, - {"name": "Microsoft認定資格_Administrator Expert", "sort_order": 132}, - { - "name": "Microsoft認定資格_Collaboration Communication Systems Engineer Associate", - "sort_order": 133, - }, - {"name": "Microsoft認定資格_Teams Administrator Associate", "sort_order": 134}, - { - "name": "Microsoft認定資格_Dynamics 365 Sales Functional Consultant Associate", - "sort_order": 135, - }, - { - "name": "Microsoft認定資格_Dynamics 365 Customer Service Functional Consultant Associate", - "sort_order": 136, - }, - { - "name": "Microsoft認定資格_Dynamics 365:Finance and Operations Apps Solution Architect Expert", - "sort_order": 137, - }, - { - "name": "Microsoft認定資格_Dynamics 365:Field Service Functional Consultant Associate", - "sort_order": 138, - }, - { - "name": "Microsoft認定資格_Dynamics 365 Finance Functional Consultant Associate", - "sort_order": 139, - }, - { - "name": "Microsoft認定資格_Dynamics 365 Supply Chain Management Functional Consultant Associate", - "sort_order": 140, - }, - { - "name": "Microsoft認定資格_Dynamics 365:Supply Chain Management Functional Consultant Expert", - "sort_order": 141, - }, - { - "name": "Microsoft認定資格_Dynamics 365:Finance and Operations Apps Developer Associate", - "sort_order": 142, - }, - { - "name": "Microsoft認定資格_Dynamics 365 Business Central Functional Consultant Associate", - "sort_order": 143, - }, - { - "name": "Microsoft認定資格_Dynamics 365 Business Central Developer Associate", - "sort_order": 144, - }, - {"name": "Microsoft認定資格_Power Platform Functional Consultant Associate", "sort_order": 145}, - {"name": "Microsoft認定資格_Power Platform Developer Associate", "sort_order": 146}, - {"name": "Microsoft認定資格_Power Automate RPA Developer Associate", "sort_order": 147}, - {"name": "Microsoft認定資格_Power Platform Solution Architect Expert", "sort_order": 148}, - {"name": "Microsoft認定資格_Azure Security Engineer Associate", "sort_order": 149}, - {"name": "Microsoft認定資格_Cybersecurity Architect Expert", "sort_order": 150}, - {"name": "Microsoft認定資格_Security Operations Analyst Associate", "sort_order": 151}, - {"name": "Microsoft認定資格_Identity and Access Administrator Associate", "sort_order": 152}, - { - "name": "Microsoft認定資格_Information Protection and Compliance Administrator Associate", - "sort_order": 153, - }, - {"name": "Microsoft認定資格_Azure for SAP Workloads Specialty", "sort_order": 154}, - {"name": "Microsoft認定資格_Azure Virtual Desktop Specialty", "sort_order": 155}, - {"name": "Microsoft認定資格_Azure Cosmos DB Developers Specialty", "sort_order": 156}, - {"name": "Microsoft認定資格_Dynamics 365 Customer Insights(Data) Specialty", "sort_order": 157}, - {"name": "Oracle認定試験_ORACLE MASTER Bronze DBA 2019", "sort_order": 158}, - {"name": "Oracle認定試験_ORACLE MASTER Silver DBA 2019", "sort_order": 159}, - {"name": "Oracle認定試験_ORACLE MASTER Gold DBA 2019", "sort_order": 160}, - {"name": "Oracle認定試験_ORACLE MASTER Platinum DBA 2019", "sort_order": 161}, - {"name": "Oracle認定試験_ORACLE MASTER Silver SQL 2019", "sort_order": 162}, - {"name": "Oracle認定試験_ORACLE MASTER Bronze Oracle Database 12c", "sort_order": 163}, - {"name": "Oracle認定試験_ORACLE MASTER Silver Oracle Database 12c", "sort_order": 164}, - {"name": "Oracle認定試験_ORACLE MASTER Gold Oracle Database 12c", "sort_order": 165}, - {"name": "Oracle認定試験_ORACLE MASTER Platinum Oracle Database 12c", "sort_order": 166}, - { - "name": "Oracle認定試験_ORACLE Certified Expert, Oracle Database 12c:RAC and Grid Infrastructure Administrator", - "sort_order": 167, - }, - {"name": "Oracle認定試験_MySQL 2021 Certified Implementation Specialist", "sort_order": 168}, - { - "name": "Oracle認定試験_MySQL HeatWave Implementation Certified Associate Rel 1", - "sort_order": 169, - }, - {"name": "Oracle認定試験_Oracle Certified Java Programmer, Bronze SE", "sort_order": 170}, - {"name": "Oracle認定試験_Oracle Certified Java Programmer, Silver SE 11", "sort_order": 171}, - {"name": "Oracle認定試験_Oracle Certified Java Programmer, Gold SE 11", "sort_order": 172}, - {"name": "Oracle認定試験_Oracle Certified Java Programmer, Silver SE 17", "sort_order": 173}, - {"name": "Oracle認定試験_Oracle Certified Java Programmer, Gold SE 17", "sort_order": 174}, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 AI Certified Foundations Associate", - "sort_order": 175, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Architect Associate", - "sort_order": 176, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Architect Professional", - "sort_order": 177, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Cloud Operations Professional", - "sort_order": 178, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Developer Professional", - "sort_order": 179, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Foundations Associate", - "sort_order": 180, - }, - { - "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Security Professional", - "sort_order": 181, - }, - { - "name": "Oracle認定試験_Data Management Oracle Autonomous Database Cloud 2023 Certified Professional", - "sort_order": 182, - }, - { - "name": "Oracle認定試験_Data Management Oracle Base Database Services 2023 Certified Professional", - "sort_order": 183, - }, - { - "name": "Oracle認定試験_Data Management Oracle Cloud Data Management 2023 Foundations Certified Associate", - "sort_order": 184, - }, - { - "name": "Oracle認定試験_Data Management Oracle Database Cloud Administrator 2023 Certified Professional", - "sort_order": 185, - }, - { - "name": "Oracle認定試験_Management Cloud Oracle Cloud Database 2023 Migration and Integration Certified Professional", - "sort_order": 186, - }, - { - "name": "Oracle認定試験_Oracle Planning 2023 Certified Implementation Professional", - "sort_order": 187, - }, - { - "name": "Oracle認定試験_Oracle Financials Cloud: General Ledger 2023 Certified Implementation Professional", - "sort_order": 188, - }, - { - "name": "Oracle認定試験_Oracle Financials Cloud: Payables 2023 Certified Implementation Professional", - "sort_order": 189, - }, - { - "name": "Oracle認定試験_Oracle Financials Cloud: Receivables 2023 Certified Implementation Professional", - "sort_order": 190, - }, - { - "name": "Oracle認定試験_Oracle Inventory Cloud 2023 Certified Implementation Professional", - "sort_order": 191, - }, - { - "name": "Oracle認定試験_Oracle Manufacturing Cloud 2023 Certified Implementation Professional", - "sort_order": 192, - }, - { - "name": "Oracle認定試験_Oracle Order Management Cloud Order to Cash 2023 Certified Implementation Professional", - "sort_order": 193, - }, - { - "name": "Oracle認定試験_Oracle Fusion Cloud Procurement 2023 Certified Implementation Professional", - "sort_order": 194, - }, - {"name": "日商簿記検定_1級", "sort_order": 195}, - {"name": "日商簿記検定_2級", "sort_order": 196}, - {"name": "日商ビジネス英語検定_1級", "sort_order": 197}, - {"name": "日商ビジネス英語検定_2級", "sort_order": 198}, - {"name": "日商ビジネス英語検定_3級", "sort_order": 199}, - {"name": "ファイナンシャルプランナー_1級", "sort_order": 200}, - {"name": "ファイナンシャルプランナー_2級", "sort_order": 201}, - {"name": "ファイナンシャルプランナー_3級", "sort_order": 202}, - {"name": "デリバティブ_3級", "sort_order": 203}, - {"name": "金融商品取引_3級", "sort_order": 204}, - {"name": "統計検定_1級", "sort_order": 205}, - {"name": "統計検定_準1級", "sort_order": 206}, - {"name": "統計検定_2級", "sort_order": 207}, - {"name": "統計検定_3級", "sort_order": 208}, - {"name": "統計検定_ 統計調査士", "sort_order": 209}, - {"name": "統計検定_ 専門統計調査士", "sort_order": 210}, - {"name": "統計検定_ データサイエンス基礎", "sort_order": 211}, - {"name": "統計検定_ データサイエンス発展", "sort_order": 212}, - {"name": "統計検定_ データサイエンスエキスパート", "sort_order": 213}, - {"name": "医療情報基礎知識検定", "sort_order": 214}, - {"name": "ビジネス実務法務検定_1級", "sort_order": 215}, - {"name": "ビジネス実務法務検定_2級", "sort_order": 216}, - {"name": "ビジネス実務法務検定_3級", "sort_order": 217}, - {"name": "給与計算実務能力検定_1級", "sort_order": 218}, - {"name": "給与計算実務能力検定_2級", "sort_order": 219}, - {"name": "マーケティング検定_1級", "sort_order": 220}, - {"name": "マーケティング検定_2級", "sort_order": 221}, - {"name": "マーケティング検定_3級", "sort_order": 222}, - {"name": "メンタルヘルス・マネジメント検定_Ⅰ種(マスターコース)", "sort_order": 223}, - {"name": "メンタルヘルス・マネジメント検定_Ⅱ種(ラインケアコース)", "sort_order": 224}, - {"name": "メンタルヘルス・マネジメント検定_Ⅲ種(セルフケアコース)", "sort_order": 225}, - {"name": "Salesforce 認定資格_アソシエイト", "sort_order": 226}, - {"name": "Salesforce 認定資格_アドミニストレーター", "sort_order": 227}, - {"name": "Salesforce 認定資格_Platform アプリケーションビルダー", "sort_order": 228}, - {"name": "Salesforce 認定資格_上級アドミニストレーター", "sort_order": 229}, - {"name": "Salesforce 認定資格_Data Cloudコンサルタント", "sort_order": 230}, - {"name": "Salesforce 認定資格_Sales Cloudコンサルタント", "sort_order": 231}, - {"name": "Salesforce 認定資格_Service Cloudコンサルタント", "sort_order": 232}, - {"name": "Salesforce 認定資格_Field Serviceコンサルタント", "sort_order": 233}, - {"name": "Salesforce 認定資格_Experience Cloudコンサルタント", "sort_order": 234}, - {"name": "Salesforce 認定資格_Platformデベロッパー", "sort_order": 235}, - {"name": "Salesforce 認定資格_上級Platformデベロッパー", "sort_order": 236}, - { - "name": "Adobe Certified Professional_Visual Design using Adobe Photoshop 2023", - "sort_order": 237, - }, - { - "name": "Adobe Certified Professional_Graphic Design & Illustration using Adobe Illustrator 2023", - "sort_order": 238, - }, - { - "name": "Adobe Certified Professional_Digital Video using Adobe Premiere Pro 2023", - "sort_order": 239, - }, - {"name": "リテールマーケティング(販売士)_1級", "sort_order": 240}, - {"name": "リテールマーケティング(販売士)_2級", "sort_order": 241}, - {"name": "リテールマーケティング(販売士)_3級", "sort_order": 242}, - {"name": "コンタクトセンター検定_エントリー資格", "sort_order": 243}, - {"name": "コンタクトセンター検定_オペレーター資格", "sort_order": 244}, - {"name": "コンタクトセンター検定_スーパーバイザー資格", "sort_order": 245}, - {"name": "コンタクトセンター検定_オペレーションマネジメント資格", "sort_order": 246}, - {"name": "コンタクトセンター検定_コンタクトセンターアーキテクチャ資格", "sort_order": 247}, - {"name": "データ分析実務スキル検定_プロジェクトマネージャー級", "sort_order": 248}, - {"name": "プロジェクトマネジメント・プロフェッショナル(PMP)", "sort_order": 249}, -] +_SEEDS_DIR = Path(__file__).parent / "seeds" + + +def _load_seed_file(filename: str) -> list[dict]: + """``seeds/`` 配下の JSON ファイルを読み込んで dict のリストを返す。""" + with (_SEEDS_DIR / filename).open(encoding="utf-8") as f: + return json.load(f) -# 技術スタックマスタ(カテゴリ付き) -_TECHNOLOGY_STACKS = [ - # --- バックエンド --- - {"category": "language", "name": "Python", "sort_order": 1}, - {"category": "language", "name": "Java", "sort_order": 2}, - {"category": "language", "name": "Go", "sort_order": 3}, - {"category": "language", "name": "Ruby", "sort_order": 4}, - {"category": "language", "name": "PHP", "sort_order": 5}, - {"category": "language", "name": "Rust", "sort_order": 6}, - {"category": "language", "name": "Scala", "sort_order": 7}, - {"category": "language", "name": "Perl", "sort_order": 8}, - {"category": "language", "name": "Groovy", "sort_order": 9}, - {"category": "language", "name": "Erlang", "sort_order": 10}, - {"category": "language", "name": "Elixir", "sort_order": 11}, - # --- フロントエンド --- - {"category": "language", "name": "JavaScript", "sort_order": 12}, - {"category": "language", "name": "TypeScript", "sort_order": 13}, - {"category": "language", "name": "HTML", "sort_order": 14}, - {"category": "language", "name": "CSS", "sort_order": 15}, - # --- モバイル --- - {"category": "language", "name": "Kotlin", "sort_order": 16}, - {"category": "language", "name": "Swift", "sort_order": 17}, - {"category": "language", "name": "Dart", "sort_order": 18}, - {"category": "language", "name": "Objective-C", "sort_order": 19}, - # --- システム / 組み込み --- - {"category": "language", "name": "C", "sort_order": 20}, - {"category": "language", "name": "C++", "sort_order": 21}, - {"category": "language", "name": "C#", "sort_order": 22}, - {"category": "language", "name": "Visual Basic", "sort_order": 23}, - {"category": "language", "name": "Fortran", "sort_order": 24}, - {"category": "language", "name": "Lua", "sort_order": 25}, - # --- データ / スクリプト --- - {"category": "language", "name": "SQL", "sort_order": 26}, - {"category": "language", "name": "R", "sort_order": 27}, - {"category": "language", "name": "Julia", "sort_order": 28}, - {"category": "language", "name": "Shell", "sort_order": 29}, - # --- 関数型 / その他 --- - {"category": "language", "name": "Haskell", "sort_order": 30}, - {"category": "language", "name": "F#", "sort_order": 31}, - {"category": "language", "name": "Lisp", "sort_order": 32}, - # --- フロントエンド (JS/TS) --- - {"category": "framework", "name": "React", "sort_order": 33}, - {"category": "framework", "name": "Vue.js", "sort_order": 34}, - {"category": "framework", "name": "Angular", "sort_order": 35}, - {"category": "framework", "name": "Next.js", "sort_order": 36}, - {"category": "framework", "name": "Nuxt.js", "sort_order": 37}, - {"category": "framework", "name": "Svelte", "sort_order": 38}, - {"category": "framework", "name": "jQuery", "sort_order": 39}, - # --- バックエンド (JS/TS) --- - {"category": "framework", "name": "Express", "sort_order": 40}, - {"category": "framework", "name": "NestJS", "sort_order": 41}, - {"category": "framework", "name": "Meteor", "sort_order": 42}, - # --- Python --- - {"category": "framework", "name": "FastAPI", "sort_order": 43}, - {"category": "framework", "name": "Django", "sort_order": 44}, - {"category": "framework", "name": "Flask", "sort_order": 45}, - {"category": "framework", "name": "Tornado", "sort_order": 46}, - # --- Java --- - {"category": "framework", "name": "Spring Boot", "sort_order": 47}, - {"category": "framework", "name": "Spring Framework", "sort_order": 48}, - {"category": "framework", "name": "Struts", "sort_order": 49}, - {"category": "framework", "name": "Play Framework", "sort_order": 50}, - # --- Ruby --- - {"category": "framework", "name": "Ruby on Rails", "sort_order": 51}, - {"category": "framework", "name": "Sinatra", "sort_order": 52}, - # --- PHP --- - {"category": "framework", "name": "Laravel", "sort_order": 53}, - {"category": "framework", "name": "Symfony", "sort_order": 54}, - {"category": "framework", "name": "CodeIgniter", "sort_order": 55}, - {"category": "framework", "name": "CakePHP", "sort_order": 56}, - {"category": "framework", "name": "Yii", "sort_order": 57}, - # --- Go --- - {"category": "framework", "name": "Gin", "sort_order": 58}, - {"category": "framework", "name": "Echo", "sort_order": 59}, - {"category": "framework", "name": "Beego", "sort_order": 60}, - # --- Rust --- - {"category": "framework", "name": "Actix-web", "sort_order": 61}, - {"category": "framework", "name": "Rocket", "sort_order": 62}, - # --- C# / .NET --- - {"category": "framework", "name": ".NET", "sort_order": 63}, - {"category": "framework", "name": "ASP.NET", "sort_order": 64}, - {"category": "framework", "name": "Xamarin", "sort_order": 65}, - {"category": "framework", "name": "Unity", "sort_order": 66}, - # --- Kotlin --- - {"category": "framework", "name": "Ktor", "sort_order": 67}, - {"category": "framework", "name": "Quarkus", "sort_order": 68}, - # --- Swift --- - {"category": "framework", "name": "SwiftUI", "sort_order": 69}, - {"category": "framework", "name": "Vapor", "sort_order": 70}, - # --- モバイル --- - {"category": "framework", "name": "Flutter", "sort_order": 71}, - {"category": "framework", "name": "React Native", "sort_order": 72}, - {"category": "os", "name": "Linux", "sort_order": 73}, - {"category": "os", "name": "Windows Server", "sort_order": 74}, - {"category": "os", "name": "macOS", "sort_order": 75}, - {"category": "os", "name": "iOS", "sort_order": 76}, - {"category": "os", "name": "Android", "sort_order": 77}, - {"category": "os", "name": "ChromeOS", "sort_order": 78}, - # --- RDB --- - {"category": "db", "name": "PostgreSQL", "sort_order": 79}, - {"category": "db", "name": "MySQL", "sort_order": 80}, - {"category": "db", "name": "MariaDB", "sort_order": 81}, - {"category": "db", "name": "SQLite", "sort_order": 82}, - {"category": "db", "name": "Oracle Database", "sort_order": 83}, - {"category": "db", "name": "SQL Server", "sort_order": 84}, - {"category": "db", "name": "Amazon Aurora", "sort_order": 85}, - # --- NoSQL (ドキュメント / カラム) --- - {"category": "db", "name": "MongoDB", "sort_order": 86}, - {"category": "db", "name": "Cassandra", "sort_order": 87}, - {"category": "db", "name": "Apache HBase", "sort_order": 88}, - {"category": "db", "name": "Firebase Realtime Database", "sort_order": 89}, - {"category": "db", "name": "Firebase Firestore", "sort_order": 90}, - # --- NoSQL (KVS / キャッシュ) --- - {"category": "db", "name": "Redis", "sort_order": 91}, - {"category": "db", "name": "DynamoDB", "sort_order": 92}, - {"category": "db", "name": "Memcached", "sort_order": 93}, - # --- クラウドプロバイダー --- - {"category": "cloud_provider", "name": "AWS", "sort_order": 94}, - {"category": "cloud_provider", "name": "Google Cloud", "sort_order": 95}, - {"category": "cloud_provider", "name": "Azure", "sort_order": 96}, - # --- コンテナ --- - {"category": "container", "name": "Docker", "sort_order": 97}, - {"category": "container", "name": "Kubernetes", "sort_order": 98}, - # --- IaC --- - {"category": "iac", "name": "Terraform", "sort_order": 99}, - {"category": "iac", "name": "Ansible", "sort_order": 100}, - {"category": "iac", "name": "cloudFormation", "sort_order": 101}, - # --- バージョン管理 --- - {"category": "vcs", "name": "Git", "sort_order": 102}, - {"category": "vcs", "name": "GitHub", "sort_order": 103}, - {"category": "vcs", "name": "GitLab", "sort_order": 104}, - {"category": "vcs", "name": "SVN", "sort_order": 105}, - # --- CI/CD --- - {"category": "ci_cd", "name": "Jenkins", "sort_order": 106}, - {"category": "ci_cd", "name": "CircleCI", "sort_order": 107}, - {"category": "ci_cd", "name": "GitHub Actions", "sort_order": 108}, - # --- プロジェクトツール --- - {"category": "project_tool", "name": "Jira", "sort_order": 109}, - {"category": "project_tool", "name": "Confluence", "sort_order": 110}, - {"category": "project_tool", "name": "Slack", "sort_order": 111}, - {"category": "project_tool", "name": "Teams", "sort_order": 112}, - {"category": "project_tool", "name": "Notion", "sort_order": 113}, - {"category": "project_tool", "name": "Trello", "sort_order": 114}, - {"category": "project_tool", "name": "figma", "sort_order": 115}, - {"category": "project_tool", "name": "Miro", "sort_order": 116}, - {"category": "project_tool", "name": "backlog", "sort_order": 117}, - # --- 監視・可観測性 --- - {"category": "monitoring", "name": "Datadog", "sort_order": 118}, - {"category": "monitoring", "name": "Grafana", "sort_order": 119}, - {"category": "monitoring", "name": "Elasticsearch", "sort_order": 120}, - {"category": "monitoring", "name": "cloudWatch", "sort_order": 121}, - {"category": "monitoring", "name": "cloudMonitoring", "sort_order": 122}, - # --- ミドルウェア --- - {"category": "middleware", "name": "Nginx", "sort_order": 121}, - {"category": "middleware", "name": "Apache", "sort_order": 122}, - {"category": "middleware", "name": "Hulft", "sort_order": 123}, - # --- AI agent --- - {"category": "ai_agent", "name": "ChatGPT", "sort_order": 124}, - {"category": "ai_agent", "name": "Claude", "sort_order": 125}, - {"category": "ai_agent", "name": "Gemini", "sort_order": 126}, -] def seed_master_data(db: Session) -> None: """マスタデータの初期シードを投入する。各テーブルが空の場合のみ投入される。""" - MQualificationRepository(db).seed_if_empty(_QUALIFICATIONS) - MTechnologyStackRepository(db).seed_if_empty(_TECHNOLOGY_STACKS) + qualifications = _load_seed_file("qualifications.json") + technology_stacks = _load_seed_file("technology_stacks.json") + MQualificationRepository(db).seed_if_empty(qualifications) + MTechnologyStackRepository(db).seed_if_empty(technology_stacks) diff --git a/backend/app/db/seeds/qualifications.json b/backend/app/db/seeds/qualifications.json new file mode 100644 index 00000000..5ad8dcfc --- /dev/null +++ b/backend/app/db/seeds/qualifications.json @@ -0,0 +1,998 @@ +[ + { + "name": "ITストラテジスト(ST)", + "sort_order": 1 + }, + { + "name": "システム監査技術者(AU)", + "sort_order": 2 + }, + { + "name": "プロジェクトマネージャ(PM)", + "sort_order": 3 + }, + { + "name": "システムアーキテクト(SA)", + "sort_order": 4 + }, + { + "name": "ITサービスマネージャ(SM)", + "sort_order": 5 + }, + { + "name": "ネットワークスペシャリスト(NW)", + "sort_order": 6 + }, + { + "name": "データベーススペシャリスト(DB)", + "sort_order": 7 + }, + { + "name": "エンベデッドシステムスペシャリスト(ES)", + "sort_order": 8 + }, + { + "name": "情報処理安全確保支援士試験(SC)", + "sort_order": 9 + }, + { + "name": "応用情報技術者試験(AP)", + "sort_order": 10 + }, + { + "name": "基本情報処理技術者(FE)", + "sort_order": 11 + }, + { + "name": "ITパスポート試験", + "sort_order": 12 + }, + { + "name": "情報セキュリティマネジメント試験", + "sort_order": 13 + }, + { + "name": "ウェブデザイン技能検定 1級", + "sort_order": 14 + }, + { + "name": "ウェブデザイン技能検定 2級", + "sort_order": 15 + }, + { + "name": "ウェブデザイン技能検定 3級", + "sort_order": 16 + }, + { + "name": "第1種衛生管理者", + "sort_order": 17 + }, + { + "name": "第2種衛生管理者", + "sort_order": 18 + }, + { + "name": "Certified Kubernetes Application Developer(CKAD)", + "sort_order": 19 + }, + { + "name": "Certified Kubernetes Administrator (CKA)", + "sort_order": 20 + }, + { + "name": "Google Cloud認定資格 Cloud Digital Leader", + "sort_order": 21 + }, + { + "name": "Google Cloud認定資格 Associate Cloud Engineer", + "sort_order": 22 + }, + { + "name": "Google Cloud認定資格 Professional Cloud Architects", + "sort_order": 23 + }, + { + "name": "Google Cloud認定資格 Professional Cloud Database Engineer", + "sort_order": 24 + }, + { + "name": "Google Cloud認定資格 Professional Cloud Developer", + "sort_order": 25 + }, + { + "name": "Google Cloud認定資格 Professional Data Engineer", + "sort_order": 26 + }, + { + "name": "Google Cloud認定資格 Professional Cloud DevOps Engineer", + "sort_order": 27 + }, + { + "name": "Google Cloud認定資格 Professional Cloud Security Engineer", + "sort_order": 28 + }, + { + "name": "Google Cloud認定資格 Professional Cloud Network Engineer", + "sort_order": 29 + }, + { + "name": "Google Cloud認定資格 Professional Google Workspace Administrator", + "sort_order": 30 + }, + { + "name": "Google Cloud認定資格 Professional Google Professional Machine Learning Engineer", + "sort_order": 31 + }, + { + "name": "ITIL認定資格[ITIL]_Master", + "sort_order": 32 + }, + { + "name": "ITIL認定資格[ITIL]_Expert", + "sort_order": 33 + }, + { + "name": "ITIL認定資格[ITIL]_Intermediate", + "sort_order": 34 + }, + { + "name": "ITIL認定資格[ITIL]_Foundation", + "sort_order": 35 + }, + { + "name": "OMG認定UML技術者_アドバンスト", + "sort_order": 36 + }, + { + "name": "OMG認定UML技術者_インターメディエイト", + "sort_order": 37 + }, + { + "name": "OMG認定UML技術者_ファンダメンタル", + "sort_order": 38 + }, + { + "name": "UMTP認定UMLモデリング技能認定_L3", + "sort_order": 39 + }, + { + "name": "UMTP認定UMLモデリング技能認定_L2", + "sort_order": 40 + }, + { + "name": "UMTP認定UMLモデリング技能認定_L1", + "sort_order": 41 + }, + { + "name": "XML技術者認定_プロフェッショナル(データベース)", + "sort_order": 42 + }, + { + "name": "XML技術者認定_プロフェッショナル(アプリケーション開発)", + "sort_order": 43 + }, + { + "name": "XML技術者認定_ベーシック", + "sort_order": 44 + }, + { + "name": "CompTIA_IT Fundamentals", + "sort_order": 45 + }, + { + "name": "CompTIA_A+", + "sort_order": 46 + }, + { + "name": "CompTIA_NetWork+", + "sort_order": 47 + }, + { + "name": "CompTIA_Security+", + "sort_order": 48 + }, + { + "name": "CompTIA_Cloud+", + "sort_order": 49 + }, + { + "name": "CompTIA_Linux+", + "sort_order": 50 + }, + { + "name": "CompTIA_Server+", + "sort_order": 51 + }, + { + "name": "CompTIA_Advanced Security Practitioner (CASP+)", + "sort_order": 52 + }, + { + "name": "CompTIA_CyberSecurity Analyst+ (CySA+)", + "sort_order": 53 + }, + { + "name": "CompTIA_PenTest+", + "sort_order": 54 + }, + { + "name": "CompTIA_Data+", + "sort_order": 55 + }, + { + "name": "CompTIA_DataSys+", + "sort_order": 56 + }, + { + "name": "CompTIA_Project+", + "sort_order": 57 + }, + { + "name": "CompTIA_Cloud Essentials+", + "sort_order": 58 + }, + { + "name": "PHP技術者認定試験_上級", + "sort_order": 59 + }, + { + "name": "PHP技術者認定試験_初級", + "sort_order": 60 + }, + { + "name": "Androidプラットフォーム技術者認定試験_ベーシック", + "sort_order": 61 + }, + { + "name": "Androidアプリケーション技術者認定試験_ベーシック", + "sort_order": 62 + }, + { + "name": "アジャイルソフトウエア開発技術者検定試験_Lv1試験", + "sort_order": 63 + }, + { + "name": "アジャイルソフトウエア開発技術者検定試験_Lv2試験", + "sort_order": 64 + }, + { + "name": "Cisco認定資格_CCIE", + "sort_order": 65 + }, + { + "name": "Cisco認定資格_CCNP", + "sort_order": 66 + }, + { + "name": "Cisco認定資格_CCDA", + "sort_order": 67 + }, + { + "name": "Cisco認定資格_CCNA", + "sort_order": 68 + }, + { + "name": "LPIC(Linux Professional Institute Certification)_Level3(上級)", + "sort_order": 69 + }, + { + "name": "LPIC(Linux Professional Institute Certification)_Level2(中級)", + "sort_order": 70 + }, + { + "name": "LPIC(Linux Professional Institute Certification)_Level1(基礎)", + "sort_order": 71 + }, + { + "name": "Red Hat認定エンジニア", + "sort_order": 72 + }, + { + "name": "JSTQB認定テスト技術者資格_Advanced Level", + "sort_order": 73 + }, + { + "name": "JSTQB認定テスト技術者資格_Foundation Level", + "sort_order": 74 + }, + { + "name": "Vmware_VMware Certified Implementation Expert", + "sort_order": 75 + }, + { + "name": "Vmware_VMware Certified Professional", + "sort_order": 76 + }, + { + "name": "Vmware_VMware Certified Associate", + "sort_order": 77 + }, + { + "name": "LinuC(Linux Professional Certification)_Level3(上級)", + "sort_order": 78 + }, + { + "name": "LinuC(Linux Professional Certification)_Level2(中級)", + "sort_order": 79 + }, + { + "name": "LinuC(Linux Professional Certification)_Level1(基礎)", + "sort_order": 80 + }, + { + "name": "JDLA Deep Learning_G検定(JDLA Deep Learning for GENERAL)", + "sort_order": 81 + }, + { + "name": "JDLA Deep Learning_E資格(JDLA Deep Learning for ENGINEER)", + "sort_order": 82 + }, + { + "name": "VBAエキスパート_Excel VBAベーシック", + "sort_order": 83 + }, + { + "name": "VBAエキスパート_Excel VBAスタンダード", + "sort_order": 84 + }, + { + "name": "VBAエキスパート_Access VBAベーシック", + "sort_order": 85 + }, + { + "name": "VBAエキスパート_Access VBAスタンダード", + "sort_order": 86 + }, + { + "name": "AWS認定試験_ソリューションアーキテクト – アソシエイト", + "sort_order": 87 + }, + { + "name": "AWS認定試験_ソリューションアーキテクト – プロフェッショナル", + "sort_order": 88 + }, + { + "name": "AWS認定試験_デベロッパー – アソシエイト", + "sort_order": 89 + }, + { + "name": "AWS認定試験_SysOpsアドミニストレーター – アソシエイト", + "sort_order": 90 + }, + { + "name": "AWS認定試験_DevOps エンジニア – プロフェッショナル", + "sort_order": 91 + }, + { + "name": "AWS認定試験_AWS 認定クラウドプラクティショナー", + "sort_order": 92 + }, + { + "name": "HTML5_プロフェッショナル 認定試験 レベル1", + "sort_order": 93 + }, + { + "name": "HTML5_プロフェッショナル 認定試験 レベル2", + "sort_order": 94 + }, + { + "name": "OSS-DB_Silver", + "sort_order": 95 + }, + { + "name": "OSS-DB_Gold", + "sort_order": 96 + }, + { + "name": "OSS-DB_Platinum", + "sort_order": 97 + }, + { + "name": "RPA技術者検定_アソシエイト", + "sort_order": 98 + }, + { + "name": "RPA技術者検定_エキスパート", + "sort_order": 99 + }, + { + "name": "PythonED_Python3エンジニア認定基礎試験", + "sort_order": 100 + }, + { + "name": "PythonED_Python3エンジニア認定データ分析試験", + "sort_order": 101 + }, + { + "name": "Ruby技術者認定資格_Silver", + "sort_order": 102 + }, + { + "name": "Ruby技術者認定資格_Gold", + "sort_order": 103 + }, + { + "name": "Professional Scrum Master™_レベルⅢ(PSMⅢ)", + "sort_order": 104 + }, + { + "name": "Professional Scrum Master™_レベルⅡ(PSMⅡ)", + "sort_order": 105 + }, + { + "name": "Professional Scrum Master™_レベルI(PSM I)", + "sort_order": 106 + }, + { + "name": "モバイルシステム技術検定_シニアモバイルシステムコンサルタント", + "sort_order": 107 + }, + { + "name": "モバイルシステム技術検定_モバイルシステム技術検定試験 1級", + "sort_order": 108 + }, + { + "name": "モバイルシステム技術検定_モバイルシステム技術検定試験 2級", + "sort_order": 109 + }, + { + "name": "モバイルシステム技術検定_モバイル技術基礎検定", + "sort_order": 110 + }, + { + "name": "Microsoft認定資格_Azure Fundamentals", + "sort_order": 111 + }, + { + "name": "Microsoft認定資格_Azure AI Fundamentals", + "sort_order": 112 + }, + { + "name": "Microsoft認定資格_Azure Data Fundamentals", + "sort_order": 113 + }, + { + "name": "Microsoft認定資格_Microsoft 365 Fundamentals", + "sort_order": 114 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Fundamentals(CRM)", + "sort_order": 115 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Fundamentals(ERP)", + "sort_order": 116 + }, + { + "name": "Microsoft認定資格_Power Platform Fundamentals", + "sort_order": 117 + }, + { + "name": "Microsoft認定資格_Security, Compliance, and Identity Fundamentals", + "sort_order": 118 + }, + { + "name": "Microsoft認定資格_Azure Administrator Associate", + "sort_order": 119 + }, + { + "name": "Microsoft認定資格_Azure Solutions Architect Expert", + "sort_order": 120 + }, + { + "name": "Microsoft認定資格_Azure Network Engineer Associate", + "sort_order": 121 + }, + { + "name": "Microsoft認定資格_Windows Server Hybrid Administrator Associate", + "sort_order": 122 + }, + { + "name": "Microsoft認定資格_Azure AI Engineer Associate", + "sort_order": 123 + }, + { + "name": "Microsoft認定資格_Power BI Data Analyst Associate", + "sort_order": 124 + }, + { + "name": "Microsoft認定資格_Azure Data Scientist Associate", + "sort_order": 125 + }, + { + "name": "Microsoft認定資格_Azure Data Engineer Associate", + "sort_order": 126 + }, + { + "name": "Microsoft認定資格_Azure Database Administrator Associate", + "sort_order": 127 + }, + { + "name": "Microsoft認定資格_Fabric Analytics Engineer Associate", + "sort_order": 128 + }, + { + "name": "Microsoft認定資格_Azure Developer Associate", + "sort_order": 129 + }, + { + "name": "Microsoft認定資格_DevOps Engineer Expert", + "sort_order": 130 + }, + { + "name": "Microsoft認定資格_Endpoint Administrator Associate", + "sort_order": 131 + }, + { + "name": "Microsoft認定資格_Administrator Expert", + "sort_order": 132 + }, + { + "name": "Microsoft認定資格_Collaboration Communication Systems Engineer Associate", + "sort_order": 133 + }, + { + "name": "Microsoft認定資格_Teams Administrator Associate", + "sort_order": 134 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Sales Functional Consultant Associate", + "sort_order": 135 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Customer Service Functional Consultant Associate", + "sort_order": 136 + }, + { + "name": "Microsoft認定資格_Dynamics 365:Finance and Operations Apps Solution Architect Expert", + "sort_order": 137 + }, + { + "name": "Microsoft認定資格_Dynamics 365:Field Service Functional Consultant Associate", + "sort_order": 138 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Finance Functional Consultant Associate", + "sort_order": 139 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Supply Chain Management Functional Consultant Associate", + "sort_order": 140 + }, + { + "name": "Microsoft認定資格_Dynamics 365:Supply Chain Management Functional Consultant Expert", + "sort_order": 141 + }, + { + "name": "Microsoft認定資格_Dynamics 365:Finance and Operations Apps Developer Associate", + "sort_order": 142 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Business Central Functional Consultant Associate", + "sort_order": 143 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Business Central Developer Associate", + "sort_order": 144 + }, + { + "name": "Microsoft認定資格_Power Platform Functional Consultant Associate", + "sort_order": 145 + }, + { + "name": "Microsoft認定資格_Power Platform Developer Associate", + "sort_order": 146 + }, + { + "name": "Microsoft認定資格_Power Automate RPA Developer Associate", + "sort_order": 147 + }, + { + "name": "Microsoft認定資格_Power Platform Solution Architect Expert", + "sort_order": 148 + }, + { + "name": "Microsoft認定資格_Azure Security Engineer Associate", + "sort_order": 149 + }, + { + "name": "Microsoft認定資格_Cybersecurity Architect Expert", + "sort_order": 150 + }, + { + "name": "Microsoft認定資格_Security Operations Analyst Associate", + "sort_order": 151 + }, + { + "name": "Microsoft認定資格_Identity and Access Administrator Associate", + "sort_order": 152 + }, + { + "name": "Microsoft認定資格_Information Protection and Compliance Administrator Associate", + "sort_order": 153 + }, + { + "name": "Microsoft認定資格_Azure for SAP Workloads Specialty", + "sort_order": 154 + }, + { + "name": "Microsoft認定資格_Azure Virtual Desktop Specialty", + "sort_order": 155 + }, + { + "name": "Microsoft認定資格_Azure Cosmos DB Developers Specialty", + "sort_order": 156 + }, + { + "name": "Microsoft認定資格_Dynamics 365 Customer Insights(Data) Specialty", + "sort_order": 157 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Bronze DBA 2019", + "sort_order": 158 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Silver DBA 2019", + "sort_order": 159 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Gold DBA 2019", + "sort_order": 160 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Platinum DBA 2019", + "sort_order": 161 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Silver SQL 2019", + "sort_order": 162 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Bronze Oracle Database 12c", + "sort_order": 163 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Silver Oracle Database 12c", + "sort_order": 164 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Gold Oracle Database 12c", + "sort_order": 165 + }, + { + "name": "Oracle認定試験_ORACLE MASTER Platinum Oracle Database 12c", + "sort_order": 166 + }, + { + "name": "Oracle認定試験_ORACLE Certified Expert, Oracle Database 12c:RAC and Grid Infrastructure Administrator", + "sort_order": 167 + }, + { + "name": "Oracle認定試験_MySQL 2021 Certified Implementation Specialist", + "sort_order": 168 + }, + { + "name": "Oracle認定試験_MySQL HeatWave Implementation Certified Associate Rel 1", + "sort_order": 169 + }, + { + "name": "Oracle認定試験_Oracle Certified Java Programmer, Bronze SE", + "sort_order": 170 + }, + { + "name": "Oracle認定試験_Oracle Certified Java Programmer, Silver SE 11", + "sort_order": 171 + }, + { + "name": "Oracle認定試験_Oracle Certified Java Programmer, Gold SE 11", + "sort_order": 172 + }, + { + "name": "Oracle認定試験_Oracle Certified Java Programmer, Silver SE 17", + "sort_order": 173 + }, + { + "name": "Oracle認定試験_Oracle Certified Java Programmer, Gold SE 17", + "sort_order": 174 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 AI Certified Foundations Associate", + "sort_order": 175 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Architect Associate", + "sort_order": 176 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Architect Professional", + "sort_order": 177 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Cloud Operations Professional", + "sort_order": 178 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Developer Professional", + "sort_order": 179 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Foundations Associate", + "sort_order": 180 + }, + { + "name": "Oracle認定試験_Oracle Cloud Infrastructure 2023 Certified Security Professional", + "sort_order": 181 + }, + { + "name": "Oracle認定試験_Data Management Oracle Autonomous Database Cloud 2023 Certified Professional", + "sort_order": 182 + }, + { + "name": "Oracle認定試験_Data Management Oracle Base Database Services 2023 Certified Professional", + "sort_order": 183 + }, + { + "name": "Oracle認定試験_Data Management Oracle Cloud Data Management 2023 Foundations Certified Associate", + "sort_order": 184 + }, + { + "name": "Oracle認定試験_Data Management Oracle Database Cloud Administrator 2023 Certified Professional", + "sort_order": 185 + }, + { + "name": "Oracle認定試験_Management Cloud Oracle Cloud Database 2023 Migration and Integration Certified Professional", + "sort_order": 186 + }, + { + "name": "Oracle認定試験_Oracle Planning 2023 Certified Implementation Professional", + "sort_order": 187 + }, + { + "name": "Oracle認定試験_Oracle Financials Cloud: General Ledger 2023 Certified Implementation Professional", + "sort_order": 188 + }, + { + "name": "Oracle認定試験_Oracle Financials Cloud: Payables 2023 Certified Implementation Professional", + "sort_order": 189 + }, + { + "name": "Oracle認定試験_Oracle Financials Cloud: Receivables 2023 Certified Implementation Professional", + "sort_order": 190 + }, + { + "name": "Oracle認定試験_Oracle Inventory Cloud 2023 Certified Implementation Professional", + "sort_order": 191 + }, + { + "name": "Oracle認定試験_Oracle Manufacturing Cloud 2023 Certified Implementation Professional", + "sort_order": 192 + }, + { + "name": "Oracle認定試験_Oracle Order Management Cloud Order to Cash 2023 Certified Implementation Professional", + "sort_order": 193 + }, + { + "name": "Oracle認定試験_Oracle Fusion Cloud Procurement 2023 Certified Implementation Professional", + "sort_order": 194 + }, + { + "name": "日商簿記検定_1級", + "sort_order": 195 + }, + { + "name": "日商簿記検定_2級", + "sort_order": 196 + }, + { + "name": "日商ビジネス英語検定_1級", + "sort_order": 197 + }, + { + "name": "日商ビジネス英語検定_2級", + "sort_order": 198 + }, + { + "name": "日商ビジネス英語検定_3級", + "sort_order": 199 + }, + { + "name": "ファイナンシャルプランナー_1級", + "sort_order": 200 + }, + { + "name": "ファイナンシャルプランナー_2級", + "sort_order": 201 + }, + { + "name": "ファイナンシャルプランナー_3級", + "sort_order": 202 + }, + { + "name": "デリバティブ_3級", + "sort_order": 203 + }, + { + "name": "金融商品取引_3級", + "sort_order": 204 + }, + { + "name": "統計検定_1級", + "sort_order": 205 + }, + { + "name": "統計検定_準1級", + "sort_order": 206 + }, + { + "name": "統計検定_2級", + "sort_order": 207 + }, + { + "name": "統計検定_3級", + "sort_order": 208 + }, + { + "name": "統計検定_ 統計調査士", + "sort_order": 209 + }, + { + "name": "統計検定_ 専門統計調査士", + "sort_order": 210 + }, + { + "name": "統計検定_ データサイエンス基礎", + "sort_order": 211 + }, + { + "name": "統計検定_ データサイエンス発展", + "sort_order": 212 + }, + { + "name": "統計検定_ データサイエンスエキスパート", + "sort_order": 213 + }, + { + "name": "医療情報基礎知識検定", + "sort_order": 214 + }, + { + "name": "ビジネス実務法務検定_1級", + "sort_order": 215 + }, + { + "name": "ビジネス実務法務検定_2級", + "sort_order": 216 + }, + { + "name": "ビジネス実務法務検定_3級", + "sort_order": 217 + }, + { + "name": "給与計算実務能力検定_1級", + "sort_order": 218 + }, + { + "name": "給与計算実務能力検定_2級", + "sort_order": 219 + }, + { + "name": "マーケティング検定_1級", + "sort_order": 220 + }, + { + "name": "マーケティング検定_2級", + "sort_order": 221 + }, + { + "name": "マーケティング検定_3級", + "sort_order": 222 + }, + { + "name": "メンタルヘルス・マネジメント検定_Ⅰ種(マスターコース)", + "sort_order": 223 + }, + { + "name": "メンタルヘルス・マネジメント検定_Ⅱ種(ラインケアコース)", + "sort_order": 224 + }, + { + "name": "メンタルヘルス・マネジメント検定_Ⅲ種(セルフケアコース)", + "sort_order": 225 + }, + { + "name": "Salesforce 認定資格_アソシエイト", + "sort_order": 226 + }, + { + "name": "Salesforce 認定資格_アドミニストレーター", + "sort_order": 227 + }, + { + "name": "Salesforce 認定資格_Platform アプリケーションビルダー", + "sort_order": 228 + }, + { + "name": "Salesforce 認定資格_上級アドミニストレーター", + "sort_order": 229 + }, + { + "name": "Salesforce 認定資格_Data Cloudコンサルタント", + "sort_order": 230 + }, + { + "name": "Salesforce 認定資格_Sales Cloudコンサルタント", + "sort_order": 231 + }, + { + "name": "Salesforce 認定資格_Service Cloudコンサルタント", + "sort_order": 232 + }, + { + "name": "Salesforce 認定資格_Field Serviceコンサルタント", + "sort_order": 233 + }, + { + "name": "Salesforce 認定資格_Experience Cloudコンサルタント", + "sort_order": 234 + }, + { + "name": "Salesforce 認定資格_Platformデベロッパー", + "sort_order": 235 + }, + { + "name": "Salesforce 認定資格_上級Platformデベロッパー", + "sort_order": 236 + }, + { + "name": "Adobe Certified Professional_Visual Design using Adobe Photoshop 2023", + "sort_order": 237 + }, + { + "name": "Adobe Certified Professional_Graphic Design & Illustration using Adobe Illustrator 2023", + "sort_order": 238 + }, + { + "name": "Adobe Certified Professional_Digital Video using Adobe Premiere Pro 2023", + "sort_order": 239 + }, + { + "name": "リテールマーケティング(販売士)_1級", + "sort_order": 240 + }, + { + "name": "リテールマーケティング(販売士)_2級", + "sort_order": 241 + }, + { + "name": "リテールマーケティング(販売士)_3級", + "sort_order": 242 + }, + { + "name": "コンタクトセンター検定_エントリー資格", + "sort_order": 243 + }, + { + "name": "コンタクトセンター検定_オペレーター資格", + "sort_order": 244 + }, + { + "name": "コンタクトセンター検定_スーパーバイザー資格", + "sort_order": 245 + }, + { + "name": "コンタクトセンター検定_オペレーションマネジメント資格", + "sort_order": 246 + }, + { + "name": "コンタクトセンター検定_コンタクトセンターアーキテクチャ資格", + "sort_order": 247 + }, + { + "name": "データ分析実務スキル検定_プロジェクトマネージャー級", + "sort_order": 248 + }, + { + "name": "プロジェクトマネジメント・プロフェッショナル(PMP)", + "sort_order": 249 + } +] \ No newline at end of file diff --git a/backend/app/db/seeds/technology_stacks.json b/backend/app/db/seeds/technology_stacks.json new file mode 100644 index 00000000..7f6e0e1a --- /dev/null +++ b/backend/app/db/seeds/technology_stacks.json @@ -0,0 +1,642 @@ +[ + { + "category": "language", + "name": "Python", + "sort_order": 1 + }, + { + "category": "language", + "name": "Java", + "sort_order": 2 + }, + { + "category": "language", + "name": "Go", + "sort_order": 3 + }, + { + "category": "language", + "name": "Ruby", + "sort_order": 4 + }, + { + "category": "language", + "name": "PHP", + "sort_order": 5 + }, + { + "category": "language", + "name": "Rust", + "sort_order": 6 + }, + { + "category": "language", + "name": "Scala", + "sort_order": 7 + }, + { + "category": "language", + "name": "Perl", + "sort_order": 8 + }, + { + "category": "language", + "name": "Groovy", + "sort_order": 9 + }, + { + "category": "language", + "name": "Erlang", + "sort_order": 10 + }, + { + "category": "language", + "name": "Elixir", + "sort_order": 11 + }, + { + "category": "language", + "name": "JavaScript", + "sort_order": 12 + }, + { + "category": "language", + "name": "TypeScript", + "sort_order": 13 + }, + { + "category": "language", + "name": "HTML", + "sort_order": 14 + }, + { + "category": "language", + "name": "CSS", + "sort_order": 15 + }, + { + "category": "language", + "name": "Kotlin", + "sort_order": 16 + }, + { + "category": "language", + "name": "Swift", + "sort_order": 17 + }, + { + "category": "language", + "name": "Dart", + "sort_order": 18 + }, + { + "category": "language", + "name": "Objective-C", + "sort_order": 19 + }, + { + "category": "language", + "name": "C", + "sort_order": 20 + }, + { + "category": "language", + "name": "C++", + "sort_order": 21 + }, + { + "category": "language", + "name": "C#", + "sort_order": 22 + }, + { + "category": "language", + "name": "Visual Basic", + "sort_order": 23 + }, + { + "category": "language", + "name": "Fortran", + "sort_order": 24 + }, + { + "category": "language", + "name": "Lua", + "sort_order": 25 + }, + { + "category": "language", + "name": "SQL", + "sort_order": 26 + }, + { + "category": "language", + "name": "R", + "sort_order": 27 + }, + { + "category": "language", + "name": "Julia", + "sort_order": 28 + }, + { + "category": "language", + "name": "Shell", + "sort_order": 29 + }, + { + "category": "language", + "name": "Haskell", + "sort_order": 30 + }, + { + "category": "language", + "name": "F#", + "sort_order": 31 + }, + { + "category": "language", + "name": "Lisp", + "sort_order": 32 + }, + { + "category": "framework", + "name": "React", + "sort_order": 33 + }, + { + "category": "framework", + "name": "Vue.js", + "sort_order": 34 + }, + { + "category": "framework", + "name": "Angular", + "sort_order": 35 + }, + { + "category": "framework", + "name": "Next.js", + "sort_order": 36 + }, + { + "category": "framework", + "name": "Nuxt.js", + "sort_order": 37 + }, + { + "category": "framework", + "name": "Svelte", + "sort_order": 38 + }, + { + "category": "framework", + "name": "jQuery", + "sort_order": 39 + }, + { + "category": "framework", + "name": "Express", + "sort_order": 40 + }, + { + "category": "framework", + "name": "NestJS", + "sort_order": 41 + }, + { + "category": "framework", + "name": "Meteor", + "sort_order": 42 + }, + { + "category": "framework", + "name": "FastAPI", + "sort_order": 43 + }, + { + "category": "framework", + "name": "Django", + "sort_order": 44 + }, + { + "category": "framework", + "name": "Flask", + "sort_order": 45 + }, + { + "category": "framework", + "name": "Tornado", + "sort_order": 46 + }, + { + "category": "framework", + "name": "Spring Boot", + "sort_order": 47 + }, + { + "category": "framework", + "name": "Spring Framework", + "sort_order": 48 + }, + { + "category": "framework", + "name": "Struts", + "sort_order": 49 + }, + { + "category": "framework", + "name": "Play Framework", + "sort_order": 50 + }, + { + "category": "framework", + "name": "Ruby on Rails", + "sort_order": 51 + }, + { + "category": "framework", + "name": "Sinatra", + "sort_order": 52 + }, + { + "category": "framework", + "name": "Laravel", + "sort_order": 53 + }, + { + "category": "framework", + "name": "Symfony", + "sort_order": 54 + }, + { + "category": "framework", + "name": "CodeIgniter", + "sort_order": 55 + }, + { + "category": "framework", + "name": "CakePHP", + "sort_order": 56 + }, + { + "category": "framework", + "name": "Yii", + "sort_order": 57 + }, + { + "category": "framework", + "name": "Gin", + "sort_order": 58 + }, + { + "category": "framework", + "name": "Echo", + "sort_order": 59 + }, + { + "category": "framework", + "name": "Beego", + "sort_order": 60 + }, + { + "category": "framework", + "name": "Actix-web", + "sort_order": 61 + }, + { + "category": "framework", + "name": "Rocket", + "sort_order": 62 + }, + { + "category": "framework", + "name": ".NET", + "sort_order": 63 + }, + { + "category": "framework", + "name": "ASP.NET", + "sort_order": 64 + }, + { + "category": "framework", + "name": "Xamarin", + "sort_order": 65 + }, + { + "category": "framework", + "name": "Unity", + "sort_order": 66 + }, + { + "category": "framework", + "name": "Ktor", + "sort_order": 67 + }, + { + "category": "framework", + "name": "Quarkus", + "sort_order": 68 + }, + { + "category": "framework", + "name": "SwiftUI", + "sort_order": 69 + }, + { + "category": "framework", + "name": "Vapor", + "sort_order": 70 + }, + { + "category": "framework", + "name": "Flutter", + "sort_order": 71 + }, + { + "category": "framework", + "name": "React Native", + "sort_order": 72 + }, + { + "category": "os", + "name": "Linux", + "sort_order": 73 + }, + { + "category": "os", + "name": "Windows Server", + "sort_order": 74 + }, + { + "category": "os", + "name": "macOS", + "sort_order": 75 + }, + { + "category": "os", + "name": "iOS", + "sort_order": 76 + }, + { + "category": "os", + "name": "Android", + "sort_order": 77 + }, + { + "category": "os", + "name": "ChromeOS", + "sort_order": 78 + }, + { + "category": "db", + "name": "PostgreSQL", + "sort_order": 79 + }, + { + "category": "db", + "name": "MySQL", + "sort_order": 80 + }, + { + "category": "db", + "name": "MariaDB", + "sort_order": 81 + }, + { + "category": "db", + "name": "SQLite", + "sort_order": 82 + }, + { + "category": "db", + "name": "Oracle Database", + "sort_order": 83 + }, + { + "category": "db", + "name": "SQL Server", + "sort_order": 84 + }, + { + "category": "db", + "name": "Amazon Aurora", + "sort_order": 85 + }, + { + "category": "db", + "name": "MongoDB", + "sort_order": 86 + }, + { + "category": "db", + "name": "Cassandra", + "sort_order": 87 + }, + { + "category": "db", + "name": "Apache HBase", + "sort_order": 88 + }, + { + "category": "db", + "name": "Firebase Realtime Database", + "sort_order": 89 + }, + { + "category": "db", + "name": "Firebase Firestore", + "sort_order": 90 + }, + { + "category": "db", + "name": "Redis", + "sort_order": 91 + }, + { + "category": "db", + "name": "DynamoDB", + "sort_order": 92 + }, + { + "category": "db", + "name": "Memcached", + "sort_order": 93 + }, + { + "category": "cloud_provider", + "name": "AWS", + "sort_order": 94 + }, + { + "category": "cloud_provider", + "name": "Google Cloud", + "sort_order": 95 + }, + { + "category": "cloud_provider", + "name": "Azure", + "sort_order": 96 + }, + { + "category": "container", + "name": "Docker", + "sort_order": 97 + }, + { + "category": "container", + "name": "Kubernetes", + "sort_order": 98 + }, + { + "category": "iac", + "name": "Terraform", + "sort_order": 99 + }, + { + "category": "iac", + "name": "Ansible", + "sort_order": 100 + }, + { + "category": "iac", + "name": "cloudFormation", + "sort_order": 101 + }, + { + "category": "vcs", + "name": "Git", + "sort_order": 102 + }, + { + "category": "vcs", + "name": "GitHub", + "sort_order": 103 + }, + { + "category": "vcs", + "name": "GitLab", + "sort_order": 104 + }, + { + "category": "vcs", + "name": "SVN", + "sort_order": 105 + }, + { + "category": "ci_cd", + "name": "Jenkins", + "sort_order": 106 + }, + { + "category": "ci_cd", + "name": "CircleCI", + "sort_order": 107 + }, + { + "category": "ci_cd", + "name": "GitHub Actions", + "sort_order": 108 + }, + { + "category": "project_tool", + "name": "Jira", + "sort_order": 109 + }, + { + "category": "project_tool", + "name": "Confluence", + "sort_order": 110 + }, + { + "category": "project_tool", + "name": "Slack", + "sort_order": 111 + }, + { + "category": "project_tool", + "name": "Teams", + "sort_order": 112 + }, + { + "category": "project_tool", + "name": "Notion", + "sort_order": 113 + }, + { + "category": "project_tool", + "name": "Trello", + "sort_order": 114 + }, + { + "category": "project_tool", + "name": "figma", + "sort_order": 115 + }, + { + "category": "project_tool", + "name": "Miro", + "sort_order": 116 + }, + { + "category": "project_tool", + "name": "backlog", + "sort_order": 117 + }, + { + "category": "monitoring", + "name": "Datadog", + "sort_order": 118 + }, + { + "category": "monitoring", + "name": "Grafana", + "sort_order": 119 + }, + { + "category": "monitoring", + "name": "Elasticsearch", + "sort_order": 120 + }, + { + "category": "monitoring", + "name": "cloudWatch", + "sort_order": 121 + }, + { + "category": "monitoring", + "name": "cloudMonitoring", + "sort_order": 122 + }, + { + "category": "middleware", + "name": "Nginx", + "sort_order": 121 + }, + { + "category": "middleware", + "name": "Apache", + "sort_order": 122 + }, + { + "category": "middleware", + "name": "Hulft", + "sort_order": 123 + }, + { + "category": "ai_agent", + "name": "ChatGPT", + "sort_order": 124 + }, + { + "category": "ai_agent", + "name": "Claude", + "sort_order": 125 + }, + { + "category": "ai_agent", + "name": "Gemini", + "sort_order": 126 + } +] \ No newline at end of file diff --git a/backend/app/routers/auth/endpoints.py b/backend/app/routers/auth/endpoints.py index 544cf757..38143bdc 100644 --- a/backend/app/routers/auth/endpoints.py +++ b/backend/app/routers/auth/endpoints.py @@ -186,6 +186,20 @@ def github_login( return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER) +def _build_callback_html_response( + frontend_url: str, + *, + error_message: str | None = None, +) -> HTMLResponse: + """コールバック処理用の HTMLResponse を構築する。 + + error_message が指定された場合は ``github_error`` クエリ付きの URL に、 + 指定が無い場合は元の URL にリダイレクトする。 + """ + redirect_url = build_frontend_redirect_url(frontend_url, error_message) + return HTMLResponse(content=_html_redirect(redirect_url)) + + @router.get("/github/callback") @limiter.limit("10/minute") async def github_callback_redirect( @@ -219,11 +233,9 @@ async def github_callback_redirect( # error.detail は AppErrorResponse の dict 形式なので message フィールドを取り出す detail = error.detail error_message = detail.get("message") if isinstance(detail, dict) else str(detail) - return HTMLResponse( - content=_html_redirect(build_frontend_redirect_url(frontend_url, error_message)) - ) + return _build_callback_html_response(frontend_url, error_message=error_message) - response = HTMLResponse(content=_html_redirect(build_frontend_redirect_url(frontend_url))) + response = _build_callback_html_response(frontend_url) set_auth_cookies(response, token_response.username, db) return response diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index a0f6d0b9..41bd1b84 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -16,7 +16,7 @@ import logging from dataclasses import asdict -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from ..core.errors import resolve_async_error_code @@ -32,7 +32,6 @@ BlogAccountUpdate, BlogArticleResponse, BlogScoreResponse, - BlogSummaryRequest, BlogSummaryResponse, BlogSyncResponse, ) @@ -48,7 +47,7 @@ from ..services.blog.scorer import blog_articles_to_score_dicts, calculate_blog_score from ..services.blog.sync_service import BlogSyncService from ..services.intelligence.llm_summarizer import check_llm_available -from ..services.tasks import TaskType, get_task_dispatcher +from ..services.tasks import AsyncTaskCacheService, TaskType logger = logging.getLogger(__name__) @@ -265,10 +264,19 @@ async def summarize_blog( user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """ブログ記事の AI サマリをバックグラウンドで生成する。""" - # 進行中のタスクがあればそのステータスを返す(期限切れキャッシュは None が返るため再生成を許可) + """ブログ記事の AI サマリをバックグラウンドで生成する。 + + 記事は worker 側で ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 + """ cache = BlogSummaryCacheRepository(db, user.id).get() - if cache and cache.status in ("pending", "processing"): + if cache is None: + cache = BlogSummaryCache(user_id=user.id) + db.add(cache) + db.flush() + service = AsyncTaskCacheService(db, cache) + + # 進行中のタスクがあればそのステータスを返す(期限切れキャッシュは None が返るため再生成を許可) + if service.is_in_progress(): return BlogSummaryResponse( summary=cache.summary or "", available=False, @@ -279,25 +287,17 @@ async def summarize_blog( if not available: return BlogSummaryResponse(summary="", available=False) - # キャッシュレコードを準備して pending にセット - if not cache: - cache = BlogSummaryCache(user_id=user.id) - db.add(cache) - cache.status = "pending" - cache.error_message = None - db.commit() + service.reset_to_pending() try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.BLOG_SUMMARIZE, {"user_id": user.id}, + failure_message="タスクの開始に失敗しました", + logger=logger, ) except Exception: - logger.exception("ブログサマリタスクのディスパッチに失敗しました") - cache.status = "dead_letter" - cache.error_message = "タスクの開始に失敗しました" - db.commit() raise HTTPException( status_code=500, detail=get_error("task.dispatch_failed"), @@ -310,22 +310,18 @@ async def summarize_blog( ) -# リトライ可能な終端ステータス(リトライ枯渇 or リトライ不可エラー) -_RETRYABLE_TERMINAL_STATUSES = {"dead_letter"} - - @router.post("/summarize/retry", response_model=BlogSummaryResponse, status_code=202) @limiter.limit("5/minute") async def retry_summarize_blog( request: Request, background_tasks: BackgroundTasks, - body: BlogSummaryRequest = Body(...), user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """失敗したブログサマリタスクを手動で再実行する。 - ``dead_letter`` 状態のキャッシュのみ再実行可能。 + ``dead_letter`` 状態のキャッシュのみ再実行可能。記事は worker 側で + ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 """ cache = BlogSummaryCacheRepository(db, user.id).get() if not cache: @@ -333,7 +329,8 @@ async def retry_summarize_blog( status_code=404, detail="サマリキャッシュが見つかりません", ) - if cache.status not in _RETRYABLE_TERMINAL_STATUSES: + service = AsyncTaskCacheService(db, cache) + if not service.is_retryable_terminal(): raise HTTPException( status_code=409, detail=f"このタスクはリトライできない状態です(現在: {cache.status})", @@ -343,24 +340,17 @@ async def retry_summarize_blog( if not available: return BlogSummaryResponse(summary="", available=False) - cache.status = "pending" - cache.error_message = None - cache.retry_count = 0 - cache.started_at = None - cache.completed_at = None - db.commit() + service.reset_to_pending(reset_retry_count=True) try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.BLOG_SUMMARIZE, {"user_id": user.id}, + failure_message="タスクの再実行に失敗しました", + logger=logger, ) except Exception: - logger.exception("ブログサマリタスクの再実行に失敗しました") - cache.status = "dead_letter" - cache.error_message = "タスクの再実行に失敗しました" - db.commit() raise HTTPException( status_code=500, detail=get_error("task.dispatch_failed"), diff --git a/backend/app/routers/career_analysis.py b/backend/app/routers/career_analysis.py index c2efd493..f4512b36 100644 --- a/backend/app/routers/career_analysis.py +++ b/backend/app/routers/career_analysis.py @@ -28,14 +28,12 @@ TaskStatusResponse, ) from ..services.intelligence.llm import get_llm_client -from ..services.tasks import TaskType, get_task_dispatcher +from ..services.tasks import AsyncTaskCacheService, TaskType logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/career-analysis", tags=["career-analysis"]) -_llm_client = get_llm_client() - def _to_response(analysis) -> CareerAnalysisResponse: """DB モデルを Pydantic レスポンスに変換する。""" @@ -81,7 +79,7 @@ async def generate( action="職務経歴書を入力してから再試行してください", ) - if not await _llm_client.check_available(): + if not await get_llm_client().check_available(): raise_app_error( status_code=503, code=ErrorCode.LLM_UNAVAILABLE, @@ -91,20 +89,22 @@ async def generate( # pending レコード作成 analysis = repo.create_pending(target_position=payload.target_position) + service = AsyncTaskCacheService(db, analysis) # バックグラウンドタスクをディスパッチ try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.CAREER_ANALYSIS, { "user_id": current_user.id, "record_id": analysis.id, "target_position": payload.target_position, }, + failure_message="タスクの開始に失敗しました", + logger=logger, ) except Exception: - logger.exception("キャリア分析タスクのディスパッチに失敗しました") raise_app_error( status_code=500, code=ErrorCode.INTERNAL_ERROR, @@ -115,10 +115,6 @@ async def generate( return _to_response(analysis) -# リトライ可能な終端ステータス(リトライ枯渇 or リトライ不可エラー) -_RETRYABLE_TERMINAL_STATUSES = {"dead_letter"} - - @router.post("/{analysis_id}/retry", response_model=CareerAnalysisResponse, status_code=202) @limiter.limit("5/minute") async def retry_analysis( @@ -142,7 +138,8 @@ async def retry_analysis( message=get_error("career_analysis.not_found"), action="一覧に戻って対象を選び直してください", ) - if analysis.status not in _RETRYABLE_TERMINAL_STATUSES: + service = AsyncTaskCacheService(db, analysis) + if not service.is_retryable_terminal(): raise_app_error( status_code=409, code=ErrorCode.VALIDATION_ERROR, @@ -150,28 +147,21 @@ async def retry_analysis( action="タスクの完了または失敗を待ってから再試行してください", ) - analysis.status = "pending" - analysis.error_message = None - analysis.retry_count = 0 - analysis.started_at = None - analysis.completed_at = None - db.commit() + service.reset_to_pending(reset_retry_count=True) try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.CAREER_ANALYSIS, { "user_id": current_user.id, "record_id": analysis.id, "target_position": analysis.target_position, }, + failure_message="タスクの再実行に失敗しました", + logger=logger, ) except Exception: - logger.exception("キャリア分析タスクの再実行に失敗しました") - analysis.status = "dead_letter" - analysis.error_message = "タスクの再実行に失敗しました" - db.commit() raise_app_error( status_code=500, code=ErrorCode.INTERNAL_ERROR, diff --git a/backend/app/routers/intelligence.py b/backend/app/routers/intelligence.py index b1035d10..8b5c3125 100644 --- a/backend/app/routers/intelligence.py +++ b/backend/app/routers/intelligence.py @@ -26,7 +26,7 @@ ProgressResponse, ) from ..services.intelligence.llm_advice_service import LLMPositionAdviceService -from ..services.tasks import TaskType, get_task_dispatcher +from ..services.tasks import AsyncTaskCacheService, TaskType logger = logging.getLogger(__name__) @@ -113,17 +113,15 @@ async def analyze( # 進行中のタスクがあればそのステータスを返す cache = _get_or_create_cache(db, user.id) - if cache.status in ("pending", "processing"): + service = AsyncTaskCacheService(db, cache) + if service.is_in_progress(): return {"status": cache.status} - # pending にセットして即座に返却 - cache.status = "pending" - cache.error_message = None - db.commit() + service.reset_to_pending() try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.GITHUB_ANALYSIS, { "user_id": user.id, @@ -131,12 +129,10 @@ async def analyze( "github_token": user.github_token, "include_forks": payload.include_forks, }, + failure_message="タスクの開始に失敗しました", + logger=logger, ) except Exception: - logger.exception("GitHub 分析タスクのディスパッチに失敗しました") - cache.status = "dead_letter" - cache.error_message = "タスクの開始に失敗しました" - db.commit() raise_app_error( status_code=500, code=ErrorCode.INTERNAL_ERROR, @@ -147,10 +143,6 @@ async def analyze( return {"status": "pending"} -# リトライ可能な終端ステータス(リトライ枯渇 or リトライ不可エラー) -_RETRYABLE_TERMINAL_STATUSES = {"dead_letter"} - - @router.post("/analyze/retry", status_code=202) @limiter.limit("5/minute") async def retry_analyze( @@ -181,7 +173,8 @@ async def retry_analyze( message=get_error("intelligence.no_analysis_cache"), action="先に GitHub 分析を実行してください", ) - if cache.status not in _RETRYABLE_TERMINAL_STATUSES: + service = AsyncTaskCacheService(db, cache) + if not service.is_retryable_terminal(): raise_app_error( status_code=409, code=ErrorCode.VALIDATION_ERROR, @@ -192,16 +185,11 @@ async def retry_analyze( github_username = user.username.removeprefix("github:") include_forks = payload.include_forks if payload else False - cache.status = "pending" - cache.error_message = None - cache.retry_count = 0 - cache.started_at = None - cache.completed_at = None - db.commit() + service.reset_to_pending(reset_retry_count=True) try: - dispatcher = get_task_dispatcher(background_tasks) - await dispatcher.dispatch( + await service.dispatch( + background_tasks, TaskType.GITHUB_ANALYSIS, { "user_id": user.id, @@ -209,12 +197,10 @@ async def retry_analyze( "github_token": user.github_token, "include_forks": include_forks, }, + failure_message="タスクの再実行に失敗しました", + logger=logger, ) except Exception: - logger.exception("GitHub 分析タスクの再実行に失敗しました") - cache.status = "dead_letter" - cache.error_message = "タスクの再実行に失敗しました" - db.commit() raise_app_error( status_code=500, code=ErrorCode.INTERNAL_ERROR, diff --git a/backend/app/schemas/intelligence.py b/backend/app/schemas/intelligence.py index d4bdee18..c556299c 100644 --- a/backend/app/schemas/intelligence.py +++ b/backend/app/schemas/intelligence.py @@ -82,7 +82,7 @@ class ProgressResponse(BaseModel): task_id: str step_index: int = Field(0, description="現在のステップ番号(0 は未開始)") - total_steps: int = Field(6, description="全ステップ数") + total_steps: int = Field(5, description="全ステップ数") step_label: Optional[str] = Field(None, description="現在のステップラベル") sub_progress: Optional[SubProgress] = Field( None, description="リポジトリ詳細取得ステップの細粒度進捗" diff --git a/backend/app/services/blog/collector.py b/backend/app/services/blog/collector.py index e5133de4..c2cfcc4e 100644 --- a/backend/app/services/blog/collector.py +++ b/backend/app/services/blog/collector.py @@ -9,6 +9,12 @@ logger = get_logger(__name__) +# 記事取得用のデフォルトタイムアウト(全ページフェッチに耐えられる長さ) +_FETCH_TIMEOUT_SECONDS = 30.0 + +# ユーザー存在確認用のタイムアウト(軽量リクエスト想定) +_VERIFY_TIMEOUT_SECONDS = 10.0 + class UnsupportedBlogPlatformError(ValueError): """未対応プラットフォームが指定された場合の例外。""" @@ -89,7 +95,7 @@ async def fetch_zenn_articles(username: str) -> list[dict]: articles: list[dict] = [] page = 1 - async with httpx.AsyncClient(timeout=30.0) as client: + async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_SECONDS) as client: while True: resp = await client.get( "https://zenn.dev/api/articles", @@ -143,7 +149,7 @@ async def fetch_note_articles(username: str) -> list[dict]: page = 1 headers = {"User-Agent": "DevForge/1.0"} - async with httpx.AsyncClient(timeout=30.0, headers=headers) as client: + async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_SECONDS, headers=headers) as client: while True: resp = await client.get( f"https://note.com/api/v2/creators/{username}/contents", @@ -198,7 +204,7 @@ async def fetch_qiita_articles(username: str) -> list[dict]: page = 1 per_page = 100 - async with httpx.AsyncClient(timeout=30.0) as client: + async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT_SECONDS) as client: while True: resp = await client.get( f"https://qiita.com/api/v2/users/{username}/items", @@ -246,18 +252,18 @@ async def verify_user_exists(platform: str, username: str) -> bool: try: if platform == "zenn": - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client: resp = await client.get(f"https://zenn.dev/api/users/{normalized_username}") return resp.status_code == 200 if platform == "note": - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client: resp = await client.get( f"https://note.com/api/v2/creators/{normalized_username}/contents", params={"kind": "note", "page": 1}, ) return resp.status_code == 200 if platform == "qiita": - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client: resp = await client.get(f"https://qiita.com/api/v2/users/{normalized_username}") return resp.status_code == 200 raise UnsupportedBlogPlatformError(platform) diff --git a/backend/app/services/intelligence/career_paths/__init__.py b/backend/app/services/intelligence/career_paths/__init__.py deleted file mode 100644 index 19e95f68..00000000 --- a/backend/app/services/intelligence/career_paths/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""キャリアパスパッケージ。 - -既存のインポートパスとの互換性を保つため、すべての公開シンボルを re-export する。 -""" - -from .definitions import ( - CAREER_ROLES, - RoleDefinition, - get_all_role_names, - get_entry_roles, - get_role, -) -from .matcher import RoleMatch, match_skills_to_roles - -__all__ = [ - "RoleDefinition", - "CAREER_ROLES", - "get_role", - "get_all_role_names", - "get_entry_roles", - "RoleMatch", - "match_skills_to_roles", -] diff --git a/backend/app/services/intelligence/career_paths/definitions.py b/backend/app/services/intelligence/career_paths/definitions.py deleted file mode 100644 index d07a1e2c..00000000 --- a/backend/app/services/intelligence/career_paths/definitions.py +++ /dev/null @@ -1,334 +0,0 @@ -"""キャリアパス・ロール定義。 - -各ロールの required_skills / required_categories / next_roles / seniority を静的に定義する。 -LLM は使用しない — 純粋なルールベース。 -""" - -from dataclasses import dataclass -from typing import Dict, List - - -@dataclass -class RoleDefinition: - """ロール定義データクラス。""" - - role_name: str - required_skills: List[str] - required_categories: List[str] - next_roles: List[str] - seniority: int = 1 - - -CAREER_ROLES: Dict[str, RoleDefinition] = { - "Backend Engineer": RoleDefinition( - role_name="Backend Engineer", - required_skills=[ - "Python", - "Go", - "Java", - "FastAPI", - "Django", - "Flask", - "Express", - "Spring Boot", - "Gin", - "Rails", - ], - required_categories=["language", "backend_framework"], - next_roles=[ - "Senior Backend Engineer", - "Full-Stack Developer", - "Platform Engineer", - "DevOps Engineer", - "Tech Lead", - ], - seniority=2, - ), - "Frontend Engineer": RoleDefinition( - role_name="Frontend Engineer", - required_skills=[ - "JavaScript", - "TypeScript", - "React", - "Vue", - "Angular", - "Svelte", - "Next.js", - ], - required_categories=["language", "frontend_framework"], - next_roles=["Senior Frontend Engineer", "Full-Stack Developer", "Tech Lead"], - seniority=2, - ), - "Full-Stack Developer": RoleDefinition( - role_name="Full-Stack Developer", - required_skills=[ - "JavaScript", - "TypeScript", - "React", - "Vue", - "Angular", - "Python", - "Go", - "FastAPI", - "Django", - "Express", - "Next.js", - ], - required_categories=["language", "frontend_framework", "backend_framework"], - next_roles=["Senior Full-Stack Developer", "Tech Lead", "Platform Engineer"], - seniority=2, - ), - "Mobile Developer": RoleDefinition( - role_name="Mobile Developer", - required_skills=[ - "Swift", - "Kotlin", - "Dart", - "React Native", - "Flutter", - "SwiftUI", - "Jetpack Compose", - ], - required_categories=["language", "mobile"], - next_roles=["Senior Mobile Developer", "Tech Lead"], - seniority=2, - ), - "DevOps Engineer": RoleDefinition( - role_name="DevOps Engineer", - required_skills=[ - "Docker", - "Kubernetes", - "GitHub Actions", - "GitLab CI", - "Jenkins", - "Terraform", - "Ansible", - "Shell", - ], - required_categories=["infrastructure", "cicd"], - next_roles=["Platform Engineer", "SRE", "Cloud Architect"], - seniority=2, - ), - "Platform Engineer": RoleDefinition( - role_name="Platform Engineer", - required_skills=[ - "Docker", - "Kubernetes", - "Terraform", - "AWS", - "GCP", - "Azure", - "Helm", - "ArgoCD", - ], - required_categories=["infrastructure", "cloud"], - next_roles=["Cloud Architect", "SRE", "Engineering Manager"], - seniority=3, - ), - "Data Engineer": RoleDefinition( - role_name="Data Engineer", - required_skills=[ - "Python", - "SQL", - "Apache Spark", - "Apache Kafka", - "Airflow", - "dbt", - "BigQuery", - "Snowflake", - "Redshift", - ], - required_categories=["language", "data_engineering"], - next_roles=["Senior Data Engineer", "ML Engineer", "Data Architect"], - seniority=2, - ), - "ML Engineer": RoleDefinition( - role_name="ML Engineer", - required_skills=[ - "Python", - "TensorFlow", - "PyTorch", - "scikit-learn", - "Hugging Face", - "MLflow", - "Pandas", - "NumPy", - "Jupyter", - ], - required_categories=["language", "ml"], - next_roles=["Senior ML Engineer", "AI Architect", "Research Engineer"], - seniority=3, - ), - "SRE": RoleDefinition( - role_name="SRE", - required_skills=[ - "Docker", - "Kubernetes", - "Prometheus", - "Grafana", - "Terraform", - "AWS", - "GCP", - "Shell", - "OpenTelemetry", - ], - required_categories=["infrastructure", "monitoring", "cloud"], - next_roles=["Cloud Architect", "Platform Engineer", "Engineering Manager"], - seniority=3, - ), - "Security Engineer": RoleDefinition( - role_name="Security Engineer", - required_skills=[ - "OAuth", - "JWT", - "OpenID Connect", - "Vault", - "Docker", - "Linux", - "Shell", - ], - required_categories=["security"], - next_roles=["Senior Security Engineer", "Cloud Architect"], - seniority=3, - ), - "Cloud Architect": RoleDefinition( - role_name="Cloud Architect", - required_skills=["AWS", "GCP", "Azure", "Terraform", "Kubernetes", "Docker"], - required_categories=["cloud", "infrastructure"], - next_roles=["Engineering Manager", "CTO"], - seniority=4, - ), - "Tech Lead": RoleDefinition( - role_name="Tech Lead", - required_skills=[], # 特定のスキルではなく、幅広さによって決定されるロール - required_categories=["language", "backend_framework"], - next_roles=["Engineering Manager", "Staff Engineer"], - seniority=3, - ), - "Senior Backend Engineer": RoleDefinition( - role_name="Senior Backend Engineer", - required_skills=[ - "Python", - "Go", - "Java", - "FastAPI", - "Django", - "Docker", - "PostgreSQL", - "Redis", - ], - required_categories=["language", "backend_framework", "database"], - next_roles=["Tech Lead", "Platform Engineer", "Staff Engineer"], - seniority=3, - ), - "Senior Frontend Engineer": RoleDefinition( - role_name="Senior Frontend Engineer", - required_skills=["TypeScript", "React", "Vue", "Next.js"], - required_categories=["language", "frontend_framework"], - next_roles=["Tech Lead", "Full-Stack Developer", "Staff Engineer"], - seniority=3, - ), - "Senior Full-Stack Developer": RoleDefinition( - role_name="Senior Full-Stack Developer", - required_skills=["TypeScript", "React", "Python", "Docker", "PostgreSQL"], - required_categories=[ - "language", - "frontend_framework", - "backend_framework", - "database", - ], - next_roles=["Tech Lead", "Staff Engineer", "Engineering Manager"], - seniority=3, - ), - "Senior Mobile Developer": RoleDefinition( - role_name="Senior Mobile Developer", - required_skills=["Swift", "Kotlin", "Flutter", "React Native"], - required_categories=["language", "mobile"], - next_roles=["Tech Lead", "Engineering Manager"], - seniority=3, - ), - "Senior Data Engineer": RoleDefinition( - role_name="Senior Data Engineer", - required_skills=["Python", "Apache Spark", "Airflow", "BigQuery", "Snowflake"], - required_categories=["language", "data_engineering"], - next_roles=["Data Architect", "ML Engineer", "Engineering Manager"], - seniority=3, - ), - "Senior ML Engineer": RoleDefinition( - role_name="Senior ML Engineer", - required_skills=["Python", "PyTorch", "TensorFlow", "MLflow"], - required_categories=["language", "ml"], - next_roles=["AI Architect", "Research Engineer", "Engineering Manager"], - seniority=4, - ), - "Senior Security Engineer": RoleDefinition( - role_name="Senior Security Engineer", - required_skills=["OAuth", "Vault", "Docker", "Kubernetes", "Linux"], - required_categories=["security", "infrastructure"], - next_roles=["Cloud Architect", "Engineering Manager"], - seniority=4, - ), - "Staff Engineer": RoleDefinition( - role_name="Staff Engineer", - required_skills=[], - required_categories=["language"], - next_roles=["Engineering Manager", "CTO"], - seniority=4, - ), - "Data Architect": RoleDefinition( - role_name="Data Architect", - required_skills=[ - "SQL", - "BigQuery", - "Snowflake", - "Apache Spark", - "Apache Kafka", - ], - required_categories=["data_engineering", "cloud"], - next_roles=["Engineering Manager", "CTO"], - seniority=4, - ), - "AI Architect": RoleDefinition( - role_name="AI Architect", - required_skills=["Python", "PyTorch", "TensorFlow", "LangChain"], - required_categories=["ml", "cloud"], - next_roles=["CTO", "Engineering Manager"], - seniority=4, - ), - "Research Engineer": RoleDefinition( - role_name="Research Engineer", - required_skills=["Python", "PyTorch", "TensorFlow", "Jupyter", "NumPy"], - required_categories=["ml"], - next_roles=["AI Architect", "Senior ML Engineer"], - seniority=4, - ), - "Engineering Manager": RoleDefinition( - role_name="Engineering Manager", - required_skills=[], - required_categories=[], - next_roles=["CTO"], - seniority=4, - ), - "CTO": RoleDefinition( - role_name="CTO", - required_skills=[], - required_categories=[], - next_roles=[], - seniority=5, - ), -} - - -def get_role(role_name: str) -> RoleDefinition | None: - """ロール名からロール定義を返す。""" - return CAREER_ROLES.get(role_name) - - -def get_all_role_names() -> list[str]: - """全ロール名の一覧を返す。""" - return list(CAREER_ROLES.keys()) - - -def get_entry_roles() -> list[str]: - """シニアリティレベル 2 のロール(典型的な開始点)の一覧を返す。""" - return [name for name, role in CAREER_ROLES.items() if role.seniority == 2] diff --git a/backend/app/services/intelligence/career_paths/matcher.py b/backend/app/services/intelligence/career_paths/matcher.py deleted file mode 100644 index c314c9f0..00000000 --- a/backend/app/services/intelligence/career_paths/matcher.py +++ /dev/null @@ -1,57 +0,0 @@ -"""スキル → ロールマッチングロジック。""" - -from dataclasses import dataclass -from typing import List, Set - -from .definitions import CAREER_ROLES - - -@dataclass -class RoleMatch: - """ロールマッチング結果を保持するデータクラス。""" - - role_name: str - skill_overlap: Set[str] - category_coverage: float - match_score: float - - -def match_skills_to_roles( - user_skills: Set[str], - user_categories: Set[str], -) -> List[RoleMatch]: - """ユーザーのスキルが各ロール定義とどの程度一致するかをスコアリングする。 - - スキルの一致度 60% + カテゴリのカバー率 40% で重み付けスコアを算出する。 - """ - matches: List[RoleMatch] = [] - - for name, role in CAREER_ROLES.items(): - if not role.required_skills and not role.required_categories: - continue - - # スキルの重複 - role_skills = set(role.required_skills) - overlap = user_skills & role_skills - skill_score = len(overlap) / max(len(role_skills), 1) - - # カテゴリのカバー率 - role_cats = set(role.required_categories) - cat_overlap = user_categories & role_cats - cat_score = len(cat_overlap) / max(len(role_cats), 1) - - # 重み付けスコア: スキルの一致 60% + カテゴリのカバー率 40% - score = 0.6 * skill_score + 0.4 * cat_score - - if score > 0: - matches.append( - RoleMatch( - role_name=name, - skill_overlap=overlap, - category_coverage=cat_score, - match_score=round(score, 3), - ) - ) - - matches.sort(key=lambda m: m.match_score, reverse=True) - return matches diff --git a/backend/app/services/intelligence/career_predictor.py b/backend/app/services/intelligence/career_predictor.py deleted file mode 100644 index 34be2e5a..00000000 --- a/backend/app/services/intelligence/career_predictor.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -キャリア予測エンジン。 - -以下の要素に基づいて現在のロールと将来の可能性が高いロールを予測します: - - ロール定義に対するスキルグラフの適合性 - - スキルの成長速度 - - カテゴリのカバレッジ - -決定論的な推論ルールを使用 — LLMは要約のみに使用(オプション)。 -""" - -import logging -from dataclasses import dataclass -from typing import Dict, List, Set - -from .career_paths import ( - CAREER_ROLES, - RoleMatch, - match_skills_to_roles, -) -from .skill_growth_analyzer import GrowthTrend, SkillGrowth -from .skill_taxonomy import get_skill_category -from .skill_timeline_builder import SkillTimeline - -logger = logging.getLogger(__name__) - - -@dataclass -class PredictedRole: - role_name: str - confidence: float # 0.0 から 1.0 - matching_skills: List[str] - missing_skills: List[str] - seniority: int - - -@dataclass -class CareerPrediction: - current_role: PredictedRole - next_roles: List[PredictedRole] - long_term_roles: List[PredictedRole] - skill_summary: Dict[str, List[str]] # カテゴリ → スキル - - -def predict_career( - timelines: List[SkillTimeline], - growth: List[SkillGrowth], -) -> CareerPrediction: - """ - スキルデータからキャリアの軌跡を予測します。 - - 1. 現在のスキルをロール定義と照合する - 2. 最も適合する現在のロールを特定する - 3. キャリアグラフを介して次および長期的なロールを予測する - """ - # スキルセットの構築 - user_skills: Set[str] = {t.skill_name for t in timelines} - user_categories: Set[str] = {get_skill_category(s) for s in user_skills} - - # 成長データのルックアップを構築 - growth_map: Dict[str, SkillGrowth] = {g.skill_name: g for g in growth} - emerging_skills = {g.skill_name for g in growth if g.trend == GrowthTrend.EMERGING} - - # 1. スキルをすべてのロールと照合する - matches = match_skills_to_roles(user_skills, user_categories) - if not matches: - return _empty_prediction(user_skills) - - # 2. 現在のロール:最も一致するもの - best_match = matches[0] - current = _match_to_predicted_role(best_match, user_skills) - - # 3. 次のロール:現在のロールの next_roles からスコアリング - current_role_def = CAREER_ROLES.get(best_match.role_name) - next_role_names = current_role_def.next_roles if current_role_def else [] - - next_roles = _score_next_roles( - next_role_names, - user_skills, - user_categories, - emerging_skills, - growth_map, - ) - - # 4. 長期的なロール:グラフを2ホップ辿る - long_term_names: Set[str] = set() - for nr in next_roles: - role_def = CAREER_ROLES.get(nr.role_name) - if role_def: - long_term_names.update(role_def.next_roles) - long_term_names -= {current.role_name} - long_term_names -= {nr.role_name for nr in next_roles} - - long_term_roles = _score_next_roles( - list(long_term_names), - user_skills, - user_categories, - emerging_skills, - growth_map, - depth_penalty=0.5, - ) - - # カテゴリ別のスキルサマリー - skill_summary: Dict[str, List[str]] = {} - for skill in sorted(user_skills): - cat = get_skill_category(skill) - skill_summary.setdefault(cat, []).append(skill) - - return CareerPrediction( - current_role=current, - next_roles=next_roles[:5], - long_term_roles=long_term_roles[:5], - skill_summary=skill_summary, - ) - - -def _match_to_predicted_role( - match: RoleMatch, - user_skills: Set[str], -) -> PredictedRole: - """RoleMatch を PredictedRole に変換します。""" - role_def = CAREER_ROLES.get(match.role_name) - required = set(role_def.required_skills) if role_def else set() - missing = sorted(required - user_skills) - seniority = role_def.seniority if role_def else 1 - - return PredictedRole( - role_name=match.role_name, - confidence=round(match.match_score, 2), - matching_skills=sorted(match.skill_overlap), - missing_skills=missing, - seniority=seniority, - ) - - -def _score_next_roles( - role_names: List[str], - user_skills: Set[str], - user_categories: Set[str], - emerging_skills: Set[str], - growth_map: Dict[str, SkillGrowth], - depth_penalty: float = 1.0, -) -> List[PredictedRole]: - """ - 潜在的な次のロールのリストをスコアリングし、ランク付けします。 - - スコアリングでは以下の要素を考慮します: - - スキルの適合性(必要なスキルのうちいくつを満たしているか) - - 台頭中のスキルのボーナス(ロールに向かって成長しているか) - - 将来の遠いロールに対する深さのペナルティ - """ - scored: List[PredictedRole] = [] - - for name in role_names: - role_def = CAREER_ROLES.get(name) - if not role_def: - continue - - required = set(role_def.required_skills) - overlap = user_skills & required - missing = required - user_skills - - # スキルの一致に基づく基本スコア - if required: - base_score = len(overlap) / len(required) - else: - # 特定の必須スキルがないロール(例:マネージャー) - # カテゴリの広さに基づくスコア - base_score = min(len(user_categories) / 4, 1.0) * 0.5 - - # 必須スキルに一致する台頭中のスキルのボーナス - emerging_match = emerging_skills & required - emerging_bonus = len(emerging_match) * 0.1 - - # カテゴリのカバレッジ - role_cats = set(role_def.required_categories) - cat_score = len(user_categories & role_cats) / len(role_cats) if role_cats else 0.3 - - confidence = min( - (base_score * 0.5 + cat_score * 0.3 + emerging_bonus) * depth_penalty, - 1.0, - ) - - scored.append( - PredictedRole( - role_name=name, - confidence=round(confidence, 2), - matching_skills=sorted(overlap), - missing_skills=sorted(missing), - seniority=role_def.seniority, - ) - ) - - scored.sort(key=lambda r: r.confidence, reverse=True) - return scored - - -def _empty_prediction(user_skills: Set[str]) -> CareerPrediction: - """一致するロールがない場合に空の予測を返します。""" - skill_summary: Dict[str, List[str]] = {} - for skill in sorted(user_skills): - cat = get_skill_category(skill) - skill_summary.setdefault(cat, []).append(skill) - - return CareerPrediction( - current_role=PredictedRole( - role_name="Developer", - confidence=0.0, - matching_skills=[], - missing_skills=[], - seniority=1, - ), - next_roles=[], - long_term_roles=[], - skill_summary=skill_summary, - ) diff --git a/backend/app/services/intelligence/career_simulator.py b/backend/app/services/intelligence/career_simulator.py deleted file mode 100644 index c1b138b3..00000000 --- a/backend/app/services/intelligence/career_simulator.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -キャリアシミュレーションエンジン。 - -キャリアグラフを走査することで、複数の可能なキャリアパスをシミュレーションします。 -信頼スコアとともに分岐するパスを返します。 - -決定論的なグラフ走査 — LLMは使用しません。 -""" - -import logging -from dataclasses import dataclass -from typing import Dict, List, Set - -from .career_paths import CAREER_ROLES -from .career_predictor import CareerPrediction -from .confidence_scorer import score_path -from .skill_growth_analyzer import SkillGrowth -from .skill_taxonomy import get_skill_category -from .skill_timeline_builder import SkillTimeline - -logger = logging.getLogger(__name__) - -MAX_PATH_DEPTH = 4 -MAX_BRANCH_FACTOR = 3 - - -@dataclass -class SimulatedPath: - path: List[str] # ロール名のシーケンス - confidence: float # パス全体の信頼度 (0.0 から 1.0) - description: str # 人間が読める形式のサマリー - - -@dataclass -class CareerSimulation: - current_role: str - paths: List[SimulatedPath] - total_paths_explored: int - - -def simulate_careers( - prediction: CareerPrediction, - timelines: List[SkillTimeline], - growth: List[SkillGrowth], - max_paths: int = 5, -) -> CareerSimulation: - """ - 現在の予測ロールから複数のキャリアパスをシミュレーションします。 - - キャリアグラフ上で深さ制限付きの深さ優先探索(DFS)を使用し、各パスをスコアリングします。 - """ - current = prediction.current_role.role_name - user_skills: Set[str] = {t.skill_name for t in timelines} - user_categories: Set[str] = {get_skill_category(s) for s in user_skills} - growth_map: Dict[str, SkillGrowth] = {g.skill_name: g for g in growth} - - # すべてのパスを見つけるための DFS - all_paths: List[List[str]] = [] - _dfs(current, [current], set(), all_paths) - - # 各パスをスコアリング - scored: List[SimulatedPath] = [] - for path in all_paths: - if len(path) < 2: - continue - - conf = score_path( - path, - user_skills, - user_categories, - growth_map, - ) - desc = _generate_description(path) - scored.append( - SimulatedPath( - path=path, - confidence=conf, - description=desc, - ) - ) - - scored.sort(key=lambda s: s.confidence, reverse=True) - - logger.info( - "Simulated %d career paths from %s (showing top %d)", - len(scored), - current, - max_paths, - ) - - return CareerSimulation( - current_role=current, - paths=scored[:max_paths], - total_paths_explored=len(all_paths), - ) - - -def _dfs( - role: str, - current_path: List[str], - visited: Set[str], - all_paths: List[List[str]], -) -> None: - """キャリアグラフ上の深さ制限付き DFS。""" - if len(current_path) >= MAX_PATH_DEPTH: - all_paths.append(list(current_path)) - return - - role_def = CAREER_ROLES.get(role) - if not role_def or not role_def.next_roles: - all_paths.append(list(current_path)) - return - - visited.add(role) - branches_taken = 0 - - for next_role in role_def.next_roles: - if next_role in visited: - continue - if branches_taken >= MAX_BRANCH_FACTOR: - break - - current_path.append(next_role) - _dfs(next_role, current_path, visited, all_paths) - current_path.pop() - branches_taken += 1 - - # 分岐が発生しなかった場合、現在のパスを終端として保存 - if branches_taken == 0: - all_paths.append(list(current_path)) - - visited.discard(role) - - -def _generate_description(path: List[str]) -> str: - """人間が読める形式のパス説明を生成します。""" - if len(path) <= 1: - return path[0] if path else "" - - parts: List[str] = [] - for i, role in enumerate(path): - if i == 0: - parts.append(f"{role}として開始") - elif i == len(path) - 1: - parts.append(f"目標: {role}") - else: - parts.append(f"{role}へ成長") - - return " -> ".join(parts) diff --git a/backend/app/services/intelligence/confidence_scorer.py b/backend/app/services/intelligence/confidence_scorer.py deleted file mode 100644 index eb13c2e7..00000000 --- a/backend/app/services/intelligence/confidence_scorer.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -キャリアパスの信頼度スコアリング。 - -以下の要素に基づいてキャリアパスをスコアリングします: - - パス内の各ロールとのスキルの適合性 - - スキルの成長速度(台頭中のスキルは信頼度を向上させる) - - カテゴリのカバレッジの広さ - -決定論的 — LLMは使用しません。 -""" - -from typing import Dict, List, Set - -from .career_paths import CAREER_ROLES -from .skill_growth_analyzer import GrowthTrend, SkillGrowth - - -def score_path( - path: List[str], - user_skills: Set[str], - user_categories: Set[str], - growth_map: Dict[str, SkillGrowth], -) -> float: - """ - 現在のスキル適合性と成長に基づいてキャリアパスをスコアリングします。 - - 0.0 から 1.0 の間の信頼度スコアを返します。 - """ - if len(path) < 2: - return 0.0 - - # パス内の各遷移をスコアリング - transition_scores: List[float] = [] - for i in range(len(path)): - role_name = path[i] - role_def = CAREER_ROLES.get(role_name) - if not role_def: - transition_scores.append(0.0) - continue - - score = _score_role_fit( - role_def.required_skills, - role_def.required_categories, - user_skills, - user_categories, - growth_map, - ) - - # 距離減衰を適用:遠いロールほど不確実性が高まる - decay = 1.0 / (1.0 + i * 0.4) - transition_scores.append(score * decay) - - if not transition_scores: - return 0.0 - - # 加重平均:初期のロールに高い重みを置く - total_weight = 0.0 - weighted_sum = 0.0 - for i, score in enumerate(transition_scores): - weight = 1.0 / (1 + i * 0.3) - weighted_sum += score * weight - total_weight += weight - - confidence = weighted_sum / total_weight if total_weight > 0 else 0.0 - return round(min(confidence, 1.0), 2) - - -def _score_role_fit( - required_skills: List[str], - required_categories: List[str], - user_skills: Set[str], - user_categories: Set[str], - growth_map: Dict[str, SkillGrowth], -) -> float: - """ユーザーのスキルがロールの定義にどの程度適合するかをスコアリングします。""" - if not required_skills and not required_categories: - # Engineering Manager のようなロール — 広さをベースにする - return min(len(user_categories) / 5, 1.0) * 0.4 - - req_set = set(required_skills) - overlap = user_skills & req_set - - # 基本的なスキルの適合 - skill_score = len(overlap) / max(len(req_set), 1) - - # カテゴリのカバレッジ - cat_set = set(required_categories) - cat_overlap = user_categories & cat_set - cat_score = len(cat_overlap) / max(len(cat_set), 1) - - # 台頭中のスキルのボーナス - emerging_bonus = 0.0 - for skill in req_set - user_skills: - # ユーザーがこのスキルのカテゴリに向かって成長しているかを確認 - for g in growth_map.values(): - if g.trend == GrowthTrend.EMERGING and skill in req_set: - emerging_bonus += 0.05 - break - - return min( - skill_score * 0.5 + cat_score * 0.35 + emerging_bonus, - 1.0, - ) diff --git a/backend/app/services/intelligence/github_analysis_service.py b/backend/app/services/intelligence/github_analysis_service.py new file mode 100644 index 00000000..1dc87535 --- /dev/null +++ b/backend/app/services/intelligence/github_analysis_service.py @@ -0,0 +1,121 @@ +"""GitHub 分析タスクの実行サービス。 + +進捗通知付きでパイプラインを駆動し、結果を ``GitHubAnalysisCache`` に保存する。 +LLM が利用可能であれば学習アドバイスも併せて生成する。 + +worker は本サービスを呼ぶだけで、状態遷移・進捗・LLM 失敗ラベルなどは本モジュールに集約する。 +""" + +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ...core.encryption import decrypt_field +from ...core.logging_utils import get_logger +from ...models import GitHubAnalysisCache +from ..progress_service import set_progress +from .github_collector import GitHubUserNotFoundError, collect_repos +from .llm import get_llm_client +from .llm_summarizer import generate_learning_advice +from .pipeline import aggregate_intelligence +from .response_mapper import map_pipeline_result + +logger = get_logger(__name__) + +_TOTAL_STEPS = 5 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def run_github_analysis(db: Session, payload: dict) -> None: + """GitHub 分析パイプラインを実行し、AI 学習アドバイスまで一括生成してキャッシュに保存する。""" + user_id = payload["user_id"] + task_id = user_id + + cache = db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() + if not cache: + logger.error("GitHub 分析キャッシュが見つかりません", extra={"user_id": user_id}) + return + + cache.status = "processing" + cache.started_at = _now() + db.commit() + + token = decrypt_field(payload["github_token"]) if payload.get("github_token") else None + + try: + # ステップ 1: リポジトリ一覧取得 + await set_progress(task_id, 1, _TOTAL_STEPS, "リポジトリ一覧取得中...") + + async def _on_repo_fetched(done: int, total: int) -> None: + await set_progress( + task_id, + 2, + _TOTAL_STEPS, + "リポジトリ詳細取得中...", + sub_progress={"done": done, "total": total}, + ) + + repos = await collect_repos( + username=payload["github_username"], + token=token, + include_forks=payload.get("include_forks", False), + on_repo_fetched=_on_repo_fetched, + ) + except GitHubUserNotFoundError as exc: + cache.status = "dead_letter" + cache.error_message = f"GitHubユーザーが見つかりません: {payload['github_username']}" + cache.completed_at = _now() + db.commit() + raise exc + + # ステップ 3: スキル抽出 + スコア算出(同一の集計関数で一括処理) + await set_progress(task_id, 3, _TOTAL_STEPS, "スキル分析中...") + result = aggregate_intelligence(payload["github_username"], repos) + + response = map_pipeline_result(result) + analysis_dict = response.model_dump() + cache.analysis_result = analysis_dict + + # LLM が利用可能なら学習アドバイスも自動生成する + advice, llm_failed = await _generate_advice_if_available(analysis_dict) + cache.position_advice = advice + + # ステップ 4: DB 保存 + await set_progress(task_id, 4, _TOTAL_STEPS, "結果を保存中...") + cache.status = "completed" + cache.error_message = "LLM処理が利用できません" if llm_failed else None + cache.completed_at = _now() + db.commit() + + # ステップ 5: 完了 + await set_progress(task_id, 5, _TOTAL_STEPS, "完了") + + +async def _generate_advice_if_available(analysis: dict) -> tuple[str | None, bool]: + """LLM が利用可能であれば学習アドバイスを生成する。 + + 戻り値は (advice, llm_failed) のタプル。 + llm_failed=True は LLM の呼び出しを試みたが失敗したことを示す。 + LLM が未設定またはスコア情報がない場合は llm_failed=False でスキップする。 + """ + try: + llm_client = get_llm_client() + if not await llm_client.check_available(): + logger.info("LLM が利用できないため学習アドバイスの生成をスキップしました") + return None, False + + scores = analysis.get("position_scores") + if not scores: + return None, False + + advice = await generate_learning_advice(analysis, scores) + if advice is None: + logger.warning("LLM が学習アドバイスの生成に失敗しました") + return None, True + return advice, False + except Exception: + logger.warning("学習アドバイスの生成に失敗しましたが、分析結果は保存します", exc_info=True) + return None, True diff --git a/backend/app/services/intelligence/llm_summarizer.py b/backend/app/services/intelligence/llm_summarizer.py index 6388bafc..8e0f69da 100644 --- a/backend/app/services/intelligence/llm_summarizer.py +++ b/backend/app/services/intelligence/llm_summarizer.py @@ -1,24 +1,32 @@ """LLM を利用したキャリア分析・ブログ記事の要約生成。 システムプロンプトは backend/prompts/ 配下の MD ファイルから都度読み込む。 + +LLM クライアントは ``get_llm_client()`` をプロセス内で 1 度だけ初期化する +(テスト・本番起動の双方で ``LLM_PROVIDER`` 切替を反映できるよう、遅延ロードする)。 """ +from functools import lru_cache from typing import Any, Dict, List, Optional from ...core.logging_utils import get_logger from ...core.metrics import measure_time_async from ...utils.prompt_loader import load_prompt from ..llm.sanitizer import SanitizeContext, sanitize_text -from .llm import get_llm_client +from .llm import LLMClient, get_llm_client logger = get_logger(__name__) -_client = get_llm_client() + +@lru_cache(maxsize=1) +def _get_client() -> LLMClient: + """LLM クライアントをプロセス内で 1 度だけ生成して再利用する。""" + return get_llm_client() async def check_llm_available() -> bool: """LLM バックエンドが利用可能か確認します。""" - return await _client.check_available() + return await _get_client().check_available() def _build_blog_prompt( @@ -60,7 +68,7 @@ async def summarize_blog_articles( """ system_prompt = load_prompt("blog_analysis.md") prompt = _build_blog_prompt(articles, context) - return await _client.generate(system_prompt, prompt) + return await _get_client().generate(system_prompt, prompt) def _build_learning_advice_prompt( @@ -117,4 +125,4 @@ async def generate_learning_advice( """ system_prompt = load_prompt("github_analysis.md") prompt = _build_learning_advice_prompt(analysis, scores) - return await _client.generate(system_prompt, prompt) + return await _get_client().generate(system_prompt, prompt) diff --git a/backend/app/services/intelligence/pipeline.py b/backend/app/services/intelligence/pipeline.py index b4e4415a..a6cc9617 100644 --- a/backend/app/services/intelligence/pipeline.py +++ b/backend/app/services/intelligence/pipeline.py @@ -2,9 +2,9 @@ キャリアインテリジェンスパイプラインのオーケストレーター。 GitHub のデータから以下の分析を順次実行します: - GitHub → リポジトリ → スキル抽出 → タイムライン生成 → 成長分析 + GitHub → リポジトリ → 集計 → スキル抽出 → スコア算出 -オプションの LLM 要約を除き、各ステージは決定論的です。 +各ステージは決定論的(LLM は呼ばない)。 """ from collections import defaultdict @@ -16,7 +16,7 @@ from ...core.metrics import measure_time_async from .github_collector import RepoData, collect_repos from .position_scorer import PositionScores, calculate_position_scores -from .skill_extractor import ExtractionResult, extract_skills +from .skill_extractor import extract_skills logger = get_logger(__name__) @@ -36,31 +36,12 @@ class IntelligenceResult: position_scores: Optional[PositionScores] = None -@measure_time_async("intelligence.pipeline") -async def run_pipeline( - username: str, - token: Optional[str] = None, - include_forks: bool = False, -) -> IntelligenceResult: - """ - GitHub ユーザーに対してキャリアインテリジェンスパイプラインを実行します。 +def aggregate_intelligence(username: str, repos: List[RepoData]) -> IntelligenceResult: + """リポジトリ集合から ``IntelligenceResult`` を構築する純粋関数。 - パイプラインステージ: - 1. GitHub API からリポジトリを収集 - 2. スキルを抽出(決定論的) - 3. スキルタイムラインを構築 - 4. 成長速度を分析 + I/O を行わないため、CLI/テスト向けの ``run_pipeline`` と + 進捗通知付きのバックグラウンドワーカーの双方から再利用できる。 """ - logger.info("%s のインテリジェンスパイプラインを開始します", username) - - # ステージ 1: GitHub データを収集 - repos: List[RepoData] = await collect_repos( - username, - token=token, - include_forks=include_forks, - ) - - # 全リポジトリの言語バイト数を集計 lang_totals: Dict[str, int] = defaultdict(int) for repo in repos: for lang, byte_count in repo.languages.items(): @@ -78,18 +59,8 @@ async def run_pipeline( for inf in repo.detected_infras: infra_counts[inf] += 1 - # ステージ 2: スキルを抽出 - extraction: ExtractionResult = extract_skills(repos) - - # ステージ 3: ポジションスコアを算出 - scores: PositionScores = calculate_position_scores(repos) - - logger.info( - "パイプライン完了 (%s): 分析リポジトリ数=%d, ユニークスキル数=%d", - username, - extraction.repos_analyzed, - len(extraction.unique_skills), - ) + extraction = extract_skills(repos) + scores = calculate_position_scores(repos) return IntelligenceResult( username=username, @@ -102,3 +73,34 @@ async def run_pipeline( detected_infras=dict(infra_counts), position_scores=scores, ) + + +@measure_time_async("intelligence.pipeline") +async def run_pipeline( + username: str, + token: Optional[str] = None, + include_forks: bool = False, +) -> IntelligenceResult: + """GitHub ユーザーに対してキャリアインテリジェンスパイプラインを実行する。 + + 1. GitHub API からリポジトリ収集 + 2. ``aggregate_intelligence`` で集計・スキル抽出・スコア算出 + """ + logger.info("%s のインテリジェンスパイプラインを開始します", username) + + repos: List[RepoData] = await collect_repos( + username, + token=token, + include_forks=include_forks, + ) + + result = aggregate_intelligence(username, repos) + + logger.info( + "パイプライン完了 (%s): 分析リポジトリ数=%d, ユニークスキル数=%d", + username, + result.repos_analyzed, + result.unique_skills, + ) + + return result diff --git a/backend/app/services/intelligence/position_scorer.py b/backend/app/services/intelligence/position_scorer.py index 92541973..aa827241 100644 --- a/backend/app/services/intelligence/position_scorer.py +++ b/backend/app/services/intelligence/position_scorer.py @@ -3,6 +3,10 @@ GitHubリポジトリデータから5軸(Backend / Frontend / Fullstack / SRE / Cloud)の 適性スコアを0-100で算出する。 + +スキル分類辞書(言語→スキル、トピック→スキル、フレームワーク→スキル)は +``skill_taxonomy/ownership_map.py`` に集約されており、本モジュールは +スコア計算ロジックと不足スキル判定の関数のみを持つ。 """ import json @@ -12,6 +16,14 @@ from typing import Any, Dict, List, Set from .github_collector import RepoData +from .skill_taxonomy import ( + FILE_SKILL_MAP, + FRAMEWORK_SKILL_MAP, + FRAMEWORK_TO_TOPICS, + LANG_SKILL_MAP, + LANG_SKILL_THRESHOLD, + TOPIC_SKILL_MAP, +) logger = logging.getLogger(__name__) @@ -73,7 +85,7 @@ def calculate_position_scores(repos: List[RepoData]) -> PositionScores: # 検出フレームワークを等価なトピックに展開してスコア算出に反映させる # (リポジトリに topic タグが無くても依存関係から推定できるようにする) for fw in all_frameworks: - all_topics.update(_FRAMEWORK_TO_TOPICS.get(fw, ())) + all_topics.update(FRAMEWORK_TO_TOPICS.get(fw, ())) # 各ポジションのスコア算出 backend = _calc_axis_score("backend", lang_ratios, all_topics, all_files) @@ -146,132 +158,6 @@ def _calc_axis_score( return min(int(raw), 100) -# 言語バイト比率からスキルへのマッピング(1% 以上で保有とみなす) -_LANG_SKILL_MAP: Dict[str, str] = { - "Python": "Python", "Java": "Java", "Go": "Go", - "Rust": "Rust", "Ruby": "Ruby", "PHP": "PHP", - "TypeScript": "TypeScript", "JavaScript": "JavaScript", - "CSS": "CSS", "HTML": "HTML", "SCSS": "CSS", - "HCL": "Terraform", "Shell": "Shell", - "Kotlin": "Kotlin", "C#": "C#", -} - -# GitHub トピックからスキルへのマッピング -_TOPIC_SKILL_MAP: Dict[str, str] = { - "react": "React", "vue": "Vue", "angular": "Angular", - "nextjs": "Next.js", "docker": "Docker", - "kubernetes": "Kubernetes", "k8s": "Kubernetes", - "terraform": "Terraform", "aws": "AWS", - "gcp": "GCP", "azure": "Azure", - "ci-cd": "CI/CD", "graphql": "GraphQL", - "rest": "REST API", "api": "REST API", - "database": "SQL", "sql": "SQL", -} - -# リポジトリルートファイルからスキルへのマッピング -_FILE_SKILL_MAP: Dict[str, str] = { - "Dockerfile": "Docker", - "docker-compose.yml": "Docker", - "docker-compose.yaml": "Docker", - ".github": "CI/CD", - "Jenkinsfile": "CI/CD", - ".gitlab-ci.yml": "CI/CD", - ".circleci": "CI/CD", - "terraform": "Terraform", - ".terraform": "Terraform", -} - -# 検出フレームワーク → position_weights.json で参照されるトピック語のマッピング。 -# リポジトリに topic タグが無いケースでも依存関係から推定できるよう、 -# `_calc_axis_score` の topic 判定に合成的に注入する。 -_FRAMEWORK_TO_TOPICS: Dict[str, tuple] = { - # フロントエンド - "React": ("react", "frontend", "web"), - "React Native": ("react", "frontend"), - "Next.js": ("nextjs", "react", "frontend", "web"), - "Gatsby": ("gatsby", "react", "frontend"), - "Remix": ("remix", "react", "frontend"), - "Vue": ("vue", "frontend", "web"), - "Nuxt.js": ("nuxt", "vue", "frontend"), - "Angular": ("angular", "frontend", "web"), - "Svelte": ("svelte", "frontend"), - "Astro": ("astro", "frontend"), - # バックエンド - "FastAPI": ("fastapi", "api", "backend"), - "Django": ("django", "backend", "api"), - "Flask": ("flask", "backend"), - "Express": ("express", "backend", "api"), - "NestJS": ("nestjs", "backend", "api"), - "Spring Boot": ("spring", "backend", "api"), - "Gin": ("backend", "api"), - "Echo": ("backend", "api"), - "Fiber": ("backend", "api"), - # データベース / API - "PostgreSQL": ("database",), - "MongoDB": ("database",), - "Redis": ("database",), - "GraphQL": ("graphql", "api"), - # インフラ / SRE - "Docker": ("docker",), - "Docker Compose": ("docker",), - "GitHub Actions": ("ci-cd",), - "Jenkins": ("ci-cd",), - "GitLab CI": ("ci-cd",), - "CircleCI": ("ci-cd",), - "Terraform": ("terraform", "iac"), - # クラウド - "AWS": ("aws", "cloud"), - "GCP": ("gcp", "cloud"), - "Azure": ("azure", "cloud"), -} - - -# 検出フレームワークからスキルへのマッピング -_FRAMEWORK_SKILL_MAP: Dict[str, str] = { - "Docker": "Docker", - "Docker Compose": "Docker", - "GitHub Actions": "CI/CD", - "Jenkins": "CI/CD", - "GitLab CI": "CI/CD", - "CircleCI": "CI/CD", - "Terraform": "Terraform", - # フロントエンドフレームワーク - "React": "React", - "React Native": "React", - "Next.js": "React", - "Gatsby": "React", - "Remix": "React", - "Vue": "Vue", - "Nuxt.js": "Vue", - "Angular": "Angular", - "Svelte": "Svelte", - "Astro": "Astro", - # バックエンドフレームワーク - "FastAPI": "REST API", - "Django": "REST API", - "Flask": "REST API", - "Express": "REST API", - "NestJS": "REST API", - "Spring Boot": "REST API", - "Gin": "REST API", - "Echo": "REST API", - "Fiber": "REST API", - # データベース - "PostgreSQL": "SQL", - "MongoDB": "SQL", - "Redis": "SQL", - # クラウド SDK - "AWS": "AWS", - "GCP": "GCP", - "Azure": "Azure", - # GraphQL - "GraphQL": "GraphQL", -} - -# 言語比率がこの閾値以上で「保有」とみなす -_LANG_SKILL_THRESHOLD = 0.01 - - def _detect_owned_skills( lang_ratios: Dict[str, float], topics: Set[str], @@ -281,48 +167,41 @@ def _detect_owned_skills( """ユーザーが保有しているスキルを特定する。""" owned: Set[str] = set() - for lang, skill in _LANG_SKILL_MAP.items(): - if lang_ratios.get(lang, 0) >= _LANG_SKILL_THRESHOLD: + for lang, skill in LANG_SKILL_MAP.items(): + if lang_ratios.get(lang, 0) >= LANG_SKILL_THRESHOLD: owned.add(skill) - for topic, skill in _TOPIC_SKILL_MAP.items(): + for topic, skill in TOPIC_SKILL_MAP.items(): if topic in topics: owned.add(skill) - for fname, skill in _FILE_SKILL_MAP.items(): + for fname, skill in FILE_SKILL_MAP.items(): if fname in files: owned.add(skill) - for fw, skill in _FRAMEWORK_SKILL_MAP.items(): + for fw, skill in FRAMEWORK_SKILL_MAP.items(): if fw in frameworks: owned.add(skill) return owned +# 要件名 → 必要スキル集合(いずれか一つ満たせば OK) +# 中身は ``position_weights.json`` の ``requirement_skill_map`` から構築する。 +_REQUIREMENT_TO_OWNED_SKILLS: Dict[str, Set[str]] = { + req: set(skills) + for req, skills in _WEIGHTS.get("requirement_skill_map", {}).items() +} + + def _find_missing_skills(owned: Set[str]) -> List[str]: """フルスタック要件と保有スキルの差分を算出する。""" requirements = _WEIGHTS.get("fullstack_requirements", {}) missing: List[str] = [] - # 要件チェック用マッピング - # `React or Vue` は SPA フレームワーク系スキルがどれか一つあれば満たすものとする - checks = { - "Python or Java or Go": {"Python", "Java", "Go"}, - "REST API設計": {"REST API", "GraphQL"}, - "DB設計/SQL": {"SQL"}, - "TypeScript": {"TypeScript"}, - "React or Vue": {"React", "Vue", "Angular", "Svelte", "Astro"}, - "HTML/CSS": {"CSS", "HTML"}, - "Docker": {"Docker"}, - "CI/CD": {"CI/CD"}, - "Git workflow": {"CI/CD"}, # GitHub Actions等で代替 - "クラウドサービス(GCP/AWS/Azure)基礎": {"GCP", "AWS", "Azure"}, - } - - for category, req_list in requirements.items(): + for req_list in requirements.values(): for req in req_list: - needed = checks.get(req, set()) + needed = _REQUIREMENT_TO_OWNED_SKILLS.get(req, set()) if needed and not (owned & needed): missing.append(req) diff --git a/backend/app/services/intelligence/position_weights.json b/backend/app/services/intelligence/position_weights.json index b08e0351..9ce0ebc2 100644 --- a/backend/app/services/intelligence/position_weights.json +++ b/backend/app/services/intelligence/position_weights.json @@ -70,5 +70,17 @@ "frontend": ["TypeScript", "React or Vue", "HTML/CSS"], "devops": ["Docker", "CI/CD", "Git workflow"], "cloud": ["クラウドサービス(GCP/AWS/Azure)基礎"] + }, + "requirement_skill_map": { + "Python or Java or Go": ["Python", "Java", "Go"], + "REST API設計": ["REST API", "GraphQL"], + "DB設計/SQL": ["SQL"], + "TypeScript": ["TypeScript"], + "React or Vue": ["React", "Vue", "Angular", "Svelte", "Astro"], + "HTML/CSS": ["CSS", "HTML"], + "Docker": ["Docker"], + "CI/CD": ["CI/CD"], + "Git workflow": ["CI/CD"], + "クラウドサービス(GCP/AWS/Azure)基礎": ["GCP", "AWS", "Azure"] } } diff --git a/backend/app/services/intelligence/skill_extractor.py b/backend/app/services/intelligence/skill_extractor.py index 7ad4973f..9b83f747 100644 --- a/backend/app/services/intelligence/skill_extractor.py +++ b/backend/app/services/intelligence/skill_extractor.py @@ -15,10 +15,8 @@ from dataclasses import dataclass, field from typing import List, Set -from .github_collector import ( - DEPENDENCY_TO_FRAMEWORK, - RepoData, -) +from .github.repo_analyzer import DEPENDENCY_TO_FRAMEWORK +from .github_collector import RepoData from .skill_taxonomy import ( DESCRIPTION_KEYWORDS, LANGUAGE_TO_SKILL, @@ -83,40 +81,31 @@ def _extract_from_repo(repo: RepoData) -> List[ExtractedSkill]: skills: List[ExtractedSkill] = [] seen: Set[str] = set() + def add(skill_name: str, source: str, *, language_bytes: int = 0) -> None: + """スキルを ``skills`` に追加する。同一スキルは 1 度のみ追加。""" + if not skill_name or skill_name in seen: + return + seen.add(skill_name) + skills.append( + ExtractedSkill( + skill_name=skill_name, + category=get_skill_category(skill_name), + source=source, + repo_name=repo.name, + repo_created_at=repo.created_at, + repo_pushed_at=repo.pushed_at, + language_bytes=language_bytes, + ) + ) + # 1. 言語 for lang, byte_count in repo.languages.items(): - skill_name = LANGUAGE_TO_SKILL.get(lang) - if skill_name and skill_name not in seen: - seen.add(skill_name) - skills.append( - ExtractedSkill( - skill_name=skill_name, - category=get_skill_category(skill_name), - source="language", - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - language_bytes=byte_count, - ) - ) + add(LANGUAGE_TO_SKILL.get(lang, ""), "language", language_bytes=byte_count) # 2. トピック for topic in repo.topics: - topic_lower = topic.lower() - matched_skills = TOPIC_TO_SKILLS.get(topic_lower, []) - for skill_name in matched_skills: - if skill_name not in seen: - seen.add(skill_name) - skills.append( - ExtractedSkill( - skill_name=skill_name, - category=get_skill_category(skill_name), - source="topic", - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - ) - ) + for skill_name in TOPIC_TO_SKILLS.get(topic.lower(), []): + add(skill_name, "topic") # 3. 説明文のキーワード if repo.description: @@ -124,48 +113,18 @@ def _extract_from_repo(repo: RepoData) -> List[ExtractedSkill]: for keyword, matched_skills in DESCRIPTION_KEYWORDS.items(): if keyword in desc_lower: for skill_name in matched_skills: - if skill_name not in seen: - seen.add(skill_name) - skills.append( - ExtractedSkill( - skill_name=skill_name, - category=get_skill_category(skill_name), - source="description", - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - ) - ) + add(skill_name, "description") # 4. 依存関係 (requirements.txt, package.json などから解析) for dep in repo.dependencies: - framework = DEPENDENCY_TO_FRAMEWORK.get(dep) - if framework and framework not in seen: - seen.add(framework) - skills.append( - ExtractedSkill( - skill_name=framework, - category=get_skill_category(framework), - source="dependency", - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - ) - ) + add(DEPENDENCY_TO_FRAMEWORK.get(dep, ""), "dependency") # 5. ルートファイル / ディレクトリ検出(devtools・infra) for tech in [*repo.detected_devtools, *repo.detected_infras]: - if tech not in seen: - seen.add(tech) - skills.append( - ExtractedSkill( - skill_name=tech, - category=get_skill_category(tech), - source="root_file", - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - ) - ) + add(tech, "root_file") + + # 6. 依存関係由来のフレームワーク(merge_frameworks の代替) + for framework in repo.detected_frameworks: + add(framework, "root_file") return skills diff --git a/backend/app/services/intelligence/skill_growth_analyzer.py b/backend/app/services/intelligence/skill_growth_analyzer.py deleted file mode 100644 index ff640c4d..00000000 --- a/backend/app/services/intelligence/skill_growth_analyzer.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -スキルの成長速度分析。 - -年次の使用傾向に基づいて、各スキルが台頭中(emerging)、安定(stable)、または衰退中(declining)であるかを検出します。 - -決定論的 — LLMは使用しません。 -""" - -import logging -from dataclasses import dataclass -from enum import Enum -from typing import Dict, List - -from .skill_timeline_builder import SkillTimeline - -logger = logging.getLogger(__name__) - - -class GrowthTrend(str, Enum): - EMERGING = "emerging" - STABLE = "stable" - DECLINING = "declining" - NEW = "new" # 1年分のデータのみ - - -@dataclass -class SkillGrowth: - skill_name: str - category: str - trend: GrowthTrend - velocity: float # 成長率(正 = 成長中) - yearly_usage: Dict[str, int] - first_seen: str - last_seen: str - total_repos: int - - -def analyze_growth( - timelines: List[SkillTimeline], - current_year: str | None = None, -) -> List[SkillGrowth]: - """ - 各スキルの成長速度を分析します。 - - 速度は年次使用量の傾きとして計算されます: - - 正の速度 → 台頭中 (emerging) - - ゼロに近い速度 → 安定 (stable) - - 負の速度 → 衰退中 (declining) - """ - results: List[SkillGrowth] = [] - - for timeline in timelines: - yearly = timeline.yearly_usage - years = sorted(yearly.keys()) - - if len(years) <= 1: - results.append( - SkillGrowth( - skill_name=timeline.skill_name, - category=timeline.category, - trend=GrowthTrend.NEW, - velocity=0.0, - yearly_usage=yearly, - first_seen=timeline.first_seen, - last_seen=timeline.last_seen, - total_repos=timeline.usage_frequency, - ) - ) - continue - - velocity = _calculate_velocity(yearly) - trend = _classify_trend(velocity, yearly, current_year) - - results.append( - SkillGrowth( - skill_name=timeline.skill_name, - category=timeline.category, - trend=trend, - velocity=round(velocity, 3), - yearly_usage=yearly, - first_seen=timeline.first_seen, - last_seen=timeline.last_seen, - total_repos=timeline.usage_frequency, - ) - ) - - results.sort(key=lambda g: g.velocity, reverse=True) - logger.info( - "Growth analysis: %d emerging, %d stable, %d declining, %d new", - sum(1 for g in results if g.trend == GrowthTrend.EMERGING), - sum(1 for g in results if g.trend == GrowthTrend.STABLE), - sum(1 for g in results if g.trend == GrowthTrend.DECLINING), - sum(1 for g in results if g.trend == GrowthTrend.NEW), - ) - return results - - -def _calculate_velocity(yearly_usage: Dict[str, int]) -> float: - """ - 線形回回帰の傾きを使用して成長速度を計算します。 - - 年を連続したインデックス (0, 1, 2, ...) にマッピングし、 - 最小二乗法による傾きを算出します。 - """ - years = sorted(yearly_usage.keys()) - n = len(years) - if n < 2: - return 0.0 - - x_vals = list(range(n)) - y_vals = [yearly_usage[y] for y in years] - - x_mean = sum(x_vals) / n - y_mean = sum(y_vals) / n - - numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_vals, y_vals)) - denominator = sum((x - x_mean) ** 2 for x in x_vals) - - if denominator == 0: - return 0.0 - - return numerator / denominator - - -def _classify_trend( - velocity: float, - yearly_usage: Dict[str, int], - current_year: str | None = None, -) -> GrowthTrend: - """ - 速度と新近性に基づいて成長傾向を分類します。 - - 正の速度を持つスキルは台頭中 (emerging) です。 - 負の速度を持つスキルは衰退中 (declining) です。 - ゼロに近い速度は安定 (stable) です。 - - 例外:最近(過去2年間)使用されていないスキルは、 - 速度に関わらず衰退中 (declining) と見なされます。 - """ - years = sorted(yearly_usage.keys()) - last_year = years[-1] if years else "0" - - # 最近使用されていない場合は衰退中と見なす - if current_year: - gap = int(current_year) - int(last_year) - if gap >= 2: - return GrowthTrend.DECLINING - - # 「ゼロに近い」と見なす閾値 - threshold = 0.3 - - if velocity > threshold: - return GrowthTrend.EMERGING - elif velocity < -threshold: - return GrowthTrend.DECLINING - else: - return GrowthTrend.STABLE diff --git a/backend/app/services/intelligence/skill_taxonomy/__init__.py b/backend/app/services/intelligence/skill_taxonomy/__init__.py index c935132b..61d5806c 100644 --- a/backend/app/services/intelligence/skill_taxonomy/__init__.py +++ b/backend/app/services/intelligence/skill_taxonomy/__init__.py @@ -10,13 +10,27 @@ ) from .keyword_map import DESCRIPTION_KEYWORDS from .language_map import LANGUAGE_TO_SKILL +from .ownership_map import ( + FILE_SKILL_MAP, + FRAMEWORK_SKILL_MAP, + FRAMEWORK_TO_TOPICS, + LANG_SKILL_MAP, + LANG_SKILL_THRESHOLD, + TOPIC_SKILL_MAP, +) from .topic_map import TOPIC_TO_SKILLS __all__ = [ - "SKILL_CATEGORIES", + "DESCRIPTION_KEYWORDS", + "FILE_SKILL_MAP", + "FRAMEWORK_SKILL_MAP", + "FRAMEWORK_TO_TOPICS", "LANGUAGE_TO_SKILL", + "LANG_SKILL_MAP", + "LANG_SKILL_THRESHOLD", + "SKILL_CATEGORIES", + "TOPIC_SKILL_MAP", "TOPIC_TO_SKILLS", - "DESCRIPTION_KEYWORDS", - "get_skill_category", "get_all_skills", + "get_skill_category", ] diff --git a/backend/app/services/intelligence/skill_taxonomy/ownership_map.py b/backend/app/services/intelligence/skill_taxonomy/ownership_map.py new file mode 100644 index 00000000..2b652bff --- /dev/null +++ b/backend/app/services/intelligence/skill_taxonomy/ownership_map.py @@ -0,0 +1,133 @@ +"""スキル「所有」判定のためのマッピング定義。 + +GitHub リポジトリの言語比率・トピック・ルートファイル・検出フレームワークから +ユーザーが保有しているスキルを特定する際に参照する辞書群を集約する。 + +position_scorer の ``_detect_owned_skills`` と ``_find_missing_skills`` の入力で利用される。 +""" + +from typing import Dict, Tuple + +# 言語バイト比率からスキルへのマッピング(``LANG_SKILL_THRESHOLD`` 以上で保有とみなす) +LANG_SKILL_MAP: Dict[str, str] = { + "Python": "Python", "Java": "Java", "Go": "Go", + "Rust": "Rust", "Ruby": "Ruby", "PHP": "PHP", + "TypeScript": "TypeScript", "JavaScript": "JavaScript", + "CSS": "CSS", "HTML": "HTML", "SCSS": "CSS", + "HCL": "Terraform", "Shell": "Shell", + "Kotlin": "Kotlin", "C#": "C#", +} + +# GitHub トピックからスキルへのマッピング(単一スキル) +TOPIC_SKILL_MAP: Dict[str, str] = { + "react": "React", "vue": "Vue", "angular": "Angular", + "nextjs": "Next.js", "docker": "Docker", + "kubernetes": "Kubernetes", "k8s": "Kubernetes", + "terraform": "Terraform", "aws": "AWS", + "gcp": "GCP", "azure": "Azure", + "ci-cd": "CI/CD", "graphql": "GraphQL", + "rest": "REST API", "api": "REST API", + "database": "SQL", "sql": "SQL", +} + +# リポジトリルートファイルからスキルへのマッピング +FILE_SKILL_MAP: Dict[str, str] = { + "Dockerfile": "Docker", + "docker-compose.yml": "Docker", + "docker-compose.yaml": "Docker", + ".github": "CI/CD", + "Jenkinsfile": "CI/CD", + ".gitlab-ci.yml": "CI/CD", + ".circleci": "CI/CD", + "terraform": "Terraform", + ".terraform": "Terraform", +} + +# 検出フレームワーク → 代表スキル名のマッピング +FRAMEWORK_SKILL_MAP: Dict[str, str] = { + "Docker": "Docker", + "Docker Compose": "Docker", + "GitHub Actions": "CI/CD", + "Jenkins": "CI/CD", + "GitLab CI": "CI/CD", + "CircleCI": "CI/CD", + "Terraform": "Terraform", + # フロントエンドフレームワーク + "React": "React", + "React Native": "React", + "Next.js": "React", + "Gatsby": "React", + "Remix": "React", + "Vue": "Vue", + "Nuxt.js": "Vue", + "Angular": "Angular", + "Svelte": "Svelte", + "Astro": "Astro", + # バックエンドフレームワーク + "FastAPI": "REST API", + "Django": "REST API", + "Flask": "REST API", + "Express": "REST API", + "NestJS": "REST API", + "Spring Boot": "REST API", + "Gin": "REST API", + "Echo": "REST API", + "Fiber": "REST API", + # データベース + "PostgreSQL": "SQL", + "MongoDB": "SQL", + "Redis": "SQL", + # クラウド SDK + "AWS": "AWS", + "GCP": "GCP", + "Azure": "Azure", + # GraphQL + "GraphQL": "GraphQL", +} + +# 検出フレームワーク → position_weights.json で参照されるトピック語のマッピング。 +# リポジトリに topic タグが無いケースでも依存関係から推定できるよう、 +# position_scorer の topic 判定に合成的に注入する。 +FRAMEWORK_TO_TOPICS: Dict[str, Tuple[str, ...]] = { + # フロントエンド + "React": ("react", "frontend", "web"), + "React Native": ("react", "frontend"), + "Next.js": ("nextjs", "react", "frontend", "web"), + "Gatsby": ("gatsby", "react", "frontend"), + "Remix": ("remix", "react", "frontend"), + "Vue": ("vue", "frontend", "web"), + "Nuxt.js": ("nuxt", "vue", "frontend"), + "Angular": ("angular", "frontend", "web"), + "Svelte": ("svelte", "frontend"), + "Astro": ("astro", "frontend"), + # バックエンド + "FastAPI": ("fastapi", "api", "backend"), + "Django": ("django", "backend", "api"), + "Flask": ("flask", "backend"), + "Express": ("express", "backend", "api"), + "NestJS": ("nestjs", "backend", "api"), + "Spring Boot": ("spring", "backend", "api"), + "Gin": ("backend", "api"), + "Echo": ("backend", "api"), + "Fiber": ("backend", "api"), + # データベース / API + "PostgreSQL": ("database",), + "MongoDB": ("database",), + "Redis": ("database",), + "GraphQL": ("graphql", "api"), + # インフラ / SRE + "Docker": ("docker",), + "Docker Compose": ("docker",), + "GitHub Actions": ("ci-cd",), + "Jenkins": ("ci-cd",), + "GitLab CI": ("ci-cd",), + "CircleCI": ("ci-cd",), + "Terraform": ("terraform", "iac"), + # クラウド + "AWS": ("aws", "cloud"), + "GCP": ("gcp", "cloud"), + "Azure": ("azure", "cloud"), +} + +# 言語比率がこの閾値以上で「保有」とみなす +LANG_SKILL_THRESHOLD = 0.01 diff --git a/backend/app/services/intelligence/skill_timeline_builder.py b/backend/app/services/intelligence/skill_timeline_builder.py deleted file mode 100644 index 6413cbaa..00000000 --- a/backend/app/services/intelligence/skill_timeline_builder.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -抽出されたスキルデータからスキルタイムラインを構築します。 - -各スキルが最初にいつ現れ、時間の経過とともに使用状況がどのように変化したかを追跡します。 -決定論的 — LLMは使用しません。 -""" - -import logging -from collections import defaultdict -from dataclasses import dataclass -from typing import Dict, List, Set - -from .skill_extractor import ExtractedSkill, ExtractionResult - -logger = logging.getLogger(__name__) - - -@dataclass -class SkillTimeline: - skill_name: str - category: str - first_seen: str # 年の文字列、例: "2019" - last_seen: str # 年の文字列、例: "2024" - usage_frequency: int # 合計リポジトリ数 - repositories: List[str] - yearly_usage: Dict[str, int] # 年 → リポジトリ数 - - -@dataclass -class YearSnapshot: - """特定の年にアクティブだったスキル。""" - - year: str - skills: List[str] - new_skills: List[str] # 初めて現れたスキル - - -def build_timeline( - extraction: ExtractionResult, -) -> List[SkillTimeline]: - """ - 抽出結果からスキルタイムラインを構築します。 - - スキル名を基準にスキルの観測結果をグループ化し、初出/最終確認日、 - および年ごとの使用頻度を計算します。 - """ - # スキルごとに観測結果をグループ化 - skill_data: Dict[str, List[ExtractedSkill]] = defaultdict(list) - for obs in extraction.skills: - skill_data[obs.skill_name].append(obs) - - timelines: List[SkillTimeline] = [] - - for skill_name, observations in skill_data.items(): - yearly: Dict[str, Set[str]] = defaultdict(set) - all_repos: Set[str] = set() - category = observations[0].category - - for obs in observations: - year = _extract_year(obs.repo_created_at) - if year: - yearly[year].add(obs.repo_name) - all_repos.add(obs.repo_name) - - # pushed_at の年もアクティビティとしてカウント - push_year = _extract_year(obs.repo_pushed_at) - if push_year and push_year != year: - yearly[push_year].add(obs.repo_name) - - years = sorted(yearly.keys()) - if not years: - continue - - yearly_usage = {y: len(repos) for y, repos in sorted(yearly.items())} - - timelines.append( - SkillTimeline( - skill_name=skill_name, - category=category, - first_seen=years[0], - last_seen=years[-1], - usage_frequency=len(all_repos), - repositories=sorted(all_repos), - yearly_usage=yearly_usage, - ) - ) - - timelines.sort(key=lambda t: (t.first_seen, -t.usage_frequency)) - logger.info("Built timeline for %d skills", len(timelines)) - return timelines - - -def build_year_snapshots( - timelines: List[SkillTimeline], -) -> List[YearSnapshot]: - """ - 年ごとのアクティブなスキルを示す年次スナップショットを構築します。 - - 視覚化に役立ちます。 - """ - # すべての年を収集 - all_years: Set[str] = set() - for t in timelines: - all_years.update(t.yearly_usage.keys()) - - # スキルごとの初出を追跡 - first_seen_map: Dict[str, str] = {t.skill_name: t.first_seen for t in timelines} - - snapshots: List[YearSnapshot] = [] - for year in sorted(all_years): - active = [t.skill_name for t in timelines if year in t.yearly_usage] - new = [s for s in active if first_seen_map.get(s) == year] - snapshots.append( - YearSnapshot( - year=year, - skills=sorted(active), - new_skills=sorted(new), - ) - ) - - return snapshots - - -def _extract_year(iso_date: str) -> str | None: - """ISO 8601 形式の日付文字列から年を抽出します。""" - if not iso_date or len(iso_date) < 4: - return None - return iso_date[:4] diff --git a/backend/app/services/progress_service.py b/backend/app/services/progress_service.py index 8b94514d..5201d8f9 100644 --- a/backend/app/services/progress_service.py +++ b/backend/app/services/progress_service.py @@ -7,7 +7,7 @@ _KEY_PREFIX = "devforge:progress:" _TTL_SECONDS = 3600 -_GITHUB_ANALYSIS_TOTAL_STEPS = 6 +_GITHUB_ANALYSIS_TOTAL_STEPS = 5 async def set_progress( diff --git a/backend/app/services/tasks/__init__.py b/backend/app/services/tasks/__init__.py index db9962f7..cd22b24a 100644 --- a/backend/app/services/tasks/__init__.py +++ b/backend/app/services/tasks/__init__.py @@ -1,13 +1,26 @@ """バックグラウンドタスクディスパッチャー。""" -from .base import TaskDispatcher, TaskType +from .base import ( + IN_PROGRESS_STATUSES, + RETRYABLE_TERMINAL_STATUSES, + TaskDispatcher, + TaskType, + is_in_progress, + is_retryable_terminal, +) +from .dispatch_service import AsyncTaskCacheService from .exceptions import NonRetryableError, RetryableError from .factory import get_task_dispatcher __all__ = [ + "IN_PROGRESS_STATUSES", + "RETRYABLE_TERMINAL_STATUSES", + "AsyncTaskCacheService", "NonRetryableError", "RetryableError", "TaskDispatcher", "TaskType", "get_task_dispatcher", + "is_in_progress", + "is_retryable_terminal", ] diff --git a/backend/app/services/tasks/base.py b/backend/app/services/tasks/base.py index f0376713..b79b6478 100644 --- a/backend/app/services/tasks/base.py +++ b/backend/app/services/tasks/base.py @@ -12,6 +12,25 @@ class TaskType(str, Enum): CAREER_ANALYSIS = "career_analysis" +# 手動再実行を許可するキャッシュレコードのステータス集合。 +# `dead_letter`: リトライ枯渇またはリトライ不可エラーで停止した最終状態。 +RETRYABLE_TERMINAL_STATUSES: frozenset[str] = frozenset({"dead_letter"}) + + +# 進行中(pending / processing)とみなすキャッシュレコードのステータス集合。 +IN_PROGRESS_STATUSES: frozenset[str] = frozenset({"pending", "processing"}) + + +def is_retryable_terminal(status: str | None) -> bool: + """status が手動再実行可能な終端状態かどうかを返す。""" + return status in RETRYABLE_TERMINAL_STATUSES + + +def is_in_progress(status: str | None) -> bool: + """status が進行中(pending / processing)かどうかを返す。""" + return status in IN_PROGRESS_STATUSES + + class TaskDispatcher(ABC): """タスクをバックグラウンドにディスパッチする共通インターフェース。""" diff --git a/backend/app/services/tasks/dispatch_service.py b/backend/app/services/tasks/dispatch_service.py new file mode 100644 index 00000000..4ca5bdef --- /dev/null +++ b/backend/app/services/tasks/dispatch_service.py @@ -0,0 +1,98 @@ +"""非同期タスクのキャッシュレコード操作とディスパッチを共通化するサービス。 + +3つの非同期タスク(GitHub 分析 / ブログサマリ / キャリア分析)はいずれも +``status`` / ``error_message`` / ``retry_count`` / ``started_at`` / ``completed_at`` +を持つキャッシュレコードに紐づき、以下のフローを取る: + + 1. 進行中タスクの有無を確認 + 2. キャッシュを ``pending`` にリセット(再実行時は ``retry_count`` も 0 へ) + 3. ディスパッチ + 4. ディスパッチ失敗時に ``dead_letter`` へ遷移 + +この共通フローを ``AsyncTaskCacheService`` に集約することで、ルーター層から +キャッシュ状態管理の重複コードを排除する。 +""" + +import logging +from typing import Protocol + +from fastapi import BackgroundTasks +from sqlalchemy.orm import Session + +from .base import TaskType, is_in_progress, is_retryable_terminal +from .factory import get_task_dispatcher + + +class _AsyncTaskRecord(Protocol): + """status / error_message / retry_count などを持つキャッシュレコードの構造的型。 + + GitHubAnalysisCache / BlogSummaryCache / CareerAnalysis が満たす。 + """ + + status: str + error_message: str | None + retry_count: int + started_at: object + completed_at: object + + +class AsyncTaskCacheService: + """非同期タスクのキャッシュレコードに対する状態遷移とディスパッチを担う。 + + ルーター層は本サービスを介してのみキャッシュレコードの ``status`` を更新する。 + (worker 側の状態遷移は ``services/tasks/worker.py`` が担う) + """ + + def __init__(self, db: Session, record: _AsyncTaskRecord) -> None: + self._db = db + self._record = record + + @property + def status(self) -> str: + return self._record.status + + def is_in_progress(self) -> bool: + """``pending`` / ``processing`` の進行中状態かどうかを返す。""" + return is_in_progress(self._record.status) + + def is_retryable_terminal(self) -> bool: + """``dead_letter`` 等、手動再実行可能な終端状態かどうかを返す。""" + return is_retryable_terminal(self._record.status) + + def reset_to_pending(self, *, reset_retry_count: bool = False) -> None: + """キャッシュを ``pending`` 状態にリセットする。 + + ``reset_retry_count=True`` を指定するとリトライカウントもリセットする + (手動再実行時に使用)。 + """ + self._record.status = "pending" + self._record.error_message = None + if reset_retry_count: + self._record.retry_count = 0 + self._record.started_at = None + self._record.completed_at = None + self._db.commit() + + async def dispatch( + self, + background_tasks: BackgroundTasks, + task_type: TaskType, + payload: dict, + *, + failure_message: str, + logger: logging.Logger, + ) -> None: + """タスクをディスパッチし、失敗時はキャッシュを ``dead_letter`` に遷移させる。 + + 例外は呼び出し側でハンドリングするため再 raise する。 + ルーター層は本メソッドが投げた例外を捕捉して HTTP 5xx に変換すること。 + """ + try: + dispatcher = get_task_dispatcher(background_tasks) + await dispatcher.dispatch(task_type, payload) + except Exception: + logger.exception("%s タスクのディスパッチに失敗しました", task_type.value) + self._record.status = "dead_letter" + self._record.error_message = failure_message + self._db.commit() + raise diff --git a/backend/app/services/tasks/handlers/__init__.py b/backend/app/services/tasks/handlers/__init__.py new file mode 100644 index 00000000..d0ce4fc9 --- /dev/null +++ b/backend/app/services/tasks/handlers/__init__.py @@ -0,0 +1,30 @@ +"""バックグラウンドタスクのハンドラレジストリ。 + +各タスク種別ごとに ``TaskHandler`` を実装し、ここで登録する。 +worker は本レジストリを介してタスクを実行する。 +""" + +from typing import Dict + +from ..base import TaskType +from .base import TaskHandler +from .blog_summarize import BlogSummarizeHandler +from .career_analysis import CareerAnalysisHandler +from .github_analysis import GitHubAnalysisHandler + +_HANDLERS: Dict[TaskType, TaskHandler] = { + TaskType.GITHUB_ANALYSIS: GitHubAnalysisHandler(), + TaskType.BLOG_SUMMARIZE: BlogSummarizeHandler(), + TaskType.CAREER_ANALYSIS: CareerAnalysisHandler(), +} + + +def get_handler(task_type: TaskType) -> TaskHandler | None: + """タスク種別からハンドラを取得する。未登録の場合は ``None`` を返す。""" + return _HANDLERS.get(task_type) + + +__all__ = [ + "TaskHandler", + "get_handler", +] diff --git a/backend/app/services/tasks/handlers/base.py b/backend/app/services/tasks/handlers/base.py new file mode 100644 index 00000000..4023cac6 --- /dev/null +++ b/backend/app/services/tasks/handlers/base.py @@ -0,0 +1,26 @@ +"""タスクハンドラの抽象基底クラス。 + +各タスク種別は ``TaskHandler`` を継承し、以下を実装する: + - ``get_record(db, payload)``: payload から対応するキャッシュレコードを取得 + - ``run(db, payload)``: タスク本体の実行 + +レコード状態遷移(``processing`` / ``completed`` / ``dead_letter`` / ``retrying``)は +worker 側で本基底クラスが提供する共通ロジックを通じて行う。 +""" + +from abc import ABC, abstractmethod +from typing import Any + +from sqlalchemy.orm import Session + + +class TaskHandler(ABC): + """非同期タスクの実行とレコード取得を担う抽象基底クラス。""" + + @abstractmethod + def get_record(self, db: Session, payload: dict) -> Any | None: + """payload からキャッシュレコードを取得する(status / error_message 等の更新対象)。""" + + @abstractmethod + async def run(self, db: Session, payload: dict) -> None: + """タスク本体を実行する。状態遷移は呼び出し側 (worker) が担う。""" diff --git a/backend/app/services/tasks/handlers/blog_summarize.py b/backend/app/services/tasks/handlers/blog_summarize.py new file mode 100644 index 00000000..92ef0190 --- /dev/null +++ b/backend/app/services/tasks/handlers/blog_summarize.py @@ -0,0 +1,89 @@ +"""ブログ AI サマリタスクのハンドラ。""" + +from datetime import datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from ....core.logging_utils import get_logger +from ....models import BlogSummaryCache +from ....repositories import BlogArticleRepository +from .base import TaskHandler + +logger = get_logger(__name__) + + +# サマリ結果の保持期間(DB 取得時にこの期間を過ぎていれば破棄して再生成を促す) +_SUMMARY_TTL = timedelta(days=7) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class BlogSummarizeHandler(TaskHandler): + """ブログ記事の AI サマリ生成タスク。""" + + def get_record(self, db: Session, payload: dict) -> BlogSummaryCache | None: + user_id = payload.get("user_id") + if not user_id: + return None + return db.query(BlogSummaryCache).filter_by(user_id=user_id).first() + + async def run(self, db: Session, payload: dict) -> None: + from ...intelligence.llm import get_llm_client + from ...intelligence.llm_summarizer import summarize_blog_articles + + user_id = payload["user_id"] + cache = self.get_record(db, payload) + if not cache: + logger.error("ブログサマリキャッシュが見つかりません", extra={"user_id": user_id}) + return + + cache.status = "processing" + cache.started_at = _now() + db.commit() + + # 記事は payload ではなく DB から取得する(GET /api/blog/articles と同じソース) + article_rows = BlogArticleRepository(db, user_id).list_by_user() + if not article_rows: + cache.status = "dead_letter" + cache.error_message = "分析対象の記事がありません" + cache.completed_at = _now() + db.commit() + return + + articles_data = [ + { + "title": art.title, + "url": art.url, + "published_at": art.published_at, + "likes_count": art.likes_count, + "summary": art.summary, + "tags": art.tags, + "platform": art.platform, + } + for art in article_rows + ] + + llm_client = get_llm_client() + if not await llm_client.check_available(): + cache.status = "dead_letter" + cache.error_message = "LLM サービスが利用できません" + cache.completed_at = _now() + db.commit() + return + + summary = await summarize_blog_articles(articles_data) + if not summary: + cache.status = "dead_letter" + cache.error_message = "LLM処理が利用できません" + cache.completed_at = _now() + db.commit() + return + + cache.summary = summary + cache.status = "completed" + cache.error_message = None + cache.completed_at = _now() + cache.expires_at = _now() + _SUMMARY_TTL + db.commit() diff --git a/backend/app/services/tasks/handlers/career_analysis.py b/backend/app/services/tasks/handlers/career_analysis.py new file mode 100644 index 00000000..fc693ad0 --- /dev/null +++ b/backend/app/services/tasks/handlers/career_analysis.py @@ -0,0 +1,65 @@ +"""AI キャリア分析タスクのハンドラ。""" + +import json +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ....core.logging_utils import get_logger +from ....models.career_analysis import CareerAnalysis +from .base import TaskHandler + +logger = get_logger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class CareerAnalysisHandler(TaskHandler): + """AI キャリアパス分析タスク。""" + + def get_record(self, db: Session, payload: dict) -> CareerAnalysis | None: + user_id = payload.get("user_id") + record_id = payload.get("record_id") + if not user_id or not record_id: + return None + return db.query(CareerAnalysis).filter_by(id=record_id, user_id=user_id).first() + + async def run(self, db: Session, payload: dict) -> None: + from ...career_analysis.builder import build_career_analysis + from ...intelligence.llm import get_llm_client + + user_id = payload["user_id"] + record_id = payload["record_id"] + target_position = payload["target_position"] + + analysis = db.query(CareerAnalysis).filter_by(id=record_id, user_id=user_id).first() + if not analysis: + logger.error("キャリア分析レコードが見つかりません", extra={"record_id": record_id}) + return + + analysis.status = "processing" + analysis.started_at = _now() + db.commit() + + llm_client = get_llm_client() + try: + result = await build_career_analysis( + db=db, + user_id=user_id, + target_position=target_position, + llm_client=llm_client, + ) + except ValueError as exc: + analysis.status = "dead_letter" + analysis.error_message = str(exc) + analysis.completed_at = _now() + db.commit() + raise exc + + analysis.result_json = json.dumps(result, ensure_ascii=False) + analysis.status = "completed" + analysis.error_message = None + analysis.completed_at = _now() + db.commit() diff --git a/backend/app/services/tasks/handlers/github_analysis.py b/backend/app/services/tasks/handlers/github_analysis.py new file mode 100644 index 00000000..e591b3d8 --- /dev/null +++ b/backend/app/services/tasks/handlers/github_analysis.py @@ -0,0 +1,22 @@ +"""GitHub 分析タスクのハンドラ。""" + +from sqlalchemy.orm import Session + +from ....models import GitHubAnalysisCache +from .base import TaskHandler + + +class GitHubAnalysisHandler(TaskHandler): + """GitHub リポジトリ分析タスク。""" + + def get_record(self, db: Session, payload: dict) -> GitHubAnalysisCache | None: + user_id = payload.get("user_id") + if not user_id: + return None + return db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() + + async def run(self, db: Session, payload: dict) -> None: + # 循環インポート回避のため遅延 import する + from ...intelligence.github_analysis_service import run_github_analysis + + await run_github_analysis(db, payload) diff --git a/backend/app/services/tasks/worker.py b/backend/app/services/tasks/worker.py index a3db2abf..cb5e9315 100644 --- a/backend/app/services/tasks/worker.py +++ b/backend/app/services/tasks/worker.py @@ -2,25 +2,23 @@ ローカル: BackgroundTasks から直接呼ばれる(retry_count=0, max_attempts=1 でリトライなし)。 Cloud: /internal/tasks/{type} エンドポイント経由で呼ばれる(Cloud Tasks ネイティブリトライ)。 -どちらも同じ関数を実行する。 +どちらも同じ ``execute_task`` を経由してハンドラレジストリにディスパッチする。 + +タスク種別ごとの実体は ``services/tasks/handlers/`` 配下に分離されている。worker は +リトライ・dead_letter・通知などのタスク横断ロジックのみを担う。 """ -import json -import time -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from sqlalchemy.orm import Session from ...core.logging_utils import get_logger from ...core.messages import get_notification from ...db.database import SessionLocal -from ...models import BlogSummaryCache, GitHubAnalysisCache -from ...models.career_analysis import CareerAnalysis -from ...repositories import BlogArticleRepository from ...repositories.notification import NotificationRepository -from ...services.intelligence.llm import get_llm_client from .base import TaskType from .exceptions import NonRetryableError +from .handlers import get_handler logger = get_logger(__name__) @@ -29,6 +27,11 @@ _SLOW_TASK_THRESHOLD_MS = 300_000 +def _monotonic_ms_since(start: float) -> int: + """``time.monotonic()`` の開始時点からの経過ミリ秒を返す。""" + import time + return int((time.monotonic() - start) * 1000) + async def execute_task( task_type: TaskType, @@ -45,6 +48,8 @@ async def execute_task( ローカル(BackgroundTasks)呼び出しではデフォルトの ``retry_count=0, max_attempts=1`` を使い、 失敗時は即座に ``dead_letter`` へ遷移する(ローカルはネイティブリトライが無いため)。 """ + import time + user_id = payload.get("user_id", "unknown") record_id = payload.get("record_id") start = time.monotonic() @@ -61,19 +66,22 @@ async def execute_task( }, ) + if get_handler(task_type) is None: + logger.error("不明なタスク種別: %s", task_type) + return + db = SessionLocal() try: + # 各 ``_run_*`` シムはテストからの patch ポイントとして残している。 + # 実体は ``services/tasks/handlers/`` 配下のハンドラ。 if task_type == TaskType.GITHUB_ANALYSIS: await _run_github_analysis(db, payload) elif task_type == TaskType.BLOG_SUMMARIZE: await _run_blog_summarize(db, payload) elif task_type == TaskType.CAREER_ANALYSIS: await _run_career_analysis(db, payload) - else: - logger.error("不明なタスク種別: %s", task_type) - return - duration_ms = int((time.monotonic() - start) * 1000) + duration_ms = _monotonic_ms_since(start) logger.info( "タスク完了", extra={ @@ -94,7 +102,7 @@ async def execute_task( if isinstance(user_id, str) and user_id != "unknown": _create_notification(db, task_type, user_id, "completed") except NonRetryableError as exc: - duration_ms = int((time.monotonic() - start) * 1000) + duration_ms = _monotonic_ms_since(start) logger.warning( "タスク失敗(リトライ不可)", extra={ @@ -114,7 +122,7 @@ async def execute_task( _create_notification(db, task_type, user_id, "failed") raise except Exception as exc: - duration_ms = int((time.monotonic() - start) * 1000) + duration_ms = _monotonic_ms_since(start) is_final = retry_count >= max_attempts - 1 if is_final: logger.error( @@ -161,252 +169,30 @@ def _now() -> datetime: return datetime.now(timezone.utc) -# ---------- GitHub 分析 ---------- +# ---------- ハンドラ薄ラッパー(後方互換)---------- +# テストや既存呼び出しから ``_run_github_analysis`` / ``_run_blog_summarize`` / +# ``_run_career_analysis`` を直接呼べるよう、ハンドラ実装への薄いシムを残す。 async def _run_github_analysis(db: Session, payload: dict) -> None: - """GitHub 分析パイプラインを実行し、AI 学習アドバイスまで一括生成してキャッシュに保存する。""" - from collections import defaultdict - from datetime import datetime - - from ...core.encryption import decrypt_field - from ...services.intelligence.github_collector import ( - GitHubUserNotFoundError, - collect_repos, - ) - from ...services.intelligence.pipeline import IntelligenceResult - from ...services.intelligence.position_scorer import calculate_position_scores - from ...services.intelligence.response_mapper import map_pipeline_result - from ...services.intelligence.skill_extractor import extract_skills - from ...services.progress_service import set_progress - - _TOTAL_STEPS = 6 - user_id = payload["user_id"] - task_id = user_id - - cache = db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() - if not cache: - logger.error("GitHub 分析キャッシュが見つかりません", extra={"user_id": user_id}) - return - - cache.status = "processing" - cache.started_at = _now() - db.commit() - - token = decrypt_field(payload["github_token"]) if payload.get("github_token") else None - - try: - # ステップ 1: リポジトリ一覧取得 - await set_progress(task_id, 1, _TOTAL_STEPS, "リポジトリ一覧取得中...") - - async def _on_repo_fetched(done: int, total: int) -> None: - await set_progress( - task_id, - 2, - _TOTAL_STEPS, - "リポジトリ詳細取得中...", - sub_progress={"done": done, "total": total}, - ) - - repos = await collect_repos( - username=payload["github_username"], - token=token, - include_forks=payload.get("include_forks", False), - on_repo_fetched=_on_repo_fetched, - ) - except GitHubUserNotFoundError as exc: - cache.status = "dead_letter" - cache.error_message = f"GitHubユーザーが見つかりません: {payload['github_username']}" - cache.completed_at = _now() - db.commit() - raise exc - - # ステップ 3: スキル抽出 - await set_progress(task_id, 3, _TOTAL_STEPS, "スキル抽出中...") - extraction = extract_skills(repos) - - lang_totals: dict = defaultdict(int) - for repo in repos: - for lang, byte_count in repo.languages.items(): - lang_totals[lang] += byte_count - - # 全リポジトリのフレームワーク・DevTools・インフラをリポジトリ数でカウント - framework_counts: dict[str, int] = defaultdict(int) - devtool_counts: dict[str, int] = defaultdict(int) - infra_counts: dict[str, int] = defaultdict(int) - for repo in repos: - for fw in repo.detected_frameworks: - framework_counts[fw] += 1 - for dt in repo.detected_devtools: - devtool_counts[dt] += 1 - for inf in repo.detected_infras: - infra_counts[inf] += 1 - - # ステップ 4: スコア算出 - await set_progress(task_id, 4, _TOTAL_STEPS, "スコア算出中...") - scores = calculate_position_scores(repos) - - result = IntelligenceResult( - username=payload["github_username"], - repos_analyzed=extraction.repos_analyzed, - unique_skills=len(extraction.unique_skills), - analyzed_at=datetime.now().isoformat(), - languages=dict(lang_totals), - detected_frameworks=dict(framework_counts), - detected_devtools=dict(devtool_counts), - detected_infras=dict(infra_counts), - position_scores=scores, - ) - - response = map_pipeline_result(result) - analysis_dict = response.model_dump() - cache.analysis_result = analysis_dict - - # LLM が利用可能なら学習アドバイスも自動生成する - advice, llm_failed = await _generate_advice_if_available(analysis_dict) - cache.position_advice = advice - - # ステップ 5: DB 保存 - await set_progress(task_id, 5, _TOTAL_STEPS, "結果を保存中...") - cache.status = "completed" - cache.error_message = "LLM処理が利用できません" if llm_failed else None - cache.completed_at = _now() - db.commit() - - # ステップ 6: 完了 - await set_progress(task_id, 6, _TOTAL_STEPS, "完了") - - -async def _generate_advice_if_available(analysis: dict) -> tuple[str | None, bool]: - """LLM が利用可能であれば学習アドバイスを生成する。 - - 戻り値は (advice, llm_failed) のタプル。 - llm_failed=True は LLM の呼び出しを試みたが失敗したことを示す。 - LLM が未設定またはスコア情報がない場合は llm_failed=False でスキップする。 - """ - from ...services.intelligence.llm_summarizer import generate_learning_advice - - try: - llm_client = get_llm_client() - if not await llm_client.check_available(): - logger.info("LLM が利用できないため学習アドバイスの生成をスキップしました") - return None, False - - scores = analysis.get("position_scores") - if not scores: - return None, False - - advice = await generate_learning_advice(analysis, scores) - if advice is None: - logger.warning("LLM が学習アドバイスの生成に失敗しました") - return None, True - return advice, False - except Exception: - logger.warning("学習アドバイスの生成に失敗しましたが、分析結果は保存します", exc_info=True) - return None, True - - -# ---------- ブログ AI サマリ ---------- + """GitHub 分析ハンドラへのシム。""" + handler = get_handler(TaskType.GITHUB_ANALYSIS) + assert handler is not None + await handler.run(db, payload) async def _run_blog_summarize(db: Session, payload: dict) -> None: - """ブログ記事の AI サマリを生成し、キャッシュに保存する。""" - from ...services.intelligence.llm_summarizer import summarize_blog_articles - - user_id = payload["user_id"] - cache = db.query(BlogSummaryCache).filter_by(user_id=user_id).first() - if not cache: - logger.error("ブログサマリキャッシュが見つかりません", extra={"user_id": user_id}) - return - - cache.status = "processing" - cache.started_at = _now() - db.commit() - - article_rows = BlogArticleRepository(db, user_id).list_by_user() - if not article_rows: - cache.status = "dead_letter" - cache.error_message = "分析対象の記事がありません" - cache.completed_at = _now() - db.commit() - return - - articles_data = [ - { - "title": art.title, - "url": art.url, - "published_at": art.published_at, - "likes_count": art.likes_count, - "summary": art.summary, - "tags": art.tags, - "platform": art.platform, - } - for art in article_rows - ] - llm_client = get_llm_client() - if not await llm_client.check_available(): - cache.status = "dead_letter" - cache.error_message = "LLM サービスが利用できません" - cache.completed_at = _now() - db.commit() - return - - summary = await summarize_blog_articles(articles_data) - if not summary: - cache.status = "dead_letter" - cache.error_message = "LLM処理が利用できません" - cache.completed_at = _now() - db.commit() - return - - cache.summary = summary - cache.status = "completed" - cache.error_message = None - cache.completed_at = _now() - cache.expires_at = _now() + timedelta(days=7) - db.commit() - - -# ---------- キャリアパス分析 ---------- + """ブログサマリハンドラへのシム。""" + handler = get_handler(TaskType.BLOG_SUMMARIZE) + assert handler is not None + await handler.run(db, payload) async def _run_career_analysis(db: Session, payload: dict) -> None: - """AI キャリアパス分析を実行し、結果を保存する。""" - from ...services.career_analysis.builder import build_career_analysis - - user_id = payload["user_id"] - record_id = payload["record_id"] - target_position = payload["target_position"] - - analysis = db.query(CareerAnalysis).filter_by(id=record_id, user_id=user_id).first() - if not analysis: - logger.error("キャリア分析レコードが見つかりません", extra={"record_id": record_id}) - return - - analysis.status = "processing" - analysis.started_at = _now() - db.commit() - - llm_client = get_llm_client() - try: - result = await build_career_analysis( - db=db, - user_id=user_id, - target_position=target_position, - llm_client=llm_client, - ) - except ValueError as exc: - analysis.status = "dead_letter" - analysis.error_message = str(exc) - analysis.completed_at = _now() - db.commit() - raise exc - - analysis.result_json = json.dumps(result, ensure_ascii=False) - analysis.status = "completed" - analysis.error_message = None - analysis.completed_at = _now() - db.commit() + """キャリア分析ハンドラへのシム。""" + handler = get_handler(TaskType.CAREER_ANALYSIS) + assert handler is not None + await handler.run(db, payload) # ---------- 共通 ---------- @@ -437,19 +223,11 @@ def _create_notification(db: Session, task_type: TaskType, user_id: str, status: def _get_task_record(db: Session, task_type: TaskType, payload: dict): - """タスク種別に応じた DB レコードを取得する。""" - user_id = payload.get("user_id") - if not user_id: + """タスク種別に応じた DB レコードを取得する(ハンドラに委譲)。""" + handler = get_handler(task_type) + if handler is None: return None - if task_type == TaskType.GITHUB_ANALYSIS: - return db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() - if task_type == TaskType.BLOG_SUMMARIZE: - return db.query(BlogSummaryCache).filter_by(user_id=user_id).first() - if task_type == TaskType.CAREER_ANALYSIS: - record_id = payload.get("record_id") - if record_id: - return db.query(CareerAnalysis).filter_by(id=record_id).first() - return None + return handler.get_record(db, payload) def _mark_dead_letter( diff --git a/backend/tests/test_career_analysis_api.py b/backend/tests/test_career_analysis_api.py index 06b3b328..fa567fe9 100644 --- a/backend/tests/test_career_analysis_api.py +++ b/backend/tests/test_career_analysis_api.py @@ -24,9 +24,11 @@ def test_generate_returns_specific_error_when_llm_is_unavailable(client: TestCli headers = auth_header(client, "career-llm-off") _create_resume(client, headers) + mock_llm = AsyncMock() + mock_llm.check_available = AsyncMock(return_value=False) with patch( - "app.routers.career_analysis._llm_client.check_available", - new=AsyncMock(return_value=False), + "app.routers.career_analysis.get_llm_client", + return_value=mock_llm, ): resp = client.post( "/api/career-analysis/generate", @@ -49,9 +51,11 @@ def test_generate_returns_202_when_llm_is_available(client: TestClient) -> None: headers = auth_header(client, "career-llm-empty") _create_resume(client, headers) + mock_llm = AsyncMock() + mock_llm.check_available = AsyncMock(return_value=True) with patch( - "app.routers.career_analysis._llm_client.check_available", - new=AsyncMock(return_value=True), + "app.routers.career_analysis.get_llm_client", + return_value=mock_llm, ): resp = client.post( "/api/career-analysis/generate", @@ -74,9 +78,11 @@ def test_user_a_career_analysis_not_accessible_by_user_b(client: TestClient) -> headers_a = auth_header(client, "boundary-user-a") _create_resume(client, headers_a) + mock_llm = AsyncMock() + mock_llm.check_available = AsyncMock(return_value=True) with patch( - "app.routers.career_analysis._llm_client.check_available", - new=AsyncMock(return_value=True), + "app.routers.career_analysis.get_llm_client", + return_value=mock_llm, ): resp_a = client.post( "/api/career-analysis/generate", diff --git a/backend/tests/test_intelligence.py b/backend/tests/test_intelligence.py index 87b32d79..8afd50b8 100644 --- a/backend/tests/test_intelligence.py +++ b/backend/tests/test_intelligence.py @@ -7,24 +7,10 @@ import asyncio from unittest.mock import AsyncMock, patch -from app.services.intelligence.career_paths import ( - match_skills_to_roles, -) -from app.services.intelligence.career_predictor import predict_career -from app.services.intelligence.career_simulator import simulate_careers -from app.services.intelligence.confidence_scorer import score_path from app.services.intelligence.github_collector import RepoData from app.services.intelligence.pipeline import IntelligenceResult, run_pipeline from app.services.intelligence.response_mapper import map_pipeline_result from app.services.intelligence.skill_extractor import extract_skills -from app.services.intelligence.skill_growth_analyzer import ( - GrowthTrend, - analyze_growth, -) -from app.services.intelligence.skill_timeline_builder import ( - build_timeline, - build_year_snapshots, -) from fastapi.testclient import TestClient from conftest import auth_header @@ -265,467 +251,32 @@ def test_full_repo_with_all_sources(self): assert "AWS" in names # from boto3 dependency -# ── Root File Detection ───────────────────────────────────────────────── - - -class TestRootFileDetection: - def test_dockerfile_detected(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files(["Dockerfile"]) - assert "Docker" in result - - def test_docker_compose_detected(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files(["docker-compose.yml"]) - assert "Docker Compose" in result - - def test_github_actions_detected(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files([".github"]) - assert "GitHub Actions" in result - - def test_terraform_detected(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files(["terraform"]) - assert "Terraform" in result - - def test_makefile_detected(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files(["Makefile"]) - assert "Build Automation" in result - - def test_no_duplicates(self): - from app.services.intelligence.github_collector import ( - _detect_from_root_files, - ) - - result = _detect_from_root_files( - [ - "terraform", - ".terraform", - ] - ) - assert result.count("Terraform") == 1 - - -# ── Dependency Parsing ────────────────────────────────────────────────── +# ── Dependency Parsing(github_collector 経由でのみ確認すべき pom.xml / go.mod のみ)───── +# 一般的な requirements.txt / package.json / root file 検出は test_repo_analyzer.py で網羅。 class TestDependencyParsing: - def test_parse_requirements_txt(self): - from app.services.intelligence.github_collector import ( - _parse_requirements_txt, - ) - - content = "fastapi>=0.100\nuvicorn\nsqlalchemy==2.0\n# comment\n" - result = _parse_requirements_txt(content) - assert "fastapi" in result - assert "uvicorn" in result - assert "sqlalchemy" in result - - def test_parse_requirements_txt_with_extras(self): - from app.services.intelligence.github_collector import ( - _parse_requirements_txt, - ) - - content = "uvicorn[standard]>=0.20\nboto3\n" - result = _parse_requirements_txt(content) - assert "uvicorn" in result - assert "boto3" in result - - def test_parse_package_json(self): - from app.services.intelligence.github_collector import ( - _parse_package_json, - ) - - content = '{"dependencies":{"react":"^18","next":"^14"}}' - result = _parse_package_json(content) - assert "react" in result - assert "next" in result - - def test_parse_package_json_with_dev_deps(self): - from app.services.intelligence.github_collector import ( - _parse_package_json, - ) - - content = '{"dependencies":{"react":"^18"},' '"devDependencies":{"jest":"^29"}}' - result = _parse_package_json(content) - assert "react" in result - assert "jest" in result - - def test_parse_package_json_invalid(self): - from app.services.intelligence.github_collector import ( - _parse_package_json, - ) - - result = _parse_package_json("not json") - assert result == [] - def test_parse_pom_xml(self): - from app.services.intelligence.github_collector import ( - _parse_pom_xml, - ) + from app.services.intelligence.github.repo_analyzer import parse_pom_xml content = ( "" "spring-boot-starter-web" "" ) - result = _parse_pom_xml(content) + result = parse_pom_xml(content) assert "spring-boot-starter-web" in result def test_parse_go_mod(self): - from app.services.intelligence.github_collector import ( - _parse_go_mod, - ) + from app.services.intelligence.github.repo_analyzer import parse_go_mod content = ( "module example.com/app\n\nrequire (\n" "\tgithub.com/gin-gonic/gin v1.9\n)\n" ) - result = _parse_go_mod(content) + result = parse_go_mod(content) assert "github.com/gin-gonic/gin" in result - def test_dependency_to_framework_mapping(self): - from app.services.intelligence.github_collector import ( - DEPENDENCY_TO_FRAMEWORK, - ) - - assert DEPENDENCY_TO_FRAMEWORK["fastapi"] == "FastAPI" - assert DEPENDENCY_TO_FRAMEWORK["react"] == "React" - assert DEPENDENCY_TO_FRAMEWORK["torch"] == "PyTorch" - assert DEPENDENCY_TO_FRAMEWORK["boto3"] == "AWS" - - -# ── Skill Timeline ────────────────────────────────────────────────────── - - -class TestSkillTimeline: - def _build(self): - extraction = extract_skills(SAMPLE_REPOS) - return build_timeline(extraction) - - def test_builds_timeline_entries(self): - timelines = self._build() - assert len(timelines) > 0 - names = {t.skill_name for t in timelines} - assert "Python" in names - - def test_first_seen_last_seen(self): - timelines = self._build() - python = next(t for t in timelines if t.skill_name == "Python") - assert python.first_seen <= python.last_seen - - def test_yearly_usage_populated(self): - timelines = self._build() - python = next(t for t in timelines if t.skill_name == "Python") - assert len(python.yearly_usage) > 0 - for year, count in python.yearly_usage.items(): - assert len(year) == 4 - assert count > 0 - - def test_repositories_listed(self): - timelines = self._build() - python = next(t for t in timelines if t.skill_name == "Python") - assert "web-api" in python.repositories - assert "scripts" in python.repositories - - def test_java_timeline(self): - timelines = self._build() - java = next(t for t in timelines if t.skill_name == "Java") - assert java.first_seen == "2019" - - def test_year_snapshots(self): - timelines = self._build() - snapshots = build_year_snapshots(timelines) - assert len(snapshots) > 0 - years = [s.year for s in snapshots] - assert years == sorted(years) - - def test_snapshot_new_skills(self): - timelines = self._build() - snapshots = build_year_snapshots(timelines) - # Each skill should appear as new in exactly one year - all_new = [] - for s in snapshots: - all_new.extend(s.new_skills) - assert len(all_new) == len(set(all_new)) - - -# ── Skill Growth Analyzer ────────────────────────────────────────────── - - -class TestSkillGrowthAnalyzer: - def _analyze(self, current_year="2024"): - extraction = extract_skills(SAMPLE_REPOS) - timelines = build_timeline(extraction) - return analyze_growth(timelines, current_year=current_year) - - def test_returns_all_skills(self): - growth = self._analyze() - assert len(growth) > 0 - names = {g.skill_name for g in growth} - assert "Python" in names - - def test_trend_classification(self): - growth = self._analyze() - trends = {g.trend for g in growth} - # Should have at least two different trends - assert len(trends) >= 2 - - def test_velocity_is_float(self): - growth = self._analyze() - for g in growth: - assert isinstance(g.velocity, float) - - def test_old_unused_skill_declining(self): - """Java used only in 2019-2020 should be declining by 2024.""" - growth = self._analyze(current_year="2024") - java = next( - (g for g in growth if g.skill_name == "Java"), - None, - ) - if java: - assert java.trend == GrowthTrend.DECLINING - - def test_single_year_skill_is_new(self): - """A skill seen only in one year should be classified as NEW.""" - repos = [ - _make_repo( - languages={"Rust": 5000}, - created_at="2024-01-01T00:00:00Z", - pushed_at="2024-01-01T00:00:00Z", - ) - ] - extraction = extract_skills(repos) - timelines = build_timeline(extraction) - growth = analyze_growth(timelines) - rust = next(g for g in growth if g.skill_name == "Rust") - assert rust.trend == GrowthTrend.NEW - - -# ── Career Paths Matching ────────────────────────────────────────────── - - -class TestCareerPathsMatching: - def test_backend_skills_match_backend_engineer(self): - skills = {"Python", "FastAPI", "PostgreSQL"} - categories = {"language", "backend_framework", "database"} - matches = match_skills_to_roles(skills, categories) - role_names = [m.role_name for m in matches] - assert "Backend Engineer" in role_names - - def test_frontend_skills_match_frontend_engineer(self): - skills = {"TypeScript", "React", "Next.js"} - categories = {"language", "frontend_framework"} - matches = match_skills_to_roles(skills, categories) - role_names = [m.role_name for m in matches] - assert "Frontend Engineer" in role_names - - def test_infra_skills_match_devops(self): - skills = {"Docker", "Kubernetes", "Terraform", "GitHub Actions"} - categories = {"infrastructure", "cicd"} - matches = match_skills_to_roles(skills, categories) - role_names = [m.role_name for m in matches] - assert "DevOps Engineer" in role_names - - def test_empty_skills_no_match(self): - matches = match_skills_to_roles(set(), set()) - assert matches == [] - - def test_match_score_between_0_and_1(self): - skills = {"Python", "FastAPI", "Docker", "AWS"} - categories = {"language", "backend_framework", "infrastructure", "cloud"} - matches = match_skills_to_roles(skills, categories) - for m in matches: - assert 0 < m.match_score <= 1.0 - - def test_matches_sorted_by_score(self): - skills = {"Python", "FastAPI", "Docker", "TypeScript", "React"} - categories = { - "language", - "backend_framework", - "infrastructure", - "frontend_framework", - } - matches = match_skills_to_roles(skills, categories) - scores = [m.match_score for m in matches] - assert scores == sorted(scores, reverse=True) - - -# ── Career Predictor ──────────────────────────────────────────────────── - - -class TestCareerPredictor: - def _predict(self): - extraction = extract_skills(SAMPLE_REPOS) - timelines = build_timeline(extraction) - growth = analyze_growth(timelines, current_year="2024") - return predict_career(timelines, growth) - - def test_has_current_role(self): - prediction = self._predict() - assert prediction.current_role.role_name - assert prediction.current_role.confidence >= 0 - - def test_has_next_roles(self): - prediction = self._predict() - assert len(prediction.next_roles) > 0 - - def test_confidence_range(self): - prediction = self._predict() - for role in [prediction.current_role] + prediction.next_roles: - assert 0 <= role.confidence <= 1.0 - - def test_skill_summary_populated(self): - prediction = self._predict() - assert len(prediction.skill_summary) > 0 - total_skills = sum(len(skills) for skills in prediction.skill_summary.values()) - assert total_skills > 0 - - def test_empty_input(self): - prediction = predict_career([], []) - assert prediction.current_role.role_name == "Developer" - assert prediction.current_role.confidence == 0.0 - - -# ── Career Simulator ──────────────────────────────────────────────────── - - -class TestCareerSimulator: - def _simulate(self): - extraction = extract_skills(SAMPLE_REPOS) - timelines = build_timeline(extraction) - growth = analyze_growth(timelines, current_year="2024") - prediction = predict_career(timelines, growth) - return simulate_careers(prediction, timelines, growth) - - def test_returns_paths(self): - simulation = self._simulate() - assert len(simulation.paths) > 0 - - def test_paths_start_with_current_role(self): - simulation = self._simulate() - for path in simulation.paths: - assert path.path[0] == simulation.current_role - - def test_path_confidence_range(self): - simulation = self._simulate() - for path in simulation.paths: - assert 0 <= path.confidence <= 1.0 - - def test_path_length_reasonable(self): - simulation = self._simulate() - for path in simulation.paths: - assert 2 <= len(path.path) <= 5 - - def test_paths_sorted_by_confidence(self): - simulation = self._simulate() - confidences = [p.confidence for p in simulation.paths] - assert confidences == sorted(confidences, reverse=True) - - def test_no_cycles_in_paths(self): - simulation = self._simulate() - for path in simulation.paths: - assert len(path.path) == len(set(path.path)) - - def test_description_populated(self): - simulation = self._simulate() - for path in simulation.paths: - assert len(path.description) > 0 - - -# ── Confidence Scorer ─────────────────────────────────────────────────── - - -class TestConfidenceScorer: - def test_score_range(self): - skills = {"Python", "FastAPI", "Docker"} - categories = {"language", "backend_framework", "infrastructure"} - score = score_path( - ["Backend Engineer", "Platform Engineer"], - skills, - categories, - {}, - ) - assert 0 <= score <= 1.0 - - def test_longer_path_lower_confidence(self): - skills = {"Python", "FastAPI", "Docker", "AWS", "Terraform"} - categories = {"language", "backend_framework", "infrastructure", "cloud"} - short = score_path( - ["Backend Engineer", "Platform Engineer"], - skills, - categories, - {}, - ) - long = score_path( - ["Backend Engineer", "Platform Engineer", "Cloud Architect", "CTO"], - skills, - categories, - {}, - ) - assert short >= long - - def test_empty_path(self): - assert score_path([], set(), set(), {}) == 0.0 - - def test_single_role_path(self): - assert ( - score_path( - ["Backend Engineer"], - {"Python"}, - {"language"}, - {}, - ) - == 0.0 - ) - - def test_better_skill_match_higher_score(self): - categories = { - "language", - "backend_framework", - "infrastructure", - "cloud", - } - path = ["Backend Engineer", "Platform Engineer"] - low = score_path( - path, - {"Python"}, - categories, - {}, - ) - skills_high = { - "Python", - "FastAPI", - "Docker", - "Kubernetes", - "Terraform", - "AWS", - } - high = score_path( - path, - skills_high, - categories, - {}, - ) - assert high >= low - # ── Intelligence Endpoint Tests ──────────────────────────────────────── diff --git a/backend/tests/test_llm_summarizer.py b/backend/tests/test_llm_summarizer.py index 570341f2..e2628062 100644 --- a/backend/tests/test_llm_summarizer.py +++ b/backend/tests/test_llm_summarizer.py @@ -177,7 +177,10 @@ def test_calls_llm_client_generate(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="テスト要約テキスト") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): result = _run( summarize_blog_articles( [{"title": "記事", "tags": [], "summary": "", "likes_count": 0}] @@ -192,7 +195,10 @@ def test_empty_articles(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): result = _run(summarize_blog_articles([])) assert result == "" @@ -202,7 +208,10 @@ def test_llm_unavailable_returns_empty(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): result = _run(summarize_blog_articles([{"title": "記事", "tags": []}])) assert result == "" @@ -212,7 +221,10 @@ def test_system_prompt_and_user_prompt_passed(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="要約") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): _run(summarize_blog_articles([{"title": "記事", "tags": []}])) call_args = mock_client.generate.call_args @@ -250,7 +262,10 @@ def test_calls_llm_generate(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="学習アドバイスです") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): result = _run(generate_learning_advice(self._make_analysis(), self._make_scores())) assert result == "学習アドバイスです" @@ -261,7 +276,10 @@ def test_llm_returns_empty(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): result = _run(generate_learning_advice(self._make_analysis(), self._make_scores())) assert result == "" @@ -271,7 +289,10 @@ def test_system_and_user_prompt_passed(self): mock_client = AsyncMock() mock_client.generate = AsyncMock(return_value="アドバイス") - with patch("app.services.intelligence.llm_summarizer._client", mock_client): + with patch( + "app.services.intelligence.llm_summarizer._get_client", + return_value=mock_client, + ): _run(generate_learning_advice(self._make_analysis(), self._make_scores())) call_args = mock_client.generate.call_args diff --git a/backend/tests/test_retry_flow.py b/backend/tests/test_retry_flow.py index 4085c3c3..e6bded10 100644 --- a/backend/tests/test_retry_flow.py +++ b/backend/tests/test_retry_flow.py @@ -436,8 +436,11 @@ def test_intelligence_retry_resets_cache(self, client: TestClient): assert cache.retry_count == 0 assert cache.error_message is None - def test_blog_retry_requires_articles_body(self, client: TestClient): - """ブログサマリ再実行には articles 本文が必要。""" + def test_blog_retry_resets_dead_letter_cache(self, client: TestClient): + """ブログサマリ再実行で dead_letter キャッシュが pending にリセットされること。 + + 記事は worker 側で BlogArticleRepository から取得するためボディ不要。 + """ headers = auth_header(client, "retry-blog-user") db = client._db_session user = UserRepository(db).get_by_username("retry-blog-user") @@ -446,35 +449,21 @@ def test_blog_retry_requires_articles_body(self, client: TestClient): user_id=user.id, status="dead_letter", error_message="old", + retry_count=3, ) db.add(cache) db.commit() - # 本文なし → 422 (pydantic validation) - resp_missing = client.post("/api/blog/summarize/retry", headers=headers) - assert resp_missing.status_code == 422 - - # articles が 1 件以上含まれていれば 202 - articles = [ - { - "platform": "zenn", - "title": "テスト記事", - "url": "https://example.com/article", - } - ] with patch( "app.routers.blog.check_llm_available", new=AsyncMock(return_value=True), ): - resp_ok = client.post( - "/api/blog/summarize/retry", - json={"articles": articles}, - headers=headers, - ) + resp_ok = client.post("/api/blog/summarize/retry", headers=headers) assert resp_ok.status_code == 202 db.refresh(cache) assert cache.status == "pending" assert cache.retry_count == 0 + assert cache.error_message is None # ══════════════════════════════════════════════════════════════════════ diff --git a/backend/tests/test_worker_extended.py b/backend/tests/test_worker_extended.py index 78ef9690..71da1f4b 100644 --- a/backend/tests/test_worker_extended.py +++ b/backend/tests/test_worker_extended.py @@ -16,9 +16,11 @@ from app.models import BlogSummaryCache, GitHubAnalysisCache from app.models.career_analysis import CareerAnalysis from app.repositories import UserRepository +from app.services.intelligence.github_analysis_service import ( + _generate_advice_if_available, +) from app.services.tasks.base import TaskType from app.services.tasks.worker import ( - _generate_advice_if_available, _mark_dead_letter, _run_blog_summarize, _run_career_analysis, @@ -79,7 +81,7 @@ def test_status_transitions_to_completed(self, db_session: Session): with ( patch( - "app.services.intelligence.github_collector.collect_repos", + "app.services.intelligence.github_analysis_service.collect_repos", new_callable=AsyncMock, return_value=repos, ), @@ -88,10 +90,10 @@ def test_status_transitions_to_completed(self, db_session: Session): new_callable=AsyncMock, ), patch( - "app.services.tasks.worker.get_llm_client", + "app.services.intelligence.github_analysis_service.get_llm_client", return_value=MagicMock(check_available=AsyncMock(return_value=False)), ), - patch("app.core.encryption.decrypt_field", return_value="token123"), + patch("app.services.intelligence.github_analysis_service.decrypt_field", return_value="token123"), ): _run( _run_github_analysis( @@ -124,15 +126,15 @@ async def _fake_collect(**kwargs): with ( patch( - "app.services.intelligence.github_collector.collect_repos", + "app.services.intelligence.github_analysis_service.collect_repos", side_effect=_fake_collect, ), patch("app.services.progress_service.set_progress", new_callable=AsyncMock), patch( - "app.services.tasks.worker.get_llm_client", + "app.services.intelligence.github_analysis_service.get_llm_client", return_value=MagicMock(check_available=AsyncMock(return_value=False)), ), - patch("app.core.encryption.decrypt_field", return_value=None), + patch("app.services.intelligence.github_analysis_service.decrypt_field", return_value=None), ): _run( _run_github_analysis( @@ -156,12 +158,12 @@ def test_github_user_not_found_sets_dead_letter(self, db_session: Session): with ( patch( - "app.services.intelligence.github_collector.collect_repos", + "app.services.intelligence.github_analysis_service.collect_repos", new_callable=AsyncMock, side_effect=GitHubUserNotFoundError("notfound"), ), patch("app.services.progress_service.set_progress", new_callable=AsyncMock), - patch("app.core.encryption.decrypt_field", return_value=None), + patch("app.services.intelligence.github_analysis_service.decrypt_field", return_value=None), ): with pytest.raises(GitHubUserNotFoundError): _run( @@ -227,8 +229,11 @@ def test_success_status_completed(self, db_session: Session): mock_llm.check_available = AsyncMock(return_value=True) with ( - patch("app.services.tasks.worker.BlogArticleRepository", return_value=self._make_mock_repo()), - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), patch( "app.services.intelligence.llm_summarizer.summarize_blog_articles", new_callable=AsyncMock, @@ -255,8 +260,11 @@ def test_llm_unavailable_sets_dead_letter(self, db_session: Session): mock_llm.check_available = AsyncMock(return_value=False) with ( - patch("app.services.tasks.worker.BlogArticleRepository", return_value=self._make_mock_repo()), - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), ): _run( _run_blog_summarize( @@ -276,8 +284,11 @@ def test_empty_summary_sets_dead_letter(self, db_session: Session): mock_llm.check_available = AsyncMock(return_value=True) with ( - patch("app.services.tasks.worker.BlogArticleRepository", return_value=self._make_mock_repo()), - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), patch( "app.services.intelligence.llm_summarizer.summarize_blog_articles", new_callable=AsyncMock, @@ -298,7 +309,10 @@ def test_no_articles_sets_dead_letter(self, db_session: Session): """記事が 0 件の場合に status が dead_letter になること。""" user, cache = self._make_user_and_cache(db_session, "blog-no-articles") - with patch("app.services.tasks.worker.BlogArticleRepository", return_value=self._make_mock_repo(articles=[])): + with patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(articles=[]), + ): _run( _run_blog_summarize( db_session, @@ -333,8 +347,11 @@ async def _fake_check_available(): mock_llm.check_available = _fake_check_available with ( - patch("app.services.tasks.worker.BlogArticleRepository", return_value=self._make_mock_repo()), - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch( + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", + return_value=self._make_mock_repo(), + ), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), ): _run( _run_blog_summarize( @@ -369,7 +386,7 @@ def test_success_status_completed(self, db_session: Session): fake_result = {"strengths": [], "career_paths": [], "action_items": []} with ( - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), patch( "app.services.career_analysis.builder.build_career_analysis", new_callable=AsyncMock, @@ -398,7 +415,7 @@ def test_value_error_sets_dead_letter(self, db_session: Session): mock_llm = MagicMock() with ( - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), + patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm), patch( "app.services.career_analysis.builder.build_career_analysis", new_callable=AsyncMock, @@ -458,7 +475,10 @@ def test_llm_unavailable_returns_none(self): mock_llm = MagicMock() mock_llm.check_available = AsyncMock(return_value=False) - with patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm): + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): result = _run(_generate_advice_if_available(self._analysis())) assert result == (None, False) @@ -469,9 +489,12 @@ def test_llm_available_returns_advice(self): mock_llm.check_available = AsyncMock(return_value=True) with ( - patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm), patch( - "app.services.intelligence.llm_summarizer.generate_learning_advice", + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ), + patch( + "app.services.intelligence.github_analysis_service.generate_learning_advice", new_callable=AsyncMock, return_value="学習アドバイスです", ), @@ -485,7 +508,10 @@ def test_llm_exception_returns_none(self): mock_llm = MagicMock() mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) - with patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm): + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): result = _run(_generate_advice_if_available(self._analysis())) assert result == (None, True) @@ -495,7 +521,10 @@ def test_no_position_scores_returns_none(self): mock_llm = MagicMock() mock_llm.check_available = AsyncMock(return_value=True) - with patch("app.services.tasks.worker.get_llm_client", return_value=mock_llm): + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): result = _run(_generate_advice_if_available({"repos_analyzed": 5})) assert result == (None, False) @@ -505,14 +534,11 @@ def test_no_position_scores_returns_none(self): class TestExecuteTask: - def test_unknown_task_type_logs_error_and_returns(self, db_session: Session): - """不明なタスク種別の場合エラーログを出し例外を上げないこと。""" - # TaskType は Enum なのでここでは直接 string を渡すことで unknown な値をシミュレート - # execute_task は if-elif で全種別をチェックするため、該当しない場合 logger.error を呼ぶ - - # _run_github_analysis 等が呼ばれないことをモックで確認 + def test_known_task_type_routes_to_correct_handler(self, db_session: Session): + """GITHUB_ANALYSIS が _run_github_analysis に正しくディスパッチされ、 + 他のハンドラ関数は呼ばれないことを確認する。""" with ( - patch("app.db.database.SessionLocal", return_value=db_session), + patch("app.services.tasks.worker.SessionLocal", return_value=db_session), patch( "app.services.tasks.worker._run_github_analysis", new_callable=AsyncMock, @@ -521,11 +547,12 @@ def test_unknown_task_type_logs_error_and_returns(self, db_session: Session): "app.services.tasks.worker._run_blog_summarize", new_callable=AsyncMock, ) as mock_blog, + patch( + "app.services.tasks.worker._run_career_analysis", + new_callable=AsyncMock, + ) as mock_career, + patch("app.services.tasks.worker._create_notification"), ): - # TaskType.GITHUB_ANALYSIS でもなく BLOG_SUMMARIZE でもない dummy を渡す - # ただし TaskType は str Enum なので実際の enum 値のみ渡せる。 - # ここでは GITHUB_ANALYSIS を使い mock を差し替えて成功フローをシミュレート - mock_gh.return_value = None _run( execute_task( TaskType.GITHUB_ANALYSIS, @@ -535,6 +562,7 @@ def test_unknown_task_type_logs_error_and_returns(self, db_session: Session): mock_gh.assert_called_once() mock_blog.assert_not_called() + mock_career.assert_not_called() def test_execute_task_marks_dead_letter_on_error(self, db_session: Session): """予期しない例外が発生した場合(max_attempts=1)、_mark_dead_letter が diff --git a/backend/tests/test_worker_timeout.py b/backend/tests/test_worker_timeout.py index 08f72e69..c190f45f 100644 --- a/backend/tests/test_worker_timeout.py +++ b/backend/tests/test_worker_timeout.py @@ -1,6 +1,9 @@ -"""worker タイムアウト・エラーハンドリングのテスト。 +"""worker タイムアウト伝播のテスト。 -asyncio.TimeoutError を mock して、worker が適切なエラーステータスを設定するかを確認する。 +asyncio.TimeoutError が _run_* シムから素通しで上位へ届き、 +status は processing のまま(dead_letter への遷移は execute_task 層の責務) +であることを確認する。``_mark_dead_letter`` の動作は ``test_worker_extended.py`` +側でカバー済み。 """ import asyncio @@ -22,15 +25,8 @@ def _run(coro): loop.close() -# ── _run_github_analysis タイムアウトテスト ────────────────────────────────── - - def test_github_analysis_timeout_propagates(db_session: Session) -> None: - """collect_repos で asyncio.TimeoutError が発生した場合に例外が伝播することを確認する。 - - _run_github_analysis は run_pipeline を呼ばず collect_repos を直接呼ぶため、 - パッチ対象は collect_repos が正しい。set_progress(Redis)もモックが必要。 - """ + """collect_repos で asyncio.TimeoutError が発生した場合に例外が伝播することを確認する。""" user = UserRepository(db_session).create( "github:timeout-user", hashed_password=None, @@ -42,7 +38,7 @@ def test_github_analysis_timeout_propagates(db_session: Session) -> None: with ( patch( - "app.services.intelligence.github_collector.collect_repos", + "app.services.intelligence.github_analysis_service.collect_repos", new_callable=AsyncMock, side_effect=asyncio.TimeoutError, ), @@ -65,39 +61,10 @@ def test_github_analysis_timeout_propagates(db_session: Session) -> None: ) db_session.refresh(cache) - # _run_github_analysis はタイムアウトで例外を再 raise する - # ステータスは processing のまま(_mark_dead_letter は execute_task 層が担う) + # ハンドラは TimeoutError を再 raise するだけ(dead_letter 遷移は execute_task 層) assert cache.status == "processing" -def test_github_analysis_mark_dead_letter_on_unexpected_error(db_session: Session) -> None: - """予期しないエラー発生時に _mark_dead_letter でステータスが dead_letter になることを確認する。""" - from app.services.tasks.base import TaskType - from app.services.tasks.worker import _mark_dead_letter - - user = UserRepository(db_session).create( - "github:markfailed-user", - hashed_password=None, - email="markfailed@example.com", - ) - cache = GitHubAnalysisCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - _mark_dead_letter( - db_session, - TaskType.GITHUB_ANALYSIS, - {"user_id": user.id}, - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - assert cache.error_message == "予期しないエラーが発生しました" - - -# ── _run_blog_summarize タイムアウトテスト ──────────────────────────────────── - - def test_blog_summarize_timeout_propagates(db_session: Session) -> None: """ブログサマリ生成で asyncio.TimeoutError が発生した場合に例外が伝播することを確認する。""" user = UserRepository(db_session).create( @@ -109,7 +76,6 @@ def test_blog_summarize_timeout_propagates(db_session: Session) -> None: db_session.add(cache) db_session.commit() - # LLM クライアントが利用可能であることをモック mock_llm = MagicMock() mock_llm.check_available = AsyncMock(return_value=True) @@ -124,14 +90,13 @@ def test_blog_summarize_timeout_propagates(db_session: Session) -> None: mock_article.platform = "zenn" with patch( - "app.services.tasks.worker.get_llm_client", + "app.services.intelligence.llm.get_llm_client", return_value=mock_llm, ): with patch( - "app.services.tasks.worker.BlogArticleRepository", + "app.services.tasks.handlers.blog_summarize.BlogArticleRepository", ) as mock_repo_cls: mock_repo_cls.return_value.list_by_user.return_value = [mock_article] - # ローカルインポートされる summarize_blog_articles を patch する with patch( "app.services.intelligence.llm_summarizer.summarize_blog_articles", new_callable=AsyncMock, @@ -141,34 +106,9 @@ def test_blog_summarize_timeout_propagates(db_session: Session) -> None: _run( _run_blog_summarize( db_session, - {"user_id": user.id, "articles": []}, + {"user_id": user.id}, ) ) db_session.refresh(cache) assert cache.status == "processing" - - -def test_blog_summarize_mark_dead_letter_on_unexpected_error(db_session: Session) -> None: - """予期しないエラー発生時に _mark_dead_letter でブログサマリのステータスが dead_letter になることを確認する。""" - from app.services.tasks.base import TaskType - from app.services.tasks.worker import _mark_dead_letter - - user = UserRepository(db_session).create( - "blog-markfailed-user", - hashed_password=None, - email="blogmarkfailed@example.com", - ) - cache = BlogSummaryCache(user_id=user.id, status="processing") - db_session.add(cache) - db_session.commit() - - _mark_dead_letter( - db_session, - TaskType.BLOG_SUMMARIZE, - {"user_id": user.id}, - ) - - db_session.refresh(cache) - assert cache.status == "dead_letter" - assert cache.error_message == "予期しないエラーが発生しました" diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 88b1b9b8..8facc9f8 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -155,4 +155,30 @@ describe("api/client request", () => { await expect(request("/api/test")).rejects.toThrow("サーバーに接続できません"); }); + + /** POST リクエストに Cookie の csrf_token が X-CSRF-Token ヘッダーとして付与されること */ + it("POST リクエストに X-CSRF-Token ヘッダーが付与される", async () => { + Object.defineProperty(document, "cookie", { + writable: true, + value: "csrf_token=test-csrf-token", + }); + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, {})); + vi.stubGlobal("fetch", fetchMock); + + await request("/api/test", { method: "POST", body: JSON.stringify({}) }); + + const [, options] = fetchMock.mock.calls[0]; + expect((options.headers as Record)["X-CSRF-Token"]).toBe("test-csrf-token"); + }); + + /** GET リクエストには X-CSRF-Token ヘッダーが付与されないこと */ + it("GET リクエストには X-CSRF-Token ヘッダーが付与されない", async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, {})); + vi.stubGlobal("fetch", fetchMock); + + await request("/api/test"); + + const [, options] = fetchMock.mock.calls[0]; + expect((options.headers as Record)["X-CSRF-Token"]).toBeUndefined(); + }); }); diff --git a/frontend/src/components/blog/BlogArticleList.tsx b/frontend/src/components/blog/BlogArticleList.tsx new file mode 100644 index 00000000..1130b491 --- /dev/null +++ b/frontend/src/components/blog/BlogArticleList.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; + +import type { BlogArticle } from "../../types"; +import { ZennIcon } from "../icons/ZennIcon"; +import { NoteIcon } from "../icons/NoteIcon"; +import { QiitaIcon } from "../icons/QiitaIcon"; +import styles from "./BlogPage.module.css"; + +type PlatformFilter = "all" | "zenn" | "note" | "qiita"; + +const ARTICLES_PER_PAGE = 5; + +type BlogArticleListProps = { + articles: BlogArticle[]; + filter: PlatformFilter; + onFilterChange: (filter: PlatformFilter) => void; +}; + +/** 記事一覧・フィルタータブ・ページネーションを描画するコンポーネント。 */ +export function BlogArticleList({ articles, filter, onFilterChange }: BlogArticleListProps) { + const [pageByFilter, setPageByFilter] = useState>({ + all: 1, + zenn: 1, + note: 1, + qiita: 1, + }); + + const totalPages = Math.max(1, Math.ceil(articles.length / ARTICLES_PER_PAGE)); + const currentPage = Math.min(pageByFilter[filter], totalPages); + const showPagination = articles.length > ARTICLES_PER_PAGE; + const pageStartIndex = (currentPage - 1) * ARTICLES_PER_PAGE; + const visibleArticles = articles.slice(pageStartIndex, pageStartIndex + ARTICLES_PER_PAGE); + + const handleFilterChange = (f: PlatformFilter) => { + if (filter === f) return; + onFilterChange(f); + setPageByFilter((prev) => ({ ...prev, [f]: 1 })); + }; + + return ( +
+
+

記事一覧

+
+ {(["all", "zenn", "note", "qiita"] as PlatformFilter[]).map((f) => ( + + ))} +
+
+ + {articles.length === 0 ? ( +

+ 記事がありません。「同期」ボタンで記事を取得してください。 +

+ ) : ( + <> + + + {showPagination && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx new file mode 100644 index 00000000..b7555240 --- /dev/null +++ b/frontend/src/components/blog/BlogPlatformList.tsx @@ -0,0 +1,115 @@ +import type { BlogAccount } from "../../types"; +import type { PlatformKey } from "../../hooks/useBlogAccountManager"; +import { ZennIcon } from "../icons/ZennIcon"; +import { NoteIcon } from "../icons/NoteIcon"; +import { QiitaIcon } from "../icons/QiitaIcon"; +import styles from "./BlogPage.module.css"; + +/** 対応プラットフォーム定義 */ +const PLATFORMS = [ + { + key: "zenn" as const, + label: "Zenn", + urlPrefix: "https://zenn.dev/", + icon: , + }, + { + key: "note" as const, + label: "note", + urlPrefix: "https://note.com/", + icon: , + }, + { + key: "qiita" as const, + label: "Qiita", + urlPrefix: "https://qiita.com/", + icon: , + }, +] as const; + +type BlogPlatformListProps = { + accountMap: Map; + draftUsernames: Record; + setDraftUsernames: React.Dispatch>>; + savingPlatform: string | null; + syncingPlatform: string | null; + onSave: (platform: PlatformKey) => void; + onSync: (platform: PlatformKey) => void; + onDelete: (platform: PlatformKey) => void; +}; + +/** プラットフォーム連携行の一覧を描画するコンポーネント。 */ +export function BlogPlatformList({ + accountMap, + draftUsernames, + setDraftUsernames, + savingPlatform, + syncingPlatform, + onSave, + onSync, + onDelete, +}: BlogPlatformListProps) { + return ( +
+

アウトプット連携

+
+ {PLATFORMS.map((pf) => { + const linked = accountMap.get(pf.key); + return ( +
+
{pf.icon}
+ {pf.label} + + {linked ? ( + <> + {pf.urlPrefix} + {linked.username} + 連携済み + + + + ) : ( + <> + {pf.urlPrefix} + + setDraftUsernames((prev) => ({ ...prev, [pf.key]: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === "Enter") onSave(pf.key); + }} + /> + + + )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx index f176c8b0..687f62da 100644 --- a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx +++ b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx @@ -1,41 +1,7 @@ import { useState } from "react"; -import { - type CareerAnalysisResponse, - type CareerAnalysisResult, - type EvidenceSource, -} from "../../api"; - -/** スキル名 → 公式ドキュメント URL のキュレーション済みマップ */ -const OFFICIAL_DOCS: Record = { - TypeScript: "https://www.typescriptlang.org/docs/", - Python: "https://docs.python.org/ja/3/", - Go: "https://go.dev/doc/", - Rust: "https://doc.rust-lang.org/book/", - Java: "https://docs.oracle.com/javase/", - "C#": "https://learn.microsoft.com/ja-jp/dotnet/csharp/", - Kotlin: "https://kotlinlang.org/docs/", - Swift: "https://docs.swift.org/swift-book/", - React: "https://react.dev/learn", - "Next.js": "https://nextjs.org/docs", - Vue: "https://ja.vuejs.org/guide/", - Angular: "https://angular.jp/docs", - FastAPI: "https://fastapi.tiangolo.com/ja/", - Django: "https://docs.djangoproject.com/ja/", - "Spring Boot": "https://spring.io/guides", - "Node.js": "https://nodejs.org/ja/docs/", - Docker: "https://docs.docker.com/", - Kubernetes: "https://kubernetes.io/ja/docs/", - Terraform: "https://developer.hashicorp.com/terraform/docs", - "GitHub Actions": "https://docs.github.com/ja/actions", - AWS: "https://docs.aws.amazon.com/", - GCP: "https://cloud.google.com/docs?hl=ja", - Azure: "https://learn.microsoft.com/ja-jp/azure/", - PostgreSQL: "https://www.postgresql.org/docs/", - MySQL: "https://dev.mysql.com/doc/", - Redis: "https://redis.io/docs/", - MongoDB: "https://www.mongodb.com/docs/", -}; +import type { CareerAnalysisResponse } from "../../api"; import { useCareerAnalysisPage } from "../../hooks/useCareerAnalysisPage"; +import { CareerAnalysisResultView } from "./result/CareerAnalysisResultView"; import { ErrorToast } from "../ui/ErrorToast"; import { InlineSpinner } from "../ui/InlineSpinner"; import styles from "./CareerAnalysisPage.module.css"; @@ -63,8 +29,6 @@ export function CareerAnalysisPage() { } }; - // ── レンダリング ────────────────────────────────────────── - if (phase === "loading") { return ; } @@ -78,8 +42,6 @@ export function CareerAnalysisPage() { ); } - // ── 入力 + 履歴 ──────────────────────────────────────── - if (phase === "input" || phase === "list") { return (
@@ -141,7 +103,10 @@ export function CareerAnalysisPage() { {a.status === "dead_letter" && ( )} -
@@ -154,221 +119,14 @@ export function CareerAnalysisPage() { ); } - // ── 分析結果表示 ──────────────────────────────────────── - if (phase === "detail" && selected?.result) { - const r = selected.result; return ( -
-
-

- v{selected.version} — {selected.target_position} -

- -
- - - - - - - -
+ setPhase("list")} + /> ); } return null; } - -/* ── サブコンポーネント ─────────────────────────────────── */ - -function GrowthSummarySection({ result }: { result: CareerAnalysisResult }) { - return ( -
-
成長曲線
-
{result.growth_summary}
-
- ); -} - -function TechStackSection({ result }: { result: CareerAnalysisResult }) { - const grouped = { - 1: result.tech_stack.top.filter((t) => t.priority === 1), - 2: result.tech_stack.top.filter((t) => t.priority === 2), - 3: result.tech_stack.top.filter((t) => t.priority === 3), - }; - const labels = { 1: "案件実績", 2: "個人開発", 3: "資格" } as const; - const stars = { 1: "★★★", 2: "★★☆", 3: "★☆☆" } as const; - const priorityStyle = { - 1: styles.priority1, - 2: styles.priority2, - 3: styles.priority3, - } as const; - - return ( -
-
技術スタック評価
-
- {([1, 2, 3] as const).map( - (p) => - grouped[p].length > 0 && ( -
-
- {stars[p]} {labels[p]} -
-
- {grouped[p].map((t) => ( - - {t.name} - - ))} -
-
- ), - )} - {result.tech_stack.summary && ( -
{result.tech_stack.summary}
- )} -
-
- ); -} - -const BADGE_MAP: Record = { - resume: { label: "本業", className: styles.badgeResume }, - github: { label: "GitHub", className: styles.badgeGithub }, - blog: { label: "ブログ", className: styles.badgeBlog }, -}; - -function StrengthsSection({ result }: { result: CareerAnalysisResult }) { - return ( -
-
強み(根拠付き)
-
- {result.strengths.map((s, i) => { - const badge = BADGE_MAP[s.evidence_source] || BADGE_MAP.resume; - return ( -
-
- {badge.label} - {s.title} -
-
{s.detail}
-
- ); - })} -
-
- ); -} - -function CareerPathsSection({ result }: { result: CareerAnalysisResult }) { - return ( -
-
キャリアパス提案
- {result.career_paths.map((cp) => ( -
-
- {cp.title} - {cp.label} -
-
- フィットスコア {cp.fit_score} -
-
-
-
-
{cp.description}
- {cp.required_skills.length > 0 && ( -
- 必要スキル: - {cp.required_skills.map((sk) => ( - - {sk} - - ))} -
- )} - {cp.gap_skills.length > 0 && ( -
- ギャップ: - {cp.gap_skills.map((sk) => ( - - {sk} - - ))} -
- )} -
- ))} -
- ); -} - -function ActionItemsSection({ result }: { result: CareerAnalysisResult }) { - return ( -
-
アクションアイテム
-
- {result.action_items.map((a, i) => ( -
-
- {a.priority} - {a.action} -
-
{a.reason}
-
- ))} -
-
- ); -} - -function LearningResourcesSection({ result }: { result: CareerAnalysisResult }) { - const gapSkills = [...new Set(result.career_paths.flatMap((cp) => cp.gap_skills))]; - if (gapSkills.length === 0) return null; - - return ( -
-
学習リソース
-
-

- キャリアパス実現に向けて習得が推奨されるスキルの学習リソースです。 -

-
- {gapSkills.map((skill) => { - const officialUrl = OFFICIAL_DOCS[skill]; - const udemyUrl = `https://www.udemy.com/courses/search/?q=${encodeURIComponent(skill)}&lang=ja`; - return ( -
- {skill} - -
- ); - })} -
-
-
- ); -} diff --git a/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx b/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx new file mode 100644 index 00000000..a2179c21 --- /dev/null +++ b/frontend/src/components/career-analysis/result/CareerAnalysisResultView.tsx @@ -0,0 +1,255 @@ +import { + type CareerAnalysisResponse, + type CareerAnalysisResult, + type EvidenceSource, +} from "../../../api"; +import styles from "../CareerAnalysisPage.module.css"; + +/** スキル名 → 公式ドキュメント URL のキュレーション済みマップ */ +const OFFICIAL_DOCS: Record = { + TypeScript: "https://www.typescriptlang.org/docs/", + Python: "https://docs.python.org/ja/3/", + Go: "https://go.dev/doc/", + Rust: "https://doc.rust-lang.org/book/", + Java: "https://docs.oracle.com/javase/", + "C#": "https://learn.microsoft.com/ja-jp/dotnet/csharp/", + Kotlin: "https://kotlinlang.org/docs/", + Swift: "https://docs.swift.org/swift-book/", + React: "https://react.dev/learn", + "Next.js": "https://nextjs.org/docs", + Vue: "https://ja.vuejs.org/guide/", + Angular: "https://angular.jp/docs", + FastAPI: "https://fastapi.tiangolo.com/ja/", + Django: "https://docs.djangoproject.com/ja/", + "Spring Boot": "https://spring.io/guides", + "Node.js": "https://nodejs.org/ja/docs/", + Docker: "https://docs.docker.com/", + Kubernetes: "https://kubernetes.io/ja/docs/", + Terraform: "https://developer.hashicorp.com/terraform/docs", + "GitHub Actions": "https://docs.github.com/ja/actions", + AWS: "https://docs.aws.amazon.com/", + GCP: "https://cloud.google.com/docs?hl=ja", + Azure: "https://learn.microsoft.com/ja-jp/azure/", + PostgreSQL: "https://www.postgresql.org/docs/", + MySQL: "https://dev.mysql.com/doc/", + Redis: "https://redis.io/docs/", + MongoDB: "https://www.mongodb.com/docs/", +}; + +type CareerAnalysisResultViewProps = { + selected: CareerAnalysisResponse; + onBack: () => void; +}; + +/** 分析結果詳細ビュー。全結果セクションをまとめて表示する。 */ +export function CareerAnalysisResultView({ selected, onBack }: CareerAnalysisResultViewProps) { + const r = selected.result!; + return ( +
+
+

+ v{selected.version} — {selected.target_position} +

+ +
+ + + + + + + +
+ ); +} + +function GrowthSummarySection({ result }: { result: CareerAnalysisResult }) { + return ( +
+
成長曲線
+
{result.growth_summary}
+
+ ); +} + +function TechStackSection({ result }: { result: CareerAnalysisResult }) { + const grouped = { + 1: result.tech_stack.top.filter((t) => t.priority === 1), + 2: result.tech_stack.top.filter((t) => t.priority === 2), + 3: result.tech_stack.top.filter((t) => t.priority === 3), + }; + const labels = { 1: "案件実績", 2: "個人開発", 3: "資格" } as const; + const stars = { 1: "★★★", 2: "★★☆", 3: "★☆☆" } as const; + const priorityStyle = { + 1: styles.priority1, + 2: styles.priority2, + 3: styles.priority3, + } as const; + + return ( +
+
技術スタック評価
+
+ {([1, 2, 3] as const).map( + (p) => + grouped[p].length > 0 && ( +
+
+ {stars[p]} {labels[p]} +
+
+ {grouped[p].map((t) => ( + + {t.name} + + ))} +
+
+ ), + )} + {result.tech_stack.summary && ( +
{result.tech_stack.summary}
+ )} +
+
+ ); +} + +const BADGE_MAP: Record = { + resume: { label: "本業", className: styles.badgeResume }, + github: { label: "GitHub", className: styles.badgeGithub }, + blog: { label: "ブログ", className: styles.badgeBlog }, +}; + +function StrengthsSection({ result }: { result: CareerAnalysisResult }) { + return ( +
+
強み(根拠付き)
+
+ {result.strengths.map((s, i) => { + const badge = BADGE_MAP[s.evidence_source] || BADGE_MAP.resume; + return ( +
+
+ {badge.label} + {s.title} +
+
{s.detail}
+
+ ); + })} +
+
+ ); +} + +function CareerPathsSection({ result }: { result: CareerAnalysisResult }) { + return ( +
+
キャリアパス提案
+ {result.career_paths.map((cp) => ( +
+
+ {cp.title} + {cp.label} +
+
+ フィットスコア {cp.fit_score} +
+
+
+
+
{cp.description}
+ {cp.required_skills.length > 0 && ( +
+ 必要スキル: + {cp.required_skills.map((sk) => ( + + {sk} + + ))} +
+ )} + {cp.gap_skills.length > 0 && ( +
+ ギャップ: + {cp.gap_skills.map((sk) => ( + + {sk} + + ))} +
+ )} +
+ ))} +
+ ); +} + +function ActionItemsSection({ result }: { result: CareerAnalysisResult }) { + return ( +
+
アクションアイテム
+
+ {result.action_items.map((a, i) => ( +
+
+ {a.priority} + {a.action} +
+
{a.reason}
+
+ ))} +
+
+ ); +} + +function LearningResourcesSection({ result }: { result: CareerAnalysisResult }) { + const gapSkills = [...new Set(result.career_paths.flatMap((cp) => cp.gap_skills))]; + if (gapSkills.length === 0) return null; + + return ( +
+
学習リソース
+
+

+ キャリアパス実現に向けて習得が推奨されるスキルの学習リソースです。 +

+
+ {gapSkills.map((skill) => { + const officialUrl = OFFICIAL_DOCS[skill]; + const udemyUrl = `https://www.udemy.com/courses/search/?q=${encodeURIComponent(skill)}&lang=ja`; + return ( +
+ {skill} + +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx index d5d51e31..3703c364 100644 --- a/frontend/src/components/forms/ProjectModal.test.tsx +++ b/frontend/src/components/forms/ProjectModal.test.tsx @@ -3,99 +3,32 @@ import { describe, it, expect, vi } from "vitest"; import { ProjectModal } from "./ProjectModal"; import type { CareerProjectForm } from "../../payloadBuilders"; -/** テスト用のダミープロジェクトデータ(正常な日付) */ -const validProject: CareerProjectForm = { - name: "テストプロジェクト", - start_date: "2024-01", - end_date: "2024-12", +const invalidDateProject: CareerProjectForm = { + name: "テスト", + start_date: "2024-12", + end_date: "2024-01", is_current: false, role: "エンジニア", - description: "プロジェクト概要", - challenge: "課題", - action: "対応", - result: "成果", - team: { total: "5", members: [] }, + description: "", + challenge: "", + action: "", + result: "", + team: { total: "", members: [] }, technology_stacks: [], phases: [], }; -/** テスト用のダミープロジェクトデータ(不正な日付: 開始日 > 終了日) */ -const invalidDateProject: CareerProjectForm = { - ...validProject, - start_date: "2024-12", - end_date: "2024-01", -}; - -describe("ProjectModal 日付バリデーション", () => { - /** 開始日 > 終了日 の場合にエラーメッセージが表示されること */ - it("開始日が終了日より後の場合にエラーメッセージが表示される", () => { - const onSave = vi.fn(); - const onClose = vi.fn(); - - render( - , - ); - - expect(screen.getByText(/開始日は終了日より前に設定してください/)).toBeInTheDocument(); - }); - - /** 開始日 > 終了日 の場合に保存ボタンが disabled になること */ +describe("ProjectModal", () => { + /** 開始日 > 終了日 のとき保存ボタンが disabled になること */ it("開始日が終了日より後の場合に保存ボタンが disabled になる", () => { - const onSave = vi.fn(); - const onClose = vi.fn(); - render( , ); - - const saveButton = screen.getByRole("button", { name: "保存" }); - expect(saveButton).toBeDisabled(); - }); - - /** 正常な日付の場合に保存ボタンが有効であること */ - it("正常な日付の場合に保存ボタンが有効である", () => { - const onSave = vi.fn(); - const onClose = vi.fn(); - - render( - , - ); - - const saveButton = screen.getByRole("button", { name: "保存" }); - expect(saveButton).not.toBeDisabled(); - }); - - /** 正常な日付の場合にエラーメッセージが表示されないこと */ - it("正常な日付の場合にエラーメッセージが表示されない", () => { - const onSave = vi.fn(); - const onClose = vi.fn(); - - render( - , - ); - - expect( - screen.queryByText(/開始日は終了日より前に設定してください/), - ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); }); }); diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 5dc79272..3573be7c 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -30,7 +30,7 @@ type ProjectModalProps = { /** プロジェクト情報を初期化する */ function initProject(project: CareerProjectForm | null): CareerProjectForm { if (project) { - return JSON.parse(JSON.stringify(project)); + return structuredClone(project); } return { name: "", diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx index 8a780e31..ffc5e1ba 100644 --- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx +++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx @@ -1,17 +1,8 @@ import { useMemo } from "react"; -import { - blankCareerClient, - blankCareerExperience, - blankCareerProject, - blankCareerTechnologyStack, -} from "../../../constants"; -import type { - CareerExperienceFieldKey, - CareerClientFieldKey, -} from "../../../formTypes"; -import type { CareerExperienceForm, CareerProjectForm, CareerFormState } from "../../../payloadBuilders"; +import type { CareerExperienceForm, CareerFormState, CareerProjectForm } from "../../../payloadBuilders"; import type { TechStackMasterItem } from "../../../types"; +import { useCareerExperienceMutators } from "../../../hooks/useCareerExperienceMutators"; import { useProjectModalState } from "../../../hooks/useProjectModalState"; import shared from "../../../styles/shared.module.css"; import { CareerExperienceEditor } from "../CareerFormEditors/CareerExperienceEditor"; @@ -29,9 +20,8 @@ type CareerExperienceSectionProps = { /** * 職務経歴書の職務経歴セクション。 - * experiences の3階層(experience → client → project)の nested update ハンドラと - * useProjectModalState を一括で担う。 - * CareerResumeForm 本体から分離し、CRUD・モーダル操作の責務をここに集約する。 + * 更新ロジックは useCareerExperienceMutators に委譲し、 + * モーダル管理は useProjectModalState に委譲する。 */ export function CareerExperienceSection({ experiences, @@ -49,175 +39,7 @@ export function CareerExperienceSection({ return map; }, [techStackOptions]); - /** experience フィールド変更ハンドラ */ - const updateExperienceField = ( - index: number, - key: CareerExperienceFieldKey, - value: string | boolean, - ) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, i) => { - if (i !== index) return exp; - if (key === "is_current") { - const isCurrent = Boolean(value); - return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date }; - } - return { ...exp, [key]: value }; - }), - })); - }; - - /** client フィールド変更ハンドラ */ - const updateClientField = ( - expIndex: number, - clientIndex: number, - key: CareerClientFieldKey, - value: string, - ) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => { - if (ei !== expIndex) return exp; - return { - ...exp, - clients: exp.clients.map((c, ci) => - ci === clientIndex ? { ...c, [key]: value } : c, - ), - }; - }), - })); - }; - - /** 「取引先なし」フラグ切り替えハンドラ */ - const updateClientHasClient = (expIndex: number, clientIndex: number, value: boolean) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => { - if (ei !== expIndex) return exp; - return { - ...exp, - clients: exp.clients.map((c, ci) => - ci === clientIndex ? { ...c, has_client: value, name: value ? c.name : "" } : c, - ), - }; - }), - })); - }; - - /** 取引先追加ハンドラ */ - const addClient = (expIndex: number) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => - ei === expIndex - ? { ...exp, clients: [...exp.clients, { ...blankCareerClient }] } - : exp, - ), - })); - }; - - /** 取引先削除ハンドラ */ - const removeClient = (expIndex: number, clientIndex: number) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => { - if (ei !== expIndex) return exp; - return { - ...exp, - clients: - exp.clients.length === 1 - ? [{ ...blankCareerClient }] - : exp.clients.filter((_, ci) => ci !== clientIndex), - }; - }), - })); - }; - - /** プロジェクト削除ハンドラ */ - const removeProject = (expIndex: number, clientIndex: number, projIndex: number) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => { - if (ei !== expIndex) return exp; - return { - ...exp, - clients: exp.clients.map((c, ci) => { - if (ci !== clientIndex) return c; - return { - ...c, - projects: - c.projects.length === 1 - ? [{ ...blankCareerProject, technology_stacks: [{ ...blankCareerTechnologyStack }] }] - : c.projects.filter((_, pi) => pi !== projIndex), - }; - }), - }; - }), - })); - }; - - /** 職務経歴追加ハンドラ */ - const addExperience = () => { - setForm((prev) => ({ - ...prev, - experiences: [...prev.experiences, { ...blankCareerExperience }], - })); - }; - - /** 職務経歴削除ハンドラ */ - const removeExperience = (index: number) => { - setForm((prev) => ({ - ...prev, - experiences: - prev.experiences.length === 1 - ? [{ ...blankCareerExperience }] - : prev.experiences.filter((_, i) => i !== index), - })); - }; - - /** - * form の experiences からプロジェクトを取得するコールバック。 - * useProjectModalState に渡す。 - */ - const getProject = ( - expIndex: number, - clientIndex: number, - projIndex: number, - ): CareerProjectForm | null => { - return experiences[expIndex]?.clients[clientIndex]?.projects[projIndex] ?? null; - }; - - /** - * モーダルで保存されたプロジェクトをフォームに反映するコールバック。 - * useProjectModalState に渡す。 - */ - const onProjectSave = ( - expIndex: number, - clientIndex: number, - projIndex: number | null, - project: CareerProjectForm, - ) => { - setForm((prev) => ({ - ...prev, - experiences: prev.experiences.map((exp, ei) => { - if (ei !== expIndex) return exp; - return { - ...exp, - clients: exp.clients.map((c, ci) => { - if (ci !== clientIndex) return c; - if (projIndex === null) { - return { ...c, projects: [...c.projects, project] }; - } - return { - ...c, - projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)), - }; - }), - }; - }), - })); - }; + const mutators = useCareerExperienceMutators(experiences, setForm); const { modalTarget, @@ -225,9 +47,9 @@ export function CareerExperienceSection({ modalProject, handleProjectSave, closeModal, - } = useProjectModalState(getProject, onProjectSave); + } = useProjectModalState(mutators.getProject, mutators.onProjectSave); - /** プロジェクトのサマリーテキストを生成する */ + /** プロジェクトの期間サマリーテキストを生成する */ const projectSummary = (proj: CareerProjectForm) => { const period = [proj.start_date, proj.is_current ? "現在" : proj.end_date] .filter(Boolean) @@ -261,19 +83,19 @@ export function CareerExperienceSection({ key={`exp-${expIndex}`} exp={exp} expIndex={expIndex} - onUpdateExperienceField={updateExperienceField} - onUpdateClientField={updateClientField} - onUpdateClientHasClient={updateClientHasClient} - onAddClient={addClient} - onRemoveClient={removeClient} - onRemoveProject={removeProject} + onUpdateExperienceField={mutators.updateExperienceField} + onUpdateClientField={mutators.updateClientField} + onUpdateClientHasClient={mutators.updateClientHasClient} + onAddClient={mutators.addClient} + onRemoveClient={mutators.removeClient} + onRemoveProject={mutators.removeProject} onOpenProjectModal={handleOpenProjectModal} - onRemoveExperience={removeExperience} + onRemoveExperience={mutators.removeExperience} projectSummary={projectSummary} /> ))} - diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts index 04b62e89..cbeeceae 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts @@ -48,6 +48,23 @@ describe("useAsyncAnalysisPage", () => { expect(result.current.result).toBeNull(); }); + /** 初回マウント時に status が retrying の場合、polling フェーズに遷移すること */ + it("status が retrying の場合 polling フェーズに遷移する", async () => { + mockLoadCache.mockResolvedValue({ result: null, status: "retrying" }); + mockCheckStatus.mockResolvedValue({ status: "retrying" }); + + const { result } = renderHook(() => + useAsyncAnalysisPage({ + loadCache: mockLoadCache, + checkStatus: mockCheckStatus, + }), + ); + + await waitFor(() => { + expect(result.current.phase).toBe("polling"); + }); + }); + /** 初回マウント時に status が pending の場合、polling フェーズに遷移すること */ it("status が pending の場合 polling フェーズに遷移する", async () => { mockLoadCache.mockResolvedValue({ result: null, status: "pending" }); diff --git a/frontend/src/hooks/useBlogAccountManager.test.ts b/frontend/src/hooks/useBlogAccountManager.test.ts index 11a015e6..cb56b809 100644 --- a/frontend/src/hooks/useBlogAccountManager.test.ts +++ b/frontend/src/hooks/useBlogAccountManager.test.ts @@ -110,8 +110,8 @@ describe("useBlogAccountManager", () => { expect(result.current.success).toBe("アカウントを解除しました"); }); - /** getBlogAccounts がエラーの場合、error がセットされること */ - it("getBlogAccounts がエラーの場合 error がセットされる", async () => { + /** getBlogAccounts がエラーの場合、accountError がセットされること */ + it("getBlogAccounts がエラーの場合 accountError がセットされる", async () => { api.getBlogAccounts.mockRejectedValue(new Error("ネットワークエラー")); const { result } = renderHook(() => useBlogAccountManager("all")); @@ -120,7 +120,37 @@ describe("useBlogAccountManager", () => { expect(result.current.loading).toBe(false); }); - expect(result.current.error).toBe("ネットワークエラー"); + expect(result.current.accountError).toBe("ネットワークエラー"); + }); + + /** + * addBlogAccount と syncBlogAccount の両方が成功した場合、 + * 同期件数を含む success メッセージがセットされること + */ + it("addBlogAccount 成功 + syncBlogAccount 成功の場合 synced_count を含む success がセットされる", async () => { + api.getBlogAccounts + .mockResolvedValueOnce([]) + .mockResolvedValue(dummyAccounts); + api.getBlogArticles.mockResolvedValue(dummyArticles); + api.addBlogAccount.mockResolvedValue(dummyAccounts[0]); + api.syncBlogAccount.mockResolvedValue({ synced_count: 3, total_count: 5 }); + + const { result } = renderHook(() => useBlogAccountManager("all")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + result.current.setDraftUsernames((prev) => ({ ...prev, zenn: "testuser" })); + }); + + await act(async () => { + await result.current.handleSave("zenn"); + }); + + expect(result.current.success).toBe("3件の記事を取得しました(合計: 5件)"); + expect(result.current.accountError).toBeNull(); }); /** @@ -151,8 +181,8 @@ describe("useBlogAccountManager", () => { // 連携成功メッセージが出ること expect(result.current.success).toBe("アカウントを連携しました"); - // 同期失敗エラーが出ること - expect(result.current.error).toBe("同期に失敗しました"); + // 同期失敗エラーが accountError にセットされること + expect(result.current.accountError).toBe("同期に失敗しました"); }); it("handleUpdate を呼ぶと updateBlogAccount が呼ばれ未同期状態に更新される", async () => { diff --git a/frontend/src/hooks/useCareerExperienceMutators.ts b/frontend/src/hooks/useCareerExperienceMutators.ts new file mode 100644 index 00000000..4ac001ee --- /dev/null +++ b/frontend/src/hooks/useCareerExperienceMutators.ts @@ -0,0 +1,208 @@ +import { + blankCareerClient, + blankCareerExperience, + blankCareerProject, + blankCareerTechnologyStack, +} from "../constants"; +import type { + CareerClientFieldKey, + CareerExperienceFieldKey, +} from "../formTypes"; +import type { + CareerExperienceForm, + CareerFormState, + CareerProjectForm, +} from "../payloadBuilders"; + +/** + * 職務経歴フォームの experience / client / project 三階層に対する + * nested update ハンドラをまとめて提供するカスタムフック。 + * CareerExperienceSection の責務をデータ操作のみに絞るために分離。 + */ +export function useCareerExperienceMutators( + experiences: CareerExperienceForm[], + setForm: React.Dispatch>, +) { + /** experience フィールド変更ハンドラ */ + const updateExperienceField = ( + index: number, + key: CareerExperienceFieldKey, + value: string | boolean, + ) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, i) => { + if (i !== index) return exp; + if (key === "is_current") { + const isCurrent = Boolean(value); + return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date }; + } + return { ...exp, [key]: value }; + }), + })); + }; + + /** client フィールド変更ハンドラ */ + const updateClientField = ( + expIndex: number, + clientIndex: number, + key: CareerClientFieldKey, + value: string, + ) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => { + if (ei !== expIndex) return exp; + return { + ...exp, + clients: exp.clients.map((c, ci) => + ci === clientIndex ? { ...c, [key]: value } : c, + ), + }; + }), + })); + }; + + /** 「取引先なし」フラグ切り替えハンドラ */ + const updateClientHasClient = (expIndex: number, clientIndex: number, value: boolean) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => { + if (ei !== expIndex) return exp; + return { + ...exp, + clients: exp.clients.map((c, ci) => + ci === clientIndex ? { ...c, has_client: value, name: value ? c.name : "" } : c, + ), + }; + }), + })); + }; + + /** 取引先追加ハンドラ */ + const addClient = (expIndex: number) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => + ei === expIndex + ? { ...exp, clients: [...exp.clients, { ...blankCareerClient }] } + : exp, + ), + })); + }; + + /** 取引先削除ハンドラ */ + const removeClient = (expIndex: number, clientIndex: number) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => { + if (ei !== expIndex) return exp; + return { + ...exp, + clients: + exp.clients.length === 1 + ? [{ ...blankCareerClient }] + : exp.clients.filter((_, ci) => ci !== clientIndex), + }; + }), + })); + }; + + /** プロジェクト削除ハンドラ */ + const removeProject = (expIndex: number, clientIndex: number, projIndex: number) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => { + if (ei !== expIndex) return exp; + return { + ...exp, + clients: exp.clients.map((c, ci) => { + if (ci !== clientIndex) return c; + return { + ...c, + projects: + c.projects.length === 1 + ? [{ ...blankCareerProject, technology_stacks: [{ ...blankCareerTechnologyStack }] }] + : c.projects.filter((_, pi) => pi !== projIndex), + }; + }), + }; + }), + })); + }; + + /** 職務経歴追加ハンドラ */ + const addExperience = () => { + setForm((prev) => ({ + ...prev, + experiences: [...prev.experiences, { ...blankCareerExperience }], + })); + }; + + /** 職務経歴削除ハンドラ */ + const removeExperience = (index: number) => { + setForm((prev) => ({ + ...prev, + experiences: + prev.experiences.length === 1 + ? [{ ...blankCareerExperience }] + : prev.experiences.filter((_, i) => i !== index), + })); + }; + + /** + * form の experiences から指定座標のプロジェクトを取得する。 + * useProjectModalState に渡すコールバック用。 + */ + const getProject = ( + expIndex: number, + clientIndex: number, + projIndex: number, + ): CareerProjectForm | null => { + return experiences[expIndex]?.clients[clientIndex]?.projects[projIndex] ?? null; + }; + + /** + * モーダルで保存されたプロジェクトをフォームに反映する。 + * useProjectModalState に渡すコールバック用。 + */ + const onProjectSave = ( + expIndex: number, + clientIndex: number, + projIndex: number | null, + project: CareerProjectForm, + ) => { + setForm((prev) => ({ + ...prev, + experiences: prev.experiences.map((exp, ei) => { + if (ei !== expIndex) return exp; + return { + ...exp, + clients: exp.clients.map((c, ci) => { + if (ci !== clientIndex) return c; + if (projIndex === null) { + return { ...c, projects: [...c.projects, project] }; + } + return { + ...c, + projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)), + }; + }), + }; + }), + })); + }; + + return { + updateExperienceField, + updateClientField, + updateClientHasClient, + addClient, + removeClient, + removeProject, + addExperience, + removeExperience, + getProject, + onProjectSave, + }; +} diff --git a/frontend/src/hooks/useNotifications.test.ts b/frontend/src/hooks/useNotifications.test.ts new file mode 100644 index 00000000..78004da4 --- /dev/null +++ b/frontend/src/hooks/useNotifications.test.ts @@ -0,0 +1,74 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useNotifications } from "./useNotifications"; +import type { Notification } from "../api/notifications"; + +vi.mock("../api/notifications", () => ({ + getNotifications: vi.fn(), + getUnreadCount: vi.fn(), + markAsRead: vi.fn(), + markAllAsRead: vi.fn(), +})); + +const dummyNotifications: Notification[] = [ + { id: "n-1", task_type: "analysis", status: "completed", title: "通知1", message: "内容1", is_read: false, created_at: "2024-01-01T00:00:00" }, + { id: "n-2", task_type: "analysis", status: "completed", title: "通知2", message: "内容2", is_read: false, created_at: "2024-01-02T00:00:00" }, +]; + +describe("useNotifications", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let api: Record; + + beforeEach(async () => { + vi.clearAllMocks(); + api = await import("../api/notifications"); + api.getUnreadCount.mockResolvedValue({ count: 0 }); + api.getNotifications.mockResolvedValue([]); + api.markAsRead.mockResolvedValue({ id: "n-1", is_read: true }); + api.markAllAsRead.mockResolvedValue({ updated: 2 }); + }); + + /** openPanel を呼ぶと getNotifications が実行され未読通知のみ表示されること */ + it("openPanel を呼ぶと未読通知のみ notifications にセットされる", async () => { + api.getNotifications.mockResolvedValue([ + ...dummyNotifications, + { id: "n-3", task_type: "analysis", status: "completed", title: "既読", message: "既読内容", is_read: true, created_at: "2024-01-03T00:00:00" }, + ]); + + const { result } = renderHook(() => useNotifications()); + + await act(async () => { + result.current.openPanel(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.notifications).toHaveLength(2); + expect(result.current.notifications.every((n) => !n.is_read)).toBe(true); + expect(result.current.isOpen).toBe(true); + }); + + /** handleMarkAllAsRead を呼ぶと markAllAsRead が実行され unreadCount が 0 になること */ + it("markAllAsRead を呼ぶと unreadCount が 0 になる", async () => { + api.getNotifications.mockResolvedValue(dummyNotifications); + + const { result } = renderHook(() => useNotifications()); + + await act(async () => { + result.current.openPanel(); + }); + + await waitFor(() => { + expect(result.current.unreadCount).toBe(2); + }); + + await act(async () => { + await result.current.markAllAsRead(); + }); + + expect(api.markAllAsRead).toHaveBeenCalledTimes(1); + expect(result.current.unreadCount).toBe(0); + }); +}); diff --git a/frontend/src/hooks/useProjectModalState.test.ts b/frontend/src/hooks/useProjectModalState.test.ts index a52987dd..f84d5bb8 100644 --- a/frontend/src/hooks/useProjectModalState.test.ts +++ b/frontend/src/hooks/useProjectModalState.test.ts @@ -3,65 +3,23 @@ import { describe, it, expect, vi } from "vitest"; import { useProjectModalState } from "./useProjectModalState"; import type { CareerProjectForm } from "../payloadBuilders"; -/** テスト用のダミープロジェクトデータ */ const dummyProject: CareerProjectForm = { name: "テストプロジェクト", start_date: "2024-01", end_date: "2024-12", is_current: false, role: "エンジニア", - description: "説明", - challenge: "課題", - action: "対応", - result: "結果", - team: { total: "5", members: [] }, + description: "", + challenge: "", + action: "", + result: "", + team: { total: "", members: [] }, technology_stacks: [], phases: [], }; describe("useProjectModalState", () => { - /** 初期状態では modalTarget が null であること */ - it("初期状態では modalTarget が null", () => { - const getProject = vi.fn(); - const onSave = vi.fn(); - const { result } = renderHook(() => useProjectModalState(getProject, onSave)); - - expect(result.current.modalTarget).toBeNull(); - }); - - /** setModalTarget を呼ぶと modalTarget が更新されること */ - it("setModalTarget を呼ぶと modalTarget が更新される", () => { - const getProject = vi.fn(); - const onSave = vi.fn(); - const { result } = renderHook(() => useProjectModalState(getProject, onSave)); - - act(() => { - result.current.setModalTarget({ expIndex: 0, clientIndex: 1, projIndex: 2 }); - }); - - expect(result.current.modalTarget).toEqual({ expIndex: 0, clientIndex: 1, projIndex: 2 }); - }); - - /** closeModal を呼ぶと modalTarget が null に戻ること */ - it("closeModal を呼ぶと modalTarget が null に戻る", () => { - const getProject = vi.fn(); - const onSave = vi.fn(); - const { result } = renderHook(() => useProjectModalState(getProject, onSave)); - - act(() => { - result.current.setModalTarget({ expIndex: 0, clientIndex: 0, projIndex: 0 }); - }); - - expect(result.current.modalTarget).not.toBeNull(); - - act(() => { - result.current.closeModal(); - }); - - expect(result.current.modalTarget).toBeNull(); - }); - - /** handleProjectSave を呼ぶと onSave が実行され、modalTarget が null になること */ + /** handleProjectSave を呼ぶと onSave が実行され modalTarget が null になること */ it("handleProjectSave を呼ぶと onSave が呼ばれ closeModal が実行される", () => { const getProject = vi.fn().mockReturnValue(dummyProject); const onSave = vi.fn(); @@ -78,32 +36,4 @@ describe("useProjectModalState", () => { expect(onSave).toHaveBeenCalledWith(1, 2, 3, dummyProject); expect(result.current.modalTarget).toBeNull(); }); - - /** modalTarget が null のときに handleProjectSave を呼んでも onSave が実行されないこと */ - it("modalTarget が null のとき handleProjectSave を呼んでも onSave が呼ばれない", () => { - const getProject = vi.fn(); - const onSave = vi.fn(); - const { result } = renderHook(() => useProjectModalState(getProject, onSave)); - - act(() => { - result.current.handleProjectSave(dummyProject); - }); - - expect(onSave).not.toHaveBeenCalled(); - }); - - /** projIndex が null の場合(新規追加)、modalProject が null であること */ - it("projIndex が null のとき modalProject が null になる", () => { - const getProject = vi.fn().mockReturnValue(dummyProject); - const onSave = vi.fn(); - const { result } = renderHook(() => useProjectModalState(getProject, onSave)); - - act(() => { - result.current.setModalTarget({ expIndex: 0, clientIndex: 0, projIndex: null }); - }); - - expect(result.current.modalProject).toBeNull(); - // 新規追加時は getProject を呼ばない - expect(getProject).not.toHaveBeenCalled(); - }); }); diff --git a/frontend/src/hooks/useProjectModalState.validation.test.ts b/frontend/src/hooks/useProjectModalState.validation.test.ts deleted file mode 100644 index c53e4b2b..00000000 --- a/frontend/src/hooks/useProjectModalState.validation.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { validateDateRange } from "../payloadBuilders"; - -/** - * ProjectModal で使用される日付バリデーション関数のテスト。 - * 開始日 > 終了日 の場合にエラーメッセージを返すことを確認する。 - */ -describe("validateDateRange (ProjectModal 日付バリデーション)", () => { - /** 開始日 > 終了日 の場合にエラーメッセージが返されること */ - it("開始日が終了日より後の場合にエラーメッセージが返される", () => { - const error = validateDateRange("2024-06", "2024-01", false); - expect(error).not.toBeNull(); - expect(error).toContain("開始日"); - }); - - /** 開始日 === 終了日 の場合はエラーにならないこと */ - it("開始日と終了日が同じ場合はエラーにならない", () => { - const error = validateDateRange("2024-01", "2024-01", false); - expect(error).toBeNull(); - }); - - /** 開始日 < 終了日 の場合はエラーにならないこと */ - it("開始日が終了日より前の場合はエラーにならない", () => { - const error = validateDateRange("2024-01", "2024-12", false); - expect(error).toBeNull(); - }); - - /** is_current が true の場合は終了日にかかわらずエラーにならないこと */ - it("is_current が true の場合は終了日が不正でもエラーにならない", () => { - const error = validateDateRange("2024-06", "2024-01", true); - expect(error).toBeNull(); - }); - - /** 開始日が空の場合はエラーにならないこと */ - it("開始日が空の場合はエラーにならない", () => { - const error = validateDateRange("", "2024-01", false); - expect(error).toBeNull(); - }); - - /** 終了日が空の場合はエラーにならないこと */ - it("終了日が空の場合はエラーにならない", () => { - const error = validateDateRange("2024-01", "", false); - expect(error).toBeNull(); - }); -}); diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts new file mode 100644 index 00000000..ca070e27 --- /dev/null +++ b/frontend/src/payloadBuilders.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { validateDateRange } from "./payloadBuilders"; + +describe("validateDateRange", () => { + it("開始日が終了日より後の場合にエラーメッセージが返される", () => { + const error = validateDateRange("2024-06", "2024-01", false); + expect(error).not.toBeNull(); + expect(error).toContain("開始日"); + }); + + it("開始日と終了日が同じ場合はエラーにならない", () => { + expect(validateDateRange("2024-01", "2024-01", false)).toBeNull(); + }); + + it("開始日が終了日より前の場合はエラーにならない", () => { + expect(validateDateRange("2024-01", "2024-12", false)).toBeNull(); + }); + + it("is_current が true の場合は終了日が不正でもエラーにならない", () => { + expect(validateDateRange("2024-06", "2024-01", true)).toBeNull(); + }); + + it("開始日または終了日が空の場合はエラーにならない", () => { + expect(validateDateRange("", "2024-01", false)).toBeNull(); + expect(validateDateRange("2024-01", "", false)).toBeNull(); + }); +}); From 740699f90dbd1549a2d1d12f682d9481d25a714e Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 19:07:40 +0900 Subject: [PATCH 04/24] conf fix --- backend/app/routers/auth/oauth_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routers/auth/oauth_flow.py b/backend/app/routers/auth/oauth_flow.py index d81f9bb0..64dceebe 100644 --- a/backend/app/routers/auth/oauth_flow.py +++ b/backend/app/routers/auth/oauth_flow.py @@ -214,7 +214,7 @@ def begin_github_oauth( GITHUB_CLIENT_ID が未設定の場合は503を発生させる。 Args: - frontend_url: 認証完了後の遷移先(現状は `state` 検証のフロント実装で利用) + frontend_url: 認証完了後の遷移先。CALLBACK_BASE_URL 未設定時に redirect_uri のベースとして使用する。 Returns: (authorization_url, state) のタプル From 67daffb037d408e93e47e9f8ff974566a048cc21 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 3 May 2026 12:40:24 +0900 Subject: [PATCH 05/24] blog anarize remove update fix --- backend/app/routers/blog.py | 29 ++-------- backend/app/schemas/__init__.py | 54 ------------------- backend/app/schemas/blog.py | 18 ------- .../src/hooks/useBlogAccountManager.test.ts | 39 ++++++++++++++ 4 files changed, 42 insertions(+), 98 deletions(-) diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index 41bd1b84..b7a60c38 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -296,32 +296,6 @@ async def summarize_blog( {"user_id": user.id}, failure_message="タスクの開始に失敗しました", logger=logger, - ) - except Exception: - raise HTTPException( - status_code=500, - detail=get_error("task.dispatch_failed"), - ) - - return BlogSummaryResponse( - summary="", - available=False, - status="pending", - ) - - -@router.post("/summarize/retry", response_model=BlogSummaryResponse, status_code=202) -@limiter.limit("5/minute") -async def retry_summarize_blog( - request: Request, - background_tasks: BackgroundTasks, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """失敗したブログサマリタスクを手動で再実行する。 - - ``dead_letter`` 状態のキャッシュのみ再実行可能。記事は worker 側で - ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 """ cache = BlogSummaryCacheRepository(db, user.id).get() if not cache: @@ -347,8 +321,11 @@ async def retry_summarize_blog( background_tasks, TaskType.BLOG_SUMMARIZE, {"user_id": user.id}, +<<<<<<< HEAD failure_message="タスクの再実行に失敗しました", logger=logger, +======= +>>>>>>> 4fcedba (blog anarize remove update fix) ) except Exception: raise HTTPException( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index bc15970b..7bdd1df7 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -10,60 +10,6 @@ BlogScoreResponse, BlogSummaryArticleItem, BlogSummaryRequest, - BlogSummaryResponse, - BlogSyncResponse, -) -from .career_analysis import ( - ActionItem, - CareerAnalysisGenerateRequest, - CareerAnalysisResponse, - CareerAnalysisResult, - CareerPathItem, - StrengthItem, - TaskStatusResponse, - TechStackItem, - TechStackSection, -) -from .intelligence import ( - AnalysisResponse, - AnalyzeRequest, - CachedAnalysisResponse, - PositionAdviceResponse, - PositionScoresResponse, -) -from .master_data import ( - MasterItem, - MasterItemCreate, - MasterItemUpdate, - TechStackMasterCreate, - TechStackMasterItem, - TechStackMasterUpdate, -) -from .resume import ( - Client, - Experience, - Project, - ProjectTeam, - ResumeCreate, - ResumeQualificationItem, - ResumeResponse, - ResumeUpdate, - TeamMember, - TechnologyStackItem, -) - -__all__ = [ - "ActionItem", - "AnalysisResponse", - "AnalyzeRequest", - "BlogAccountCreate", - "BlogAccountResponse", - "BlogAccountUpdate", - "BlogArticleResponse", - "BlogScoreArticleResponse", - "BlogScoreResponse", - "BlogSummaryArticleItem", - "BlogSummaryRequest", "BlogSummaryResponse", "BlogSyncResponse", "CachedAnalysisResponse", diff --git a/backend/app/schemas/blog.py b/backend/app/schemas/blog.py index cea59824..ed73fcb3 100644 --- a/backend/app/schemas/blog.py +++ b/backend/app/schemas/blog.py @@ -53,24 +53,6 @@ class BlogSyncResponse(BaseModel): -class BlogSummaryArticleItem(BaseModel): - """サマリ生成リクエスト内の記事情報。""" - - platform: str - title: str - url: str - published_at: str | None = None - likes_count: int = 0 - summary: str | None = None - tags: list[str] = Field(default_factory=list) - - -class BlogSummaryRequest(BaseModel): - """ブログ記事 AI サマリ生成リクエスト(最大50件)。""" - - articles: list[BlogSummaryArticleItem] = Field(max_length=50) - - class BlogSummaryResponse(BaseModel): """ブログ記事 AI 分析レスポンス。""" diff --git a/frontend/src/hooks/useBlogAccountManager.test.ts b/frontend/src/hooks/useBlogAccountManager.test.ts index cb56b809..fe42387b 100644 --- a/frontend/src/hooks/useBlogAccountManager.test.ts +++ b/frontend/src/hooks/useBlogAccountManager.test.ts @@ -223,4 +223,43 @@ describe("useBlogAccountManager", () => { expect(result.current.accounts[0]?.last_synced_at).toBeNull(); expect(result.current.success).toBe("usernameを更新しました。再同期してください。"); }); + + it("handleUpdate を呼ぶと updateBlogAccount が呼ばれ未同期状態に更新される", async () => { + api.getBlogAccounts + .mockResolvedValueOnce(dummyAccounts) + .mockResolvedValueOnce([ + { + ...dummyAccounts[0], + username: "updated-user", + last_synced_at: null, + }, + ]); + api.getBlogArticles + .mockResolvedValueOnce(dummyArticles) + .mockResolvedValueOnce([]); + api.updateBlogAccount.mockResolvedValue({ + ...dummyAccounts[0], + username: "updated-user", + last_synced_at: null, + }); + + const { result } = renderHook(() => useBlogAccountManager("all")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let updated = false; + await act(async () => { + updated = await result.current.handleUpdate("zenn", "updated-user"); + }); + + expect(updated).toBe(true); + expect(api.updateBlogAccount).toHaveBeenCalledWith("zenn", "updated-user"); + await waitFor(() => { + expect(result.current.accounts[0]?.username).toBe("updated-user"); + }); + expect(result.current.accounts[0]?.last_synced_at).toBeNull(); + expect(result.current.success).toBe("usernameを更新しました。再同期してください。"); + }); }); From 7e4c625600b1657400768297559335e00872e697 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sun, 3 May 2026 21:39:23 +0900 Subject: [PATCH 06/24] set cokkie bug fix --- Makefile | 17 ++++ backend/app/routers/auth/endpoints.py | 2 + backend/app/routers/blog.py | 3 - frontend/package-lock.json | 15 ++++ frontend/package.json | 5 ++ frontend/src/components/analysis/TechBar.tsx | 6 -- scripts/firebase-proxy.mjs | 84 ++++++++++++++++++++ 7 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 scripts/firebase-proxy.mjs diff --git a/Makefile b/Makefile index bd2ec18b..10afe6dc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ .PHONY: help \ setup install-hooks install-backend install-frontend generate-keys \ +<<<<<<< HEAD dev dev-build dev-down dev-frontend preview-frontend \ +======= + dev dev-build dev-down dev-frontend dev-backend dev-proxy dev-proxy-only \ +>>>>>>> 0a550f7 (set cokkie bug fix) test test-backend test-frontend \ lint lint-backend lint-frontend lint-fix \ format format-check \ @@ -26,8 +30,15 @@ help: @echo " dev docker-compose で API + Ollama を起動" @echo " dev-build 再ビルドして起動" @echo " dev-down docker-compose を停止" +<<<<<<< HEAD @echo " dev-frontend Frontend 開発サーバーを起動 (Vite / localhost:5173)" @echo " preview-frontend ビルド済みを wrangler でローカル提供 (HMR なし / localhost:8788)" +======= + @echo " dev-frontend Vite dev server を起動" + @echo " dev-backend uvicorn で Backend を直接起動" + @echo " dev-proxy Vite + Firebase Cookie 再現プロキシを起動 (localhost:3000)" + @echo " dev-proxy-only プロキシのみ起動 (Vite が起動済みの場合)" +>>>>>>> 0a550f7 (set cokkie bug fix) @echo "" @echo "テスト・リント" @echo " ci lint + test + build-frontend を一括実行 (CI 相当)" @@ -99,6 +110,12 @@ dev-frontend: preview-frontend: nix develop --command bash -c "cd frontend && CLOUD_RUN_URL='http://localhost:8000' npm run build && npx wrangler pages dev dist --port 8788" +dev-proxy: + cd frontend && npm run dev:all + +dev-proxy-only: + cd frontend && npm run dev:proxy + # ------------------------------------------------------------------ # # テスト・リント # ------------------------------------------------------------------ # diff --git a/backend/app/routers/auth/endpoints.py b/backend/app/routers/auth/endpoints.py index 38143bdc..bec52e87 100644 --- a/backend/app/routers/auth/endpoints.py +++ b/backend/app/routers/auth/endpoints.py @@ -257,5 +257,7 @@ async def github_callback( callback_base = get_callback_base_url() or get_frontend_origin(frontend_url) redirect_uri = f"{callback_base}{GITHUB_CALLBACK_PATH}" token_response = await authenticate_github_user(db, payload.code, redirect_uri) + # __session を共用するため OAuth state を先にクリアしてからトークンを書き込む + clear_github_oauth_session(response) set_auth_cookies(response, token_response.username, db) return token_response diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index b7a60c38..4b2a131f 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -321,11 +321,8 @@ async def summarize_blog( background_tasks, TaskType.BLOG_SUMMARIZE, {"user_id": user.id}, -<<<<<<< HEAD failure_message="タスクの再実行に失敗しました", logger=logger, -======= ->>>>>>> 4fcedba (blog anarize remove update fix) ) except Exception: raise HTTPException( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7c178a8d..3386cf2e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3440,6 +3440,7 @@ "require-from-string": "^2.0.2" } }, +<<<<<<< HEAD "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3447,6 +3448,8 @@ "dev": true, "license": "MIT" }, +======= +>>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4116,6 +4119,7 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, +<<<<<<< HEAD "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -4126,6 +4130,8 @@ "url": "https://github.com/sponsors/antfu" } }, +======= +>>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6526,6 +6532,7 @@ "dev": true, "license": "ISC" }, +<<<<<<< HEAD "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -6584,6 +6591,8 @@ "node": ">=10" } }, +======= +>>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7092,6 +7101,7 @@ "dev": true, "license": "MIT" }, +<<<<<<< HEAD "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -7102,6 +7112,8 @@ "pathe": "^2.0.3" } }, +======= +>>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8014,6 +8026,7 @@ "dev": true, "license": "ISC" }, +<<<<<<< HEAD "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -8036,6 +8049,8 @@ } } }, +======= +>>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9111d42b..94a7aa91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,12 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", +<<<<<<< HEAD "deploy": "wrangler pages deploy dist --project-name=devforge" +======= + "dev:proxy": "node ../scripts/firebase-proxy.mjs", + "dev:all": "concurrently \"npm run dev\" \"npm run dev:proxy\"" +>>>>>>> 0a550f7 (set cokkie bug fix) }, "dependencies": { "@reduxjs/toolkit": "^2.11.2", diff --git a/frontend/src/components/analysis/TechBar.tsx b/frontend/src/components/analysis/TechBar.tsx index 41d6f929..907ba733 100644 --- a/frontend/src/components/analysis/TechBar.tsx +++ b/frontend/src/components/analysis/TechBar.tsx @@ -64,12 +64,6 @@ interface TechBarProps { techs: Record; /** リスト要素の aria-label */ ariaLabel?: string; -} - -/** - * フレームワーク・DevTools・インフラを使用リポジトリ数の割合バーで表示するコンポーネント。 - */ -export function TechBar({ techs, ariaLabel }: TechBarProps) { const [hoveredTech, setHoveredTech] = useState(null); const items = useMemo(() => { diff --git a/scripts/firebase-proxy.mjs b/scripts/firebase-proxy.mjs new file mode 100644 index 00000000..97b32416 --- /dev/null +++ b/scripts/firebase-proxy.mjs @@ -0,0 +1,84 @@ +/** + * Firebase Hosting Cookie strip 挙動をローカルで再現するプロキシサーバー。 + * + * Firebase Hosting は __session という名前の Cookie のみ Cloud Run に転送し、 + * それ以外の Cookie を除去する。このスクリプトは同じ挙動をローカルで再現する。 + * + * 使い方: + * make dev-proxy # Vite + プロキシを同時起動 + * make dev-proxy-only # プロキシのみ起動 + * + * ブラウザは http://localhost:3000 にアクセスすること。 + */ + +import express from "express"; +import { createProxyMiddleware } from "http-proxy-middleware"; + +const PROXY_PORT = parseInt(process.env.PROXY_PORT ?? "3000", 10); +const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8000"; +const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173"; + +const app = express(); + +// __session 以外の Cookie を除去するミドルウェア +function stripCookies(req, _res, next) { + const rawCookie = req.headers["cookie"]; + if (!rawCookie) { + next(); + return; + } + + const kept = []; + const removed = []; + + for (const part of rawCookie.split(";")) { + const name = part.trim().split("=")[0]; + if (name === "__session") { + kept.push(part.trim()); + } else { + removed.push(name); + } + } + + if (removed.length > 0) { + for (const name of removed) { + console.log(`[proxy] Cookie strip: ${name} → removed`); + } + if (kept.length > 0) { + console.log(`[proxy] Forwarding __session only`); + req.headers["cookie"] = kept.join("; "); + } else { + console.log(`[proxy] No __session found — Cookie header removed`); + delete req.headers["cookie"]; + } + } + + next(); +} + +// バックエンドへのプロキシ(Cookie strip あり) +const backendProxy = createProxyMiddleware({ + target: BACKEND_URL, + changeOrigin: true, +}); + +app.use("/auth", stripCookies, backendProxy); +app.use("/api", stripCookies, backendProxy); +app.use("/health", stripCookies, backendProxy); + +// フロントエンドへのプロキシ(Cookie strip なし) +app.use( + "/", + createProxyMiddleware({ + target: FRONTEND_URL, + changeOrigin: true, + ws: true, + }) +); + +app.listen(PROXY_PORT, () => { + console.log(`[proxy] Firebase Cookie 再現プロキシ起動`); + console.log(`[proxy] http://localhost:${PROXY_PORT} → frontend: ${FRONTEND_URL}`); + console.log(`[proxy] /auth /api /health → backend: ${BACKEND_URL}`); + console.log(`[proxy] __session 以外の Cookie を除去して転送`); +}); From 6c3a9897c1ad0b08749036702cb3785a5314e047 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 6 May 2026 10:50:46 +0900 Subject: [PATCH 07/24] set cokkie bug fix --- Makefile | 17 ++++++----------- frontend/package-lock.json | 15 --------------- frontend/package.json | 5 ----- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index 10afe6dc..ace8c164 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,6 @@ .PHONY: help \ setup install-hooks install-backend install-frontend generate-keys \ -<<<<<<< HEAD dev dev-build dev-down dev-frontend preview-frontend \ -======= - dev dev-build dev-down dev-frontend dev-backend dev-proxy dev-proxy-only \ ->>>>>>> 0a550f7 (set cokkie bug fix) test test-backend test-frontend \ lint lint-backend lint-frontend lint-fix \ format format-check \ @@ -30,15 +26,8 @@ help: @echo " dev docker-compose で API + Ollama を起動" @echo " dev-build 再ビルドして起動" @echo " dev-down docker-compose を停止" -<<<<<<< HEAD @echo " dev-frontend Frontend 開発サーバーを起動 (Vite / localhost:5173)" @echo " preview-frontend ビルド済みを wrangler でローカル提供 (HMR なし / localhost:8788)" -======= - @echo " dev-frontend Vite dev server を起動" - @echo " dev-backend uvicorn で Backend を直接起動" - @echo " dev-proxy Vite + Firebase Cookie 再現プロキシを起動 (localhost:3000)" - @echo " dev-proxy-only プロキシのみ起動 (Vite が起動済みの場合)" ->>>>>>> 0a550f7 (set cokkie bug fix) @echo "" @echo "テスト・リント" @echo " ci lint + test + build-frontend を一括実行 (CI 相当)" @@ -116,6 +105,12 @@ dev-proxy: dev-proxy-only: cd frontend && npm run dev:proxy +dev-proxy: + cd frontend && npm run dev:all + +dev-proxy-only: + cd frontend && npm run dev:proxy + # ------------------------------------------------------------------ # # テスト・リント # ------------------------------------------------------------------ # diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3386cf2e..7c178a8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3440,7 +3440,6 @@ "require-from-string": "^2.0.2" } }, -<<<<<<< HEAD "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3448,8 +3447,6 @@ "dev": true, "license": "MIT" }, -======= ->>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4119,7 +4116,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, -<<<<<<< HEAD "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -4130,8 +4126,6 @@ "url": "https://github.com/sponsors/antfu" } }, -======= ->>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6532,7 +6526,6 @@ "dev": true, "license": "ISC" }, -<<<<<<< HEAD "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -6591,8 +6584,6 @@ "node": ">=10" } }, -======= ->>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7101,7 +7092,6 @@ "dev": true, "license": "MIT" }, -<<<<<<< HEAD "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -7112,8 +7102,6 @@ "pathe": "^2.0.3" } }, -======= ->>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8026,7 +8014,6 @@ "dev": true, "license": "ISC" }, -<<<<<<< HEAD "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -8049,8 +8036,6 @@ } } }, -======= ->>>>>>> 0a550f7 (set cokkie bug fix) "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 94a7aa91..9111d42b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,12 +17,7 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", -<<<<<<< HEAD "deploy": "wrangler pages deploy dist --project-name=devforge" -======= - "dev:proxy": "node ../scripts/firebase-proxy.mjs", - "dev:all": "concurrently \"npm run dev\" \"npm run dev:proxy\"" ->>>>>>> 0a550f7 (set cokkie bug fix) }, "dependencies": { "@reduxjs/toolkit": "^2.11.2", From 06199b2400955411a0d019c054bb2d39cbf3dc05 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 6 May 2026 13:48:29 +0900 Subject: [PATCH 08/24] set cokkie bug fix --- backend/app/schemas/blog.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/app/schemas/blog.py b/backend/app/schemas/blog.py index ed73fcb3..cea59824 100644 --- a/backend/app/schemas/blog.py +++ b/backend/app/schemas/blog.py @@ -53,6 +53,24 @@ class BlogSyncResponse(BaseModel): +class BlogSummaryArticleItem(BaseModel): + """サマリ生成リクエスト内の記事情報。""" + + platform: str + title: str + url: str + published_at: str | None = None + likes_count: int = 0 + summary: str | None = None + tags: list[str] = Field(default_factory=list) + + +class BlogSummaryRequest(BaseModel): + """ブログ記事 AI サマリ生成リクエスト(最大50件)。""" + + articles: list[BlogSummaryArticleItem] = Field(max_length=50) + + class BlogSummaryResponse(BaseModel): """ブログ記事 AI 分析レスポンス。""" From 898df97baa4079c038d7507fb553c1cedeb28e2c Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 6 May 2026 21:18:04 +0900 Subject: [PATCH 09/24] set cokkie bug fix --- backend/app/routers/blog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index 4b2a131f..d43b01b9 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -16,7 +16,7 @@ import logging from dataclasses import asdict -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from ..core.errors import resolve_async_error_code @@ -32,6 +32,7 @@ BlogAccountUpdate, BlogArticleResponse, BlogScoreResponse, + BlogSummaryRequest, BlogSummaryResponse, BlogSyncResponse, ) From 89932b6704bcb0e2cca66d6c6a8a7e7a07bf589f Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 7 May 2026 14:20:46 +0900 Subject: [PATCH 10/24] set cokkie bug fix --- backend/app/routers/auth/endpoints.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/app/routers/auth/endpoints.py b/backend/app/routers/auth/endpoints.py index bec52e87..38143bdc 100644 --- a/backend/app/routers/auth/endpoints.py +++ b/backend/app/routers/auth/endpoints.py @@ -257,7 +257,5 @@ async def github_callback( callback_base = get_callback_base_url() or get_frontend_origin(frontend_url) redirect_uri = f"{callback_base}{GITHUB_CALLBACK_PATH}" token_response = await authenticate_github_user(db, payload.code, redirect_uri) - # __session を共用するため OAuth state を先にクリアしてからトークンを書き込む - clear_github_oauth_session(response) set_auth_cookies(response, token_response.username, db) return token_response From c8b5461752097d0197cf862a9b37a9ab617d3de5 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 7 May 2026 20:50:13 +0900 Subject: [PATCH 11/24] local firebase emulater --- Makefile | 6 --- .../scripts}/firebase-proxy.mjs | 53 ++++++++++++++----- 2 files changed, 40 insertions(+), 19 deletions(-) rename {scripts => frontend/scripts}/firebase-proxy.mjs (55%) diff --git a/Makefile b/Makefile index ace8c164..3b65642a 100644 --- a/Makefile +++ b/Makefile @@ -105,12 +105,6 @@ dev-proxy: dev-proxy-only: cd frontend && npm run dev:proxy -dev-proxy: - cd frontend && npm run dev:all - -dev-proxy-only: - cd frontend && npm run dev:proxy - # ------------------------------------------------------------------ # # テスト・リント # ------------------------------------------------------------------ # diff --git a/scripts/firebase-proxy.mjs b/frontend/scripts/firebase-proxy.mjs similarity index 55% rename from scripts/firebase-proxy.mjs rename to frontend/scripts/firebase-proxy.mjs index 97b32416..3fc0a320 100644 --- a/scripts/firebase-proxy.mjs +++ b/frontend/scripts/firebase-proxy.mjs @@ -56,29 +56,56 @@ function stripCookies(req, _res, next) { next(); } +/** + * 3xx レスポンスから Set-Cookie を除去する。 + * + * Firebase Hosting CDN は 303 等のリダイレクトレスポンスに付いた Set-Cookie を転送しない。 + * この挙動をローカルで再現することで、HTML リダイレクト方式の修正が正しく機能するか検証できる。 + */ +function stripRedirectSetCookie(proxyRes) { + if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400) { + const cookies = proxyRes.headers["set-cookie"]; + if (cookies) { + console.log( + `[proxy] ${proxyRes.statusCode} response: Set-Cookie を除去 (${cookies.length} 件) — Firebase CDN 挙動の再現` + ); + delete proxyRes.headers["set-cookie"]; + } + } +} + // バックエンドへのプロキシ(Cookie strip あり) const backendProxy = createProxyMiddleware({ target: BACKEND_URL, changeOrigin: true, + on: { + proxyRes: stripRedirectSetCookie, + }, +}); + +const frontendProxy = createProxyMiddleware({ + target: FRONTEND_URL, + changeOrigin: true, + ws: true, }); -app.use("/auth", stripCookies, backendProxy); -app.use("/api", stripCookies, backendProxy); -app.use("/health", stripCookies, backendProxy); +// app.use("/auth", ...) のような Express prefix matching は req.url から /auth を除去するため、 +// バックエンドに /github/login-url のような不完全なパスが届く。 +// catch-all で受けて req.url を自前でチェックし、パス全体を保持したままルーティングする。 +const BACKEND_PREFIXES = ["/auth", "/api", "/health"]; -// フロントエンドへのプロキシ(Cookie strip なし) -app.use( - "/", - createProxyMiddleware({ - target: FRONTEND_URL, - changeOrigin: true, - ws: true, - }) -); +app.use((req, res, next) => { + if (BACKEND_PREFIXES.some((prefix) => req.url.startsWith(prefix))) { + stripCookies(req, res, () => backendProxy(req, res, next)); + } else { + frontendProxy(req, res, next); + } +}); app.listen(PROXY_PORT, () => { console.log(`[proxy] Firebase Cookie 再現プロキシ起動`); console.log(`[proxy] http://localhost:${PROXY_PORT} → frontend: ${FRONTEND_URL}`); console.log(`[proxy] /auth /api /health → backend: ${BACKEND_URL}`); - console.log(`[proxy] __session 以外の Cookie を除去して転送`); + console.log(`[proxy] リクエスト: __session 以外の Cookie を除去して転送`); + console.log(`[proxy] レスポンス: 3xx リダイレクトの Set-Cookie を除去 (Firebase CDN 挙動の再現)`); }); From a6250a0752bc25cce2ca9ddc9d141c57ecc5945a Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 9 May 2026 14:14:25 +0900 Subject: [PATCH 12/24] firebase resourse remove --- frontend/scripts/firebase-proxy.mjs | 111 ---------------------------- 1 file changed, 111 deletions(-) delete mode 100644 frontend/scripts/firebase-proxy.mjs diff --git a/frontend/scripts/firebase-proxy.mjs b/frontend/scripts/firebase-proxy.mjs deleted file mode 100644 index 3fc0a320..00000000 --- a/frontend/scripts/firebase-proxy.mjs +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Firebase Hosting Cookie strip 挙動をローカルで再現するプロキシサーバー。 - * - * Firebase Hosting は __session という名前の Cookie のみ Cloud Run に転送し、 - * それ以外の Cookie を除去する。このスクリプトは同じ挙動をローカルで再現する。 - * - * 使い方: - * make dev-proxy # Vite + プロキシを同時起動 - * make dev-proxy-only # プロキシのみ起動 - * - * ブラウザは http://localhost:3000 にアクセスすること。 - */ - -import express from "express"; -import { createProxyMiddleware } from "http-proxy-middleware"; - -const PROXY_PORT = parseInt(process.env.PROXY_PORT ?? "3000", 10); -const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8000"; -const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173"; - -const app = express(); - -// __session 以外の Cookie を除去するミドルウェア -function stripCookies(req, _res, next) { - const rawCookie = req.headers["cookie"]; - if (!rawCookie) { - next(); - return; - } - - const kept = []; - const removed = []; - - for (const part of rawCookie.split(";")) { - const name = part.trim().split("=")[0]; - if (name === "__session") { - kept.push(part.trim()); - } else { - removed.push(name); - } - } - - if (removed.length > 0) { - for (const name of removed) { - console.log(`[proxy] Cookie strip: ${name} → removed`); - } - if (kept.length > 0) { - console.log(`[proxy] Forwarding __session only`); - req.headers["cookie"] = kept.join("; "); - } else { - console.log(`[proxy] No __session found — Cookie header removed`); - delete req.headers["cookie"]; - } - } - - next(); -} - -/** - * 3xx レスポンスから Set-Cookie を除去する。 - * - * Firebase Hosting CDN は 303 等のリダイレクトレスポンスに付いた Set-Cookie を転送しない。 - * この挙動をローカルで再現することで、HTML リダイレクト方式の修正が正しく機能するか検証できる。 - */ -function stripRedirectSetCookie(proxyRes) { - if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400) { - const cookies = proxyRes.headers["set-cookie"]; - if (cookies) { - console.log( - `[proxy] ${proxyRes.statusCode} response: Set-Cookie を除去 (${cookies.length} 件) — Firebase CDN 挙動の再現` - ); - delete proxyRes.headers["set-cookie"]; - } - } -} - -// バックエンドへのプロキシ(Cookie strip あり) -const backendProxy = createProxyMiddleware({ - target: BACKEND_URL, - changeOrigin: true, - on: { - proxyRes: stripRedirectSetCookie, - }, -}); - -const frontendProxy = createProxyMiddleware({ - target: FRONTEND_URL, - changeOrigin: true, - ws: true, -}); - -// app.use("/auth", ...) のような Express prefix matching は req.url から /auth を除去するため、 -// バックエンドに /github/login-url のような不完全なパスが届く。 -// catch-all で受けて req.url を自前でチェックし、パス全体を保持したままルーティングする。 -const BACKEND_PREFIXES = ["/auth", "/api", "/health"]; - -app.use((req, res, next) => { - if (BACKEND_PREFIXES.some((prefix) => req.url.startsWith(prefix))) { - stripCookies(req, res, () => backendProxy(req, res, next)); - } else { - frontendProxy(req, res, next); - } -}); - -app.listen(PROXY_PORT, () => { - console.log(`[proxy] Firebase Cookie 再現プロキシ起動`); - console.log(`[proxy] http://localhost:${PROXY_PORT} → frontend: ${FRONTEND_URL}`); - console.log(`[proxy] /auth /api /health → backend: ${BACKEND_URL}`); - console.log(`[proxy] リクエスト: __session 以外の Cookie を除去して転送`); - console.log(`[proxy] レスポンス: 3xx リダイレクトの Set-Cookie を除去 (Firebase CDN 挙動の再現)`); -}); From c09b8af818efbc65a8f4b391646592cf8dcd5f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:23:24 +0000 Subject: [PATCH 13/24] =?UTF-8?q?infra:=20Terraform=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=20OpenTofu=20=E3=81=AB=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88?= =?UTF-8?q?=E3=80=81CLI=20=E3=82=92=20Nix=20=E7=AE=A1=E7=90=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flake.nix に opentofu パッケージを追加し、dev shell から `tofu` を提供 - .github/workflows/terraform-ci.yml を opentofu-ci.yml にリネームし、 hashicorp/setup-terraform を opentofu/setup-opentofu@v1 に差し替え - 旧 .terraform.lock.hcl を削除(初回 tofu init で registry.opentofu.org ベースに再生成する想定) - Makefile に infra-fmt / infra-validate-{dev,stg,prod} を追加 - .claude/rules/infra/terraform.md を opentofu.md にリネームし内容更新 - README / infra/README / runbook の terraform 表記を tofu / OpenTofu に更新 GCS backend 互換のため state はそのまま流用可能。Branch protection の status check 名は terraform-* から opentofu-* に変わる点に注意。 --- flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flake.nix b/flake.nix index ee2c286e..2333761e 100644 --- a/flake.nix +++ b/flake.nix @@ -52,6 +52,9 @@ # --- IaC --- opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) + # --- IaC --- + opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) + # --- 共通ツール --- git gh # GitHub CLI From 7e9d7fb49b00a13df5893a1fd96c533576883e49 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Tue, 12 May 2026 16:29:52 +0900 Subject: [PATCH 14/24] env local fix --- backend/app/db/bootstrap.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/app/db/bootstrap.py b/backend/app/db/bootstrap.py index b566dab2..37e67da0 100644 --- a/backend/app/db/bootstrap.py +++ b/backend/app/db/bootstrap.py @@ -5,6 +5,23 @@ from .migrations import run_migrations +def _validate_jwt_keys() -> None: + """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" + from jose import JWTError, jwt + + from ..core.settings import get_jwt_private_key, get_jwt_public_key + + try: + priv = get_jwt_private_key() + pub = get_jwt_public_key() + tok = jwt.encode({"sub": "__bootstrap_check__"}, priv, algorithm="RS256") + jwt.decode(tok, pub, algorithms=["RS256"]) + except JWTError as e: + raise RuntimeError( + f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" + ) from e + + def _validate_jwt_keys() -> None: """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" from jose import JWTError, jwt From c87d13aa916a3dc7c156798fcd5dc8c1d3223ac0 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 14 May 2026 15:11:44 +0900 Subject: [PATCH 15/24] turso env --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index d86f38ea..5e9ff8c9 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,40 @@ TURSO_AUTH_TOKEN= > **注意**: 旧 SQLite ファイル方式(`data/devforge.sqlite` の bind mount, DBeaver の SQLite 直接接続)は廃止しました。 +#### Turso (libSQL) ローカル起動 + +SQLite → Turso 移行の準備として、`turso dev` で libSQL 互換のローカルサーバーを起動できます。 +Turso CLI は `flake.nix` で管理されているため、`nix develop` 後にそのまま利用可能です。 + +```bash +# 既存の SQLite ファイルを引き継いだまま libSQL HTTP サーバーを起動(ホスト側で別ターミナル) +turso dev --db-path ./backend/local.sqlite +# 起動すると http://127.0.0.1:8080 で待ち受ける +``` + +接続用の環境変数は `backend/.env.example` を参考に `backend/.env` へ設定してください。 + +``` +TURSO_DATABASE_URL=http://127.0.0.1:8080 +TURSO_AUTH_TOKEN= +``` + +Docker コンテナ内から接続する場合は、コンテナ → ホストの `turso dev` に到達できるよう +`host.docker.internal` を使用します(`docker-compose.yml` の `extra_hosts` で設定済み)。 +プロジェクトルートの `.env` を以下のように書き換えてから `docker compose up` してください。 + +``` +TURSO_DATABASE_URL=http://host.docker.internal:8080 +TURSO_AUTH_TOKEN= +``` + +##### TablePlus からローカル Turso に接続する + +1. TablePlus で **新規接続** → **libSQL** を選択 +2. **URL** に `http://127.0.0.1:8080` を指定 +3. **Token** は空のままで OK +4. **テスト** → **接続** + --- ### 本番デプロイ(GCP) From bb1a25ff34e1387fe3c701575db58e776e49c1cf Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 14 May 2026 17:49:51 +0900 Subject: [PATCH 16/24] turso setting add --- backend/app/routers/admin.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 backend/app/routers/admin.py diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 00000000..c51debf6 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,34 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from ..core.messages import get_error +from ..core.security.dependencies import verify_admin_token +from ..core.settings import get_turso_database_url +from ..db.sqlite_backup import backup_sqlite_to_gcs + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.post("/backup") +def admin_backup( + _: None = Depends(verify_admin_token), +) -> dict[str, str]: + # Turso (libSQL) モード時は GCS への SQLite バックアップが無効。 + # Issue 3 のインフラ移行で本エンドポイント自体を廃止する予定 + if get_turso_database_url(): + raise HTTPException( + status_code=409, + detail="Turso モードでは SQLite の GCS バックアップは無効です。", + ) + try: + return backup_sqlite_to_gcs() + except RuntimeError as error: + logging.warning("sqlite backup runtime error: %s", error) + raise HTTPException( + status_code=503, + detail=get_error("server.backup_service_unavailable"), + ) from error + except Exception as error: + logging.exception("sqlite backup failed") + raise HTTPException(status_code=500, detail=get_error("server.backup_failed")) from error From d25de8a3383c1b59fc2a30f17e1216b23a171bef Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 14 May 2026 20:39:39 +0900 Subject: [PATCH 17/24] sqlite logic remove --- .claude/rules/infra/terraform.md | 16 ++++++++ README.md | 11 ++++-- backend/app/routers/admin.py | 34 ---------------- infra/environments/prod/.terraform.lock.hcl | 44 +++++++++++++++++++++ infra/environments/stg/.terraform.lock.hcl | 44 +++++++++++++++++++++ 5 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 .claude/rules/infra/terraform.md delete mode 100644 backend/app/routers/admin.py create mode 100644 infra/environments/prod/.terraform.lock.hcl create mode 100644 infra/environments/stg/.terraform.lock.hcl diff --git a/.claude/rules/infra/terraform.md b/.claude/rules/infra/terraform.md new file mode 100644 index 00000000..149dc937 --- /dev/null +++ b/.claude/rules/infra/terraform.md @@ -0,0 +1,16 @@ +--- +paths: + - infra/** +--- + +# Infrastructure (Terraform) + +``` +infra/ +├── modules/ # cloud_run, artifact_registry, cloud_tasks, cloudflare, monitoring, service_account +└── environments/ # dev, stg, prod(各環境で tfvars 管理) +``` + +デプロイ: GitHub Actions で `dev` ブランチ push 時に frontend → Cloudflare Pages、backend → Docker → Artifact Registry → Cloud Run。 + +DB は Turso (libSQL) を使用。Terraform 対象外で `turso CLI` 手動管理。詳細は `infra/README.md` の「Turso のセットアップ」参照。 diff --git a/README.md b/README.md index 5e9ff8c9..ed02f242 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,15 @@ TURSO_AUTH_TOKEN= #### Turso (libSQL) ローカル起動 -SQLite → Turso 移行の準備として、`turso dev` で libSQL 互換のローカルサーバーを起動できます。 +DB は Turso (libSQL) を使用します。`turso dev` で libSQL 互換のローカルサーバーを起動できます。 Turso CLI は `flake.nix` で管理されているため、`nix develop` 後にそのまま利用可能です。 +> **注意**: `turso dev` 実行には別途 `sqld` (libsql-server) のインストールが必要です(nixpkgs に未収録)。 +> 公式手順: https://docs.turso.tech/local-development/sqld + ```bash -# 既存の SQLite ファイルを引き継いだまま libSQL HTTP サーバーを起動(ホスト側で別ターミナル) -turso dev --db-path ./backend/local.sqlite +# libSQL HTTP サーバーを起動(ホスト側で別ターミナル) +turso dev --db-file ./backend/local.sqlite # 起動すると http://127.0.0.1:8080 で待ち受ける ``` @@ -183,6 +186,8 @@ TURSO_AUTH_TOKEN= 3. **Token** は空のままで OK 4. **テスト** → **接続** +> **注意**: 旧 SQLite ファイル方式は廃止しました。`data/devforge.sqlite` の bind mount や DBeaver の SQLite 直接接続は使えません。 + --- ### 本番デプロイ(GCP) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py deleted file mode 100644 index c51debf6..00000000 --- a/backend/app/routers/admin.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -from fastapi import APIRouter, Depends, HTTPException - -from ..core.messages import get_error -from ..core.security.dependencies import verify_admin_token -from ..core.settings import get_turso_database_url -from ..db.sqlite_backup import backup_sqlite_to_gcs - -router = APIRouter(prefix="/admin", tags=["admin"]) - - -@router.post("/backup") -def admin_backup( - _: None = Depends(verify_admin_token), -) -> dict[str, str]: - # Turso (libSQL) モード時は GCS への SQLite バックアップが無効。 - # Issue 3 のインフラ移行で本エンドポイント自体を廃止する予定 - if get_turso_database_url(): - raise HTTPException( - status_code=409, - detail="Turso モードでは SQLite の GCS バックアップは無効です。", - ) - try: - return backup_sqlite_to_gcs() - except RuntimeError as error: - logging.warning("sqlite backup runtime error: %s", error) - raise HTTPException( - status_code=503, - detail=get_error("server.backup_service_unavailable"), - ) from error - except Exception as error: - logging.exception("sqlite backup failed") - raise HTTPException(status_code=500, detail=get_error("server.backup_failed")) from error diff --git a/infra/environments/prod/.terraform.lock.hcl b/infra/environments/prod/.terraform.lock.hcl new file mode 100644 index 00000000..9e37125f --- /dev/null +++ b/infra/environments/prod/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "4.52.7" + constraints = "~> 4.0" + hashes = [ + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "7.23.0" + hashes = [ + "h1:8NwMbqR/drRxeWfc1IG6yfvQeXesX8cFSpA0Pnpe+fk=", + "zh:23b834f436dec7132cc42fdbe6ab7d74fc574b7a86ec7a56f7be9d42911a3afc", + "zh:2f1e2db52a79ee913fef18f0a0445d8c1cbda4c253d7ffb2a7c1f2b87e84980c", + "zh:4903221645602f42c6b642275a748b85b9621f6a24a2417cc6424d68a46ca88c", + "zh:4cae4ab9dc9e6779437395a353e173b4eb697a06e29fbc1aefa0cf2f5b5e31cc", + "zh:50e6bf71226e688519f4fff24a925ff396495afbdbddadb9c960a291c351fe84", + "zh:67fe1762aff9d270624c8d5a002c527d37a0e0ccc3c5eea712dfed7d0ecc46ab", + "zh:7ae8fb0cc76fbca06cd4f32bacd69984152b8d4b34268546e295f1e28892af13", + "zh:b8ec2d6626bb0795aea3fc6db052e7d472777ce9838e92f4396826bcdbfc1880", + "zh:d5642b8d0e2dda6316379a7e128d724f604b0f8625f9ecc706c662d9f302c32b", + "zh:dfc8f2d943aeed758b89166a1c20d6af67ae692b9cae832a9048e0cfb1e01ff5", + "zh:e768422d524b19d7a327de97f9621c1abd5958d3787efa02c0c61bb164d3e925", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/infra/environments/stg/.terraform.lock.hcl b/infra/environments/stg/.terraform.lock.hcl new file mode 100644 index 00000000..9e37125f --- /dev/null +++ b/infra/environments/stg/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "4.52.7" + constraints = "~> 4.0" + hashes = [ + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "7.23.0" + hashes = [ + "h1:8NwMbqR/drRxeWfc1IG6yfvQeXesX8cFSpA0Pnpe+fk=", + "zh:23b834f436dec7132cc42fdbe6ab7d74fc574b7a86ec7a56f7be9d42911a3afc", + "zh:2f1e2db52a79ee913fef18f0a0445d8c1cbda4c253d7ffb2a7c1f2b87e84980c", + "zh:4903221645602f42c6b642275a748b85b9621f6a24a2417cc6424d68a46ca88c", + "zh:4cae4ab9dc9e6779437395a353e173b4eb697a06e29fbc1aefa0cf2f5b5e31cc", + "zh:50e6bf71226e688519f4fff24a925ff396495afbdbddadb9c960a291c351fe84", + "zh:67fe1762aff9d270624c8d5a002c527d37a0e0ccc3c5eea712dfed7d0ecc46ab", + "zh:7ae8fb0cc76fbca06cd4f32bacd69984152b8d4b34268546e295f1e28892af13", + "zh:b8ec2d6626bb0795aea3fc6db052e7d472777ce9838e92f4396826bcdbfc1880", + "zh:d5642b8d0e2dda6316379a7e128d724f604b0f8625f9ecc706c662d9f302c32b", + "zh:dfc8f2d943aeed758b89166a1c20d6af67ae692b9cae832a9048e0cfb1e01ff5", + "zh:e768422d524b19d7a327de97f9621c1abd5958d3787efa02c0c61bb164d3e925", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} From e9d3d991d85397da717cb5dad26c73cd5309e39b Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 14 May 2026 22:18:13 +0900 Subject: [PATCH 18/24] local turso fix --- README.md | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ed02f242..6733c3a2 100644 --- a/README.md +++ b/README.md @@ -151,42 +151,22 @@ TURSO_AUTH_TOKEN= #### Turso (libSQL) ローカル起動 -DB は Turso (libSQL) を使用します。`turso dev` で libSQL 互換のローカルサーバーを起動できます。 -Turso CLI は `flake.nix` で管理されているため、`nix develop` 後にそのまま利用可能です。 - -> **注意**: `turso dev` 実行には別途 `sqld` (libsql-server) のインストールが必要です(nixpkgs に未収録)。 -> 公式手順: https://docs.turso.tech/local-development/sqld - -```bash -# libSQL HTTP サーバーを起動(ホスト側で別ターミナル) -turso dev --db-file ./backend/local.sqlite -# 起動すると http://127.0.0.1:8080 で待ち受ける -``` - -接続用の環境変数は `backend/.env.example` を参考に `backend/.env` へ設定してください。 +`docker compose up libsql` だけ起動すれば、ホストの `127.0.0.1:8080` に libSQL サーバーが公開されます。 +`backend/.env` で以下を設定すれば、ホストの uvicorn から接続できます。 ``` TURSO_DATABASE_URL=http://127.0.0.1:8080 TURSO_AUTH_TOKEN= ``` -Docker コンテナ内から接続する場合は、コンテナ → ホストの `turso dev` に到達できるよう -`host.docker.internal` を使用します(`docker-compose.yml` の `extra_hosts` で設定済み)。 -プロジェクトルートの `.env` を以下のように書き換えてから `docker compose up` してください。 - -``` -TURSO_DATABASE_URL=http://host.docker.internal:8080 -TURSO_AUTH_TOKEN= -``` - -##### TablePlus からローカル Turso に接続する +##### TablePlus からローカル libSQL に接続する 1. TablePlus で **新規接続** → **libSQL** を選択 -2. **URL** に `http://127.0.0.1:8080` を指定 +2. **URL** に `http://127.0.0.1:8080` を指定(`docker compose up libsql` 経由) 3. **Token** は空のままで OK 4. **テスト** → **接続** -> **注意**: 旧 SQLite ファイル方式は廃止しました。`data/devforge.sqlite` の bind mount や DBeaver の SQLite 直接接続は使えません。 +> **注意**: 旧 SQLite ファイル方式(`data/devforge.sqlite` の bind mount, DBeaver の SQLite 直接接続)は廃止しました。 --- From 3fff13ef44be09c297aeffe57df0ce14cd8b78ad Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 13:44:19 +0900 Subject: [PATCH 19/24] BE/FE refactaring --- backend/app/routers/blog.py | 29 ++++++++++++++++-- backend/app/schemas/__init__.py | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index d43b01b9..41bd1b84 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -16,7 +16,7 @@ import logging from dataclasses import asdict -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from ..core.errors import resolve_async_error_code @@ -32,7 +32,6 @@ BlogAccountUpdate, BlogArticleResponse, BlogScoreResponse, - BlogSummaryRequest, BlogSummaryResponse, BlogSyncResponse, ) @@ -297,6 +296,32 @@ async def summarize_blog( {"user_id": user.id}, failure_message="タスクの開始に失敗しました", logger=logger, + ) + except Exception: + raise HTTPException( + status_code=500, + detail=get_error("task.dispatch_failed"), + ) + + return BlogSummaryResponse( + summary="", + available=False, + status="pending", + ) + + +@router.post("/summarize/retry", response_model=BlogSummaryResponse, status_code=202) +@limiter.limit("5/minute") +async def retry_summarize_blog( + request: Request, + background_tasks: BackgroundTasks, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """失敗したブログサマリタスクを手動で再実行する。 + + ``dead_letter`` 状態のキャッシュのみ再実行可能。記事は worker 側で + ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 """ cache = BlogSummaryCacheRepository(db, user.id).get() if not cache: diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 7bdd1df7..bc15970b 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -10,6 +10,60 @@ BlogScoreResponse, BlogSummaryArticleItem, BlogSummaryRequest, + BlogSummaryResponse, + BlogSyncResponse, +) +from .career_analysis import ( + ActionItem, + CareerAnalysisGenerateRequest, + CareerAnalysisResponse, + CareerAnalysisResult, + CareerPathItem, + StrengthItem, + TaskStatusResponse, + TechStackItem, + TechStackSection, +) +from .intelligence import ( + AnalysisResponse, + AnalyzeRequest, + CachedAnalysisResponse, + PositionAdviceResponse, + PositionScoresResponse, +) +from .master_data import ( + MasterItem, + MasterItemCreate, + MasterItemUpdate, + TechStackMasterCreate, + TechStackMasterItem, + TechStackMasterUpdate, +) +from .resume import ( + Client, + Experience, + Project, + ProjectTeam, + ResumeCreate, + ResumeQualificationItem, + ResumeResponse, + ResumeUpdate, + TeamMember, + TechnologyStackItem, +) + +__all__ = [ + "ActionItem", + "AnalysisResponse", + "AnalyzeRequest", + "BlogAccountCreate", + "BlogAccountResponse", + "BlogAccountUpdate", + "BlogArticleResponse", + "BlogScoreArticleResponse", + "BlogScoreResponse", + "BlogSummaryArticleItem", + "BlogSummaryRequest", "BlogSummaryResponse", "BlogSyncResponse", "CachedAnalysisResponse", From 9363d69f259ba47ad69e1ad15004781051e46c59 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 20:16:12 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix:=20TechBar.tsx=20=E3=81=AE=20rebase?= =?UTF-8?q?=20=E8=A7=A3=E6=B6=88=E3=83=9F=E3=82=B9=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/analysis/TechBar.tsx | 3 + frontend/src/components/blog/BlogPage.tsx | 379 +------------------ frontend/src/hooks/useBlogAccountManager.ts | 29 +- 3 files changed, 39 insertions(+), 372 deletions(-) diff --git a/frontend/src/components/analysis/TechBar.tsx b/frontend/src/components/analysis/TechBar.tsx index 907ba733..6a4467ae 100644 --- a/frontend/src/components/analysis/TechBar.tsx +++ b/frontend/src/components/analysis/TechBar.tsx @@ -64,6 +64,9 @@ interface TechBarProps { techs: Record; /** リスト要素の aria-label */ ariaLabel?: string; +} + +export function TechBar({ techs, ariaLabel }: TechBarProps) { const [hoveredTech, setHoveredTech] = useState(null); const items = useMemo(() => { diff --git a/frontend/src/components/blog/BlogPage.tsx b/frontend/src/components/blog/BlogPage.tsx index 5c1565dc..88586a32 100644 --- a/frontend/src/components/blog/BlogPage.tsx +++ b/frontend/src/components/blog/BlogPage.tsx @@ -1,122 +1,42 @@ import { useState } from "react"; import { useBlogAccountManager } from "../../hooks/useBlogAccountManager"; -import { ConfirmDialog } from "../ConfirmDialog"; -import { ZennIcon } from "../icons/ZennIcon"; -import { NoteIcon } from "../icons/NoteIcon"; -import { QiitaIcon } from "../icons/QiitaIcon"; import { BlogScoreCard } from "./BlogScoreCard"; import { BlogAnalysisSection } from "./BlogAnalysisSection"; +import { BlogPlatformList } from "./BlogPlatformList"; +import { BlogArticleList } from "./BlogArticleList"; import { InlineSpinner } from "../ui/InlineSpinner"; import shared from "../../styles/shared.module.css"; import styles from "./BlogPage.module.css"; type PlatformFilter = "all" | "zenn" | "note" | "qiita"; -type PlatformKey = "zenn" | "note" | "qiita"; - -const ARTICLES_PER_PAGE = 5; - -/** 対応プラットフォーム定義 */ -const PLATFORMS = [ - { - key: "zenn" as const, - label: "Zenn", - urlPrefix: "https://zenn.dev/", - icon: , - }, - { - key: "note" as const, - label: "note", - urlPrefix: "https://note.com/", - icon: , - }, - { - key: "qiita" as const, - label: "Qiita", - urlPrefix: "https://qiita.com/", - icon: , - }, -] as const; /** * ブログ連携ページ。固定プラットフォーム一覧でアカウント連携 → 記事一覧・AI分析。 */ export function BlogPage() { const [filter, setFilter] = useState("all"); - const [editingPlatform, setEditingPlatform] = useState(null); - const [confirmingPlatform, setConfirmingPlatform] = useState(null); - const [deletingConfirmPlatform, setDeletingConfirmPlatform] = useState(null); - const [pageByFilter, setPageByFilter] = useState>({ - all: 1, - zenn: 1, - note: 1, - qiita: 1, - }); const { accounts, articles, loading, - error, + accountError, + summaryError, success, draftUsernames, setDraftUsernames, savingPlatform, syncingPlatform, - updatingPlatform, - deletingPlatform, summary, summaryLoading, accountMap, handleSave, handleSync, handleDelete, - handleUpdate, handleSummarize, } = useBlogAccountManager(filter); - const totalPages = Math.max(1, Math.ceil(articles.length / ARTICLES_PER_PAGE)); - const currentPage = Math.min(pageByFilter[filter], totalPages); - const showPagination = articles.length > ARTICLES_PER_PAGE; - const pageStartIndex = (currentPage - 1) * ARTICLES_PER_PAGE; - const visibleArticles = articles.slice( - pageStartIndex, - pageStartIndex + ARTICLES_PER_PAGE, - ); - - const startEditing = (platform: PlatformKey, username: string) => { - setDraftUsernames((prev) => ({ ...prev, [platform]: username })); - setEditingPlatform(platform); - }; - - const cancelEditing = (platform: PlatformKey) => { - setDraftUsernames((prev) => ({ ...prev, [platform]: "" })); - if (editingPlatform === platform) { - setEditingPlatform(null); - } - if (confirmingPlatform === platform) { - setConfirmingPlatform(null); - } - }; - - const confirmUpdate = async () => { - if (!confirmingPlatform) return; - const updated = await handleUpdate( - confirmingPlatform, - draftUsernames[confirmingPlatform] ?? "", - ); - if (updated) { - setConfirmingPlatform(null); - setEditingPlatform(null); - } - }; - - const confirmDelete = async () => { - if (!deletingConfirmPlatform) return; - await handleDelete(deletingConfirmPlatform); - setDeletingConfirmPlatform(null); - }; - if (loading) { return ( <> @@ -132,34 +52,6 @@ export function BlogPage() { return ( <> - {confirmingPlatform && ( - { - if (updatingPlatform !== confirmingPlatform) { - setConfirmingPlatform(null); - } - }} - confirming={updatingPlatform === confirmingPlatform} - /> - )} - {deletingConfirmPlatform && ( - p.key === deletingConfirmPlatform)?.label ?? deletingConfirmPlatform} の連携を解除すると、同期済みのブログ記事がすべて削除されます。よろしいですか?`} - confirmLabel="解除する" - confirmingLabel="解除中..." - onConfirm={confirmDelete} - onCancel={() => { - if (deletingPlatform !== deletingConfirmPlatform) { - setDeletingConfirmPlatform(null); - } - }} - confirming={deletingPlatform === deletingConfirmPlatform} - /> - )}

ブログ連携

@@ -174,260 +66,31 @@ export function BlogPage() {
- {error &&

{error}

} + {accountError &&

{accountError}

} + {summaryError &&

{summaryError}

} {success &&

{success}

} - {/* プラットフォーム連携セクション */} -
-

アウトプット連携

- -
- {PLATFORMS.map((pf) => { - const linked = accountMap.get(pf.key); - const isEditing = editingPlatform === pf.key; - return ( -
-
{pf.icon}
- {pf.label} - - {linked ? ( - /* ── 連携済み ── */ - <> - {pf.urlPrefix} - {isEditing ? ( - <> - - setDraftUsernames((prev) => ({ - ...prev, - [pf.key]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter" && draftUsernames[pf.key]?.trim()) { - setConfirmingPlatform(pf.key); - } - }} - /> - - - - ) : ( - <> - - {linked.username} - - - {linked.last_synced_at ? "同期済み" : "未同期"} - - - - - - )} - - ) : ( - /* ── 未連携 ── */ - <> - {pf.urlPrefix} - - setDraftUsernames((prev) => ({ - ...prev, - [pf.key]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(pf.key); - }} - /> - - - )} -
- ); - })} -
-
+ - {/* ブログスコア */} {articles.length > 0 && } - {/* 記事一覧 */} {accounts.length > 0 && ( -
-
-

記事一覧

-
- {(["all", "zenn", "note", "qiita"] as PlatformFilter[]).map((f) => ( - - ))} -
-
- - {articles.length === 0 ? ( -

- 記事がありません。「同期」ボタンで記事を取得してください。 -

- ) : ( - <> - - - {showPagination && ( -
- - - {currentPage} / {totalPages} - - -
- )} - - )} -
+ )} - {/* AI 分析結果 */}
diff --git a/frontend/src/hooks/useBlogAccountManager.ts b/frontend/src/hooks/useBlogAccountManager.ts index 3836f067..6e77ac8e 100644 --- a/frontend/src/hooks/useBlogAccountManager.ts +++ b/frontend/src/hooks/useBlogAccountManager.ts @@ -11,7 +11,7 @@ import { import type { BlogAccount, BlogArticle } from "../types"; import { useBlogSummaryPolling } from "./useBlogSummaryPolling"; -type PlatformKey = "zenn" | "note" | "qiita"; +export type PlatformKey = "zenn" | "note" | "qiita"; /** * BlogPage のブログアカウント管理・同期・AI分析ロジックを提供するカスタムフック。 @@ -20,7 +20,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const [accounts, setAccounts] = useState([]); const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [accountError, setAccountError] = useState(null); const [success, setSuccess] = useState(null); /** プラットフォームごとの入力中ユーザー名 */ @@ -56,7 +56,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") setArticles([]); } } catch (e) { - setError(e instanceof Error ? e.message : "データの取得に失敗しました"); + setAccountError(e instanceof Error ? e.message : "データの取得に失敗しました"); } finally { setLoading(false); } @@ -76,7 +76,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const username = draftUsernames[platform]?.trim(); if (!username) return; setSavingPlatform(platform); - setError(null); + setAccountError(null); setSuccess(null); try { const account = await addBlogAccount(platform, username); @@ -91,14 +91,14 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") ); } catch (syncErr) { setSuccess("アカウントを連携しました"); - setError( + setAccountError( syncErr instanceof Error ? syncErr.message : "記事の同期に失敗しました。「同期」ボタンで再試行してください。", ); } } catch (e) { - setError(e instanceof Error ? e.message : "アカウントの連携に失敗しました"); + setAccountError(e instanceof Error ? e.message : "アカウントの連携に失敗しました"); } finally { setSavingPlatform(null); } @@ -111,7 +111,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const account = accountMap.get(platform); if (!account) return; setSyncingPlatform(platform); - setError(null); + setAccountError(null); setSuccess(null); try { const result = await syncBlogAccount(account.id); @@ -120,7 +120,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") `${result.synced_count}件の新しい記事を取得しました(合計: ${result.total_count}件)`, ); } catch (e) { - setError(e instanceof Error ? e.message : "同期に失敗しました"); + setAccountError(e instanceof Error ? e.message : "同期に失敗しました"); } finally { setSyncingPlatform(null); } @@ -133,14 +133,14 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const account = accountMap.get(platform); if (!account) return; setDeletingPlatform(platform); - setError(null); + setAccountError(null); setSuccess(null); try { await deleteBlogAccount(account.id); await loadData(); setSuccess("アカウントを解除しました"); } catch (e) { - setError(e instanceof Error ? e.message : "アカウントの解除に失敗しました"); + setAccountError(e instanceof Error ? e.message : "アカウントの解除に失敗しました"); } finally { setDeletingPlatform(null); } @@ -155,7 +155,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const trimmedUsername = username.trim(); if (!trimmedUsername) return false; setUpdatingPlatform(platform); - setError(null); + setAccountError(null); setSuccess(null); try { await updateBlogAccount(platform, trimmedUsername); @@ -170,7 +170,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") ); } catch (syncErr) { setSuccess("usernameを更新しました。再同期してください。"); - setError( + setAccountError( syncErr instanceof Error ? syncErr.message : "記事の同期に失敗しました。「同期」ボタンで再試行してください。", @@ -178,7 +178,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") } return true; } catch (e) { - setError(e instanceof Error ? e.message : "usernameの更新に失敗しました"); + setAccountError(e instanceof Error ? e.message : "usernameの更新に失敗しました"); return false; } finally { setUpdatingPlatform(null); @@ -189,7 +189,8 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") accounts, articles, loading, - error: error || summaryError, + accountError, + summaryError, success, draftUsernames, setDraftUsernames, From 08370948ae71f4b0645b077cf6ba3145e750de69 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 23:25:45 +0900 Subject: [PATCH 21/24] code rabbit RV fix --- .claude/rules/infra/terraform.md | 2 +- .gitignore | 4 -- Makefile | 2 +- README.md | 21 +-------- ...arning_message_to_github_analysis_cache.py | 26 +++++++++++ backend/app/core/errors.py | 4 +- backend/app/core/security/auth.py | 15 ++++++ backend/app/db/bootstrap.py | 37 +-------------- backend/app/db/seeds/technology_stacks.json | 12 ++--- backend/app/models/cache.py | 3 ++ backend/app/repositories/blog.py | 21 +++++++++ backend/app/routers/blog.py | 29 ++++++------ backend/app/routers/career_analysis.py | 9 +++- backend/app/routers/intelligence.py | 16 +++++-- backend/app/schemas/intelligence.py | 2 + .../intelligence/github_analysis_service.py | 14 ++++-- .../intelligence/position_weights.json | 2 +- .../services/intelligence/skill_extractor.py | 2 +- .../app/services/tasks/dispatch_service.py | 13 ++++++ .../services/tasks/handlers/blog_summarize.py | 5 +- .../tasks/handlers/career_analysis.py | 2 +- backend/app/services/tasks/worker.py | 12 +++-- backend/pyproject.toml | 7 +++ backend/tests/test_intelligence.py | 6 +-- backend/tests/test_worker_extended.py | 46 ++++++++++++------- flake.nix | 3 -- frontend/src/api/intelligence.ts | 2 + .../src/components/blog/BlogArticleList.tsx | 1 + .../src/components/blog/BlogPlatformList.tsx | 13 ++++-- .../result/CareerAnalysisResultView.tsx | 3 +- .../src/hooks/useBlogAccountManager.test.ts | 39 ---------------- .../src/hooks/useCareerExperienceMutators.ts | 3 +- pyrightconfig.json | 11 +++++ 33 files changed, 217 insertions(+), 170 deletions(-) create mode 100644 backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py create mode 100644 pyrightconfig.json diff --git a/.claude/rules/infra/terraform.md b/.claude/rules/infra/terraform.md index 149dc937..1b1746d9 100644 --- a/.claude/rules/infra/terraform.md +++ b/.claude/rules/infra/terraform.md @@ -5,7 +5,7 @@ paths: # Infrastructure (Terraform) -``` +```text infra/ ├── modules/ # cloud_run, artifact_registry, cloud_tasks, cloudflare, monitoring, service_account └── environments/ # dev, stg, prod(各環境で tfvars 管理) diff --git a/.gitignore b/.gitignore index d749b834..20e948a9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,6 @@ backend/local.sqlite-wal frontend/.env.local backend/.env -# Firebase(削除済みだが生成物の混入を防ぐために保持) -.firebase/ -firebase-debug.log - # Wrangler .wrangler/ diff --git a/Makefile b/Makefile index 3b65642a..58e30cce 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: help \ setup install-hooks install-backend install-frontend generate-keys \ - dev dev-build dev-down dev-frontend preview-frontend \ + dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only \ test test-backend test-frontend \ lint lint-backend lint-frontend lint-fix \ format format-check \ diff --git a/README.md b/README.md index 6733c3a2..c72bdf39 100644 --- a/README.md +++ b/README.md @@ -135,26 +135,7 @@ docker compose up `docker compose up libsql` だけ起動すれば、ホストの `127.0.0.1:8080` に libSQL サーバーが公開されます。 `backend/.env` で以下を設定すれば、ホストの uvicorn から接続できます。 -``` -TURSO_DATABASE_URL=http://127.0.0.1:8080 -TURSO_AUTH_TOKEN= -``` - -##### TablePlus からローカル libSQL に接続する - -1. TablePlus で **新規接続** → **libSQL** を選択 -2. **URL** に `http://127.0.0.1:8080` を指定(`docker compose up libsql` 経由) -3. **Token** は空のままで OK -4. **テスト** → **接続** - -> **注意**: 旧 SQLite ファイル方式(`data/devforge.sqlite` の bind mount, DBeaver の SQLite 直接接続)は廃止しました。 - -#### Turso (libSQL) ローカル起動 - -`docker compose up libsql` だけ起動すれば、ホストの `127.0.0.1:8080` に libSQL サーバーが公開されます。 -`backend/.env` で以下を設定すれば、ホストの uvicorn から接続できます。 - -``` +```env TURSO_DATABASE_URL=http://127.0.0.1:8080 TURSO_AUTH_TOKEN= ``` diff --git a/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py b/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py new file mode 100644 index 00000000..a377af0f --- /dev/null +++ b/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py @@ -0,0 +1,26 @@ +"""add warning_message to github_analysis_cache + +Revision ID: 0031_add_warning_message_to_github_analysis_cache +Revises: 0030_add_expires_at_to_blog_summary_cache +Create Date: 2026-05-15 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0031_add_warning_message_to_github_analysis_cache" +down_revision: Union[str, None] = "0030_add_expires_at_to_blog_summary_cache" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("github_analysis_cache") as batch_op: + batch_op.add_column(sa.Column("warning_message", sa.Text(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("github_analysis_cache") as batch_op: + batch_op.drop_column("warning_message") diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 24cd4584..a86f6791 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any +from typing import Any, NoReturn from uuid import uuid4 from fastapi import HTTPException @@ -66,7 +66,7 @@ def raise_app_error( action: str | None = None, retry_after: int | None = None, headers: dict[str, str] | None = None, -) -> None: +) -> NoReturn: raise HTTPException( status_code=status_code, detail=build_app_error_response( diff --git a/backend/app/core/security/auth.py b/backend/app/core/security/auth.py index 7f2bf1fe..696d03c6 100644 --- a/backend/app/core/security/auth.py +++ b/backend/app/core/security/auth.py @@ -30,6 +30,21 @@ _ALGORITHM = "RS256" +def validate_jwt_key_pair() -> None: + """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" + try: + tok = jwt.encode( + {"sub": "__bootstrap_check__"}, + get_jwt_private_key(), + algorithm=_ALGORITHM, + ) + jwt.decode(tok, get_jwt_public_key(), algorithms=[_ALGORITHM]) + except JWTError as e: + raise RuntimeError( + f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" + ) from e + + def create_access_token(username: str) -> str: """短命のアクセストークン(15分)を生成する。""" expire = datetime.now(timezone.utc) + timedelta(minutes=_ACCESS_TOKEN_EXPIRE_MINUTES) diff --git a/backend/app/db/bootstrap.py b/backend/app/db/bootstrap.py index 37e67da0..753b1390 100644 --- a/backend/app/db/bootstrap.py +++ b/backend/app/db/bootstrap.py @@ -2,45 +2,12 @@ from datetime import datetime, timezone from ..core.logging_utils import log_event +from ..core.security.auth import validate_jwt_key_pair from .migrations import run_migrations -def _validate_jwt_keys() -> None: - """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" - from jose import JWTError, jwt - - from ..core.settings import get_jwt_private_key, get_jwt_public_key - - try: - priv = get_jwt_private_key() - pub = get_jwt_public_key() - tok = jwt.encode({"sub": "__bootstrap_check__"}, priv, algorithm="RS256") - jwt.decode(tok, pub, algorithms=["RS256"]) - except JWTError as e: - raise RuntimeError( - f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" - ) from e - - -def _validate_jwt_keys() -> None: - """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" - from jose import JWTError, jwt - - from ..core.settings import get_jwt_private_key, get_jwt_public_key - - try: - priv = get_jwt_private_key() - pub = get_jwt_public_key() - tok = jwt.encode({"sub": "__bootstrap_check__"}, priv, algorithm="RS256") - jwt.decode(tok, pub, algorithms=["RS256"]) - except JWTError as e: - raise RuntimeError( - f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" - ) from e - - def bootstrap() -> None: - _validate_jwt_keys() + validate_jwt_key_pair() # Turso (libSQL) がデータ永続化を担うため、起動時の DB 復元処理は不要 run_migrations() log_event(logging.INFO, "bootstrap_migration_succeeded") diff --git a/backend/app/db/seeds/technology_stacks.json b/backend/app/db/seeds/technology_stacks.json index 7f6e0e1a..b9004195 100644 --- a/backend/app/db/seeds/technology_stacks.json +++ b/backend/app/db/seeds/technology_stacks.json @@ -612,31 +612,31 @@ { "category": "middleware", "name": "Nginx", - "sort_order": 121 + "sort_order": 123 }, { "category": "middleware", "name": "Apache", - "sort_order": 122 + "sort_order": 124 }, { "category": "middleware", "name": "Hulft", - "sort_order": 123 + "sort_order": 125 }, { "category": "ai_agent", "name": "ChatGPT", - "sort_order": 124 + "sort_order": 126 }, { "category": "ai_agent", "name": "Claude", - "sort_order": 125 + "sort_order": 127 }, { "category": "ai_agent", "name": "Gemini", - "sort_order": 126 + "sort_order": 128 } ] \ No newline at end of file diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py index d35c514b..7d992caa 100644 --- a/backend/app/models/cache.py +++ b/backend/app/models/cache.py @@ -21,6 +21,9 @@ class GitHubAnalysisCache(Base): position_advice: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="completed", server_default="completed") error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + # LLM 失敗のような「分析自体は完了したが部分的に欠落した」非致命的状況を残す。 + # error_message は真の失敗のみに使う。 + warning_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3") next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/backend/app/repositories/blog.py b/backend/app/repositories/blog.py index 0be64551..b8a7c284 100644 --- a/backend/app/repositories/blog.py +++ b/backend/app/repositories/blog.py @@ -2,6 +2,7 @@ from typing import Any from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from ..core.date_utils import parse_iso_date @@ -223,6 +224,26 @@ def get(self) -> BlogSummaryCache | None: return None return cache + def get_or_create(self) -> BlogSummaryCache: + """キャッシュを取得する。存在しない場合は新規作成して返す。 + + user_id の unique 制約を利用し、IntegrityError 時に再 SELECT することで + 同時リクエストによる重複生成を防ぐ。 + """ + cache = self.get() + if cache is not None: + return cache + try: + cache = BlogSummaryCache(user_id=self.user_id) + self.db.add(cache) + self.db.flush() + return cache + except IntegrityError: + self.db.rollback() + return self.db.scalar( + select(BlogSummaryCache).where(BlogSummaryCache.user_id == self.user_id) + ) + def invalidate(self, *, commit: bool = True) -> bool: cache = self.get() if not cache: diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index 41bd1b84..46e73f43 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -24,7 +24,7 @@ from ..core.security.auth import get_current_user from ..core.security.dependencies import limiter from ..db import get_db -from ..models import BlogSummaryCache, User +from ..models import User from ..repositories import BlogAccountRepository, BlogArticleRepository, BlogSummaryCacheRepository from ..schemas import ( BlogAccountCreate, @@ -268,27 +268,21 @@ async def summarize_blog( 記事は worker 側で ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 """ - cache = BlogSummaryCacheRepository(db, user.id).get() - if cache is None: - cache = BlogSummaryCache(user_id=user.id) - db.add(cache) - db.flush() + cache = BlogSummaryCacheRepository(db, user.id).get_or_create() service = AsyncTaskCacheService(db, cache) - # 進行中のタスクがあればそのステータスを返す(期限切れキャッシュは None が返るため再生成を許可) - if service.is_in_progress(): + available = await check_llm_available() + if not available: + return BlogSummaryResponse(summary="", available=False) + + # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン + if not service.try_reset_to_pending(): return BlogSummaryResponse( summary=cache.summary or "", available=False, status=cache.status, ) - available = await check_llm_available() - if not available: - return BlogSummaryResponse(summary="", available=False) - - service.reset_to_pending() - try: await service.dispatch( background_tasks, @@ -340,7 +334,12 @@ async def retry_summarize_blog( if not available: return BlogSummaryResponse(summary="", available=False) - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise HTTPException( + status_code=409, + detail=f"このタスクはリトライできない状態です(現在: {cache.status})", + ) try: await service.dispatch( diff --git a/backend/app/routers/career_analysis.py b/backend/app/routers/career_analysis.py index f4512b36..b36a3dde 100644 --- a/backend/app/routers/career_analysis.py +++ b/backend/app/routers/career_analysis.py @@ -147,7 +147,14 @@ async def retry_analysis( action="タスクの完了または失敗を待ってから再試行してください", ) - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=f"このタスクはリトライできない状態です(現在: {analysis.status})", + action="タスクの完了または失敗を待ってから再試行してください", + ) try: await service.dispatch( diff --git a/backend/app/routers/intelligence.py b/backend/app/routers/intelligence.py index 8b5c3125..a2cf7d39 100644 --- a/backend/app/routers/intelligence.py +++ b/backend/app/routers/intelligence.py @@ -58,6 +58,7 @@ def get_cache( status=cache.status, error_message=cache.error_message, error_code=resolve_async_error_code(cache.error_message), + warning_message=cache.warning_message, ) @@ -114,10 +115,10 @@ async def analyze( # 進行中のタスクがあればそのステータスを返す cache = _get_or_create_cache(db, user.id) service = AsyncTaskCacheService(db, cache) - if service.is_in_progress(): - return {"status": cache.status} - service.reset_to_pending() + # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン + if not service.try_reset_to_pending(): + return {"status": cache.status} try: await service.dispatch( @@ -185,7 +186,14 @@ async def retry_analyze( github_username = user.username.removeprefix("github:") include_forks = payload.include_forks if payload else False - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=f"このタスクはリトライできない状態です(現在: {cache.status})", + action="タスクの完了または失敗を待ってから再試行してください", + ) try: await service.dispatch( diff --git a/backend/app/schemas/intelligence.py b/backend/app/schemas/intelligence.py index c556299c..fab8bc88 100644 --- a/backend/app/schemas/intelligence.py +++ b/backend/app/schemas/intelligence.py @@ -68,6 +68,8 @@ class CachedAnalysisResponse(BaseModel): status: Optional[str] = None error_message: Optional[str] = None error_code: Optional[str] = None + # 分析自体は完了したが LLM など部分的に欠落した場合の警告メッセージ + warning_message: Optional[str] = None class SubProgress(BaseModel): diff --git a/backend/app/services/intelligence/github_analysis_service.py b/backend/app/services/intelligence/github_analysis_service.py index 1dc87535..f4db8ac2 100644 --- a/backend/app/services/intelligence/github_analysis_service.py +++ b/backend/app/services/intelligence/github_analysis_service.py @@ -14,6 +14,7 @@ from ...core.logging_utils import get_logger from ...models import GitHubAnalysisCache from ..progress_service import set_progress +from ..tasks.exceptions import NonRetryableError, RetryableError from .github_collector import GitHubUserNotFoundError, collect_repos from .llm import get_llm_client from .llm_summarizer import generate_learning_advice @@ -37,10 +38,12 @@ async def run_github_analysis(db: Session, payload: dict) -> None: cache = db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() if not cache: logger.error("GitHub 分析キャッシュが見つかりません", extra={"user_id": user_id}) - return + raise RuntimeError(f"GitHub analysis cache not found: user_id={user_id}") cache.status = "processing" cache.started_at = _now() + # 前回実行の警告が残らないようリセット(error_message はディスパッチ時点でリセット済み) + cache.warning_message = None db.commit() token = decrypt_field(payload["github_token"]) if payload.get("github_token") else None @@ -86,7 +89,9 @@ async def _on_repo_fetched(done: int, total: int) -> None: # ステップ 4: DB 保存 await set_progress(task_id, 4, _TOTAL_STEPS, "結果を保存中...") cache.status = "completed" - cache.error_message = "LLM処理が利用できません" if llm_failed else None + cache.error_message = None + # LLM 失敗は分析自体は成功しているため warning_message に分けて記録する + cache.warning_message = "LLM処理が利用できません" if llm_failed else None cache.completed_at = _now() db.commit() @@ -116,6 +121,9 @@ async def _generate_advice_if_available(analysis: dict) -> tuple[str | None, boo logger.warning("LLM が学習アドバイスの生成に失敗しました") return None, True return advice, False - except Exception: + except (RetryableError, NonRetryableError): logger.warning("学習アドバイスの生成に失敗しましたが、分析結果は保存します", exc_info=True) return None, True + except Exception: + logger.exception("学習アドバイスの生成で予期しないエラーが発生しました") + raise diff --git a/backend/app/services/intelligence/position_weights.json b/backend/app/services/intelligence/position_weights.json index 9ce0ebc2..b89a074a 100644 --- a/backend/app/services/intelligence/position_weights.json +++ b/backend/app/services/intelligence/position_weights.json @@ -80,7 +80,7 @@ "HTML/CSS": ["CSS", "HTML"], "Docker": ["Docker"], "CI/CD": ["CI/CD"], - "Git workflow": ["CI/CD"], + "Git workflow": ["Git", "GitHub", "GitLab"], "クラウドサービス(GCP/AWS/Azure)基礎": ["GCP", "AWS", "Azure"] } } diff --git a/backend/app/services/intelligence/skill_extractor.py b/backend/app/services/intelligence/skill_extractor.py index 9b83f747..68c16e62 100644 --- a/backend/app/services/intelligence/skill_extractor.py +++ b/backend/app/services/intelligence/skill_extractor.py @@ -125,6 +125,6 @@ def add(skill_name: str, source: str, *, language_bytes: int = 0) -> None: # 6. 依存関係由来のフレームワーク(merge_frameworks の代替) for framework in repo.detected_frameworks: - add(framework, "root_file") + add(framework, "dependency") return skills diff --git a/backend/app/services/tasks/dispatch_service.py b/backend/app/services/tasks/dispatch_service.py index 4ca5bdef..1e2a2059 100644 --- a/backend/app/services/tasks/dispatch_service.py +++ b/backend/app/services/tasks/dispatch_service.py @@ -73,6 +73,19 @@ def reset_to_pending(self, *, reset_retry_count: bool = False) -> None: self._record.completed_at = None self._db.commit() + def try_reset_to_pending(self, *, reset_retry_count: bool = False) -> bool: + """DB から最新状態を取得してから ``pending`` へアトミックに遷移する。 + + 既に ``pending`` / ``processing`` であれば何もせず False を返す。 + 遷移に成功した場合は True を返す。 + ``reset_to_pending`` の代わりにこちらを使うことで TOCTOU を軽減できる。 + """ + self._db.refresh(self._record) + if is_in_progress(self._record.status): + return False + self.reset_to_pending(reset_retry_count=reset_retry_count) + return True + async def dispatch( self, background_tasks: BackgroundTasks, diff --git a/backend/app/services/tasks/handlers/blog_summarize.py b/backend/app/services/tasks/handlers/blog_summarize.py index 92ef0190..ac099bb3 100644 --- a/backend/app/services/tasks/handlers/blog_summarize.py +++ b/backend/app/services/tasks/handlers/blog_summarize.py @@ -33,7 +33,10 @@ async def run(self, db: Session, payload: dict) -> None: from ...intelligence.llm import get_llm_client from ...intelligence.llm_summarizer import summarize_blog_articles - user_id = payload["user_id"] + user_id = payload.get("user_id") + if not user_id: + logger.error("ペイロードに user_id がありません", extra={"payload_keys": list(payload.keys())}) + return cache = self.get_record(db, payload) if not cache: logger.error("ブログサマリキャッシュが見つかりません", extra={"user_id": user_id}) diff --git a/backend/app/services/tasks/handlers/career_analysis.py b/backend/app/services/tasks/handlers/career_analysis.py index fc693ad0..5d0217a7 100644 --- a/backend/app/services/tasks/handlers/career_analysis.py +++ b/backend/app/services/tasks/handlers/career_analysis.py @@ -56,7 +56,7 @@ async def run(self, db: Session, payload: dict) -> None: analysis.error_message = str(exc) analysis.completed_at = _now() db.commit() - raise exc + raise analysis.result_json = json.dumps(result, ensure_ascii=False) analysis.status = "completed" diff --git a/backend/app/services/tasks/worker.py b/backend/app/services/tasks/worker.py index cb5e9315..3c8dec0d 100644 --- a/backend/app/services/tasks/worker.py +++ b/backend/app/services/tasks/worker.py @@ -19,6 +19,7 @@ from .base import TaskType from .exceptions import NonRetryableError from .handlers import get_handler +import time logger = get_logger(__name__) @@ -29,7 +30,6 @@ def _monotonic_ms_since(start: float) -> int: """``time.monotonic()`` の開始時点からの経過ミリ秒を返す。""" - import time return int((time.monotonic() - start) * 1000) @@ -48,7 +48,6 @@ async def execute_task( ローカル(BackgroundTasks)呼び出しではデフォルトの ``retry_count=0, max_attempts=1`` を使い、 失敗時は即座に ``dead_letter`` へ遷移する(ローカルはネイティブリトライが無いため)。 """ - import time user_id = payload.get("user_id", "unknown") record_id = payload.get("record_id") @@ -177,21 +176,24 @@ def _now() -> datetime: async def _run_github_analysis(db: Session, payload: dict) -> None: """GitHub 分析ハンドラへのシム。""" handler = get_handler(TaskType.GITHUB_ANALYSIS) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.GITHUB_ANALYSIS}") await handler.run(db, payload) async def _run_blog_summarize(db: Session, payload: dict) -> None: """ブログサマリハンドラへのシム。""" handler = get_handler(TaskType.BLOG_SUMMARIZE) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.BLOG_SUMMARIZE}") await handler.run(db, payload) async def _run_career_analysis(db: Session, payload: dict) -> None: """キャリア分析ハンドラへのシム。""" handler = get_handler(TaskType.CAREER_ANALYSIS) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.CAREER_ANALYSIS}") await handler.run(db, payload) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 822ba7c6..d6d3bcff 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,3 +14,10 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I"] ignore = ["E501"] + +[tool.pyright] +venvPath = "." +venv = ".venv" +pythonVersion = "3.13" +include = ["app", "tests", "alembic_migrations"] +extraPaths = ["."] diff --git a/backend/tests/test_intelligence.py b/backend/tests/test_intelligence.py index 8afd50b8..3d626fe2 100644 --- a/backend/tests/test_intelligence.py +++ b/backend/tests/test_intelligence.py @@ -7,11 +7,11 @@ import asyncio from unittest.mock import AsyncMock, patch +from app.services.intelligence.github.repo_analyzer import parse_go_mod, parse_pom_xml from app.services.intelligence.github_collector import RepoData from app.services.intelligence.pipeline import IntelligenceResult, run_pipeline from app.services.intelligence.response_mapper import map_pipeline_result from app.services.intelligence.skill_extractor import extract_skills -from fastapi.testclient import TestClient from conftest import auth_header @@ -257,7 +257,6 @@ def test_full_repo_with_all_sources(self): class TestDependencyParsing: def test_parse_pom_xml(self): - from app.services.intelligence.github.repo_analyzer import parse_pom_xml content = ( "" @@ -268,7 +267,6 @@ def test_parse_pom_xml(self): assert "spring-boot-starter-web" in result def test_parse_go_mod(self): - from app.services.intelligence.github.repo_analyzer import parse_go_mod content = ( "module example.com/app\n\nrequire (\n" @@ -281,7 +279,7 @@ def test_parse_go_mod(self): # ── Intelligence Endpoint Tests ──────────────────────────────────────── -def test_analyze_requires_github_user(client: TestClient) -> None: +def test_analyze_requires_github_user(client) -> None: """通常ユーザー(非 GitHub)で analyze を呼ぶと 403 になること。""" headers = auth_header(client, "normal_analyze") resp = client.post( diff --git a/backend/tests/test_worker_extended.py b/backend/tests/test_worker_extended.py index 71da1f4b..295dbd0c 100644 --- a/backend/tests/test_worker_extended.py +++ b/backend/tests/test_worker_extended.py @@ -181,20 +181,20 @@ def test_github_user_not_found_sets_dead_letter(self, db_session: Session): db_session.refresh(cache) assert cache.status == "dead_letter" - def test_no_cache_returns_early(self, db_session: Session): - """キャッシュが見つからない場合、例外なく早期リターンすること。""" - _run( - _run_github_analysis( - db_session, - { - "user_id": "nonexistent-user-id", - "github_username": "nobody", - "github_token": None, - "include_forks": False, - }, + def test_no_cache_raises_runtime_error(self, db_session: Session): + """キャッシュが見つからない場合、RuntimeError が送出されること。""" + with pytest.raises(RuntimeError, match="GitHub analysis cache not found"): + _run( + _run_github_analysis( + db_session, + { + "user_id": "nonexistent-user-id", + "github_username": "nobody", + "github_token": None, + "include_forks": False, + }, + ) ) - ) - # 例外が発生しないことを確認 # ── _run_blog_summarize ─────────────────────────────────────────────────── @@ -503,10 +503,12 @@ def test_llm_available_returns_advice(self): assert result == ("学習アドバイスです", False) - def test_llm_exception_returns_none(self): - """LLM が例外を送出した場合 (None, True) を返し例外が外に漏れないこと。""" + def test_llm_retryable_error_returns_warning(self): + """LLM が想定内の RetryableError を送出した場合 (None, True) を返し例外を握りつぶすこと。""" + from app.services.tasks.exceptions import RetryableError + mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) + mock_llm.check_available = AsyncMock(side_effect=RetryableError("LLM 一時障害")) with patch( "app.services.intelligence.github_analysis_service.get_llm_client", @@ -516,6 +518,18 @@ def test_llm_exception_returns_none(self): assert result == (None, True) + def test_llm_unexpected_exception_propagates(self): + """LLM が予期しない例外を送出した場合は再送出されること(プログラミングエラー検知のため)。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + with pytest.raises(Exception, match="LLM クラッシュ"): + _run(_generate_advice_if_available(self._analysis())) + def test_no_position_scores_returns_none(self): """position_scores が None の場合 (None, False) を返すこと。""" mock_llm = MagicMock() diff --git a/flake.nix b/flake.nix index 2333761e..ee2c286e 100644 --- a/flake.nix +++ b/flake.nix @@ -52,9 +52,6 @@ # --- IaC --- opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) - # --- IaC --- - opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) - # --- 共通ツール --- git gh # GitHub CLI diff --git a/frontend/src/api/intelligence.ts b/frontend/src/api/intelligence.ts index ff7dbee1..c82ab528 100644 --- a/frontend/src/api/intelligence.ts +++ b/frontend/src/api/intelligence.ts @@ -43,6 +43,8 @@ export interface CachedAnalysisResponse { status?: string; error_message?: string; error_code?: string; + /** LLM 不在など、分析自体は完了したが部分的に欠落した場合の警告 */ + warning_message?: string; } /** diff --git a/frontend/src/components/blog/BlogArticleList.tsx b/frontend/src/components/blog/BlogArticleList.tsx index 1130b491..2babd8cc 100644 --- a/frontend/src/components/blog/BlogArticleList.tsx +++ b/frontend/src/components/blog/BlogArticleList.tsx @@ -47,6 +47,7 @@ export function BlogArticleList({ articles, filter, onFilterChange }: BlogArticl key={f} type="button" className={`${styles.filterTab} ${filter === f ? styles.filterTabActive : ""}`} + aria-pressed={filter === f} onClick={() => handleFilterChange(f)} > {f === "all" ? "All" : f === "zenn" ? "Zenn" : f === "note" ? "note" : "Qiita"} diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx index b7555240..6cb5f3d2 100644 --- a/frontend/src/components/blog/BlogPlatformList.tsx +++ b/frontend/src/components/blog/BlogPlatformList.tsx @@ -33,9 +33,9 @@ type BlogPlatformListProps = { setDraftUsernames: React.Dispatch>>; savingPlatform: string | null; syncingPlatform: string | null; - onSave: (platform: PlatformKey) => void; - onSync: (platform: PlatformKey) => void; - onDelete: (platform: PlatformKey) => void; + onSave: (platform: PlatformKey) => Promise; + onSync: (platform: PlatformKey) => Promise; + onDelete: (platform: PlatformKey) => Promise; }; /** プラットフォーム連携行の一覧を描画するコンポーネント。 */ @@ -93,7 +93,12 @@ export function BlogPlatformList({ setDraftUsernames((prev) => ({ ...prev, [pf.key]: e.target.value })) } onKeyDown={(e) => { - if (e.key === "Enter") onSave(pf.key); + if ( + e.key === "Enter" && + savingPlatform !== pf.key && + draftUsernames[pf.key]?.trim() + ) + onSave(pf.key); }} />