feat(types): Phase 14.3 共用型別系統 (#97-#100)

建立 Pydantic → TypeScript 自動生成工具鏈:

1. scripts/generate-schemas.py
   - 從 Pydantic 模型生成 JSON Schema
   - 正確處理 Pydantic 2.x 的 $defs 格式
   - 支援 Approval/Incident/Terminal/Playbook/CSRF 模型

2. packages/shared-types/
   - @awoooi/shared-types 套件
   - 44 個型別定義,40 個介面
   - json-schema-to-typescript 自動生成

3. 前端整合
   - apps/web 加入 @awoooi/shared-types 依賴
   - typecheck 通過

使用方式:
  cd packages/shared-types
  pnpm generate  # 重新生成型別

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-31 19:10:33 +08:00
parent a028b44c84
commit 936f1d64de
8 changed files with 3925 additions and 0 deletions

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@awoooi/lewooogo-core": "workspace:*",
"@awoooi/shared-types": "workspace:^",
"@sentry/nextjs": "^10.45.0",
"@tanstack/react-query": "^5.17.0",
"class-variance-authority": "^0.7.1",

View File

@@ -0,0 +1,24 @@
{
"name": "@awoooi/shared-types",
"version": "1.0.0",
"description": "AWOOOI 前後端共用型別定義 (自動生成自 Pydantic)",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate:schema": "cd ../../apps/api && python ../../scripts/generate-schemas.py",
"generate:types": "json2ts -i ./schemas/api-types.json -o ./src/api-types.ts --bannerComment \"/* Auto-generated from Pydantic models - DO NOT EDIT */\" --unreachableDefinitions",
"generate": "pnpm generate:schema && pnpm generate:types",
"clean": "rm -rf ./schemas/*.json ./src/api-types.ts"
},
"devDependencies": {
"json-schema-to-typescript": "^15.0.0",
"typescript": "^5.0.0"
},
"files": [
"src",
"schemas"
],
"publishConfig": {
"access": "public"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
/**
* @awoooi/shared-types
* ====================
* 前後端共用型別定義
*
* 自動生成自 Pydantic 模型,請勿手動編輯 api-types.ts
*
* 使用方式:
* import type { ApprovalRequest, IncidentResponse } from '@awoooi/shared-types'
*
* 重新生成:
* pnpm --filter @awoooi/shared-types generate
*/
// 自動生成的 API 型別 (執行 pnpm generate 後產生)
export * from './api-types'

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

49
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@awoooi/lewooogo-core':
specifier: workspace:*
version: link:../../packages/lewooogo-core
'@awoooi/shared-types':
specifier: workspace:^
version: link:../../packages/shared-types
'@sentry/nextjs':
specifier: ^10.45.0
version: 10.45.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(next@14.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)
@@ -149,6 +152,15 @@ importers:
specifier: ^1.2.0
version: 1.6.1(@types/node@20.19.37)(terser@5.46.1)
packages/shared-types:
devDependencies:
json-schema-to-typescript:
specifier: ^15.0.0
version: 15.0.4
typescript:
specifier: ^5.0.0
version: 5.9.3
packages/tsconfig: {}
packages:
@@ -157,6 +169,10 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -608,6 +624,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -1470,6 +1489,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/mysql@2.15.27':
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
@@ -2825,6 +2847,11 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-to-typescript@15.0.4:
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
engines: {node: '>=16.0.0'}
hasBin: true
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -4002,6 +4029,12 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@apidevtools/json-schema-ref-parser@11.9.3':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -4370,6 +4403,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsdevtools/ono@7.1.3': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.9.1
@@ -5207,6 +5242,8 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/lodash@4.17.24': {}
'@types/mysql@2.15.27':
dependencies:
'@types/node': 20.19.37
@@ -6810,6 +6847,18 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-typescript@15.0.4:
dependencies:
'@apidevtools/json-schema-ref-parser': 11.9.3
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.1.1
lodash: 4.17.23
minimist: 1.2.8
prettier: 3.8.1
tinyglobby: 0.2.15
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}

257
scripts/generate-schemas.py Normal file
View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
Pydantic → JSON Schema 生成器
=============================
Phase 14.3: 共用型別系統
功能:
- 從 Pydantic 模型生成 JSON Schema
- 正確處理 Pydantic 2.x 的 $defs 格式
- 輸出到 packages/shared-types/schemas/
使用方式:
cd apps/api
python ../../scripts/generate-schemas.py
建立: 2026-03-31 (台北時區)
建立者: Claude Code (Phase 14.3)
"""
import json
import sys
from pathlib import Path
from typing import Any
# 加入 apps/api/src 到 Python path
api_src = Path(__file__).parent.parent / "apps" / "api" / "src"
sys.path.insert(0, str(api_src))
# 輸出目錄
OUTPUT_DIR = Path(__file__).parent.parent / "packages" / "shared-types" / "schemas"
def extract_and_merge_defs(schema: dict[str, Any], all_defs: dict[str, Any]) -> dict[str, Any]:
"""
從單一模型 schema 中提取 $defs 並合併到全局 $defs
同時修正 $ref 路徑
"""
# 提取並合併 $defs
if "$defs" in schema:
for def_name, def_schema in schema["$defs"].items():
if def_name not in all_defs:
# 遞迴處理巢狀 $defs
cleaned_def = extract_and_merge_defs(def_schema.copy(), all_defs)
all_defs[def_name] = cleaned_def
# 移除 schema 中的 $defs (已合併到全局)
cleaned_schema = {k: v for k, v in schema.items() if k != "$defs"}
return cleaned_schema
def fix_refs(obj: Any) -> Any:
"""遞迴修正所有 $ref 路徑從 #/$defs/X 到 #/definitions/X"""
if isinstance(obj, dict):
result = {}
for key, value in obj.items():
if key == "$ref" and isinstance(value, str):
# 修正 ref 路徑
result[key] = value.replace("#/$defs/", "#/definitions/")
else:
result[key] = fix_refs(value)
return result
elif isinstance(obj, list):
return [fix_refs(item) for item in obj]
else:
return obj
def generate_schemas():
"""生成所有模型的 JSON Schema"""
# 確保輸出目錄存在
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# 收集所有 definitions
all_definitions: dict[str, Any] = {}
model_count = 0
print("📦 Approval Models:")
# === Approval Models ===
try:
from models.approval import (
ApprovalRequest,
ApprovalRequestCreate,
ApprovalRequestResponse,
BlastRadius,
DryRunCheck,
PendingApprovalsResponse,
RejectRequest,
Signature,
SignRequest,
SignResponse,
)
approval_models = [
ApprovalRequest,
ApprovalRequestCreate,
ApprovalRequestResponse,
BlastRadius,
DryRunCheck,
PendingApprovalsResponse,
RejectRequest,
Signature,
SignRequest,
SignResponse,
]
for model in approval_models:
schema = model.model_json_schema()
cleaned = extract_and_merge_defs(schema, all_definitions)
all_definitions[model.__name__] = cleaned
model_count += 1
print(f"{model.__name__}")
except ImportError as e:
print(f" ⚠ Import failed: {e}")
print("\n📦 Incident Models:")
# === Incident Models ===
try:
from models.incident import (
AIDecisionChain,
Incident,
IncidentCreate,
IncidentOutcome,
IncidentResponse,
IncidentUpdate,
Signal,
)
incident_models = [
AIDecisionChain,
Incident,
IncidentCreate,
IncidentOutcome,
IncidentResponse,
IncidentUpdate,
Signal,
]
for model in incident_models:
schema = model.model_json_schema()
cleaned = extract_and_merge_defs(schema, all_definitions)
all_definitions[model.__name__] = cleaned
model_count += 1
print(f"{model.__name__}")
except ImportError as e:
print(f" ⚠ Import failed: {e}")
print("\n📦 Terminal Models:")
# === Terminal Models ===
try:
from models.terminal import (
SpatialContext,
TerminalAbortRequest,
TerminalAbortResponse,
TerminalIntentRequest,
TerminalIntentResponse,
TerminalStatusResponse,
TerminalThoughtEvent,
)
terminal_models = [
TerminalIntentRequest,
TerminalIntentResponse,
TerminalStatusResponse,
TerminalAbortRequest,
TerminalAbortResponse,
TerminalThoughtEvent,
SpatialContext,
]
for model in terminal_models:
schema = model.model_json_schema()
cleaned = extract_and_merge_defs(schema, all_definitions)
all_definitions[model.__name__] = cleaned
model_count += 1
print(f"{model.__name__}")
except ImportError as e:
print(f" ⚠ Import failed: {e}")
print("\n📦 Playbook Models:")
# === Playbook Models ===
try:
from models.playbook import (
Playbook,
PlaybookCreateRequest,
PlaybookListResponse,
PlaybookRecommendation,
PlaybookResponse,
PlaybookUpdateRequest,
RepairStep,
SymptomPattern,
)
playbook_models = [
Playbook,
PlaybookResponse,
PlaybookListResponse,
PlaybookCreateRequest,
PlaybookUpdateRequest,
PlaybookRecommendation,
SymptomPattern,
RepairStep,
]
for model in playbook_models:
schema = model.model_json_schema()
cleaned = extract_and_merge_defs(schema, all_definitions)
all_definitions[model.__name__] = cleaned
model_count += 1
print(f"{model.__name__}")
except ImportError as e:
print(f" ⚠ Import failed: {e}")
print("\n📦 Other Models:")
# === CSRF Models ===
try:
from models.csrf import CSRFTokenResponse
schema = CSRFTokenResponse.model_json_schema()
cleaned = extract_and_merge_defs(schema, all_definitions)
all_definitions["CSRFTokenResponse"] = cleaned
model_count += 1
print(" ✓ CSRFTokenResponse")
except ImportError as e:
print(f" ⚠ CSRF import failed: {e}")
# 修正所有 $ref 路徑
all_definitions = fix_refs(all_definitions)
# 組裝最終 schema
final_schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AWOOOI API Types",
"description": "Auto-generated from Pydantic models - DO NOT EDIT",
"definitions": all_definitions,
}
# 寫入檔案
output_file = OUTPUT_DIR / "api-types.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(final_schema, f, indent=2, ensure_ascii=False)
print(f"\n✅ Schema 生成完成: {output_file}")
print(f"{len(all_definitions)} 個型別定義")
return final_schema
if __name__ == "__main__":
print("🔄 開始生成 JSON Schema...\n")
generate_schemas()