전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -22,6 +22,7 @@ from ..config import settings
from ..db import acquire, get_pool
from ..deps import CurrentPrincipal, Role
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.phase3_kpi_contract import PREPOST_MEASURE_NAMES, PREPOST_TIMEPOINTS
from ..services.support_tickets import support_ticket_fingerprint
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
@ -37,13 +38,6 @@ AVATAR_CONTENT_TYPES = {
}
DEFAULT_PREPOST_PILOT_ID = "phase3-pilot-draft"
DEFAULT_PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"
PREPOST_MEASURE_NAMES = (
"self_efficacy",
"skill_proficiency",
"training_satisfaction",
)
PREPOST_TIMEPOINTS = ("pre", "post")
TERMS_BODY = """Vignette 서비스 이용약관 초안
1. 목적
@ -193,7 +187,9 @@ class UserPreferencesResponse(BaseModel):
theme: str = "system"
voice_preset_id: str = "soft-young-fem"
voice_rate: float = 1.0
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
notifications: NotificationPreferences = Field(
default_factory=NotificationPreferences
)
class UserPreferencesPatch(BaseModel):
@ -221,7 +217,9 @@ TicketCategory = Literal[
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
PrepostMeasureName = Literal["self_efficacy", "skill_proficiency", "training_satisfaction"]
PrepostMeasureName = Literal[
"self_efficacy", "skill_proficiency", "training_satisfaction"
]
PrepostTimepoint = Literal["pre", "post"]
@ -386,7 +384,9 @@ def _preferences_from_row(row) -> UserPreferencesResponse:
theme=row["theme"],
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
voice_rate=float(row["voice_rate"]),
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
notifications=NotificationPreferences.model_validate(
row["notifications"] or {}
),
)
@ -420,7 +420,9 @@ def _validated_avatar_extension(content_type: str, content: bytes) -> str:
ext, magic = AVATAR_CONTENT_TYPES[normalized]
if normalized == "image/webp":
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file"
)
elif not content.startswith(magic):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
return ext
@ -484,16 +486,28 @@ async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
@router.patch("/me", response_model=UserProfileResponse)
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
async def patch_me(
body: UserProfilePatch, principal: CurrentPrincipal
) -> UserProfileResponse:
profile = await _profile_for(principal)
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
department=body.department if body.department is not None else profile.department,
grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
display_name=body.display_name
if body.display_name is not None
else profile.display_name,
affiliation=body.affiliation
if body.affiliation is not None
else profile.affiliation,
legal_name=body.legal_name
if body.legal_name is not None
else profile.legal_name,
department=body.department
if body.department is not None
else profile.department,
grade_level=body.grade_level
if body.grade_level is not None
else profile.grade_level,
phone=body.phone if body.phone is not None else profile.phone,
contact_address=(
body.contact_address
@ -506,7 +520,9 @@ async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserP
if body.self_introduction is not None
else profile.self_introduction
),
avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
avatar_url=body.avatar_url
if body.avatar_url is not None
else profile.avatar_url,
),
)
if updated is None:
@ -525,7 +541,9 @@ async def upload_my_avatar(
if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
if len(content) > AVATAR_MAX_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large"
)
ext = _validated_avatar_extension(content_type, content)
root = _upload_root()
@ -597,7 +615,9 @@ async def create_support_ticket(
) -> UserSupportTicketResponse:
profile = await _profile_for(principal)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
async with acquire(
role=principal.role.value, user_id=principal.user_id
) as conn:
subject = body.subject.strip()
ticket_body = body.body.strip()
source_path = body.source_path.strip()
@ -725,7 +745,9 @@ async def _support_tickets_for_user(
resolution_note=row["resolution_note"] or "",
created_at=float(row["created_at"] or 0.0),
updated_at=float(row["updated_at"] or 0.0),
resolved_at=float(row["resolved_at"]) if row["resolved_at"] is not None else None,
resolved_at=float(row["resolved_at"])
if row["resolved_at"] is not None
else None,
)
for row in rows
],
@ -741,11 +763,13 @@ async def list_my_support_tickets(
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="support ticket persistence unavailable",
detail="support ticket persistence unavailable",
) from exc
def _normalized_prepost_score(raw_score: float, min_score: float, max_score: float) -> float:
def _normalized_prepost_score(
raw_score: float, min_score: float, max_score: float
) -> float:
if max_score <= min_score:
return 0.0
return round(((raw_score - min_score) / (max_score - min_score)) * 100.0, 3)
@ -803,7 +827,8 @@ async def _prepost_measures_for_user(
pairs = {
item.measure_name
for item in measures
if {m.timepoint for m in measures if m.measure_name == item.measure_name} == {"pre", "post"}
if {m.timepoint for m in measures if m.measure_name == item.measure_name}
== {"pre", "post"}
}
return UserPrepostMeasuresResponse(
source="database",
@ -820,7 +845,9 @@ async def _prepost_measures_for_user(
@router.get("/me/prepost-measures", response_model=UserPrepostMeasuresResponse)
async def list_my_prepost_measures(
principal: CurrentPrincipal,
pilot_id: str = Query(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80),
pilot_id: str = Query(
default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80
),
) -> UserPrepostMeasuresResponse:
try:
return await _prepost_measures_for_user(principal, pilot_id=pilot_id)
@ -837,9 +864,13 @@ async def upsert_my_prepost_measure(
principal: CurrentPrincipal,
) -> UserPrepostMeasureItem:
pilot_id = body.pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID
instrument_version = body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
instrument_version = (
body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
async with acquire(
role=principal.role.value, user_id=principal.user_id
) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.learner_prepost_measure (
@ -982,7 +1013,9 @@ async def patch_preferences(
if body.voice_preset_id is not None
else current_prefs.voice_preset_id
),
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
voice_rate=body.voice_rate
if body.voice_rate is not None
else current_prefs.voice_rate,
notifications=(
body.notifications
if body.notifications is not None