diff --git a/.dockerignore b/.dockerignore index 9fe5d33..f03786b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,8 @@ __pycache__ .env tests README.md +node_modules +package-lock.json +pnpm-lock.yaml +e2e_test_efs +e2e_test_efs_agg diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50e8b56 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Runtime +APP_ENV=local +APP_NAME=recommendation-server +APP_MODE=realtime +APP_PORT=8000 +LOG_LEVEL=INFO + +# Database (Java 레포와 동일 DB) +# 1) 명시 URL (권장: 로컬 개발) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/holliverse +# DB_URL도 동일 의미로 사용 가능 + +# 2) ECS/Secrets Manager 분해 주입 (POSTGRES_DSN 미설정 시 자동 조합) +# - Secret JSON key(username/password)를 POSTGRES_USER/POSTGRES_PASSWORD로 매핑해 주입 +# - host/port/db는 일반 env로 주입 +# POSTGRES_HOST=your-instance.xxxxx.ap-northeast-2.rds.amazonaws.com +# POSTGRES_PORT=5432 +# POSTGRES_DB=holliverse +# POSTGRES_USER=holliverse +# POSTGRES_PASSWORD=***** +# POSTGRES_SSLMODE=require + +# 3) 배치용 raw DSN (asyncpg) +# POSTGRES_DSN=postgresql://USER:PASSWORD@HOST:5432/DBNAME?sslmode=require + +# RDS 연결 시 SSL 필요하면 true (기본 false) +DATABASE_SSL=false + +# OpenAI +OPENAI_API_KEY=your_openai_api_key +OPENAI_CHAT_MODEL=gpt-4o-mini +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# Recommendation +RECOMMEND_TOP_K=3 +CACHE_TTL_DAYS=7 diff --git a/.gitignore b/.gitignore index b9699ae..6d52abe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,29 @@ +# IDE .idea -.venv -.env + +# Python __pycache__/ *.pyc +*.pyo +*.pyd +dist/ +build/ +*.egg-info/ +.eggs/ + +# Environment +.venv +.env + +# Logs +*.log +logs/ + +# Node +node_modules/ + +# OS +.DS_Store + +# E2E test data (keep out of git) +e2e_test_efs/** diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..98475b5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm test diff --git a/Dockerfile b/Dockerfile index 418160e..1ccda6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,49 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS builder -WORKDIR /app +WORKDIR /build ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential python3-dev \ + && pip wheel --wheel-dir /wheels -r requirements.txt \ + && rm -rf /var/lib/apt/lists/* + + +FROM python:3.11-slim AS runtime + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + APP_MODE=realtime \ + APP_HOST=0.0.0.0 \ + APP_PORT=8000 + +RUN addgroup --system appgroup \ + && adduser --system --ingroup appgroup --home /app appuser + +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir /wheels/* \ + && python -m spacy download ko_core_news_sm \ + && rm -rf /wheels COPY app ./app +COPY scripts ./scripts +COPY docker-entrypoint.sh ./docker-entrypoint.sh + +RUN chmod +x /app/docker-entrypoint.sh \ + && chown -R appuser:appgroup /app + +USER appuser EXPOSE 8000 -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 660fb94..a1c534b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # counseling-analytics - +하이 FastAPI 개발 서버 실행 가이드입니다. ## Prerequisites @@ -56,7 +56,13 @@ pip install -r requirements.txt ## 5) FastAPI 서버 실행 ```bash -python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +python -m uvicorn app.realtime.main:app --reload --host 0.0.0.0 --port 8000 +``` + +## 5-1) 배치 1회 실행 + +```bash +python -m app.batch.main ``` ## 6) 접속 확인 diff --git a/app/api/deps.py b/app/api/deps.py deleted file mode 100644 index 703c068..0000000 --- a/app/api/deps.py +++ /dev/null @@ -1,24 +0,0 @@ -"""DI(의존성 주입) 팩토리 모음 -""" - -from functools import lru_cache -from app.core.config import get_settings -from app.infra.state.request_registry import RequestRegistry -from app.services.analyze_service import AnalyzeService -from app.services.idempotency_service import IdempotencyService - -@lru_cache -def get_registry() -> RequestRegistry: - """요청 처리 상태 저장소 생성""" - return RequestRegistry() - -@lru_cache -def get_analyze_service() -> AnalyzeService: - """분석 오케스트레이션 서비스 생성""" - settings = get_settings() - registry = get_registry() - - return AnalyzeService( - settings=settings, - idempotency=IdempotencyService(registry), - ) diff --git a/app/api/router.py b/app/api/router.py deleted file mode 100644 index 019519f..0000000 --- a/app/api/router.py +++ /dev/null @@ -1,11 +0,0 @@ -#API 라우터 통합 - -from fastapi import APIRouter -from app.api.v1.analyze import router as analyze_router -from app.api.v1.health import router as health_router -from app.api.v1.ops import router as ops_router - -api_router = APIRouter() -api_router.include_router(health_router) -api_router.include_router(analyze_router) -api_router.include_router(ops_router) diff --git a/app/api/v1/analyze.py b/app/api/v1/analyze.py deleted file mode 100644 index 300bf05..0000000 --- a/app/api/v1/analyze.py +++ /dev/null @@ -1,38 +0,0 @@ -"""분석 요청 API. - -클라이언트가 요청하면 서비스 레이어에서 -EFS 읽기 -> 매칭/집계 -> 결과 파일 저장까지 수행한다. -""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, status - -from app.api.deps import get_analyze_service -from app.schemas.analyze_request import AnalyzeRequest -from app.schemas.analyze_response import AnalyzeResponse -from app.services.analyze_service import AnalyzeService - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.post("/analyze", response_model=AnalyzeResponse) -def analyze( - payload: AnalyzeRequest, - service: AnalyzeService = Depends(get_analyze_service), -) -> AnalyzeResponse: - """분석 실행 - - `requestId`가 이미 존재하면 `duplicated` 반환 - - 내부 예외는 500으로 반환 - """ - try: - accepted, message = service.analyze(payload) - except Exception as exc: - logger.error(f"분석 요청 처리 실패 (requestId: {payload.request_id})", exc_info=True) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="서버 내부 오류가 발생했습니다.") from exc - - if not accepted: - return AnalyzeResponse(status="duplicated", requestId=payload.request_id, message=message) - - return AnalyzeResponse(status="accepted", requestId=payload.request_id, message=message) diff --git a/app/api/v1/health.py b/app/api/v1/health.py deleted file mode 100644 index d75550d..0000000 --- a/app/api/v1/health.py +++ /dev/null @@ -1,37 +0,0 @@ -"""헬스체크 API.""" - -from fastapi import APIRouter - -from app.core.config import get_settings - -router = APIRouter(tags=["health"]) -public_router = APIRouter(tags=["health"]) - - -def _health_payload(status: str) -> dict[str, str]: - settings = get_settings() - return { - "status": status, - "service": settings.app_name, - "env": settings.app_env, - } - - -@router.get("/health", summary="Liveness check") -def health() -> dict[str, str]: - return _health_payload("ok") - - -@router.get("/ready", summary="Readiness check") -def ready() -> dict[str, str]: - return _health_payload("ready") - - -@public_router.get("/health", include_in_schema=False) -def public_health() -> dict[str, str]: - return _health_payload("ok") - - -@public_router.get("/ready", include_in_schema=False) -def public_ready() -> dict[str, str]: - return _health_payload("ready") diff --git a/app/api/v1/ops.py b/app/api/v1/ops.py deleted file mode 100644 index f4d2952..0000000 --- a/app/api/v1/ops.py +++ /dev/null @@ -1,20 +0,0 @@ -"""운영/상태 조회 API.""" - -from fastapi import APIRouter, Depends, HTTPException, status - -from app.api.deps import get_registry -from app.infra.state.request_registry import RequestRegistry - -router = APIRouter(prefix="/ops") - - -@router.get("/requests/{request_id}") -def get_request_state( - request_id: str, - registry: RequestRegistry = Depends(get_registry), -) -> dict[str, str]: - state = registry.get(request_id) - if state is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="request not found") - - return {"requestId": request_id, **state} diff --git a/app/batch/main.py b/app/batch/main.py new file mode 100644 index 0000000..2b168d5 --- /dev/null +++ b/app/batch/main.py @@ -0,0 +1,88 @@ +"""One-off Kafka batch entrypoint for keyword mapping/extraction.""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from aiokafka import AIOKafkaConsumer + +from app.core.config import get_settings +from app.core.logging import configure_logging +from app.infra.postgres.analysis_repository import AnalysisRepository +from app.infra.postgres.client import create_postgres_pool +from app.infra.postgres.dispatch_outbox_repository import DispatchOutboxRepository +from app.schemas.analysis_request_message import AnalysisRequestMessage +from app.services.kafka_analysis_consumer_service import KafkaAnalysisConsumerService +from app.services.sql_keyword_analysis_service import SqlKeywordAnalysisService + +logger = logging.getLogger(__name__) + + +async def run_once() -> int: + settings = get_settings() + configure_logging(settings.debug) + + service = KafkaAnalysisConsumerService(settings) + service._db_pool = await create_postgres_pool(settings) # noqa: SLF001 + service._analysis_repository = AnalysisRepository(service._db_pool) # noqa: SLF001 + service._outbox_repository = DispatchOutboxRepository(service._db_pool) # noqa: SLF001 + service._analysis_service = SqlKeywordAnalysisService() # noqa: SLF001 + + consumer = AIOKafkaConsumer( + settings.kafka_analysis_request_topic, + bootstrap_servers=[s.strip() for s in settings.kafka_bootstrap_servers.split(",") if s.strip()], + group_id=settings.kafka_consumer_group_id, + auto_offset_reset=settings.kafka_auto_offset_reset, + enable_auto_commit=False, + value_deserializer=lambda value: json.loads(value.decode("utf-8")), + ) + + processed_count = 0 + received_count = 0 + dropped_count = 0 + try: + await consumer.start() + polled = await consumer.getmany( + timeout_ms=settings.kafka_poll_timeout_ms, + max_records=settings.kafka_batch_size, + ) + + messages: list[AnalysisRequestMessage] = [] + for _, records in polled.items(): + for record in records: + received_count += 1 + parsed = service._parse_message(record.value) # noqa: SLF001 + if parsed is None: + dropped_count += 1 + continue + messages.append(parsed) + + if messages: + for chunk in service._chunk(messages, settings.kafka_batch_size): # noqa: SLF001 + await service._process_batch(chunk) # noqa: SLF001 + processed_count += len(chunk) + await consumer.commit() + elif received_count > 0 and dropped_count == received_count: + await consumer.commit() + + logger.info( + "Keyword batch run finished once. received=%d dropped=%d processed=%d", + received_count, + dropped_count, + processed_count, + ) + return processed_count + finally: + await consumer.stop() + if service._db_pool is not None: # noqa: SLF001 + await service._db_pool.close() # noqa: SLF001 + + +def main() -> None: + asyncio.run(run_once()) + + +if __name__ == "__main__": + main() diff --git a/app/core/config.py b/app/core/config.py index 11bd169..183a733 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,11 @@ from functools import lru_cache from pathlib import Path +from urllib.parse import quote + +from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict +import os + class Settings(BaseSettings): app_name: str = "counseling-analytics" @@ -8,14 +13,95 @@ class Settings(BaseSettings): debug: bool = False api_v1_prefix: str = "/api/v1" + log_level: str = "INFO" + + # recommendation (미설정 시 빈 문자열, DB/OpenAI 사용 시점에 연결 실패) + database_url: str = Field(default="", validation_alias=AliasChoices("DATABASE_URL", "DB_URL")) + database_ssl: bool = Field(default=False, validation_alias=AliasChoices("DATABASE_SSL", "DB_SSL")) + openai_api_key: str = "" + openai_chat_model: str = "gpt-4o-mini" + openai_embedding_model: str = "text-embedding-3-small" + recommend_top_k: int = 3 + cache_ttl_days: int = 7 # 실제 운영 => EFS 마운트 경로를 지정 efs_base_dir: Path = Path("./data/efs") state_dir: Path = Path("./data/state") + # Kafka consumer (analysis request) + kafka_consumer_enabled: bool = False + kafka_bootstrap_servers: str = "localhost:9092" + kafka_analysis_request_topic: str = "analysis.request.v1" + kafka_consumer_group_id: str = "counseling-analytics-consumer" + kafka_auto_offset_reset: str = "earliest" + kafka_batch_size: int = 1000 + kafka_poll_timeout_ms: int = 1000 + kafka_log_each_message: bool = False + kafka_log_result_limit: int = 20 + + # PostgreSQL connection for bulk lookup + postgres_dsn: str = Field(default="", validation_alias=AliasChoices("POSTGRES_DSN", "DB_DSN")) + postgres_host: str = Field(default="", validation_alias=AliasChoices("POSTGRES_HOST", "DB_HOST")) + postgres_port: int = Field(default=5432, validation_alias=AliasChoices("POSTGRES_PORT", "DB_PORT")) + postgres_db: str = Field( + default="", + validation_alias=AliasChoices("POSTGRES_DB", "DB_NAME", "DB_DATABASE", "RDS_DB_NAME", "DBNAME"), + ) + postgres_user: str = Field( + default="", + validation_alias=AliasChoices("POSTGRES_USER", "DB_USER", "RDS_USERNAME", "DB_USERNAME"), + ) + postgres_password: str = Field( + default="", + validation_alias=AliasChoices("POSTGRES_PASSWORD", "DB_PASSWORD", "RDS_PASSWORD"), + ) + postgres_sslmode: str = Field(default="", validation_alias=AliasChoices("POSTGRES_SSLMODE", "DB_SSLMODE")) + postgres_pool_min_size: int = 1 + postgres_pool_max_size: int = 10 + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + @property + def effective_postgres_dsn(self) -> str: + explicit_dsn = self.postgres_dsn.strip() + if explicit_dsn: + return explicit_dsn + + if not (self.postgres_host and self.postgres_db and self.postgres_user and self.postgres_password): + return "" + + user = quote(self.postgres_user, safe="") + password = quote(self.postgres_password, safe="") + dsn = f"postgresql://{user}:{password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" + + sslmode = self.postgres_sslmode.strip() + if sslmode: + dsn = f"{dsn}?sslmode={sslmode}" + + return dsn + + @property + def effective_database_url(self) -> str: + explicit_url = self.database_url.strip() + if explicit_url: + return explicit_url + + dsn = self.effective_postgres_dsn + if not dsn: + return "" + if dsn.startswith("postgresql+asyncpg://"): + return dsn + if dsn.startswith("postgresql://"): + return dsn.replace("postgresql://", "postgresql+asyncpg://", 1) + if dsn.startswith("postgres://"): + return dsn.replace("postgres://", "postgresql+asyncpg://", 1) + return dsn + @lru_cache def get_settings() -> Settings: - return Settings() + app_env = os.getenv("APP_ENV", "local") + candidate = Path(f".env.{app_env}") + env_file = str(candidate) if candidate.exists() else ".env" + + return Settings(_env_file=env_file) diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..a386213 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,64 @@ +from collections.abc import AsyncGenerator + +from pgvector.asyncpg import register_vector +from sqlalchemy import event, text +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.config import get_settings + +settings = get_settings() + + +def create_engine() -> AsyncEngine: + database_url = settings.effective_database_url + connect_args = {} + if settings.database_ssl: + connect_args["ssl"] = True # RDS 환경에서 SSL + return create_async_engine( + database_url, + pool_pre_ping=True, + future=True, + connect_args=connect_args, + ) + + +# DATABASE_URL 없으면 엔진 미생성(앱 기동은 가능, DB 사용 시점에 에러) +engine: AsyncEngine | None = None +SessionLocal: async_sessionmaker[AsyncSession] | None = None + +if (settings.effective_database_url or "").strip(): + engine = create_engine() + # pgvector 등록: asyncpg 연결 시 vector 타입 등록 + @event.listens_for(engine.sync_engine, "connect") + def register_pgvector(dbapi_connection, connection_record) -> None: + dbapi_connection.run_async(register_vector) + + SessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + if SessionLocal is None: + raise RuntimeError( + "Database is not configured. " + "Set DATABASE_URL/DB_URL or POSTGRES_HOST/POSTGRES_PORT/POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD." + ) + async with SessionLocal() as session: + yield session + + +async def check_db_connection() -> None: + if engine is None: + return + async with engine.connect() as connection: + await connection.execute(text("SELECT 1")) diff --git a/app/infra/cache/alias_cache.py b/app/infra/cache/alias_cache.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/infra/efs/jsonl.py b/app/infra/efs/jsonl.py deleted file mode 100644 index d50d17a..0000000 --- a/app/infra/efs/jsonl.py +++ /dev/null @@ -1,45 +0,0 @@ -"""JSONL/.jsonl.gz reader/writer utilities.""" - -import gzip -import json -from pathlib import Path -from typing import Any, Generator, TextIO - - -def _is_gzip(path: Path) -> bool: - return path.name.endswith(".gz") - - -def _open_read(path: Path) -> TextIO: - if _is_gzip(path): - return gzip.open(path, "rt", encoding="utf-8") - return path.open("r", encoding="utf-8") - - -def _open_write(path: Path) -> TextIO: - if _is_gzip(path): - return gzip.open(path, "wt", encoding="utf-8") - return path.open("w", encoding="utf-8") - - -def read_jsonl(path: Path) -> Generator[dict[str, Any], None, None]: - with _open_read(path) as f: - for line_no, line in enumerate(f, start=1): - content = line.strip() - if not content: - continue - try: - yield json.loads(content) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid JSONL at {path}:{line_no}") from exc - - -def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - - with _open_write(tmp) as f: - for row in rows: - f.write(json.dumps(row, ensure_ascii=False) + "\n") - - tmp.replace(path) diff --git a/app/infra/efs/paths.py b/app/infra/efs/paths.py deleted file mode 100644 index 19b809c..0000000 --- a/app/infra/efs/paths.py +++ /dev/null @@ -1,66 +0,0 @@ -# EFS 경로 처리 유틸 -from pathlib import Path - -from app.core.exceptions import InvalidPathError - -INPUT_SUFFIX = ".input.jsonl.gz" -MANIFEST_SUFFIX = ".manifest.json" -MAPPING_SUFFIX = ".mapping.jsonl.gz" -CHUNK_SUMMARY_SUFFIX = ".chunk.json" -ALIAS_FILE_SUFFIX = ".alias.jsonl.gz" - - -def resolve_efs_path(base_dir: Path, requested_path: str) -> Path: - """요청 경로를 절대경로로 변환하고 base_dir 내부인지 검증.""" - raw = Path(requested_path) - full = raw if raw.is_absolute() else (base_dir / raw) - resolved = full.resolve() - base_resolved = base_dir.resolve() - - if not str(resolved).startswith(str(base_resolved)): - raise InvalidPathError(f"Path is outside EFS base dir: {requested_path}") - return resolved - - -def build_req_dir(base_dir: Path, job_instance_id: str) -> Path: - # Spring이 올려둔 입력 chunk들이 있는 위치 - # [수정] 외부 입력값이 포함된 경로를 resolve_efs_path로 검증 - target = f"analysis/req/{job_instance_id}" - return resolve_efs_path(base_dir, target) - - -def build_res_dir(base_dir: Path, job_instance_id: str) -> Path: - # Python이 처리 결과를 저장하는 위치 - # [수정] 외부 입력값이 포함된 경로를 resolve_efs_path로 검증 - target = f"analysis/res/{job_instance_id}" - return resolve_efs_path(base_dir, target) - - -def build_ref_alias_path(base_dir: Path, analysis_version: str) -> Path: - # 예: /mnt/efs/analysis/ref/v1.alias.jsonl.gz - # [수정] 외부 입력값이 포함된 경로를 resolve_efs_path로 검증 - target = f"analysis/ref/{analysis_version}{ALIAS_FILE_SUFFIX}" - return resolve_efs_path(base_dir, target) - - -def list_chunk_inputs(req_dir: Path) -> list[Path]: - # 처리 대상 입력 파일 규칙: {chunkId}.input.jsonl.gz - return sorted(req_dir.glob(f"*{INPUT_SUFFIX}")) - - -def parse_chunk_id(input_path: Path) -> str: - name = input_path.name - if not name.endswith(INPUT_SUFFIX): - raise ValueError(f"invalid input file name: {name}") - return name[: -len(INPUT_SUFFIX)] - - -def build_manifest_path(req_dir: Path, chunk_id: str) -> Path: - return req_dir / f"{chunk_id}{MANIFEST_SUFFIX}" - - -def build_output_paths(base_dir: Path, job_instance_id: str, chunk_id: str) -> tuple[Path, Path]: - res_dir = build_res_dir(base_dir, job_instance_id) - mapping_path = res_dir / f"{chunk_id}{MAPPING_SUFFIX}" - chunk_summary_path = res_dir / f"{chunk_id}{CHUNK_SUMMARY_SUFFIX}" - return mapping_path, chunk_summary_path diff --git a/app/infra/efs/reader.py b/app/infra/efs/reader.py deleted file mode 100644 index a228e2d..0000000 --- a/app/infra/efs/reader.py +++ /dev/null @@ -1,17 +0,0 @@ -"""EFS input file readers.""" - -from pathlib import Path - -from app.infra.efs.jsonl import read_jsonl -from app.schemas.alias_record import AliasRecord -from app.schemas.counsel_record import CounselRecord - - -def read_counsel_records(path: Path) -> list[CounselRecord]: - # 상담 JSONL(또는 JSONL gzip)을 스키마 객체 리스트로 변환 - return [CounselRecord.model_validate(row) for row in read_jsonl(path)] - - -def read_alias_records(path: Path) -> list[AliasRecord]: - # 키워드/별칭 사전을 스키마 객체 리스트로 변환 - return [AliasRecord.model_validate(row) for row in read_jsonl(path)] diff --git a/app/infra/efs/writer.py b/app/infra/efs/writer.py deleted file mode 100644 index a5713e1..0000000 --- a/app/infra/efs/writer.py +++ /dev/null @@ -1,11 +0,0 @@ -"""EFS output file writers.""" - -from pathlib import Path - -from app.infra.efs.jsonl import write_jsonl -from app.schemas.result_record import ResultRecord - - -def write_result_records(path: Path, rows: list[ResultRecord]) -> None: - payload = [row.model_dump(by_alias=True, mode="json") for row in rows] - write_jsonl(path, payload) diff --git a/app/infra/postgres/analysis_repository.py b/app/infra/postgres/analysis_repository.py new file mode 100644 index 0000000..bff0ccd --- /dev/null +++ b/app/infra/postgres/analysis_repository.py @@ -0,0 +1,55 @@ +from asyncpg import Pool, Record + + +class AnalysisRepository: + def __init__(self, pool: Pool) -> None: + self._pool = pool + + async def find_targets_by_case_and_version( + self, + case_ids: list[int], + analyzer_versions: list[int], + ) -> list[Record]: + if not case_ids: + return [] + + sql = """ + WITH input_pairs AS ( + SELECT * + FROM unnest($1::bigint[], $2::bigint[]) AS t(case_id, analyzer_version) + ) + SELECT + ca.analysis_id, + ca.case_id, + ca.analyzer_version, + sc.member_id, + sc.title, + sc.question_text + FROM input_pairs ip + JOIN consultation_analysis ca + ON ca.case_id = ip.case_id + AND ca.analyzer_version = ip.analyzer_version + JOIN support_case sc + ON sc.case_id = ca.case_id + """ + async with self._pool.acquire() as conn: + return await conn.fetch(sql, case_ids, analyzer_versions) + + async def load_active_keyword_rows(self) -> list[Record]: + sql = """ + SELECT + bk.business_keyword_id, + bk.keyword_code, + bk.keyword_name, + bka.alias_id, + bka.alias_text, + bka.alias_norm + FROM business_keyword bk + LEFT JOIN business_keyword_alias bka + ON bka.business_keyword_id = bk.business_keyword_id + AND bka.is_active = TRUE + WHERE bk.is_active = TRUE + ORDER BY bk.business_keyword_id, bka.alias_id + """ + async with self._pool.acquire() as conn: + return await conn.fetch(sql) diff --git a/app/infra/postgres/client.py b/app/infra/postgres/client.py new file mode 100644 index 0000000..8209678 --- /dev/null +++ b/app/infra/postgres/client.py @@ -0,0 +1,18 @@ +import asyncpg +from asyncpg import Pool + +from app.core.config import Settings + + +async def create_postgres_pool(settings: Settings) -> Pool: + dsn = settings.effective_postgres_dsn + if not dsn: + raise RuntimeError( + "PostgreSQL is not configured. Set POSTGRES_DSN or " + "POSTGRES_HOST/POSTGRES_PORT/POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD." + ) + return await asyncpg.create_pool( + dsn=dsn, + min_size=settings.postgres_pool_min_size, + max_size=settings.postgres_pool_max_size, + ) diff --git a/app/infra/postgres/dispatch_outbox_repository.py b/app/infra/postgres/dispatch_outbox_repository.py new file mode 100644 index 0000000..d1b3b95 --- /dev/null +++ b/app/infra/postgres/dispatch_outbox_repository.py @@ -0,0 +1,25 @@ +from asyncpg import Pool + + +class DispatchOutboxRepository: + def __init__(self, pool: Pool) -> None: + self._pool = pool + + async def mark_acked_by_request_ids(self, request_ids: list[str]) -> set[str]: + if not request_ids: + return set() + + sql = """ + UPDATE analysis_dispatch_outbox + SET + dispatch_status = 'ACKED'::dispatch_status, + analysis_status = 'READY'::analysis_status, + updated_at = NOW() + WHERE request_id = ANY($1::text[]) + RETURNING request_id + """ + + async with self._pool.acquire() as conn: + rows = await conn.fetch(sql, request_ids) + + return {str(row["request_id"]) for row in rows} diff --git a/app/infra/state/lock.py b/app/infra/state/lock.py deleted file mode 100644 index c6fdcf8..0000000 --- a/app/infra/state/lock.py +++ /dev/null @@ -1,16 +0,0 @@ -"""프로세스 내 중복 실행 방지용 전역 락.""" - -from contextlib import contextmanager -from threading import Lock - -_LOCK = Lock() - - -@contextmanager -def process_lock(): - """임계 구역 보호 컨텍스트.""" - _LOCK.acquire() - try: - yield - finally: - _LOCK.release() diff --git a/app/infra/state/request_registry.py b/app/infra/state/request_registry.py deleted file mode 100644 index 0e3a294..0000000 --- a/app/infra/state/request_registry.py +++ /dev/null @@ -1,29 +0,0 @@ -# 요청 처리 상태 저장소(in-memory) -from threading import Lock - -class RequestRegistry: - def __init__(self) -> None: - self._state: dict[str, dict[str, str]] = {} - self._lock = Lock() - - def get(self, request_id: str) -> dict[str, str] | None: - """요청 상태 조회""" - with self._lock: - item = self._state.get(request_id) - return None if item is None else dict(item) - - def create_if_absent(self, request_id: str, status: str) -> bool: - """없을 때만 생성. 이미 있으면 False.""" - with self._lock: - if request_id in self._state: - return False - - self._state[request_id] = {"status": status} - return True - - def update_status(self, request_id: str, status: str) -> None: - """요청 상태 변경.""" - with self._lock: - item = self._state.get(request_id, {}) - item["status"] = status - self._state[request_id] = item diff --git a/app/main.py b/app/main.py index be24623..bb7062a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,25 +1,10 @@ -"""FastAPI application entrypoint. +"""Backward-compatible entrypoint. -- 공개 헬스체크(`/health`, `/ready`)는 prefix 없이 노출 -- 나머지 API는 `/api/v1` prefix로 노출 +Prefer `app.realtime.main` for the realtime API server. """ -from fastapi import FastAPI +from app.realtime.main import app, run -from app.api.router import api_router -from app.api.v1.health import public_router as public_health_router -from app.core.config import get_settings -from app.core.logging import configure_logging -settings = get_settings() -configure_logging(settings.debug) - -app = FastAPI(title=settings.app_name) -app.include_router(public_health_router) -app.include_router(api_router, prefix=settings.api_v1_prefix) - - -@app.get("/") -def root() -> dict[str, str]: - #Root 확인용 엔드 포인트 - return {"message": "counseling analytics api"} +if __name__ == "__main__": + run() diff --git a/app/pipeline/aggregator.py b/app/pipeline/aggregator.py index e69de29..f6c689e 100644 --- a/app/pipeline/aggregator.py +++ b/app/pipeline/aggregator.py @@ -0,0 +1,100 @@ +import json +import gzip +from pathlib import Path +from collections import defaultdict +from typing import Dict, Any, List + +from app.core.config import Settings +from app.infra.efs.paths import build_res_dir + +class ResultAggregator: + """ + 최종 결과 집계기 + AnalyzeService가 처리한 여러 chunk 결과를 모아, + '회원(member_id)'을 기준으로 어떤 키워드를 몇 번 문의했는지 총합을 구함 + """ + def __init__(self, settings: Settings): + self.settings = settings + + def aggregate_job(self, job_instance_id: str) -> List[Dict[str, Any]]: + """ + 특정 Job의 모든 청크 결과를 읽어 member_id 기준 키워드 누적합 결과를 반환 및 저장 + """ + # 1. 결과 폴더 경로 탐색 + res_dir = build_res_dir(Path(self.settings.efs_base_dir), job_instance_id) + if not res_dir.exists(): + raise FileNotFoundError(f"결과 폴더를 찾을 수 없습니다: {res_dir}") + + # 2. 집계를 위한 자료구조 세팅 + # 구조: member_counts[member_id][keyword_code] = count 누적 + member_counts = defaultdict(lambda: defaultdict(int)) + + # 키워드 메타데이터(이름, ID)를 기억해두기 위한 딕셔너리 + keyword_meta_map = {} + + # 3. 폴더 내의 모든 mapping.jsonl.gz 파일 순회 + mapping_files = list(res_dir.glob("*.mapping.jsonl.gz")) + if not mapping_files: + print(f"[Aggregator] 집계할 파일이 없습니다. (job: {job_instance_id})") + return [] + + for file_path in mapping_files: + with gzip.open(file_path, "rt", encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + + record = json.loads(line) + member_id = record.get("memberId") + matched_keywords = record.get("matchedKeywords", []) + + if not member_id or not matched_keywords: + continue + + # 4. 키워드별 카운트 누적 (+) + for mk in matched_keywords: + k_code = mk["keywordCode"] + count = mk.get("count", 1) + + # 카운트 더하기 + member_counts[member_id][k_code] += count + + # 나중에 출력하기 위해 메타데이터 기억해두기 + if k_code not in keyword_meta_map: + keyword_meta_map[k_code] = { + "businessKeywordId": mk["businessKeywordId"], + "keywordName": mk["keywordName"] + } + + # 5. 백엔드(Spring)가 먹기 좋게 JSON 리스트 포맷으로 변환 + final_results = [] + for member_id, counts_by_code in member_counts.items(): + keywords_list = [] + + for k_code, total_count in counts_by_code.items(): + meta = keyword_meta_map[k_code] + keywords_list.append({ + "businessKeywordId": meta["businessKeywordId"], + "keywordCode": k_code, + "keywordName": meta["keywordName"], + "totalCount": total_count + }) + + # 많이 문의한 키워드가 위로 오도록 내림차순 정렬 + keywords_list.sort(key=lambda x: x["totalCount"], reverse=True) + + final_results.append({ + "memberId": member_id, + "topKeywords": keywords_list + }) + + # 6. 최종 결과를 하나의 파일로 저장 + output_path = res_dir / "aggregated_summary.json" + tmp_path = output_path.with_suffix(".tmp") + + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(final_results, f, ensure_ascii=False, indent=2) + tmp_path.replace(output_path) + + print(f"[Aggregator] 집계 완료! 총 {len(final_results)}명의 회원 통계가 저장되었습니다: {output_path}") + return final_results \ No newline at end of file diff --git a/app/pipeline/extractor.py b/app/pipeline/extractor.py index 26df006..03773cf 100644 --- a/app/pipeline/extractor.py +++ b/app/pipeline/extractor.py @@ -66,11 +66,20 @@ def build_automaton(self, dict_data: List[Dict[str, Any]]): self.is_built = True def _add_to_automaton(self, text: str, payload: Dict[str, Any]): - """내부 헬퍼 함수: 다중 충돌(하나의 단어가 여러 ID를 가짐)을 안전하게 처리하며 오토마톤에 추가""" + """내부 헬퍼 함수: 다중 충돌(하나의 단어가 여러 ID를 가짐)을 안전하게 처리하며, 동일한 키워드 ID에 대한 중복 등록 방지""" if self.automaton.exists(text): existing_payloads = self.automaton.get(text) - existing_payloads.append(payload) - self.automaton.add_word(text, existing_payloads) # 덮어쓰기 + + # 이미 똑같은 keyword_id 꼬리표가 달려있으면 추가하지 않고 무시 + is_duplicate = False + for existing_payload in existing_payloads: + if existing_payload["keyword_id"] == payload["keyword_id"]: + is_duplicate = True + break + + if not is_duplicate: + existing_payloads.append(payload) + self.automaton.add_word(text, existing_payloads) # 덮어쓰기 else: self.automaton.add_word(text, [payload]) # 리스트 형태로 첫 등록 diff --git a/app/pipeline/scorer.py b/app/pipeline/scorer.py index 7f86b5c..9b3b031 100644 --- a/app/pipeline/scorer.py +++ b/app/pipeline/scorer.py @@ -82,8 +82,15 @@ def rescue_typos(self, doc: spacy.tokens.Doc, masked_text: str, canon_index: Dic current_noun = "" start_idx = -1 + # [디버깅 1] 단어장이 제대로 넘어왔는지 확인 + # print(f"\n[디버깅-다메라우] 넘겨받은 원본 텍스트: '{doc.text}'") + # print(f"[디버깅-다메라우] 단어장 상태 (Canon): {canon_index}") + # --- STEP 1: 미탐 영역 형태소 분석 (명사 찰흙놀이) --- for token in doc: + # [디버깅 2] spaCy가 단어를 어떻게 분류했는지 하나하나 확인 + # print(f" > 분석중 토큰: '{token.text}' (pos: {token.pos_}, idx: {token.idx})") + # token.idx 위치가 마스킹('*') 되어있다면, 이미 1,2단계에서 찾은 단어이므로 무시 if masked_text[token.idx] == "*": if current_noun: @@ -91,11 +98,16 @@ def rescue_typos(self, doc: spacy.tokens.Doc, masked_text: str, canon_index: Dic current_noun = "" continue - # 명사(NOUN)이면 계속 이어 붙입니다. (예: '선텍' + '약정') - if token.pos_ in ["NOUN", "PROPN"]: + if token.pos_ in ["NOUN", "PROPN", "ADV", "X", "VERB", "ADJ", "SCONJ"]: if not current_noun: start_idx = token.idx current_noun += token.text + + # 뒤에 띄어쓰기가 있으면 덩어리 짓기를 멈추고 확정 + if token.whitespace_: + nouns_to_check.append((current_noun, start_idx, start_idx + len(current_noun) - 1)) + current_noun = "" + else: # 명사가 끝났으면 (예: 조사가 나오면) 지금까지 뭉친 명사를 검사 후보 리스트에 넣음 if current_noun: @@ -107,6 +119,9 @@ def rescue_typos(self, doc: spacy.tokens.Doc, masked_text: str, canon_index: Dic if current_noun: nouns_to_check.append((current_noun, start_idx, start_idx + len(current_noun) - 1)) + # [디버깅 3] spaCy가 최종적으로 뭉쳐낸 '명사 덩어리' 확인 + # print(f"[디버깅-다메라우] 최종 검사할 명사 덩어리들: {nouns_to_check}") + # --- STEP 2: O(1) 매칭 및 다메라우 연산 --- for noun_text, s, e in nouns_to_check: # 글자가 2글자 미만(예: '나', '저')이면 오타 검사에서 제외 (무의미한 연산 방지) @@ -128,13 +143,22 @@ def rescue_typos(self, doc: spacy.tokens.Doc, masked_text: str, canon_index: Dic }) continue # 다음 명사 덩어리로 넘어감 + # [디버깅 4] 다메라우 연산 진입 확인 + # print(f"[디버깅-다메라우] '{noun_text}' 단어에 대해 오타 검사 시작!") + # [핵심 로직 2] 다메라우 연산 (rapidfuzz 활용 오타 검사) # O(1)에서 실패했으니, 이제 진짜 오타인지 단어장을 돌면서 검사 # itertools.chain을 사용해 표준 키워드(canon)와 별칭(alias) 단어장을 이어서 순회 for dict_word, label_ids in itertools.chain(canon_index.items(), alias_index.items()): # DamerauLevenshtein: 글자 바뀜, 순서 바뀜 등을 C++ 엔진으로 초고속 계산 + dist = DamerauLevenshtein.distance(noun_text, dict_word) + + # [디버깅 5] 모든 비교 결과 출력 + # print(f" - 비교: '{noun_text}' vs '{dict_word}' -> Distance: {dist}") + # 거리 1 = 1글자만 틀린 오타 - if DamerauLevenshtein.distance(noun_text, dict_word) == 1: + if dist == 1: + # print(f"[구출 성공!] '{noun_text}'는 '{dict_word}'의 오타입니다.") rescued_results.append({ "keyword_id": label_ids[0], "source": "FALLBACK_TYPO", # 오타를 교정해서 구출됨 @@ -193,8 +217,8 @@ def rescue_typos(self, doc: spacy.tokens.Doc, masked_text: str, canon_index: Dic # 7. 패자부활전용 가상의 고객 입력 # 상황: 1, 2단계가 오류로 인해 단어를 하나도 못 찾았다고 가정 - fallback_text = "선텍약정 언제 끝나고 스마트폰 어떻게 해요?" - fallback_masked = "선텍약정 언제 끝나고 스마트폰 어떻게 해요?" + fallback_text = "그거 선텍약정 언제 끝나고 스마트폰 어떻게 해요?" + fallback_masked = "그거 선텍약정 언제 끝나고 스마트폰 어떻게 해요?" print(f" - 원본 문장: '{fallback_text}'") diff --git a/app/realtime/api/router.py b/app/realtime/api/router.py new file mode 100644 index 0000000..7fce61b --- /dev/null +++ b/app/realtime/api/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from app.realtime.api.v1.recommendation import router as recommendation_router + +api_router = APIRouter() +api_router.include_router(recommendation_router) diff --git a/app/realtime/api/v1/recommendation.py b/app/realtime/api/v1/recommendation.py new file mode 100644 index 0000000..6c227d6 --- /dev/null +++ b/app/realtime/api/v1/recommendation.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db_session +from app.schemas.recommendation import RecommendationRequest, RecommendationResponse +from app.services.recommendation_service import get_recommendation + +router = APIRouter() + + +@router.post("/recommendations", response_model=RecommendationResponse) +async def post_recommendations( + body: RecommendationRequest, + session: AsyncSession = Depends(get_db_session), +) -> RecommendationResponse: + return await get_recommendation( + session=session, + member_id=body.member_id, + profile_text=body.profile_text, + ) diff --git a/app/realtime/main.py b/app/realtime/main.py new file mode 100644 index 0000000..e3db33d --- /dev/null +++ b/app/realtime/main.py @@ -0,0 +1,50 @@ +"""FastAPI application entrypoint for realtime recommendation API.""" + +from __future__ import annotations + +import os + +from fastapi import FastAPI + +from app.core.config import get_settings +from app.core.logging import configure_logging +from app.realtime.api.router import api_router + +settings = get_settings() +configure_logging(settings.debug) + + +def create_app() -> FastAPI: + application = FastAPI(title=settings.app_name) + application.include_router(api_router, prefix=settings.api_v1_prefix) + + @application.get("/") + async def root() -> dict[str, str]: + return {"app": settings.app_name, "docs": "/docs", "health": "/health"} + + @application.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @application.get("/ready") + async def ready() -> dict[str, str]: + return {"status": "ready"} + + return application + + +app = create_app() + + +def run() -> None: + import uvicorn + + uvicorn.run( + "app.realtime.main:app", + host=os.getenv("APP_HOST", "0.0.0.0"), + port=int(os.getenv("APP_PORT", "8000")), + ) + + +if __name__ == "__main__": + run() diff --git a/app/schemas/aggregate_record.py b/app/schemas/aggregate_record.py deleted file mode 100644 index 16ef118..0000000 --- a/app/schemas/aggregate_record.py +++ /dev/null @@ -1,21 +0,0 @@ -#개인별 집계 JSONL -from datetime import datetime -from pydantic import Field -from app.schemas.base import SchemaBase - - -class AggregateRecord(SchemaBase): - request_id: str = Field(..., alias="requestId") - #Batch Job Instance Id - job_instance_id: int = Field(..., alias="jobInstanceId", ge=1) - - member_id: int = Field(..., alias="memberId", ge=1) - - business_keyword_id: int = Field(..., alias="businessKeywordId", ge=1) - keyword_code: str = Field(..., alias="keywordCode", min_length=1, max_length=20) - keyword_name: str = Field(..., alias="keywordName", min_length=1, max_length=100) - - count: int = Field(..., ge=0) - - # 어떤 상담에서 집계됐는지 추적용 - case_ids: list[int] = Field(default_factory=list, alias="caseIds") diff --git a/app/schemas/alias_record.py b/app/schemas/alias_record.py deleted file mode 100644 index 18c7514..0000000 --- a/app/schemas/alias_record.py +++ /dev/null @@ -1,22 +0,0 @@ -#별칭 JSONL 스키마 -from pydantic import Field -from app.schemas.base import SchemaBase - -class AliasItem(SchemaBase): - #별칭 Id - alias_id: int = Field(..., alias="aliasId", ge=1) - #별칭 원문 - alias_text: str = Field(..., alias="aliasText", min_length=1, max_length=100) - #별칭 정규화 - alias_norm: str = Field(..., alias="aliasNorm", min_length=1, max_length=100) - - -class AliasRecord(SchemaBase): - #바즈니스 키워드 ID - business_keyword_id: int = Field(..., alias="businessKeywordId", ge=1) - #키워드 코드 - keyword_code: str = Field(..., alias="keywordCode", min_length=1, max_length=20) - #키워드 이름 - keyword_name: str = Field(..., alias="keywordName", min_length=1, max_length=100) - #별칭 목록들 - aliases: list[AliasItem] = Field(default_factory=list, min_length=1) \ No newline at end of file diff --git a/app/schemas/analysis_request_message.py b/app/schemas/analysis_request_message.py new file mode 100644 index 0000000..cb7bd22 --- /dev/null +++ b/app/schemas/analysis_request_message.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class AnalysisRequestMessage(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + dispatch_request_id: str = Field(..., alias="dispatchRequestId", min_length=1) + case_id: int = Field(..., alias="caseId", ge=1) + analyzer_version: int = Field(..., alias="analyzerVersion", ge=1) diff --git a/app/schemas/analyze_request.py b/app/schemas/analyze_request.py deleted file mode 100644 index 82de7a9..0000000 --- a/app/schemas/analyze_request.py +++ /dev/null @@ -1,14 +0,0 @@ -"""분석 요청 DTO (job 단위).""" - -from pydantic import Field - -from app.schemas.base import SchemaBase - - -class AnalyzeRequest(SchemaBase): - # 요청 추적 및 멱등성 키 - request_id: str = Field(..., alias="requestId", min_length=1, max_length=200) - # 배치 실행 인스턴스 식별자 - job_instance_id: str = Field(..., alias="jobInstanceId", min_length=1, max_length=200) - # 분석 버전 태그 - analysis_version: str = Field(..., alias="analysisVersion", min_length=1, max_length=50) diff --git a/app/schemas/analyze_response.py b/app/schemas/analyze_response.py deleted file mode 100644 index 05b1568..0000000 --- a/app/schemas/analyze_response.py +++ /dev/null @@ -1,9 +0,0 @@ -# 키워드 분석 응답 DTO -from typing import Literal -from pydantic import Field -from app.schemas.base import SchemaBase - -class AnalyzeResponse(SchemaBase): - status: Literal["accepted","rejected","duplicated"] - request_id: str = Field(...,alias="requestId") - message: str = Field(...,alias="message") \ No newline at end of file diff --git a/app/schemas/base.py b/app/schemas/base.py deleted file mode 100644 index f9a794c..0000000 --- a/app/schemas/base.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Pydantic 공통 베이스 스키마.""" - -from pydantic import BaseModel, ConfigDict - - -class SchemaBase(BaseModel): - model_config = ConfigDict( - extra="forbid", - populate_by_name=True, - str_strip_whitespace=True, - ) \ No newline at end of file diff --git a/app/schemas/callback_request.py b/app/schemas/callback_request.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/schemas/counsel_record.py b/app/schemas/counsel_record.py deleted file mode 100644 index 3110ecf..0000000 --- a/app/schemas/counsel_record.py +++ /dev/null @@ -1,22 +0,0 @@ -#상담 원문 JSONL 1 줄 단위 -from datetime import datetime -from typing import Literal -from pydantic import Field -from app.schemas.base import SchemaBase - - -class CounselRecord(SchemaBase): - #상담 데이터 Id - case_id: int = Field(..., alias="caseId", ge=1) - #멤버 Id - member_id: int = Field(..., alias="memberId", ge=1) - #상담 카테고리 Id - category_code: str = Field(..., alias="categoryCode", min_length=1, max_length=20) - #상담 제목 - title: str = Field(..., min_length=1, max_length=100) - #상담 제목 - question_text: str = Field(..., alias="questionText", min_length=1) - #상담 답변 - answer_text: str | None = Field(default=None, alias="answerText") - #상담 상태 - status: Literal["OPEN", "SUPPORTING", "CLOSED"] \ No newline at end of file diff --git a/app/schemas/recommendation.py b/app/schemas/recommendation.py new file mode 100644 index 0000000..b38c20d --- /dev/null +++ b/app/schemas/recommendation.py @@ -0,0 +1,31 @@ +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class Segment(str, Enum): + upsell = "UPSELL" + churn_risk = "CHURN_RISK" + normal = "NORMAL" + + +class RecommendationRequest(BaseModel): + member_id: int + profile_text: str | None = None # 테스트용: 있으면 이 텍스트와 유사한 상품 top-k 추천 + + +class RecommendedProductItem(BaseModel): + product_id: int = Field(..., serialization_alias="productId") + reason: str + + model_config = ConfigDict(serialize_by_alias=True) + + +class RecommendationResponse(BaseModel): + segment: Segment + cached_llm_recommendation: str = Field(..., serialization_alias="cachedLlmRecommendation") + recommended_products: list[RecommendedProductItem] = Field( + ..., serialization_alias="recommendedProducts" + ) + + model_config = ConfigDict(serialize_by_alias=True) diff --git a/app/schemas/result_record.py b/app/schemas/result_record.py deleted file mode 100644 index 5611712..0000000 --- a/app/schemas/result_record.py +++ /dev/null @@ -1,31 +0,0 @@ -"""분석 결과 JSONL (상담 1건당 1줄).""" - -from datetime import datetime - -from pydantic import Field - -from app.schemas.base import SchemaBase - - -class KeywordCountRecord(SchemaBase): - # 비즈니스 키워드 식별자 - business_keyword_id: int = Field(..., alias="businessKeywordId", ge=1) - # 사람이 읽기 쉬운 코드/이름 - keyword_code: str = Field(..., alias="keywordCode", min_length=1, max_length=20) - keyword_name: str = Field(..., alias="keywordName", min_length=1, max_length=100) - # 상담 1건 텍스트 안에서 발견된 총 횟수 - count: int = Field(..., ge=1, description="이 상담에서 해당 키워드가 발견된 횟수") - - -class ResultRecord(SchemaBase): - request_id: str = Field(..., alias="requestId") - job_instance_id: str = Field(..., alias="jobInstanceId") - chunk_id: str = Field(..., alias="chunkId") - - case_id: int = Field(..., alias="caseId", ge=1) - member_id: int = Field(..., alias="memberId", ge=1) - - # 상담 1건에서 키워드별 발견 횟수 - matched_keywords: list[KeywordCountRecord] = Field(default_factory=list, alias="matchedKeywords") - analysis_version: str = Field(..., alias="analysisVersion") - processed_at: datetime = Field(..., alias="processedAt") diff --git a/app/services/alias_loader_service.py b/app/services/alias_loader_service.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/analysis_outcome_service.py b/app/services/analysis_outcome_service.py new file mode 100644 index 0000000..05f6ba7 --- /dev/null +++ b/app/services/analysis_outcome_service.py @@ -0,0 +1,108 @@ +from datetime import datetime, timezone +from typing import Any + +from app.schemas.analysis_request_message import AnalysisRequestMessage + + +class AnalysisOutcomeService: + def __init__(self, result_limit: int) -> None: + self._result_limit = max(1, result_limit) + + def build_message_outcomes( + self, + batch: list[AnalysisRequestMessage], + acked_request_ids: set[str], + target_by_pair: dict[tuple[int, int], Any], + mapping_rows: list[tuple[int, int, int]], + completed_ids: list[int], + failed_items: list[tuple[int, str]], + keyword_info_by_id: dict[int, dict[str, str]], + ) -> list[dict[str, Any]]: + failed_by_analysis_id = {int(analysis_id): error for analysis_id, error in failed_items} + completed_id_set = {int(analysis_id) for analysis_id in completed_ids} + produced_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + mapping_summary_by_analysis_id: dict[int, dict[str, int]] = {} + mapping_detail_by_analysis_id: dict[int, dict[int, int]] = {} + for analysis_id, _, count in mapping_rows: + key = int(analysis_id) + summary = mapping_summary_by_analysis_id.setdefault( + key, + {"keywordTypes": 0, "keywordHits": 0}, + ) + summary["keywordTypes"] += 1 + summary["keywordHits"] += int(count) + + for analysis_id, keyword_id, count in mapping_rows: + detail = mapping_detail_by_analysis_id.setdefault(int(analysis_id), {}) + key = int(keyword_id) + detail[key] = detail.get(key, 0) + int(count) + + outcomes: list[dict[str, Any]] = [] + for message in batch: + pair = (message.case_id, message.analyzer_version) + target = target_by_pair.get(pair) + acked = message.dispatch_request_id in acked_request_ids + + if target is None: + outcomes.append( + { + "schema": "analysis.response.v1", + "dispatchRequestId": message.dispatch_request_id, + "caseId": message.case_id, + "analyzerVersion": message.analyzer_version, + "analysisId": None, + "memberId": None, + "status": "MISSING_TARGET", + "requestAcked": acked, + "keywordTypes": 0, + "keywordHits": 0, + "keywordCounts": [], + "error": "target not found", + "producedAt": produced_at, + } + ) + continue + + analysis_id = int(target["analysis_id"]) + member_id = int(target["member_id"]) + summary = mapping_summary_by_analysis_id.get(analysis_id, {"keywordTypes": 0, "keywordHits": 0}) + detail = mapping_detail_by_analysis_id.get(analysis_id, {}) + error_message = failed_by_analysis_id.get(analysis_id) + + if error_message is not None: + status = "FAILED" + elif analysis_id in completed_id_set: + status = "COMPLETED" + else: + status = "UNKNOWN" + + keyword_counts = [ + { + "keywordId": keyword_id, + "businessKeywordId": keyword_id, + "keywordCode": keyword_info_by_id.get(keyword_id, {}).get("keywordCode", "-"), + "keywordName": keyword_info_by_id.get(keyword_id, {}).get("keywordName", "-"), + "count": count, + } + for keyword_id, count in sorted(detail.items(), key=lambda item: (-item[1], item[0]))[:self._result_limit] + ] + outcomes.append( + { + "schema": "analysis.response.v1", + "dispatchRequestId": message.dispatch_request_id, + "caseId": message.case_id, + "analyzerVersion": message.analyzer_version, + "analysisId": analysis_id, + "memberId": member_id, + "status": status, + "requestAcked": acked, + "keywordTypes": summary["keywordTypes"], + "keywordHits": summary["keywordHits"], + "keywordCounts": keyword_counts, + "error": error_message, + "producedAt": produced_at, + } + ) + + return outcomes diff --git a/app/services/analyze_service.py b/app/services/analyze_service.py deleted file mode 100644 index e1d70ae..0000000 --- a/app/services/analyze_service.py +++ /dev/null @@ -1,324 +0,0 @@ -"""분석 오케스트레이션 서비스. - -요청 1건(jobInstanceId)에 대해 아래 순서로 처리 -1) 멱등성 체크 -2) `/analysis/req/{jobInstanceId}` 입력 청크 전체 조회 -3) chunk별 결과 생성/저장 (이미 처리된 chunk는 스킵) -4) 상태 업데이트 - -""" - -import json -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import List, Dict, Any -import gzip - -from app.core.config import Settings -from app.core.constants import REQUEST_STATUS_COMPLETED, REQUEST_STATUS_FAILED -from app.infra.efs.paths import ( - build_manifest_path, - build_output_paths, - build_ref_alias_path, - build_req_dir, - list_chunk_inputs, - parse_chunk_id, -) -from app.infra.efs.reader import read_alias_records, read_counsel_records -from app.infra.efs.writer import write_result_records -from app.schemas.analyze_request import AnalyzeRequest -from app.schemas.result_record import KeywordCountRecord, ResultRecord -from app.services.idempotency_service import IdempotencyService - -from app.pipeline.mapper import ExactMapper -from app.pipeline.extractor import AhoCorasickExtractor -from app.pipeline.scorer import ContextScorer -from app.pipeline.normalizer import normalize_with_offsets - -class AnalyzeService: - def __init__( - self, - settings: Settings, - idempotency: IdempotencyService, - ) -> None: - self.settings = settings - self.idempotency = idempotency - - # 1, 2, 3단계 엔진 초기화 - self.mapper = ExactMapper() - self.extractor = AhoCorasickExtractor() - self.scorer = ContextScorer() - - # Name을 동시에 저장하도록 구조 변경 - # { "BK-100": {"id": 100, "name": "요금조회"} } - self.keyword_meta: Dict[str, Dict[str, Any]] = {} - - def _initialize_pipeline(self, alias_records: List[Any]): # AliasRecord 객체 리스트 - """ - [사전 적재] Pydantic 객체 데이터를 엔진들이 쓸 수 있는 리스트로 가공 - """ - dict_data_for_pipeline = [] - - for record in alias_records: - k_code = str(record.keyword_code) - - # 메타데이터에 실제 DB ID와 이름을 함께 저장 - self.keyword_meta[k_code] = { - "id": record.business_keyword_id, - "name": record.keyword_name - } - - # 1단계/2단계용 데이터 포맷팅 - dict_data_for_pipeline.append({ - "schema": "dict.keyword.v1", - "label_id": k_code, - "business_keyword": record.keyword_name - }) - - for alias in record.aliases: - dict_data_for_pipeline.append({ - "schema": "dict.alias.v1", - "label_id": k_code, - "business_keyword": record.keyword_name, - "alias_text": alias.alias_text, - "alias_norm": alias.alias_norm or alias.alias_text - }) - - # 가공된 데이터를 엔진들에게 전송 - self.mapper.build_index(dict_data_for_pipeline) - self.extractor.build_automaton(dict_data_for_pipeline) - - def _apply_masking(self, text: str, matches: List[Dict[str, Any]]) -> str: - """ - [마스킹 기술] 이미 찾은 단어 위치를 '*'로 가려 다음 단계의 중복 추출을 방지 - """ - chars = list(text) - for m in matches: - for i in range(m["orig_start"], m["orig_end"] + 1): - if i < len(chars): - chars[i] = "*" - return "".join(chars) - - def analyze(self, request: AnalyzeRequest) -> tuple[bool, str]: - """jobInstanceId 하위 모든 chunk를 처리""" - # 같은 requestId가 다시 들어오면 중복 처리하지 않음 - accepted = self.idempotency.register_or_reject(request.request_id) - if not accepted: - return False, "duplicate requestId" - - try: - req_dir = build_req_dir(self.settings.efs_base_dir, request.job_instance_id) - if not req_dir.exists() or not req_dir.is_dir(): - raise FileNotFoundError(f"request directory not found: {req_dir}") - - # 분석 버전에 맞는 키워드/별칭 사전 로드 - alias_path = build_ref_alias_path(self.settings.efs_base_dir, request.analysis_version) - if not alias_path.exists(): - raise FileNotFoundError(f"alias dictionary not found: {alias_path}") - alias_records = read_alias_records(alias_path) - - # 모든 Chunk가 이 준비된 엔진을 공유해서 사용 - self._initialize_pipeline(alias_records) - - input_files = list_chunk_inputs(req_dir) - processed_chunks = 0 - skipped_chunks = 0 - total_records = 0 - - for input_file in input_files: - chunk_id = parse_chunk_id(input_file) - manifest_path = build_manifest_path(req_dir, chunk_id) - mapping_path, chunk_summary_path = build_output_paths( - self.settings.efs_base_dir, - request.job_instance_id, - chunk_id, - ) - - if mapping_path.exists() and chunk_summary_path.exists(): - # 결과 파일 2개가 이미 있으면 완료된 chunk로 판단하고 skip - skipped_chunks += 1 - continue - - # chunk 입력(상담 JSONL gzip)을 읽어서 case 단위 결과로 변환 - counsel_records = read_counsel_records(input_file) - results = self._build_results(request, counsel_records, chunk_id) - # write_result_records(mapping_path, results) - self._write_atomic(mapping_path, results, is_jsonl=True) - - chunk_summary = self._build_chunk_summary( - request=request, - chunk_id=chunk_id, - input_file=input_file, - manifest_path=manifest_path, - output_file=mapping_path, - record_count=len(results), - ) - # self._write_json_atomic(chunk_summary_path, chunk_summary) - self._write_atomic(chunk_summary_path, chunk_summary, is_jsonl=False) - - processed_chunks += 1 - total_records += len(results) - - self.idempotency.registry.update_status(request.request_id, REQUEST_STATUS_COMPLETED) - return ( - True, - f"job={request.job_instance_id} chunks(total={len(input_files)}, processed={processed_chunks}, skipped={skipped_chunks}) records={total_records}", - ) - except Exception: - self.idempotency.registry.update_status(request.request_id, REQUEST_STATUS_FAILED) - raise - - def _run_full_pipeline(self, text: str) -> List[Dict[str, Any]]: - """ - [3단계 연쇄 반응] - 정규화 지도를 활용해 원문 위치를 보존하며 1->2->3단계를 실행 - """ - # 0. 기초 세팅: 분석용 정규화 텍스트와 위치 지도 생성 - # norm_text: "u+ tv 안나와요" -> "utv안나와요" - # offset_map: [0, 3, 4, 6, 7, 8, 9] (각 글자가 원본의 몇 번째인지 기록) - norm_text, offset_map = normalize_with_offsets(text) - if not norm_text: - return [] - - # --- 1단계: 완전 일치 (Exact Match) --- - # 원문(text) 전체가 사전에 있는지 확인 - step1_results = self.mapper.exact_match(text) - - # 1단계 결과 마스킹 (찾은 곳은 '*'로 가리기) - masked_raw = self._apply_masking(text, step1_results) - - # --- 2단계: 부분 일치 (Aho-Corasick) --- - # 마스킹된 원문을 다시 정규화하여 2단계용 텍스트 생성 - # (이미 1단계에서 찾은 부분은 정규화 결과에서도 '*'로 남거나 사라짐) - norm_masked, _ = normalize_with_offsets(masked_raw) - - step2_results = self.extractor.extract_keywords(norm_masked, offset_map) - - # 2단계 결과까지 합산하여 다시 마스킹 - all_matches_so_far = step1_results + step2_results - masked_v2 = self._apply_masking(text, all_matches_so_far) - - # --- 3단계: 오타 교정 및 중의성 해소 (Context Scorer) --- - doc = self.scorer.parse_document(text) - step3_results = self.scorer.rescue_typos( - doc=doc, - masked_text=masked_v2, # 1, 2단계가 모두 가려진 텍스트 전달 - canon_index=self.mapper.canon_norm_index, - alias_index=self.mapper.alias_norm_index - ) - - return step1_results + step2_results + step3_results - - def _build_results(self, request: AnalyzeRequest, counsel_records, chunk_id: str) -> list[ResultRecord]: - """ - 상담 1건마다 키워드별 횟수를 계산해 결과 생성. - """ - rows: list[ResultRecord] = [] - now = datetime.now(timezone.utc) - - for counsel in counsel_records: - # 수정: Pydantic 객체이므로 .get() 대신 속성으로 접근 - # title과 question_text를 합쳐서 풍부한 문맥으로 분석 - full_text = f"{counsel.title} {counsel.question_text}" - - # 3단계 파이프라인 통합 실행 - all_matches = self._run_full_pipeline(full_text) - - # 결과 집계 (동일 키워드 카운팅) - keyword_counts: Dict[str, int] = {} - for m in all_matches: - kid = m["keyword_id"] - keyword_counts[kid] = keyword_counts.get(kid, 0) + 1 - - # 최종 스키마 변환 - matched_records = [] - for k_code, count in keyword_counts.items(): - # keyword_meta에서 실제 ID와 Name 추출 - meta = self.keyword_meta.get(k_code, {"id": 0, "name": "Unknown"}) - matched_records.append( - KeywordCountRecord( - businessKeywordId=meta["id"], - keywordCode=k_code, - keywordName=meta["name"], - count=count - ) - ) - - if matched_records: - rows.append(ResultRecord( - requestId=request.request_id, - jobInstanceId=request.job_instance_id, - chunkId=chunk_id, - caseId=counsel.case_id, # .case_id 속성 접근 - memberId=counsel.member_id, # .member_id 속성 접근 - matchedKeywords=matched_records, - analysisVersion=request.analysis_version, - processedAt=now - )) - return rows - - def _build_search_text(self, title: str, question: str) -> str: - # None일 수 있는 답변은 빈 문자열로 처리해 안전하게 결합 - return " ".join(part for part in [title, question] if part) - - def _build_chunk_summary( - self, - request: AnalyzeRequest, - chunk_id: str, - input_file: Path, - manifest_path: Path, - output_file: Path, - record_count: int, - ) -> dict[str, object]: - """chunk 처리 요약 payload 생성.""" - return { - "requestId": request.request_id, - "jobInstanceId": request.job_instance_id, - "chunkId": chunk_id, - "analysisVersion": request.analysis_version, - "inputFile": str(input_file), - "manifestFile": str(manifest_path) if manifest_path.exists() else None, - "outputFile": str(output_file), - "recordCount": record_count, - "status": "processed", - "processedAt": datetime.now(timezone.utc).isoformat(), - } - - def _write_json_atomic(self, path: Path, payload: dict[str, object]) -> None: - """JSON 파일 저장(원자적 교체).""" - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - tmp.replace(path) - - def _write_atomic(self, path: Path, data: Any, is_jsonl: bool = False) -> None: - """ - [로컬 세이프 라이터] - 공용 infra 코드를 수정하지 않고, 우리 서비스에서 압축과 원자적 저장을 직접 처리 - """ - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(".tmp") - - # 파일 확장자가 .gz로 끝나면 gzip으로 열고, 아니면 일반 open으로 연다 - is_gzip = path.name.endswith(".gz") - opener = gzip.open(tmp, "wt", encoding="utf-8") if is_gzip else tmp.open("w", encoding="utf-8") - - with opener as f: - if is_jsonl: - # 결과 데이터(List)를 한 줄씩 저장 - for row in data: - # mode='json'을 추가하여 날짜를 문자열로 자동 변환 - if hasattr(row, "model_dump"): - d = row.model_dump(mode='json', by_alias=True) - else: - d = row - - # 혹시 모를 상황을 대비해 default=str을 넣어주면 더 안전 - f.write(json.dumps(d, ensure_ascii=False, default=str) + "\n") - else: - # 요약 데이터 저장 시에도 날짜가 있을 수 있으므로 default=str 추가 - json.dump(data, f, ensure_ascii=False, indent=2, default=str) - - tmp.replace(path) diff --git a/app/services/callback_service.py b/app/services/callback_service.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/idempotency_service.py b/app/services/idempotency_service.py deleted file mode 100644 index 900df4a..0000000 --- a/app/services/idempotency_service.py +++ /dev/null @@ -1,16 +0,0 @@ -# 요청 멱등성 처리 - -from app.core.constants import REQUEST_STATUS_PROCESSING -from app.infra.state.request_registry import RequestRegistry -from app.infra.state.lock import process_lock - - -class IdempotencyService: - def __init__(self, registry: RequestRegistry) -> None: - self.registry = registry - - def register_or_reject(self, request_id: str) -> bool: - """처음 들어온 requestId면 등록하고 True, 중복이면 False.""" - # 락을 걸고 분석 처리 과정 진행 - with process_lock(): - return self.registry.create_if_absent(request_id, REQUEST_STATUS_PROCESSING) diff --git a/app/services/kafka_analysis_consumer_service.py b/app/services/kafka_analysis_consumer_service.py new file mode 100644 index 0000000..0250f17 --- /dev/null +++ b/app/services/kafka_analysis_consumer_service.py @@ -0,0 +1,268 @@ +import asyncio +import json +import logging +from typing import Any +from collections.abc import Iterator + +from aiokafka import AIOKafkaConsumer +from asyncpg import Pool +from pydantic import ValidationError + +from app.core.config import Settings +from app.infra.postgres.analysis_repository import AnalysisRepository +from app.infra.postgres.client import create_postgres_pool +from app.infra.postgres.dispatch_outbox_repository import DispatchOutboxRepository +from app.schemas.analysis_request_message import AnalysisRequestMessage +from app.services.sql_keyword_analysis_service import SqlKeywordAnalysisService + +logger = logging.getLogger(__name__) + + +class KafkaAnalysisConsumerService: + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._consumer: AIOKafkaConsumer | None = None + self._db_pool: Pool | None = None + self._analysis_repository: AnalysisRepository | None = None + self._outbox_repository: DispatchOutboxRepository | None = None + self._analysis_service: SqlKeywordAnalysisService | None = None + self._task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if not self._settings.kafka_consumer_enabled: + logger.info("Kafka consumer is disabled. (KAFKA_CONSUMER_ENABLED=false)") + return + + self._db_pool = await create_postgres_pool(self._settings) + self._analysis_repository = AnalysisRepository(self._db_pool) + self._outbox_repository = DispatchOutboxRepository(self._db_pool) + self._analysis_service = SqlKeywordAnalysisService() + + self._consumer = AIOKafkaConsumer( + self._settings.kafka_analysis_request_topic, + bootstrap_servers=[s.strip() for s in self._settings.kafka_bootstrap_servers.split(",") if s.strip()], + group_id=self._settings.kafka_consumer_group_id, + auto_offset_reset=self._settings.kafka_auto_offset_reset, + enable_auto_commit=False, + value_deserializer=lambda v: json.loads(v.decode("utf-8")), + ) + await self._consumer.start() + self._stop_event.clear() + self._task = asyncio.create_task(self._consume_loop(), name="kafka-analysis-consumer") + + logger.info( + "Kafka consumer started. topic=%s group=%s batch_size=%d", + self._settings.kafka_analysis_request_topic, + self._settings.kafka_consumer_group_id, + self._settings.kafka_batch_size, + ) + + async def stop(self) -> None: + self._stop_event.set() + + if self._task: + try: + await self._task + except asyncio.CancelledError: + pass + + if self._consumer: + await self._consumer.stop() + self._consumer = None + + if self._db_pool: + await self._db_pool.close() + self._db_pool = None + self._analysis_repository = None + self._outbox_repository = None + self._analysis_service = None + + logger.info("Kafka consumer stopped.") + + async def _consume_loop(self) -> None: + assert self._consumer is not None + assert self._analysis_repository is not None + assert self._analysis_service is not None + + while not self._stop_event.is_set(): + try: + polled = await self._consumer.getmany( + timeout_ms=self._settings.kafka_poll_timeout_ms, + max_records=self._settings.kafka_batch_size, + ) + + received_count = 0 + dropped_count = 0 + messages: list[AnalysisRequestMessage] = [] + for _, records in polled.items(): + for record in records: + received_count += 1 + message = self._parse_message(record.value) + if message is None: + dropped_count += 1 + continue + messages.append(message) + + if messages: + for batch in self._chunk(messages, self._settings.kafka_batch_size): + await self._process_batch(batch) + await self._consumer.commit() + continue + + if received_count > 0 and dropped_count == received_count: + # 유효하지 않은 메시지만 들어온 경우엔 무한 재소비를 막기 위해 commit + await self._consumer.commit() + except asyncio.CancelledError: + raise + except Exception: + logger.error("Kafka consume loop failed. retry after short backoff.", exc_info=True) + await asyncio.sleep(1.0) + + def _parse_message(self, payload: Any) -> AnalysisRequestMessage | None: + try: + return AnalysisRequestMessage.model_validate(payload) + except ValidationError: + logger.warning("Invalid analysis request message dropped. payload=%s", payload, exc_info=True) + return None + + def _chunk(self, rows: list[AnalysisRequestMessage], size: int) -> Iterator[list[AnalysisRequestMessage]]: + for idx in range(0, len(rows), size): + yield rows[idx: idx + size] + + async def _process_batch(self, batch: list[AnalysisRequestMessage]) -> None: + assert self._analysis_repository is not None + assert self._outbox_repository is not None + assert self._analysis_service is not None + + unique_request_ids = list(dict.fromkeys(msg.dispatch_request_id for msg in batch)) + acked_request_ids = await self._outbox_repository.mark_acked_by_request_ids(unique_request_ids) + acked_count = len(acked_request_ids) + + unique_pairs = list(dict.fromkeys((msg.case_id, msg.analyzer_version) for msg in batch)) + case_ids = [pair[0] for pair in unique_pairs] + analyzer_versions = [pair[1] for pair in unique_pairs] + target_rows = await self._analysis_repository.find_targets_by_case_and_version(case_ids, analyzer_versions) + target_by_pair = { + (int(row["case_id"]), int(row["analyzer_version"])): row + for row in target_rows + } + missing_pairs = [pair for pair in unique_pairs if pair not in target_by_pair] + + keyword_rows = await self._analysis_repository.load_active_keyword_rows() + keyword_dict_rows = [dict(row) for row in keyword_rows] + self._analysis_service.load_dictionary(keyword_dict_rows) + keyword_name_by_id: dict[int, str] = {} + for row in keyword_dict_rows: + keyword_id = int(row["business_keyword_id"]) + if keyword_id not in keyword_name_by_id: + keyword_name_by_id[keyword_id] = str(row["keyword_name"]) + + targets = [dict(row) for row in target_rows] + mapping_rows, completed_ids, failed_items = self._analysis_service.analyze_targets(targets) + # DB write 권한은 Spring에만 있으므로 Python에서는 결과를 DB에 반영하지 않는다. + # (business_keyword_mapping_result INSERT, consultation_analysis status UPDATE 미수행) + + if self._settings.kafka_log_each_message: + self._log_message_outcomes( + batch=batch, + acked_request_ids=acked_request_ids, + target_by_pair=target_by_pair, + mapping_rows=mapping_rows, + completed_ids=completed_ids, + failed_items=failed_items, + keyword_name_by_id=keyword_name_by_id, + ) + + logger.info( + "Kafka batch consumed. messages=%d unique_requests=%d acked=%d unique_pairs=%d loaded_targets=%d missing_pairs=%d completed=%d failed=%d mappings=%d (only-outbox-write=enabled)", + len(batch), + len(unique_request_ids), + acked_count, + len(unique_pairs), + len(target_rows), + len(missing_pairs), + len(completed_ids), + len(failed_items), + len(mapping_rows), + ) + + def _log_message_outcomes( + self, + batch: list[AnalysisRequestMessage], + acked_request_ids: set[str], + target_by_pair: dict[tuple[int, int], Any], + mapping_rows: list[tuple[int, int, int]], + completed_ids: list[int], + failed_items: list[tuple[int, str]], + keyword_name_by_id: dict[int, str], + ) -> None: + failed_by_analysis_id = {int(analysis_id): error for analysis_id, error in failed_items} + completed_id_set = {int(analysis_id) for analysis_id in completed_ids} + + mapping_summary_by_analysis_id: dict[int, dict[str, int]] = {} + mapping_detail_by_analysis_id: dict[int, dict[int, int]] = {} + for analysis_id, _, count in mapping_rows: + key = int(analysis_id) + summary = mapping_summary_by_analysis_id.setdefault( + key, + {"keyword_types": 0, "keyword_hits": 0}, + ) + summary["keyword_types"] += 1 + summary["keyword_hits"] += int(count) + for analysis_id, keyword_id, count in mapping_rows: + detail = mapping_detail_by_analysis_id.setdefault(int(analysis_id), {}) + kid = int(keyword_id) + detail[kid] = detail.get(kid, 0) + int(count) + + for message in batch: + pair = (message.case_id, message.analyzer_version) + target = target_by_pair.get(pair) + acked = message.dispatch_request_id in acked_request_ids + + if target is None: + logger.info( + "Kafka message outcome. request_id=%s case_id=%d analyzer_version=%d acked=%s status=MISSING_TARGET analysis_id=- keyword_types=0 keyword_hits=0", + message.dispatch_request_id, + message.case_id, + message.analyzer_version, + acked, + ) + continue + + analysis_id = int(target["analysis_id"]) + summary = mapping_summary_by_analysis_id.get(analysis_id, {"keyword_types": 0, "keyword_hits": 0}) + detail = mapping_detail_by_analysis_id.get(analysis_id, {}) + error_message = failed_by_analysis_id.get(analysis_id) + + if error_message is not None: + status = "FAILED" + elif analysis_id in completed_id_set: + status = "COMPLETED" + else: + status = "UNKNOWN" + + result_limit = max(1, self._settings.kafka_log_result_limit) + result_items = [ + { + "keyword_id": keyword_id, + "keyword_name": keyword_name_by_id.get(keyword_id, "-"), + "count": cnt, + } + for keyword_id, cnt in sorted(detail.items(), key=lambda x: (-x[1], x[0]))[:result_limit] + ] + results_json = json.dumps(result_items, ensure_ascii=False) + + logger.info( + "Kafka message outcome. request_id=%s case_id=%d analyzer_version=%d acked=%s status=%s analysis_id=%d keyword_types=%d keyword_hits=%d results=%s error=%s", + message.dispatch_request_id, + message.case_id, + message.analyzer_version, + acked, + status, + analysis_id, + summary["keyword_types"], + summary["keyword_hits"], + results_json, + error_message or "-", + ) diff --git a/app/services/kafka_request_consumer_service.py b/app/services/kafka_request_consumer_service.py new file mode 100644 index 0000000..289a19d --- /dev/null +++ b/app/services/kafka_request_consumer_service.py @@ -0,0 +1,94 @@ +import json +import logging +from dataclasses import dataclass +from typing import Any + +from aiokafka import AIOKafkaConsumer +from aiokafka.errors import CommitFailedError +from pydantic import ValidationError + +from app.core.config import Settings +from app.schemas.analysis_request_message import AnalysisRequestMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class KafkaPollResult: + received_count: int + dropped_count: int + messages: list[AnalysisRequestMessage] + + +class KafkaRequestConsumerService: + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._consumer: AIOKafkaConsumer | None = None + + async def start(self) -> None: + bootstrap_servers = [s.strip() for s in self._settings.kafka_bootstrap_servers.split(",") if s.strip()] + self._consumer = AIOKafkaConsumer( + self._settings.kafka_analysis_request_topic, + bootstrap_servers=bootstrap_servers, + group_id=self._settings.kafka_consumer_group_id, + auto_offset_reset=self._settings.kafka_auto_offset_reset, + max_poll_interval_ms=self._settings.kafka_max_poll_interval_ms, + session_timeout_ms=self._settings.kafka_session_timeout_ms, + heartbeat_interval_ms=self._settings.kafka_heartbeat_interval_ms, + enable_auto_commit=False, + value_deserializer=lambda value: json.loads(value.decode("utf-8")), + ) + await self._consumer.start() + + async def stop(self) -> None: + if self._consumer is not None: + await self._consumer.stop() + self._consumer = None + + async def poll(self, max_records: int, timeout_ms: int) -> KafkaPollResult: + assert self._consumer is not None + polled = await self._consumer.getmany( + timeout_ms=timeout_ms, + max_records=max_records, + ) + + received_count = 0 + dropped_count = 0 + messages: list[AnalysisRequestMessage] = [] + for _, records in polled.items(): + for record in records: + received_count += 1 + message = self._parse_message(record.value) + if message is None: + dropped_count += 1 + continue + messages.append(message) + + return KafkaPollResult( + received_count=received_count, + dropped_count=dropped_count, + messages=messages, + ) + + async def commit(self) -> bool: + assert self._consumer is not None + try: + await self._consumer.commit() + return True + except CommitFailedError: + logger.warning( + "Kafka commit failed due to rebalance. " + "Increase KAFKA_MAX_POLL_INTERVAL_MS or reduce KAFKA_BATCH_SIZE. " + "Current max_poll_interval_ms=%d batch_size=%d", + self._settings.kafka_max_poll_interval_ms, + self._settings.kafka_batch_size, + exc_info=True, + ) + return False + + def _parse_message(self, payload: Any) -> AnalysisRequestMessage | None: + try: + return AnalysisRequestMessage.model_validate(payload) + except ValidationError: + logger.warning("Invalid analysis request message dropped. payload=%s", payload, exc_info=True) + return None diff --git a/app/services/kafka_result_publisher_service.py b/app/services/kafka_result_publisher_service.py new file mode 100644 index 0000000..929cc93 --- /dev/null +++ b/app/services/kafka_result_publisher_service.py @@ -0,0 +1,46 @@ +import json +from typing import Any + +from aiokafka import AIOKafkaProducer + +from app.core.config import Settings + + +class KafkaResultPublisherService: + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._producer: AIOKafkaProducer | None = None + + async def start(self) -> None: + bootstrap_servers = [s.strip() for s in self._settings.kafka_bootstrap_servers.split(",") if s.strip()] + self._producer = AIOKafkaProducer( + bootstrap_servers=bootstrap_servers, + key_serializer=lambda value: value.encode("utf-8"), + value_serializer=lambda value: json.dumps(value, ensure_ascii=False).encode("utf-8"), + ) + await self._producer.start() + + async def stop(self) -> None: + if self._producer is not None: + await self._producer.stop() + self._producer = None + + async def publish_response_messages(self, payloads: list[dict[str, Any]]) -> int: + return await self._publish_messages( + topic=self._settings.kafka_analysis_response_topic, + payloads=payloads, + key_field="dispatchRequestId", + ) + + async def _publish_messages(self, topic: str, payloads: list[dict[str, Any]], key_field: str) -> int: + if not payloads: + return 0 + assert self._producer is not None + + for payload in payloads: + await self._producer.send_and_wait( + topic=topic, + key=str(payload[key_field]), + value=payload, + ) + return len(payloads) diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py new file mode 100644 index 0000000..f20645e --- /dev/null +++ b/app/services/recommendation_service.py @@ -0,0 +1,135 @@ +import json +import logging +import re + +from openai import AsyncOpenAI +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.schemas.recommendation import ( + RecommendedProductItem, + RecommendationResponse, + Segment, +) + +# 프로필 텍스트와 유사한 상품 검색 (정규화된 벡터 기준 내적, <#> 사용) +SEARCH_SIMILAR_SQL = text(""" +SELECT product_id +FROM product +WHERE embedding_vector IS NOT NULL +ORDER BY embedding_vector <#> :query_vec +LIMIT :k +""") + +# IN 절에 리스트 바인딩 (expanding). ANY(:ids)는 드라이버에 따라 실패할 수 있어 IN 사용. +FETCH_PRODUCT_NAMES_SQL = text(""" +SELECT product_id, name FROM product WHERE product_id IN :ids +""").bindparams(bindparam("ids", expanding=True)) + + +async def _generate_recommendation_reasons( + client: AsyncOpenAI, + model: str, + profile_text: str, + product_names: list[tuple[int, str]], +) -> list[str]: + """ + OpenAI로 각 상품을 왜 추천했는지 한 문장씩 생성. 실패 시 빈 문자열 또는 기본 문구 반환. + """ + if not product_names: + return [] + lines = [f"{i+1}. {name} (product_id={pid})" for i, (pid, name) in enumerate(product_names)] + product_list = "\n".join(lines) + prompt = f"""사용자 프로필: {profile_text} + +아래 상품들을 이 프로필에 맞춰 추천했습니다. 각 상품을 왜 추천했는지 한 문장으로만 설명해주세요. +상품 목록: +{product_list} + +응답은 반드시 JSON만 주세요. 다른 말 없이 예시 형식만 따르세요. +예시: {{"reasons": ["이유1", "이유2", "이유3"]}} +""" + try: + resp = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.3, + ) + content = (resp.choices[0].message.content or "").strip() + # JSON 블록만 추출 (```json ... ``` 감싸진 경우 대비) + m = re.search(r"\{[\s\S]*\}", content) + if m: + data = json.loads(m.group()) + reasons = data.get("reasons") or [] + if isinstance(reasons, list): + processed_reasons = [str(r).strip() or "프로필과 유사한 상품입니다." for r in reasons] + if len(processed_reasons) < len(product_names): + processed_reasons.extend( + ["프로필과 유사한 상품입니다."] * (len(product_names) - len(processed_reasons)) + ) + return processed_reasons[: len(product_names)] + except (json.JSONDecodeError, KeyError, IndexError) as e: + logging.warning("LLM 추천 이유 파싱 실패, 기본 문구 사용: %s", e, exc_info=True) + return ["프로필과 유사한 상품입니다."] * len(product_names) + + +async def get_recommendation( + session: AsyncSession, + member_id: int, + profile_text: str | None = None, +) -> RecommendationResponse: + settings = get_settings() + top_k = settings.recommend_top_k + + if profile_text and profile_text.strip(): + client = AsyncOpenAI(api_key=settings.openai_api_key) + resp = await client.embeddings.create( + model=settings.openai_embedding_model, + input=profile_text.strip(), + ) + query_vec = resp.data[0].embedding + result = await session.execute( + SEARCH_SIMILAR_SQL, + {"query_vec": query_vec, "k": top_k}, + ) + rows = result.fetchall() + product_ids = [row[0] for row in rows] + if not product_ids: + return RecommendationResponse( + segment=Segment.normal, + cached_llm_recommendation="[테스트] 추천할 상품이 없습니다.", + recommended_products=[], + ) + + name_result = await session.execute( + FETCH_PRODUCT_NAMES_SQL, + {"ids": product_ids}, + ) + id_to_name = {r[0]: r[1] or "" for r in name_result.fetchall()} + product_names = [(pid, id_to_name.get(pid, "")) for pid in product_ids] + + reasons = await _generate_recommendation_reasons( + client, + settings.openai_chat_model, + profile_text.strip(), + product_names, + ) + recommended_products = [ + RecommendedProductItem(product_id=pid, reason=reasons[i]) + for i, pid in enumerate(product_ids) + ] + return RecommendationResponse( + segment=Segment.normal, + cached_llm_recommendation="[테스트] 프로필 텍스트와 벡터 유사도로 추천했고, 각 추천 이유는 LLM으로 생성했습니다.", + recommended_products=recommended_products, + ) + + # profile_text 없으면 기존 스텁 + return RecommendationResponse( + segment=Segment.normal, + cached_llm_recommendation="[stub] 현재 사용 패턴 기반 추천입니다.", + recommended_products=[ + RecommendedProductItem(product_id=1, reason="[stub] 테스트 상품입니다."), + ], + ) diff --git a/app/services/result_writer_service.py b/app/services/result_writer_service.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/sql_keyword_analysis_service.py b/app/services/sql_keyword_analysis_service.py new file mode 100644 index 0000000..639de30 --- /dev/null +++ b/app/services/sql_keyword_analysis_service.py @@ -0,0 +1,116 @@ +from typing import Any + +from app.pipeline.extractor import AhoCorasickExtractor +from app.pipeline.mapper import ExactMapper +from app.pipeline.normalizer import normalize_with_offsets +from app.pipeline.scorer import ContextScorer + + +class SqlKeywordAnalysisService: + def __init__(self) -> None: + self.mapper = ExactMapper() + self.extractor = AhoCorasickExtractor() + self.scorer = ContextScorer() + self.keyword_meta: dict[str, dict[str, Any]] = {} + + def load_dictionary(self, keyword_rows: list[dict[str, Any]]) -> None: + self.mapper = ExactMapper() + self.extractor = AhoCorasickExtractor() + self.keyword_meta = {} + + dict_rows: list[dict[str, Any]] = [] + seen_codes: set[str] = set() + for row in keyword_rows: + code = str(row["keyword_code"]) + if code not in seen_codes: + seen_codes.add(code) + self.keyword_meta[code] = { + "id": int(row["business_keyword_id"]), + "name": row["keyword_name"], + } + dict_rows.append( + { + "schema": "dict.keyword.v1", + "label_id": code, + "business_keyword": row["keyword_name"], + } + ) + + alias_text = row.get("alias_text") + alias_norm = row.get("alias_norm") + if alias_text: + dict_rows.append( + { + "schema": "dict.alias.v1", + "label_id": code, + "business_keyword": row["keyword_name"], + "alias_text": alias_text, + "alias_norm": alias_norm or alias_text, + } + ) + + self.mapper.build_index(dict_rows) + self.extractor.build_automaton(dict_rows) + + def analyze_targets( + self, + targets: list[dict[str, Any]], + ) -> tuple[list[tuple[int, int, int]], list[int], list[tuple[int, str]]]: + mapping_rows: list[tuple[int, int, int]] = [] + completed_ids: list[int] = [] + failed_items: list[tuple[int, str]] = [] + + for target in targets: + analysis_id = int(target["analysis_id"]) + try: + title = target.get("title") or "" + question = target.get("question_text") or "" + full_text = " ".join(part for part in [title, question] if part) + + matches = self._run_full_pipeline(full_text) + keyword_count_by_code: dict[str, int] = {} + for match in matches: + code = match["keyword_id"] + keyword_count_by_code[code] = keyword_count_by_code.get(code, 0) + 1 + + for code, count in keyword_count_by_code.items(): + meta = self.keyword_meta.get(code) + if not meta: + continue + mapping_rows.append((analysis_id, int(meta["id"]), int(count))) + + completed_ids.append(analysis_id) + except Exception as exc: + failed_items.append((analysis_id, str(exc)[:1000])) + + return mapping_rows, completed_ids, failed_items + + def _run_full_pipeline(self, text: str) -> list[dict[str, Any]]: + norm_text, offset_map = normalize_with_offsets(text) + if not norm_text: + return [] + + step1_results = self.mapper.exact_match(text) + masked_raw = self._apply_masking(text, step1_results) + norm_masked, _ = normalize_with_offsets(masked_raw) + step2_results = self.extractor.extract_keywords(norm_masked, offset_map) + + all_matches_so_far = step1_results + step2_results + masked_v2 = self._apply_masking(text, all_matches_so_far) + doc = self.scorer.parse_document(text) + step3_results = self.scorer.rescue_typos( + doc=doc, + masked_text=masked_v2, + canon_index=self.mapper.canon_norm_index, + alias_index=self.mapper.alias_norm_index, + ) + + return step1_results + step2_results + step3_results + + def _apply_masking(self, text: str, matches: list[dict[str, Any]]) -> str: + chars = list(text) + for match in matches: + for idx in range(match["orig_start"], match["orig_end"] + 1): + if idx < len(chars): + chars[idx] = "*" + return "".join(chars) diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml new file mode 100644 index 0000000..328a51e --- /dev/null +++ b/docker-compose.local.yaml @@ -0,0 +1,10 @@ +# 앱만 띄움. DB/Kafka는 Java 레포 또는 외부에서 제공 +services: + app: + build: . + image: python-server:latest + container_name: python-server + ports: + - "8035:8000" + env_file: .env + restart: "no" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..8eeaa29 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env sh +set -eu + +APP_MODE="${APP_MODE:-realtime}" +APP_HOST="${APP_HOST:-0.0.0.0}" +APP_PORT="${APP_PORT:-8000}" + +child_pid="" + +forward_signal() { + signal="$1" + if [ -n "${child_pid}" ] && kill -0 "${child_pid}" 2>/dev/null; then + kill "-${signal}" "${child_pid}" 2>/dev/null || true + wait "${child_pid}" || true + fi +} + +on_sigterm() { + echo "[entrypoint] SIGTERM received. shutting down..." + forward_signal TERM + exit 143 +} + +on_sigint() { + echo "[entrypoint] SIGINT received. shutting down..." + forward_signal INT + exit 130 +} + +trap on_sigterm TERM +trap on_sigint INT + +run_bg_and_wait() { + "$@" & + child_pid=$! + wait "${child_pid}" + status=$? + child_pid="" + return "${status}" +} + +run_realtime() { + echo "[entrypoint] APP_MODE=realtime -> starting recommendation server" + run_bg_and_wait uvicorn app.realtime.main:app --host "${APP_HOST}" --port "${APP_PORT}" +} + +run_batch() { + echo "[entrypoint] APP_MODE=batch -> running one-off batch" + run_bg_and_wait python -m app.batch.main + echo "[entrypoint] batch completed." +} + +case "${APP_MODE}" in + realtime) + run_realtime + ;; + batch) + run_batch + ;; + *) + echo "[entrypoint] Unknown APP_MODE: ${APP_MODE} (allowed: realtime|batch)" + exit 2 + ;; +esac diff --git a/e2e_test_efs/analysis/ref/v1-aho.alias.jsonl.gz b/e2e_test_efs/analysis/ref/v1-aho.alias.jsonl.gz deleted file mode 100644 index 2866a8c..0000000 Binary files a/e2e_test_efs/analysis/ref/v1-aho.alias.jsonl.gz and /dev/null differ diff --git a/e2e_test_efs/analysis/req/job-2026/chunk-01.input.jsonl.gz b/e2e_test_efs/analysis/req/job-2026/chunk-01.input.jsonl.gz deleted file mode 100644 index 7a392df..0000000 Binary files a/e2e_test_efs/analysis/req/job-2026/chunk-01.input.jsonl.gz and /dev/null differ diff --git a/e2e_test_efs/analysis/res/job-2026/chunk-01.chunk.json b/e2e_test_efs/analysis/res/job-2026/chunk-01.chunk.json deleted file mode 100644 index 1a2c500..0000000 --- a/e2e_test_efs/analysis/res/job-2026/chunk-01.chunk.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "requestId": "unique-req-id", - "jobInstanceId": "job-2026", - "chunkId": "chunk-01", - "analysisVersion": "v1-aho", - "inputFile": "C:\\Users\\LG\\Desktop\\holliverse\\counseling-analytics\\e2e_test_efs\\analysis\\req\\job-2026\\chunk-01.input.jsonl.gz", - "manifestFile": null, - "outputFile": "C:\\Users\\LG\\Desktop\\holliverse\\counseling-analytics\\e2e_test_efs\\analysis\\res\\job-2026\\chunk-01.mapping.jsonl.gz", - "recordCount": 2, - "status": "processed", - "processedAt": "2026-03-05T10:54:44.449419+00:00" -} \ No newline at end of file diff --git a/e2e_test_efs/analysis/res/job-2026/chunk-01.mapping.jsonl.gz b/e2e_test_efs/analysis/res/job-2026/chunk-01.mapping.jsonl.gz deleted file mode 100644 index 88f7939..0000000 Binary files a/e2e_test_efs/analysis/res/job-2026/chunk-01.mapping.jsonl.gz and /dev/null differ diff --git a/e2e_test_efs_agg/analysis/res/job-9999/aggregated_summary.json b/e2e_test_efs_agg/analysis/res/job-9999/aggregated_summary.json new file mode 100644 index 0000000..c6b0fb6 --- /dev/null +++ b/e2e_test_efs_agg/analysis/res/job-9999/aggregated_summary.json @@ -0,0 +1,30 @@ +[ + { + "memberId": 10, + "topKeywords": [ + { + "businessKeywordId": 100, + "keywordCode": "BK-100", + "keywordName": "요금조회", + "totalCount": 3 + }, + { + "businessKeywordId": 300, + "keywordCode": "BK-300", + "keywordName": "결합할인", + "totalCount": 1 + } + ] + }, + { + "memberId": 20, + "topKeywords": [ + { + "businessKeywordId": 100, + "keywordCode": "BK-100", + "keywordName": "요금조회", + "totalCount": 1 + } + ] + } +] \ No newline at end of file diff --git a/e2e_test_efs_agg/analysis/res/job-9999/chunk-01.mapping.jsonl.gz b/e2e_test_efs_agg/analysis/res/job-9999/chunk-01.mapping.jsonl.gz new file mode 100644 index 0000000..a063b6d Binary files /dev/null and b/e2e_test_efs_agg/analysis/res/job-9999/chunk-01.mapping.jsonl.gz differ diff --git a/e2e_test_efs_agg/analysis/res/job-9999/chunk-02.mapping.jsonl.gz b/e2e_test_efs_agg/analysis/res/job-9999/chunk-02.mapping.jsonl.gz new file mode 100644 index 0000000..69c9271 Binary files /dev/null and b/e2e_test_efs_agg/analysis/res/job-9999/chunk-02.mapping.jsonl.gz differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fe972c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "api-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-server", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "husky": "^9.1.7" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + } + } +} diff --git a/package.json b/package.json index c2661a4..180be61 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "echo \"No tests\" && exit 0", "prepare": "husky" }, "keywords": [], "author": "", "license": "ISC", - "packageManager": "pnpm@10.28.2" -} \ No newline at end of file + "packageManager": "pnpm@10.28.2", + "devDependencies": { + "husky": "^9.1.7" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a177305 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + +packages: + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + husky@9.1.7: {} diff --git a/requirements.txt b/requirements.txt index 854889e..f0bec4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,18 @@ fastapi==0.115.8 uvicorn[standard]==0.34.0 pydantic==2.10.6 pydantic-settings==2.8.0 -spacy>=3.7.0 -pyahocorasick>=2.0.0 -rapidfuzz>=3.6.0 \ No newline at end of file +aiokafka==0.11.0 +spacy==3.8.2 +pyahocorasick==2.1.0 +rapidfuzz==3.6.2 +langchain==0.3.9 +langchain-openai==0.2.14 +openai==1.58.1 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +pgvector==0.3.6 +python-dotenv==1.0.1 +httpx==0.27.2 +orjson==3.10.12 +pytest==8.3.4 +pytest-asyncio==0.24.0 diff --git a/scripts/embed_products.py b/scripts/embed_products.py new file mode 100644 index 0000000..f840008 --- /dev/null +++ b/scripts/embed_products.py @@ -0,0 +1,345 @@ +""" +상품 임베딩 파이프라인 (Step 1~5). +Step 1: Postgres에서 상품 공통 + 카테고리별 상세를 조회해 상품당 1 row로 정규화. +Step 2: tag_strategy.csv 로드 후 상품별 tags로 tag_contexts 주입. +Step 3: tag_contexts에서 [대상]/[추천 이유]/[참고] 있는 필드만 라벨 붙여 targeting_summary 생성. +Step 4: tag_contexts의 upsell_points를 합쳐 [업셀] 라벨로 upsell_summary 생성. +Step 5(embedding_text만): 공통+detail+targeting_summary로 embedding_text 생성 후 product 테이블에 UPDATE. 벡터 임베딩은 별도 파일. +""" +from __future__ import annotations + +import asyncio +import csv +import json +import os +import sys +from typing import Any + +# 프로젝트 루트를 path에 넣어 app 모듈 import 가능하게 함 +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT_DIR = os.path.dirname(_SCRIPT_DIR) +if _ROOT_DIR not in sys.path: + sys.path.insert(0, _ROOT_DIR) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import SessionLocal + +# scripts/tag_strategy.csv 경로 (스크립트와 같은 디렉터리) +TAG_STRATEGY_CSV = os.path.join(_SCRIPT_DIR, "tag_strategy.csv") + +# Java 레포 DDL 기준: product (product_id, name, product_type, ...) + 카테고리별 LEFT JOIN +# docs/product_schema.md 참고. one_line_summary, core_benefits 없음. +NORMALIZE_PRODUCTS_SQL = text(""" +SELECT + p.product_id, + p.name, + p.product_type, + p.price, + p.sale_price, + p.discount_type, + p.tags, + COALESCE(mp.data_amount, tw.data_amount) AS data_amount, + mp.tethering_sharing_data, + COALESCE(mp.benefit_voice_call, tw.benefit_voice_call) AS benefit_voice_call, + COALESCE(mp.benefit_sms, tw.benefit_sms) AS benefit_sms, + mp.benefit_media, + mp.benefit_premium, + mp.benefit_signature_family_discount, + mp.benefit_brands, + i.speed, + COALESCE(i.plan_title, ip.plan_title) AS plan_title, + COALESCE(i.benefits, ip.benefits) AS benefits, + ip.channel, + a.addon_type, + a.description +FROM product p +LEFT JOIN mobile_plan mp ON p.product_id = mp.product_id +LEFT JOIN internet i ON p.product_id = i.product_id +LEFT JOIN iptv ip ON p.product_id = ip.product_id +LEFT JOIN addon_service a ON p.product_id = a.product_id +LEFT JOIN tab_watch_plan tw ON p.product_id = tw.product_id +ORDER BY p.product_id +""") + +# product_type별로 detail에 넣을 키만 정의. Step 2~5에서 product["detail"]로 타입별 필드 접근. +DETAIL_KEYS_BY_TYPE: dict[str, list[str]] = { + "MOBILE_PLAN": [ + "data_amount", + "tethering_sharing_data", + "benefit_voice_call", + "benefit_sms", + "benefit_media", + "benefit_premium", + "benefit_signature_family_discount", + "benefit_brands", + ], + "INTERNET": ["speed", "plan_title", "benefits"], + "IPTV": ["channel", "plan_title", "benefits"], + "ADDON": ["addon_type", "description"], + "TAB_WATCH_PLAN": ["data_amount", "benefit_voice_call", "benefit_sms"], +} + +# Step 5(embedding_text만): detail 블록 출력용 키별 라벨. embedding_text에는 targeting_summary·upsell_summary 모두 포함. 벡터 API 호출 시에만 업셀 제외 가능. +DETAIL_LABELS_BY_TYPE: dict[str, list[tuple[str, str]]] = { + "MOBILE_PLAN": [ + ("data_amount", "[데이터량]"), + ("tethering_sharing_data", "[테더링/공유]"), + ("benefit_voice_call", "[혜택_음성]"), + ("benefit_sms", "[혜택_문자]"), + ("benefit_media", "[혜택_미디어]"), + ("benefit_premium", "[혜택_프리미엄]"), + ("benefit_signature_family_discount", "[혜택_시그니처가족할인]"), + ("benefit_brands", "[혜택_브랜드]"), + ], + "INTERNET": [("speed", "[속도]"), ("plan_title", "[플랜명]"), ("benefits", "[혜택]")], + "IPTV": [("channel", "[채널수]"), ("plan_title", "[플랜명]"), ("benefits", "[혜택]")], + "ADDON": [("addon_type", "[부가유형]"), ("description", "[설명]")], + "TAB_WATCH_PLAN": [ + ("data_amount", "[데이터량]"), + ("benefit_voice_call", "[혜택_음성]"), + ("benefit_sms", "[혜택_문자]"), + ], +} + + +def build_embedding_text(product: dict[str, Any]) -> str: + """ + 상품 dict로 임베딩용 한 덩어리 문자열 생성. DB 저장용이므로 targeting_summary·upsell_summary 모두 포함. + 공통 + product_type별 detail + [추천 대상/상황] + [업셀 포인트]. (나중에 벡터 API 호출 시 업셀만 빼고 보낼 수 있음) + """ + lines = [] + name = (product.get("name") or "").strip() + if name: + lines.append(f"[상품명] {name}") + price = product.get("price") + sale_price = product.get("sale_price") + discount_type = (product.get("discount_type") or "").strip() + if price is not None: + line = f"[가격] {price}원" + if sale_price is not None: + line += f" (할인가 {sale_price}원)" + if discount_type: + line += f" 할인유형: {discount_type}" + lines.append(line) + tags = product.get("tags") or [] + if isinstance(tags, list): + tag_str = ", ".join(str(t).strip() for t in tags if str(t).strip()) + else: + tag_str = str(tags).strip() + if tag_str: + lines.append(f"[태그] {tag_str}") + product_type = (product.get("product_type") or "").strip() + if product_type: + lines.append(f"[상품유형] {product_type}") + + detail = product.get("detail") or {} + for key, label in DETAIL_LABELS_BY_TYPE.get(product_type, []): + val = detail.get(key) + if val is not None and str(val).strip(): + lines.append(f"{label} {str(val).strip()}") + + targeting = (product.get("targeting_summary") or "").strip() + if targeting: + lines.append(f"[추천 대상/상황] {targeting}") + + upsell = (product.get("upsell_summary") or "").strip() + if upsell: + lines.append(f"[업셀 포인트] {upsell}") + + return "\n".join(lines) + + +UPDATE_EMBEDDING_TEXT_SQL = text( + "UPDATE product SET embedding_text = :txt WHERE product_id = :pid" +) + + +async def update_embedding_texts( + session: AsyncSession, + products: list[dict[str, Any]], +) -> int: + """ + 각 상품의 embedding_text를 DB product 테이블에 반영. product_id, embedding_text 키 필요. + 반환: 업데이트한 행 수. + """ + if not products: + return 0 + + update_params = [ + {"pid": p.get("product_id"), "txt": p.get("embedding_text") or ""} + for p in products + if p.get("product_id") is not None + ] + + if not update_params: + return 0 + + result = await session.execute(UPDATE_EMBEDDING_TEXT_SQL, update_params) + await session.commit() + return result.rowcount + + +def _row_to_normalized(row: Any) -> dict[str, Any]: + """ResultProxy row를 정규화된 상품 dict로 변환. tags는 JSONB → list. detail은 product_type별 키만 포함.""" + raw = dict(row._mapping) if hasattr(row, "_mapping") else dict(row) + tags_raw = raw.get("tags") + if tags_raw is None: + tags: list[str] = [] + elif isinstance(tags_raw, str): + tags = json.loads(tags_raw) if tags_raw else [] + else: + tags = list(tags_raw) if tags_raw else [] + + product_type_raw = raw.get("product_type") + product_type = str(product_type_raw) if product_type_raw is not None else "" + + detail_keys = DETAIL_KEYS_BY_TYPE.get(product_type, []) + detail = {k: raw.get(k) for k in detail_keys} + + return { + "product_id": raw["product_id"], + "product_type": product_type, + "name": raw["name"], + "price": raw["price"], + "sale_price": raw["sale_price"], + "discount_type": raw.get("discount_type"), + "tags": tags, + "detail": detail, + } + + +async def fetch_normalized_products(session: AsyncSession) -> list[dict[str, Any]]: + """ + Postgres에서 상품 공통 정보 + 카테고리별 상세를 조회해 상품당 1 row로 정규화된 리스트 반환. + 각 항목: 공통 필드(product_id, product_type, name, price, sale_price, discount_type, tags) + detail. + detail은 product_type별로 해당 타입 전용 키만 포함 (DETAIL_KEYS_BY_TYPE 참고). + """ + result = await session.execute(NORMALIZE_PRODUCTS_SQL) + rows = result.fetchall() + return [_row_to_normalized(r) for r in rows] + + +def load_tag_strategy(csv_path: str = TAG_STRATEGY_CSV) -> dict[str, dict[str, Any]]: + """ + tag_strategy.csv를 로드해 tag_name 기준 딕셔너리 반환. + 반환: { tag_name: { "tag_group", "target_audience", "marketing_message", "upsell_points", "recommendation_hint", "related_tags", "caution", ... } } + """ + strategy: dict[str, dict[str, Any]] = {} + with open(csv_path, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + name = row.get("tag_name", "").strip() + if name: + strategy[name] = dict(row) + return strategy + + +def inject_tag_contexts( + products: list[dict[str, Any]], + strategy: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """ + 각 상품의 tags에 있는 태그명으로 strategy에서 row를 찾아 tag_contexts 리스트를 만들어 상품에 붙인 새 리스트 반환. + 산출: (정규화 상품 + "tag_contexts": [ {...}, ... ]) 리스트. + """ + result = [] + for p in products: + tag_contexts: list[dict[str, Any]] = [] + for tag_name in p.get("tags") or []: + if isinstance(tag_name, str) and tag_name.strip(): + ctx = strategy.get(tag_name.strip()) + if ctx: + tag_contexts.append(ctx) + out = {**p, "tag_contexts": tag_contexts} + result.append(out) + return result + + +# Step 3: 타겟팅 문장 — 있는 필드만 + 라벨. 순서: [대상] [추천 이유] [참고] +TARGETING_FIELDS = [ + ("target_audience", "[대상]"), + ("recommendation_hint", "[추천 이유]"), + ("caution", "[참고]"), +] +TARGETING_SEP_INNER = " · " # 태그 내 필드 구분 +TARGETING_SEP_BLOCKS = " | " # 태그 블록 구분 + + +def _build_one_tag_targeting(ctx: dict[str, Any]) -> str: + """태그 하나의 target_audience, recommendation_hint, caution 중 값 있는 것만 라벨 붙여 이어 붙임.""" + parts = [] + for key, label in TARGETING_FIELDS: + val = (ctx.get(key) or "").strip() + if val: + parts.append(f"{label} {val}") + return TARGETING_SEP_INNER.join(parts) + + +def build_targeting_summaries( + products: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + 각 상품의 tag_contexts에서 [대상] target_audience, [추천 이유] recommendation_hint, [참고] caution을 + 있는 필드만 라벨 붙여 블록으로 만들고, 태그 블록은 | 로 이어서 targeting_summary 문자열 생성. + """ + result = [] + for p in products: + blocks = [_build_one_tag_targeting(ctx) for ctx in p.get("tag_contexts") or []] + blocks = [b for b in blocks if b] + targeting_summary = TARGETING_SEP_BLOCKS.join(blocks) + result.append({**p, "targeting_summary": targeting_summary}) + return result + + +# Step 4: 업셀링 문장 — tag_contexts의 upsell_points 합침. 태그별 [업셀] 라벨 + | 구분 +UPSELL_LABEL = "[업셀]" +UPSELL_SEP = " | " + + +def build_upsell_summaries( + products: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + 각 상품의 tag_contexts에서 upsell_points를 모아 "다음으로 추천할 수 있는 옵션" 요약 문자열 생성. + 태그별로 값 있으면 [업셀] upsell_points, 블록은 | 로 이어서 upsell_summary 반환. + """ + result = [] + for p in products: + parts = [] + for ctx in p.get("tag_contexts") or []: + val = (ctx.get("upsell_points") or "").strip() + if val: + parts.append(f"{UPSELL_LABEL} {val}") + upsell_summary = UPSELL_SEP.join(parts) + result.append({**p, "upsell_summary": upsell_summary}) + return result + + +async def main() -> None: + """Step 1~4 후 embedding_text 생성·DB 반영. 임베딩 벡터 호출은 별도 파일에서.""" + async with SessionLocal() as session: + products = await fetch_normalized_products(session) + print(f"정규화된 상품 수: {len(products)}") + + strategy = load_tag_strategy() + products_with_contexts = inject_tag_contexts(products, strategy) + products_with_targeting = build_targeting_summaries(products_with_contexts) + products_with_upsell = build_upsell_summaries(products_with_targeting) + + for p in products_with_upsell: + p["embedding_text"] = build_embedding_text(p) + + async with SessionLocal() as session: + updated = await update_embedding_texts(session, products_with_upsell) + print(f"embedding_text DB 반영 완료: {updated}개 상품") + + if products_with_upsell: + p0 = products_with_upsell[0] + print("첫 상품 embedding_text:") + print(p0.get("embedding_text", "")) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/embed_vectors.py b/scripts/embed_vectors.py new file mode 100644 index 0000000..b2529aa --- /dev/null +++ b/scripts/embed_vectors.py @@ -0,0 +1,154 @@ +""" +embedding_text를 DB에서 읽어 [업셀 포인트] 구간을 제거한 뒤 OpenAI 임베딩 API에 넘기고, +반환된 벡터를 product.embedding_vector에 UPDATE. +""" +from __future__ import annotations + +import asyncio +import os +import sys +from typing import Any + +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT_DIR = os.path.dirname(_SCRIPT_DIR) +if _ROOT_DIR not in sys.path: + sys.path.insert(0, _ROOT_DIR) + +from openai import AsyncOpenAI +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.database import SessionLocal + +# API 호출 시 넣을 텍스트에서 제거할 블록 라벨 (포함 ~ 문자열 끝) +UPSELL_BLOCK_LABEL = "[업셀 포인트]" + +# 배치 크기 (OpenAI 한 번에 보낼 건수) +EMBED_BATCH_SIZE = 50 + +# 테스트용: 처리할 상품 수 제한. 환경변수 EMBED_LIMIT=1 등으로 지정. 없으면 전부 처리. +def _get_embed_limit() -> int | None: + v = os.environ.get("EMBED_LIMIT", "").strip() + if not v: + return None + try: + n = int(v) + return n if n > 0 else None + except ValueError: + return None + + +def strip_upsell_from_embedding_text(embedding_text: str | None) -> str: + """ + embedding_text에서 [업셀 포인트] ... 구간을 제거한 문자열 반환. + API에 넘길 때만 사용. 없으면 원문 그대로. + """ + if not embedding_text or not isinstance(embedding_text, str): + return "" + if UPSELL_BLOCK_LABEL not in embedding_text: + return embedding_text.strip() + before = embedding_text.split(UPSELL_BLOCK_LABEL)[0] + return before.rstrip() + + +async def fetch_products_with_embedding_text(session: AsyncSession) -> list[dict[str, Any]]: + """embedding_text가 비어 있지 않은 product_id, embedding_text 목록 조회.""" + result = await session.execute( + text( + "SELECT product_id, embedding_text FROM product " + "WHERE embedding_text IS NOT NULL AND TRIM(embedding_text) != '' ORDER BY product_id" + ) + ) + rows = result.fetchall() + return [{"product_id": r[0], "embedding_text": r[1]} for r in rows] + + +async def get_embeddings_batch( + client: AsyncOpenAI, texts: list[str], model: str +) -> list[list[float]]: + """OpenAI embedding API 비동기 호출. texts 순서와 동일한 벡터 리스트 반환.""" + if not texts: + return [] + resp = await client.embeddings.create(model=model, input=texts) + order = {e.index: e.embedding for e in resp.data} + return [order[i] for i in range(len(texts))] + + +def _build_bulk_update_vectors_sql(num_rows: int) -> str: + """num_rows개 행을 한 번에 UPDATE하기 위한 VALUES 절 생성.""" + values = ", ".join( + f"(:pid_{i}, :vec_{i}::vector)" for i in range(num_rows) + ) + return ( + "UPDATE product AS p SET embedding_vector = v.vec FROM (VALUES " + + values + + ") AS v(pid, vec) WHERE p.product_id = v.pid" + ) + + +async def update_embedding_vectors( + session: AsyncSession, + product_id_and_vectors: list[tuple[int, list[float]]], +) -> int: + """ + product_id별 embedding_vector를 한 번의 execute로 일괄 UPDATE. + 벡터는 list[float]로 전달. database.py에서 register_vector 등록된 연결 사용. + """ + if not product_id_and_vectors: + return 0 + + n = len(product_id_and_vectors) + update_sql = text(_build_bulk_update_vectors_sql(n)) + params = {} + for i, (pid, vec) in enumerate(product_id_and_vectors): + params[f"pid_{i}"] = pid + params[f"vec_{i}"] = vec + + result = await session.execute(update_sql, params) + await session.commit() + return result.rowcount + + +async def main() -> None: + settings = get_settings() + client = AsyncOpenAI(api_key=settings.openai_api_key) + model = settings.openai_embedding_model + + async with SessionLocal() as session: + products = await fetch_products_with_embedding_text(session) + if not products: + print("embedding_text가 있는 상품이 없습니다.") + return + + limit = _get_embed_limit() + if limit is not None: + products = products[:limit] + print(f"테스트: 상품 {limit}건만 처리 (EMBED_LIMIT={limit})") + print(f"대상 상품 수: {len(products)} (업셀 블록 제거 후 API 호출)") + + total_updated = 0 + for i in range(0, len(products), EMBED_BATCH_SIZE): + batch = products[i : i + EMBED_BATCH_SIZE] + texts_for_api = [ + strip_upsell_from_embedding_text(p["embedding_text"]) for p in batch + ] + # 빈 문자열 제거 시 해당 인덱스는 벡터 받지 않음 — 여기서는 빈 문자열도 API에 넘김(호출 수 유지). API는 빈 입력 시 에러 가능하므로 필터링. + to_embed = [(p["product_id"], t) for p, t in zip(batch, texts_for_api) if t] + if not to_embed: + continue + pids = [x[0] for x in to_embed] + texts_only = [x[1] for x in to_embed] + vectors = await get_embeddings_batch(client, texts_only, model) + id_vec_pairs = list(zip(pids, vectors)) + + async with SessionLocal() as session: + n = await update_embedding_vectors(session, id_vec_pairs) + total_updated += n + print(f" 배치 {i // EMBED_BATCH_SIZE + 1}: {n}건 반영") + + print(f"완료: {total_updated}개 상품 embedding_vector 반영됨.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/run_keyword_batch_once.py b/scripts/run_keyword_batch_once.py new file mode 100644 index 0000000..7cab394 --- /dev/null +++ b/scripts/run_keyword_batch_once.py @@ -0,0 +1,20 @@ +"""Backward-compatible batch entrypoint. + +Prefer `python -m app.batch.main`. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +# Ensure `app` imports still work when this file is executed directly. +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.batch.main import main + + +if __name__ == "__main__": + main() diff --git a/scripts/tag_strategy.csv b/scripts/tag_strategy.csv new file mode 100644 index 0000000..d0415cd --- /dev/null +++ b/scripts/tag_strategy.csv @@ -0,0 +1,42 @@ +tag_group,tag_name,assignment_rule,target_audience,marketing_message,upsell_points,recommendation_hint,related_tags,caution +데이터 활용력 / 인터넷 속도,데이터데일리,"일/매일 nGB, 월 nGB+매일 mGB",데일리 요금제(군인/청소년/시니어/일상 루틴형),"매일 리셋, 오늘도 넉넉하게 | 출퇴근/등하교 루틴 데이터",테더링 옵션 | OTT/유튜브형 혜택 | 게임/콘텐츠 번들,일 단위 데이터 리셋 니즈가 있거나 규칙적 사용 패턴이 뚜렷한 사용자에게 적합,청소년 | 시니어 | 현역병사 | 게임 | 영상OTT,월 총량 중심 사용자에게는 데이터데일리보다 데이터적정/데이터헤비가 더 적합할 수 있음 +데이터 활용력 / 인터넷 속도,데이터무제한,무제한,프리미어/시그니처 등 상위,데이터는 신경 끄기 | 끊김/용량 스트레스 0,프리미엄 구독 번들(OTT/음악) | 가족결합 메인 전환 | VIP/보험/케어,"데이터 부족 스트레스, 스트리밍, 게임, 핫스팟 사용량이 큰 사용자에게 핵심 추천 태그",영상OTT | 게임 | 가족결합메인 | 디바이스케어,가격 민감도가 높은 사용자에게는 단독 주력 추천보다 가성비/혜택 근거를 함께 제시하는 것이 좋음 +데이터 활용력 / 인터넷 속도,데이터헤비,101~210GB,스트리밍/재택/핫스팟,스트리밍 마음껏 | 핫스팟까지 넉넉,테더링 상향 | OTT/음악 번들 | 초고속 인터넷 결합,무제한까지는 아니지만 월 사용량이 높은 사용자에게 중상위 데이터 요금제로 연결하기 좋음,테더링쉐어링 | 영상OTT | 음악 | 초고속인터넷,실제 사용량이 낮은 사용자에게는 과추천 가능성이 있으므로 데이터 사용량 근거와 함께 제안 +데이터 활용력 / 인터넷 속도,데이터적정,31~100GB,직장인/대학생 메인폰,"딱 맞는 용량, 월말까지 안정",OTT/음악 소액 번들 | 클라우드(사진/백업) | 테더링 패스,과소비는 피하면서도 메인폰으로 안정적 사용을 원하는 사용자에게 적합,20대청년 | 영상OTT | 음악 | 클라우드 | 테더링쉐어링,영상/게임/테더링 사용량이 급증하는 사용자에게는 데이터헤비와 비교 제안 필요 +데이터 활용력 / 인터넷 속도,데이터알뜰,0~30GB,라이트/시니어/키즈/서브폰,"쓸 만큼만, 통신비 다이어트",안심보안 | 소액 보험/케어 | 가족결합 연결,사용량이 낮고 비용 효율을 중시하는 사용자에게 적합,가성비 | 시니어 | 키즈 | 안심보안 | 가족결합메인,메인폰 고사용량 사용자에게는 품질 불만이 생길 수 있으므로 서브폰/라이트 사용 맥락 확인 필요 +데이터 활용력 / 인터넷 속도,초고속인터넷,1Gbps/기가/게임,1G 인터넷,핑 줄이고 속도 올리기 | 게임/재택 최적,Wi-Fi 공유기 업그레이드 | IPTV/가족결합,"게임, 재택근무, 다인 가구, 고화질 스트리밍 수요가 큰 홈 환경에 적합",게임 | IPTV | 가족결합메인 | 기가라이트,단순 웹서핑/영상 소비 위주 가구에는 과한 제안이 될 수 있어 500M와 비교 제안 권장 +데이터 활용력 / 인터넷 속도,기가라이트,500Mbps,500M 인터넷,체감 성능+가성비 밸런스,1G 업그레이드 제안 | IPTV 결합,성능과 비용 사이 균형을 원하는 가정용 인터넷 고객에게 적합,가성비 | IPTV | 초고속인터넷,고사양 게임/다수 동시 접속 환경에서는 초고속인터넷이 더 적합할 수 있음 +데이터 활용력 / 인터넷 속도,실속인터넷,100Mbps/광랜/기본,100M 인터넷,"기본에 충실, 비용 최소",500M로 단계 업 | 보안/자녀 보호 서비스,"기본 사용 중심, 저비용 홈인터넷 니즈에 적합",가성비 | 자녀보호 | 안심보안 | 기가라이트,"다인 가구, IPTV 고사용량, 재택/게임 환경에서는 속도 부족 체감이 있을 수 있음" +트렌드 민감도 (미디어 및 콘텐츠 지수),영상OTT,넷플릭스/유튜브/디즈니+/티빙/OTT,OTT 부가서비스,구독료를 요금제에 묶어서 절약,OTT 번들팩 | 무제한 요금제 | 가족공유형 상품,영상 콘텐츠 소비가 높은 사용자에게 데이터·번들 추천의 연결 고리 역할,OTT프리미엄 | OTT스탠다드 | OTT베이직 | 데이터무제한 | 가족공유,OTT 실제 이용의향이 낮은 사용자에게는 혜택 체감이 약할 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),OTT프리미엄,프리미엄 등급 OTT,4K/TV 시청,TV·4K·가족 공유까지,데이터무제한 | 가족결합메인 | 초고속인터넷,대화면·고화질·가족 공유 중심의 콘텐츠 소비자에게 적합,영상OTT | 가족공유 | 데이터무제한 | 초고속인터넷,모바일 위주·저가 선호 고객에게는 OTT스탠다드/베이직이 더 적합할 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),OTT스탠다드,스탠다드 등급,모바일+TV 혼합,딱 좋은 화질과 가격,데이터헤비 | OTT 번들 상향,화질과 가격의 균형을 원하는 일반 콘텐츠 소비자에게 적합,영상OTT | 데이터헤비 | OTT프리미엄 | OTT베이직,가족 공유 또는 4K 시청 니즈가 명확하면 프리미엄 등급 제안을 병행 +트렌드 민감도 (미디어 및 콘텐츠 지수),OTT베이직,베이직 등급,모바일 위주,"모바일 중심, 가성비 선택",데이터적정 | 소액 OTT 번들 유지 전략,모바일 위주의 가벼운 OTT 사용자에게 적합,영상OTT | 데이터적정 | 가성비 | OTT스탠다드,TV 시청 비중이 높거나 가족 공유 니즈가 있으면 상위 등급을 검토 +트렌드 민감도 (미디어 및 콘텐츠 지수),구독결제,통신 합산 구독,OTT/음악/전자책,통신비로 한 번에 관리,번들팩 제안 | 프리미엄 요금제 전환,여러 구독을 통신비로 일원화하려는 사용자에게 적합,번들상품 | 영상OTT | 음악 | 독서,단일 서비스만 이용하는 고객에게는 복잡하게 느껴질 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),음악,지니/V컬러링,음악 구독,매달 음악 구독 대신 혜택으로,헤비/무제한 데이터 | 콘텐츠 통합팩,음악 스트리밍 빈도가 높고 월 구독 혜택 체감이 큰 고객에게 적합,데이터헤비 | 데이터무제한 | 구독결제 | 번들상품,음악 이용 빈도가 낮으면 혜택 체감이 약할 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),독서,밀리의/전자책,독서 구독,출퇴근 10분 독서 루틴,클라우드 저장 확대 | 프리미엄 콘텐츠팩,전자책/오디오북 중심의 자기계발형 사용자에게 적합,구독결제 | 클라우드 | 20대청년,독서 콘텐츠 무관심 고객에게는 다른 구독 혜택이 더 적합함 +트렌드 민감도 (미디어 및 콘텐츠 지수),게임,게임/게이밍,게이머,랙/핑에 예민한 당신,초고속인터넷(1G) | 테더링 상향 | 무제한 데이터,속도·지연시간·데이터 품질 민감도가 높은 사용자에게 적합,초고속인터넷 | 데이터무제한 | 데이터데일리 | 테더링쉐어링,캐주얼 게임 수준이면 고가 상향 제안이 과할 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),클라우드,구글원/저장공간,사진/백업 수요,사진·영상 자동 백업,저장용량 상향 | 가족 공유 플랜 | 프리미엄 요금제,사진·영상 보관과 디바이스 백업 니즈가 큰 고객에게 적합,가족공유 | 독서 | 20대청년 | 프리미엄 요금제,저장 공간 사용량이 낮은 경우 과도한 부가 혜택이 될 수 있음 +트렌드 민감도 (미디어 및 콘텐츠 지수),AI,ixio/AI,AI 기능형,AI를 일상으로,AI 프리미엄팩 | 고가 요금제 전환,신기능 수용도가 높고 AI 부가가치를 체감할 가능성이 높은 사용자에게 적합,구독결제 | 데이터무제한,AI 효용을 체감하지 못하는 고객에게는 가격 저항이 클 수 있음 +스마트 생태계 (멀티 디바이스 지수),테더링쉐어링,테더링/쉐어링/나눠쓰기,멀티디바이스/재택,노트북·태블릿까지 한 번에,테더링 상향 | 태블릿/워치 추가회선(#기기추가혜택),폰 외에도 노트북·태블릿·워치와 함께 쓰는 사용 패턴에 적합,기기추가혜택 | 스마트워치 | 데이터헤비 | 데이터무제한,단일 디바이스 사용자에게는 체감 포인트가 약할 수 있음 +스마트 생태계 (멀티 디바이스 지수),기기추가혜택,워치/태블릿/기기추가,세컨드 디바이스,기기 하나 더 써도 부담 없이,가족결합 | 데이터 쉐어링 확장,워치·태블릿 등 추가 회선 확대 가능성이 있는 고객에게 적합,테더링쉐어링 | 스마트워치 | 가족결합메인,추가기기 보유/구매 계획이 없는 고객에게는 설득력이 낮을 수 있음 +스마트 생태계 (멀티 디바이스 지수),스마트워치,워치,웨어러블,워치 단독통화/안심 연결,키즈/시니어 안심 기능 | 가족결합,"웨어러블 활용, 안심 연결, 세컨드 디바이스 니즈에 적합",기기추가혜택 | 키즈 | 시니어 | 가족결합메인,워치 사용 의향이 없는 고객에게는 불필요한 부가 제안이 될 수 있음 +가족 / 홈 결합도,가족결합메인,가족/홈/IPTV/프리미어,결합 베이스,우리집 통신비 한 번에 절약,인터넷/IPTV 결합 | 추가 회선(워치/키즈/시니어),가구 단위 결합 설계의 중심이 되는 메인 상품 추천에 적합,가족공유 | IPTV | 초고속인터넷 | 기기추가혜택,1인 가구·결합 의향이 없는 고객에게는 효과가 약할 수 있음 +가족 / 홈 결합도,가족공유,OTT/클라우드 공유형,공유 소비자,가족이 함께 쓰는 구독,가족결합메인 전환 | 프리미엄 요금제 업,가족 단위 구독 공유 혜택을 중시하는 고객에게 적합,가족결합메인 | 영상OTT | 클라우드 | OTT프리미엄,개인 사용만 하는 고객에게는 공유 가치가 낮을 수 있음 +가족 / 홈 결합도 | 통신 평화도,안심보안,"피싱/해킹/스팸 | 안심, 보안, 피싱, 해킹, 스팸, 차단, 보호, 지킴이, 금융, 딥페이크, 안전, 인증, 유해사이트","보안 민감 고객 | 악성코드 차단 인터넷, 스마트피싱보호, 가족안부전화 등","요즘 제일 무서운 건 피싱 | 부모님 폰, 안전부터 | 스팸·악성링크 자동 차단",보안팩 상향(스마트피싱/스팸차단 강화) | 가족 안부/자녀 보호 기능 연결(#키즈) | 금융/인증 보호형 서비스 묶음 | 인터넷 보안 옵션,"보안 불안, 부모님/자녀 보호, 금융사기 우려 등 심리적 리스크를 줄이는 추천에 적합",스팸차단 | 피싱보호 | 딥페이크보호 | 키즈 | 시니어,과도한 공포 마케팅은 거부감을 줄 수 있으므로 실제 보안 니즈 문맥 확인 필요 +가족 / 홈 결합도,스팸차단,후후/스팸,전화 스트레스,영업전화 차단,프리미엄 보안팩,전화·문자 스팸 불편이 큰 고객에게 즉시 체감형 혜택으로 제안하기 좋음,안심보안 | 피싱보호,스팸 불편 경험이 없으면 차별 포인트가 약할 수 있음 +가족 / 홈 결합도,피싱보호,피싱/금융,금융 이용자,금융사기 예방,프리미엄 보안팩 | 가족 보호 연결,금융 거래 빈도가 높거나 사기 위험에 민감한 사용자에게 적합,안심보안 | 스팸차단 | 시니어,일반 보안보다 금융보호가 핵심이므로 문맥 없는 광범위 추천은 비효율적일 수 있음 +가족 / 홈 결합도,딥페이크보호,딥페이크,부모/시니어,딥페이크 범죄 예방,시니어 요금제 + 보안팩 번들,최신 사기 유형에 대한 불안감이 큰 가족 보호형 맥락에서 유효,안심보안 | 시니어 | 가족결합메인,지나치게 낯선 용어는 설명 없이 제안 시 설득력이 떨어질 수 있음 +가족 / 홈 결합도 | 통신 평화도,디바이스케어,"보험/파손 | 폰케어, 보험, 파손, 분실, 폰교체, 수리비 보상","단말 고가 사용자 | 폰 안심패스, PC 수리비 보상 인터넷","수리비 리스크를 월정액으로 | 수리비 폭탄, 월정액으로 끝 | 분실/파손 걱정 줄이기 | 바꾸는 비용까지 관리",보험 상향 | 프리미엄 요금제 전환 | 보험 상향(파손+분실 풀커버) | 프리미엄 케어(당일 교체/수리 지원) | 중고폰/기변 프로그램 연결 | 가족 단체 케어 번들,고가 단말 보호와 수리비 리스크 회피 니즈가 큰 사용자에게 적합,휴대폰보험 | 폰교체 | 데이터무제한,저가 단말·짧은 사용주기 고객에게는 비용 대비 체감이 낮을 수 있음 +가족 / 홈 결합도,휴대폰보험,폰 보험,프리미엄폰 사용자,고가폰 보호,프리미어 요금제 | 기변 프로그램,프리미엄폰 사용자의 손실 회피 심리를 겨냥한 태그,디바이스케어 | 폰교체,보험 필요성 체감이 낮은 고객에게는 요금 인상 저항이 큼 +가족 / 홈 결합도,폰교체,교체형 패스,2년 교체 선호,교체 비용 분산,프리미엄 요금제 + 교체형 번들,기변 주기가 짧고 최신 단말 선호도가 높은 고객에게 적합,휴대폰보험 | 디바이스케어,단말 교체 주기가 긴 고객에게는 우선순위가 낮음 +타겟 세대 및 특수 상황 (라이프사이클),키즈,키즈/자녀,초등 자녀,"아이 첫 폰, 안전이 먼저",자녀보호 + 워치 추가 | 가족결합,자녀 첫 단말·안심 기능·보호자 관리 니즈에 적합,자녀보호 | 스마트워치 | 가족결합메인 | 안심보안,실사용자가 성인인 경우 자동 태깅/추천 오류 가능성이 높음 +타겟 세대 및 특수 상황 (라이프사이클),자녀보호,위치/유해차단,보호자,아이 위치·앱 관리,키즈 요금제 | 보안팩 상향,보호자가 자녀의 위치/앱/유해콘텐츠 관리를 원할 때 적합,키즈 | 안심보안,자녀 유무가 불명확하면 오추천 가능성이 큼 +타겟 세대 및 특수 상황 (라이프사이클),청소년,틴/19세,중고등학생,공부·취미 균형,데이터데일리 | 게임/OTT 소액팩,학습·콘텐츠·가격 균형이 중요한 틴 고객군에 적합,데이터데일리 | 게임 | 영상OTT,단순 연령 추정만으로 태깅하기보다 실제 이용 주체 확인 필요 +타겟 세대 및 특수 상황 (라이프사이클),20대청년,유쓰/Youth,대학생,혜택 최대화 전략,클라우드/OTT 번들 | 테더링,가성비와 혜택 활용도를 동시에 중시하는 대학생/청년층에 적합,클라우드 | 영상OTT | 테더링쉐어링 | 가성비,직장인·가족형 고객에게는 다른 세그먼트 태그가 더 적합할 수 있음 +타겟 세대 및 특수 상황 (라이프사이클),시니어,실버,부모 세대,복잡한 건 빼고 편하게,안심보안 번들 | 가족결합 연결,복잡도 최소화와 안심 기능을 중시하는 고객에게 적합,안심보안 | 딥페이크보호 | 가족결합메인 | 데이터알뜰,연령만으로 디지털 친숙도를 단정하면 안 됨 +타겟 세대 및 특수 상황 (라이프사이클),현역병사,군인,병사,부대에서도 끊김 없이,데이터데일리 | 콘텐츠팩 | 테더링,생활 패턴이 제한적인 환경에서 데이터/콘텐츠 활용성을 강조하는 태그,데이터데일리 | 게임 | 영상OTT,신분 검증 또는 대상 여부 확인 없이 추천하면 정책 오류 가능성 있음 +타겟 세대 및 특수 상황 (라이프사이클),복지혜택,복지 대상,복지 요금제,부담 없이 충분하게,데이터 상향 제안 | 보안팩 번들,가격 부담 완화와 필수 혜택 확보를 동시에 고려하는 고객에게 적합,가성비 | 안심보안,자격 조건 확인 없이 일반 추천으로 쓰면 오해 소지가 큼 +마케팅 및 프로모션 (체리피커 지수),포인트할인,포인트/할인팩,체리피커 성향,혜택을 놓치지 않는 선택,프로모션 결합안 | 할인팩 유지 전략,적립·할인 체감이 큰 사용자에게 가격 설득력을 높이는 보조 태그,월정액할인 | 가성비 | 번들상품,실효 혜택이 작으면 오히려 기대 대비 만족도가 떨어질 수 있음 +마케팅 및 프로모션 (체리피커 지수),월정액할인,월정액 할인형,구독 할인 민감,매달 체감되는 할인,장기 유지형 프로모션 | 번들 전환,월별 고정비 절감을 중시하는 사용자에게 적합,포인트할인 | 번들상품 | 가성비,할인 종료 조건이나 기간이 있으면 함께 안내 필요 +마케팅 및 프로모션 (체리피커 지수),번들상품,패키지/멀티팩,묶음형 소비자,한 번에 묶고 더 절약,복수 서비스 결합 | 프리미엄 전환,단일 상품보다 묶음의 총 혜택을 중시하는 고객에게 적합,구독결제 | 월정액할인 | 가족공유,원치 않는 서비스까지 포함되면 번들 피로감이 생길 수 있음 +마케팅 및 프로모션 (체리피커 지수),가성비,저가형/알뜰,가격 민감,"꼭 필요한 만큼, 가장 합리적으로",실속형 유지 전략 | 소액 번들 제안,가격 우선 의사결정 고객에게 추천 설득력을 높이는 핵심 태그,데이터알뜰 | 기가라이트 | 실속인터넷 | 포인트할인,무조건 저가만 강조하면 성능 기대와 충돌할 수 있어 사용량 정보와 함께 제안 필요 \ No newline at end of file diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py new file mode 100644 index 0000000..506d1aa --- /dev/null +++ b/tests/test_aggregator.py @@ -0,0 +1,56 @@ +import json +import gzip +import shutil +from pathlib import Path +from app.core.config import Settings +from app.pipeline.aggregator import ResultAggregator + +def test_member_aggregation(): + # 1. 격리된 테스트 폴더 준비 + test_efs_base = Path("./e2e_test_efs_agg") + job_id = "job-9999" + res_dir = test_efs_base / "analysis" / "res" / job_id + + if test_efs_base.exists(): + shutil.rmtree(test_efs_base) + res_dir.mkdir(parents=True) + + # 2. 가짜 Chunk 1 결과 생성 (회원 10번이 요금조회 1번, 회원 20번이 요금조회 1번) + chunk1_path = res_dir / "chunk-01.mapping.jsonl.gz" + with gzip.open(chunk1_path, "wt", encoding="utf-8") as f: + f.write(json.dumps({"memberId": 10, "matchedKeywords": [{"businessKeywordId": 100, "keywordCode": "BK-100", "keywordName": "요금조회", "count": 1}]}) + "\n") + f.write(json.dumps({"memberId": 20, "matchedKeywords": [{"businessKeywordId": 100, "keywordCode": "BK-100", "keywordName": "요금조회", "count": 1}]}) + "\n") + + # 3. 가짜 Chunk 2 결과 생성 (회원 10번이 요금조회 2번 또 함, 그리고 결합할인 1번) + chunk2_path = res_dir / "chunk-02.mapping.jsonl.gz" + with gzip.open(chunk2_path, "wt", encoding="utf-8") as f: + f.write(json.dumps({"memberId": 10, "matchedKeywords": [{"businessKeywordId": 100, "keywordCode": "BK-100", "keywordName": "요금조회", "count": 2}]}) + "\n") + f.write(json.dumps({"memberId": 10, "matchedKeywords": [{"businessKeywordId": 300, "keywordCode": "BK-300", "keywordName": "결합할인", "count": 1}]}) + "\n") + + # 4. 집계기(Aggregator) 실행 + settings = Settings(efs_base_dir=test_efs_base) + aggregator = ResultAggregator(settings) + + print("--- Aggregator 집계 시작 ---") + results = aggregator.aggregate_job(job_id) + + # 5. 결과 검증 + print("\n[집계 결과 확인]") + print(json.dumps(results, ensure_ascii=False, indent=2)) + + # 회원 10번의 데이터 검증 (Chunk1에서 1번 + Chunk2에서 2번 = 총 3번이어야 함) + member_10_data = next(r for r in results if r["memberId"] == 10) + bk_100_data = next(k for k in member_10_data["topKeywords"] if k["keywordCode"] == "BK-100") + + assert bk_100_data["totalCount"] == 3, f"회원 10의 요금조회 카운트 오류: {bk_100_data['totalCount']}" + assert member_10_data["topKeywords"][0]["keywordCode"] == "BK-100", "가장 많이 나온 키워드가 1등으로 정렬되지 않았음" + + print("\n검증 통과: 누적합(+) 및 정렬 로직이 정상적으로 작동합니다") + + # 파일이 실제로 잘 저장되었는지 확인 + summary_file = res_dir / "aggregated_summary.json" + assert summary_file.exists(), "aggregated_summary.json 파일이 생성되지 않았습니다!" + print(f"검증 통과: 최종 요약 파일이 정상적으로 저장되었습니다 ({summary_file})") + +if __name__ == "__main__": + test_member_aggregation() \ No newline at end of file diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f5ff187..f040138 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -2,30 +2,30 @@ import gzip import shutil from pathlib import Path + from app.core.config import Settings from app.services.analyze_service import AnalyzeService from app.services.idempotency_service import IdempotencyService from app.infra.state.request_registry import RequestRegistry from app.schemas.analyze_request import AnalyzeRequest +from app.pipeline.aggregator import ResultAggregator def test_analyze_e2e(): - # 1. 안전한 격리 폴더 설정 (경로 오버라이딩) + # 1. 안전한 격리 폴더 설정 test_efs_base = Path("./e2e_test_efs") if test_efs_base.exists(): shutil.rmtree(test_efs_base) - # 2. 테스트용 가짜 데이터 준비 (paths.py 규칙 준수) + # 2. 테스트용 가짜 데이터 준비 job_id = "job-2026" version = "v1-aho" - # 경로 생성 req_dir = test_efs_base / "analysis" / "req" / job_id ref_dir = test_efs_base / "analysis" / "ref" req_dir.mkdir(parents=True) ref_dir.mkdir(parents=True) - # 가짜 사전 데이터 생성 (.alias.jsonl.gz) - # AliasRecord 데이터 + # [사전 데이터 세팅] 기본 키워드와 별칭이 똑같은 '요금조회' (중복 카운트 버그 재현용) alias_path = ref_dir / f"{version}.alias.jsonl.gz" with gzip.open(alias_path, "wt", encoding="utf-8") as f: ref_data = [ @@ -34,12 +34,7 @@ def test_analyze_e2e(): "keywordCode": "BK-100", "keywordName": "요금조회", "aliases": [ - { - "aliasId": 1, # 필수 추가 - "aliasText": "요금조회", - "aliasNorm": "요금조회" - # match_mode는 제거 (모델에 없음) - } + {"aliasId": 1, "aliasText": "요금조회", "aliasNorm": "요금조회"} ] }, { @@ -47,89 +42,71 @@ def test_analyze_e2e(): "keywordCode": "BK-200", "keywordName": "선택약정", "aliases": [ - { - "aliasId": 2, # 필수 추가 - "aliasText": "선텍약정", - "aliasNorm": "선텍약정" - } + {"aliasId": 2, "aliasText": "아무별칭", "aliasNorm": "아무별칭"} ] } ] for d in ref_data: f.write(json.dumps(d, ensure_ascii=False) + "\n") - # 가짜 상담 입력 데이터 생성 (.input.jsonl.gz) - # CounselRecord 데이터 + # [상담 입력 데이터 세팅] 10번 회원이 요금조회를 2번 물어보도록 Case 3 추가 input_path = req_dir / "chunk-01.input.jsonl.gz" with gzip.open(input_path, "wt", encoding="utf-8") as f: counsel_data = [ - { - "caseId": 1, - "memberId": 10, - "categoryCode": "CAT-01", - "title": "요금 문의", - "questionText": "요금조회 해주세요", # 분석 대상 - "status": "OPEN" - }, - { - "caseId": 2, - "memberId": 20, - "categoryCode": "CAT-02", - "title": "약정 문의", - "questionText": "선텍약정 얼마에요", # 분석 대상 - "status": "OPEN" - } + {"caseId": 1, "memberId": 10, "categoryCode": "CAT-01", "title": "요금 문의", "questionText": "요금조회 해주세요", "status": "OPEN"}, + {"caseId": 2, "memberId": 20, "categoryCode": "CAT-02", "title": "약정 문의", "questionText": "그거 선텍약정 얼마에요", "status": "OPEN"}, + {"caseId": 3, "memberId": 10, "categoryCode": "CAT-01", "title": "추가 문의", "questionText": "아까 그 요금조회 다시 확인요", "status": "OPEN"} ] for d in counsel_data: f.write(json.dumps(d, ensure_ascii=False) + "\n") - # 3. 서비스 조립 (진짜 설정 대신 가짜 설정을 주입) + # 3. 분석기(AnalyzeService) 조립 및 실행 test_settings = Settings(efs_base_dir=test_efs_base) registry = RequestRegistry() idempotency = IdempotencyService(registry) service = AnalyzeService(settings=test_settings, idempotency=idempotency) - # 4. 실행 (지휘자에게 명령 내리기) - request = AnalyzeRequest( - request_id="unique-req-id", - job_instance_id=job_id, - analysis_version=version - ) + request = AnalyzeRequest(request_id="unique-req-id", job_instance_id=job_id, analysis_version=version) - print("--- 분석 시작 ---") + print("--- 1. 분석(AnalyzeService) 파이프라인 시작 ---") success, message = service.analyze(request) print(f"결과: {success}, 메시지: {message}") - # 5. 검증 (결과 파일이 진짜 생겼고, 압축되어 있는가?) + # 4. 분석 결과(mapping) 검증 result_file = test_efs_base / "analysis" / "res" / job_id / "chunk-01.mapping.jsonl.gz" assert result_file.exists(), "결과 파일이 생성되지 않았습니다!" - # 압축된 결과 파일 열어서 실제 분석 내용 검증 with gzip.open(result_file, "rt", encoding="utf-8") as f: - print("\n--- 최종 분석 결과 파일 내용 및 검증 ---") - - # 파일 내용을 한 줄씩 읽어서 파이썬 딕셔너리로 변환 results = [json.loads(line) for line in f] - - # 터미널에서 파일 내용 확인 - print("[생성된 파일 내용]") - for i, res_dict in enumerate(results): - print(f"[{i+1}번째 기록]") - print(json.dumps(res_dict, ensure_ascii=False, indent=2)) - print("-" * 40) - # 1. 2개의 상담 데이터를 넣었으니, 결과도 2개가 나와야 함 - assert len(results) == 2, f"예상한 결과 갯수(2)와 다름: {len(results)}" + # [핵심 검증 1] Aho-Corasick 중복 카운트 버그가 고쳐졌는가? (count가 2가 아니라 1이어야 함) + case1_keyword = results[0]["matchedKeywords"][0] + assert case1_keyword["keywordCode"] == "BK-100", "Case 1 매핑 실패" + assert case1_keyword["count"] == 1, f"중복 카운트 버그 발생! count가 1이어야 하는데 {case1_keyword['count']} 입니다." + print(f"검증 통과: Aho-Corasick 중복 카운트 버그 수정됨 (count: {case1_keyword['count']})") - # 2. 첫 번째 상담("요금조회 해주세요") 검증 - # -> BK-100 키워드가 매칭되어야 함 - assert results[0]["matchedKeywords"][0]["keywordCode"] == "BK-100", "첫 번째 데이터 분석 실패" - print(f"검증 통과: Case 1 매핑 결과 - {results[0]['matchedKeywords'][0]['keywordCode']}") - - # 3. 두 번째 상담("선텍약정 얼마에요" - 오타) 검증 - # -> 패자부활전(Fallback)을 통해 BK-200 키워드가 매칭되어야 함 - assert results[1]["matchedKeywords"][0]["keywordCode"] == "BK-200", "두 번째 데이터 분석 실패 (오타 교정 실패)" - print(f"검증 통과: Case 2 매핑 결과 - {results[1]['matchedKeywords'][0]['keywordCode']}") + # [핵심 검증 2] 오타 교정(Fallback)이 잘 되었는가? + assert results[1]["matchedKeywords"][0]["keywordCode"] == "BK-200", "Case 2 오타 교정 매핑 실패" + + # 5. 집계기(Aggregator) 실행 및 검증 + print("\n--- 2. 집계(Aggregator) 파이프라인 시작 ---") + aggregator = ResultAggregator(test_settings) + aggregated_results = aggregator.aggregate_job(job_id) + + # 회원별로 그룹핑이 잘 되었는지 확인 + member_10_data = next(r for r in aggregated_results if r["memberId"] == 10) + member_20_data = next(r for r in aggregated_results if r["memberId"] == 20) + + # [핵심 검증 3] 10번 회원이 '요금조회'를 총 2번(Case 1, Case 3) 물어봤으므로 totalCount는 2여야 함 + member_10_bk_100 = member_10_data["topKeywords"][0] + assert member_10_bk_100["totalCount"] == 2, f"회원 10의 누적합 오류! 2여야 하는데 {member_10_bk_100['totalCount']} 입니다." + + # 파일 생성 확인 + summary_file = test_efs_base / "analysis" / "res" / job_id / "aggregated_summary.json" + assert summary_file.exists(), "aggregated_summary.json 최종 요약 파일이 생성되지 않았습니다!" + + print("검증 통과: 회원별(memberId) 키워드 카운트 누적합(+)이 정상적으로 계산 및 저장되었습니다.") + print("\n모든 E2E 테스트가 성공적으로 통과되었습니다!") if __name__ == "__main__": test_analyze_e2e() \ No newline at end of file