Frist commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.postgres
|
||||||
|
postgres
|
||||||
|
incoming
|
||||||
|
backend/uploads
|
||||||
|
backend/logs
|
||||||
|
backend/.venv
|
||||||
|
backend/__pycache__
|
||||||
|
backend/**/__pycache__
|
||||||
|
backend/_sample
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
**/.DS_Store
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
APP_NAME=mail_summary
|
||||||
|
|
||||||
|
FRONTEND_PORT=4888
|
||||||
|
POSTGRES_PORT=5437
|
||||||
|
|
||||||
|
POSTGRES_USER=mail_summary
|
||||||
|
POSTGRES_PASSWORD=strongpassword
|
||||||
|
POSTGRES_DB=mail_summary
|
||||||
|
|
||||||
|
# Host folder mounted into backend as /app/uploads.
|
||||||
|
# Windows: ./backend/uploads
|
||||||
|
# Linux (Synology): /volume1/09_container_manager/13_mail_summary/backend/uploads
|
||||||
|
UPLOAD_HOST=./backend/uploads
|
||||||
|
|
||||||
|
# Host folder mounted into backend as /app/incoming (for multi-GB zips).
|
||||||
|
INCOMING_HOST=C:/Users/Administrator/Downloads/20260719
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# 20260719 tak created gitignore
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# backend
|
||||||
|
/backend/.venv
|
||||||
|
/backend/__pycache__
|
||||||
|
/backend/**/__pycache__
|
||||||
|
/backend/logs
|
||||||
|
/backend/uploads/*
|
||||||
|
!/backend/uploads/.gitkeep
|
||||||
|
/backend/_sample
|
||||||
|
/backend/.cache
|
||||||
|
/backend/.idea
|
||||||
|
|
||||||
|
# frontend
|
||||||
|
/frontend/node_modules
|
||||||
|
/frontend/dist
|
||||||
|
/frontend/.idea
|
||||||
|
|
||||||
|
# postgres
|
||||||
|
/postgres
|
||||||
|
|
||||||
|
# large mailbox drops (optional local folder)
|
||||||
|
/incoming
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# DATABASE_URL is injected by compose (points at the mail_summary postgres service).
|
||||||
|
|
||||||
|
CORS_ORIGINS=https://ms.takits.me,http://localhost:4888,http://localhost:5173
|
||||||
|
|
||||||
|
# Local LLM (Ollama on Mac).
|
||||||
|
LLM_BASE_URL=https://mac-ollama.takits.me
|
||||||
|
LLM_MODEL=gemma4:31b-mlx
|
||||||
|
LLM_TIMEOUT_SECONDS=300
|
||||||
|
LLM_MAX_MAILS=80
|
||||||
|
LLM_BODY_CHARS=3000
|
||||||
|
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
INCOMING_DIR=/app/incoming
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# tzdata lets the TZ env var (Asia/Seoul) resolve so log timestamps follow KST.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
ENV TZ=Asia/Seoul
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads /app/logs
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
"""mail_summary API — local LLM weekly mail work summary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from config import CORS_ORIGINS, INCOMING_DIR, LLM_MODEL, UPLOAD_DIR
|
||||||
|
from db.pool import close_pool, init_pool, pool_enabled
|
||||||
|
from job_repository import create_job, ensure_tables, get_job, get_report, list_jobs
|
||||||
|
from llm_client import ping_llm
|
||||||
|
from pipeline import run_analysis_job
|
||||||
|
|
||||||
|
logger = logging.getLogger("mail_summary")
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
MailboxScope = Literal["inbox", "sent", "both"]
|
||||||
|
Mailbox = Literal["inbox", "sent"]
|
||||||
|
STAGING_DIR = UPLOAD_DIR / "staging"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
STAGING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
await init_pool()
|
||||||
|
await ensure_tables()
|
||||||
|
yield
|
||||||
|
await close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Mail Summary API", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=CORS_ORIGINS or ["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobCreateResponse(BaseModel):
|
||||||
|
job: dict
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
database: str
|
||||||
|
model: str = Field(default="")
|
||||||
|
llm: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResponse(BaseModel):
|
||||||
|
upload_id: str
|
||||||
|
mailbox: str
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
source: str # upload | incoming
|
||||||
|
|
||||||
|
|
||||||
|
def _staging_path(upload_id: str) -> Path:
|
||||||
|
path = (STAGING_DIR / upload_id).resolve()
|
||||||
|
if not str(path).startswith(str(STAGING_DIR.resolve())):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid_upload_id")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_staging_meta(stage_dir: Path, meta: dict) -> None:
|
||||||
|
(stage_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_staging_meta(upload_id: str) -> dict:
|
||||||
|
stage_dir = _staging_path(upload_id)
|
||||||
|
meta_path = stage_dir / "meta.json"
|
||||||
|
if not meta_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="upload_not_found")
|
||||||
|
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_source(src: Path, stage_dir: Path, mailbox: str, filename: str) -> Path:
|
||||||
|
dest = stage_dir / f"{mailbox}_{filename}"
|
||||||
|
if dest.exists() or dest.is_symlink():
|
||||||
|
dest.unlink()
|
||||||
|
try:
|
||||||
|
dest.symlink_to(src)
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _find_incoming_match(filename: str, size_bytes: int) -> Path | None:
|
||||||
|
candidate = INCOMING_DIR / Path(filename).name
|
||||||
|
if not candidate.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if candidate.stat().st_size != size_bytes:
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return candidate.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_staging_to_job(upload_id: str, job_dir: Path, mailbox: str) -> str:
|
||||||
|
"""Copy/link staged zip into a job dir, keeping staging for re-analysis."""
|
||||||
|
meta = _read_staging_meta(upload_id)
|
||||||
|
if meta.get("mailbox") != mailbox:
|
||||||
|
raise HTTPException(status_code=400, detail="upload_mailbox_mismatch")
|
||||||
|
stage_dir = _staging_path(upload_id)
|
||||||
|
filename = meta["filename"]
|
||||||
|
src = stage_dir / f"{mailbox}_{filename}"
|
||||||
|
if not src.exists() and not src.is_symlink():
|
||||||
|
raise HTTPException(status_code=404, detail="upload_file_missing")
|
||||||
|
dest = job_dir / f"{mailbox}_{filename}"
|
||||||
|
if dest.exists() or dest.is_symlink():
|
||||||
|
dest.unlink()
|
||||||
|
# Prefer symlink so multi-GB files are not duplicated.
|
||||||
|
try:
|
||||||
|
target = src.resolve() if src.is_symlink() else src
|
||||||
|
dest.symlink_to(target)
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health", response_model=HealthResponse)
|
||||||
|
async def health() -> HealthResponse:
|
||||||
|
llm = await ping_llm()
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok",
|
||||||
|
database="up" if pool_enabled() else "down",
|
||||||
|
model=LLM_MODEL,
|
||||||
|
llm=llm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/uploads/resolve", response_model=UploadResponse)
|
||||||
|
async def api_resolve_upload(
|
||||||
|
mailbox: Mailbox = Form(...),
|
||||||
|
filename: str = Form(...),
|
||||||
|
size_bytes: int = Form(...),
|
||||||
|
) -> UploadResponse:
|
||||||
|
"""If the selected file already exists on the incoming mount, stage it without re-upload."""
|
||||||
|
match = _find_incoming_match(filename, size_bytes)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="incoming_match_not_found")
|
||||||
|
|
||||||
|
upload_id = uuid.uuid4().hex
|
||||||
|
stage_dir = _staging_path(upload_id)
|
||||||
|
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
_attach_source(match, stage_dir, mailbox, safe_name)
|
||||||
|
meta = {
|
||||||
|
"upload_id": upload_id,
|
||||||
|
"mailbox": mailbox,
|
||||||
|
"filename": safe_name,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"source": "incoming",
|
||||||
|
}
|
||||||
|
_write_staging_meta(stage_dir, meta)
|
||||||
|
return UploadResponse(**meta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/uploads", response_model=UploadResponse)
|
||||||
|
async def api_upload_zip(
|
||||||
|
mailbox: Mailbox = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> UploadResponse:
|
||||||
|
name = (file.filename or "").lower()
|
||||||
|
if not name.endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="file_must_be_zip")
|
||||||
|
|
||||||
|
upload_id = uuid.uuid4().hex
|
||||||
|
stage_dir = _staging_path(upload_id)
|
||||||
|
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = Path(file.filename or f"{mailbox}.zip").name
|
||||||
|
dest = stage_dir / f"{mailbox}_{safe_name}"
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
with dest.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||||
|
logger.exception("Upload failed")
|
||||||
|
raise HTTPException(status_code=500, detail="upload_failed") from None
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"upload_id": upload_id,
|
||||||
|
"mailbox": mailbox,
|
||||||
|
"filename": safe_name,
|
||||||
|
"size_bytes": size,
|
||||||
|
"source": "upload",
|
||||||
|
}
|
||||||
|
_write_staging_meta(stage_dir, meta)
|
||||||
|
return UploadResponse(**meta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/uploads/{upload_id}")
|
||||||
|
async def api_delete_upload(upload_id: str) -> dict:
|
||||||
|
stage_dir = _staging_path(upload_id)
|
||||||
|
if stage_dir.is_dir():
|
||||||
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||||
|
return {"ok": True, "upload_id": upload_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs")
|
||||||
|
async def api_list_jobs(limit: int = 20) -> dict:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise HTTPException(status_code=503, detail="database_unavailable")
|
||||||
|
jobs = await list_jobs(limit=max(1, min(limit, 100)))
|
||||||
|
return {"jobs": jobs}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/{job_id}")
|
||||||
|
async def api_get_job(job_id: int) -> dict:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise HTTPException(status_code=503, detail="database_unavailable")
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
return {"job": job}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/{job_id}/report")
|
||||||
|
async def api_get_report(job_id: int) -> dict:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise HTTPException(status_code=503, detail="database_unavailable")
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
report = await get_report(job_id)
|
||||||
|
return {"job": job, "report": report}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs", response_model=JobCreateResponse)
|
||||||
|
async def api_create_job(
|
||||||
|
date_from: date = Form(...),
|
||||||
|
date_to: date = Form(...),
|
||||||
|
mailbox_scope: MailboxScope = Form("both"),
|
||||||
|
model: str = Form(""),
|
||||||
|
inbox_upload_id: str = Form(""),
|
||||||
|
sent_upload_id: str = Form(""),
|
||||||
|
) -> JobCreateResponse:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise HTTPException(status_code=503, detail="database_unavailable")
|
||||||
|
if date_from > date_to:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid_date_range")
|
||||||
|
|
||||||
|
inbox_upload_id = inbox_upload_id.strip()
|
||||||
|
sent_upload_id = sent_upload_id.strip()
|
||||||
|
|
||||||
|
if mailbox_scope in ("inbox", "both") and not inbox_upload_id:
|
||||||
|
raise HTTPException(status_code=400, detail="inbox_zip_required")
|
||||||
|
if mailbox_scope in ("sent", "both") and not sent_upload_id:
|
||||||
|
raise HTTPException(status_code=400, detail="sent_zip_required")
|
||||||
|
|
||||||
|
selected_model = (model or LLM_MODEL).strip() or LLM_MODEL
|
||||||
|
job_token = uuid.uuid4().hex
|
||||||
|
job_dir = UPLOAD_DIR / job_token
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
inbox_name = (
|
||||||
|
_attach_staging_to_job(inbox_upload_id, job_dir, "inbox") if inbox_upload_id else None
|
||||||
|
)
|
||||||
|
sent_name = (
|
||||||
|
_attach_staging_to_job(sent_upload_id, job_dir, "sent") if sent_upload_id else None
|
||||||
|
)
|
||||||
|
job = await create_job(
|
||||||
|
mailbox_scope=mailbox_scope,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
model=selected_model,
|
||||||
|
inbox_zip_name=inbox_name,
|
||||||
|
sent_zip_name=sent_name,
|
||||||
|
)
|
||||||
|
final_dir = UPLOAD_DIR / str(job["id"])
|
||||||
|
if final_dir.exists():
|
||||||
|
shutil.rmtree(final_dir)
|
||||||
|
job_dir.rename(final_dir)
|
||||||
|
except HTTPException:
|
||||||
|
shutil.rmtree(job_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(job_dir, ignore_errors=True)
|
||||||
|
logger.exception("Failed to create analysis job")
|
||||||
|
raise HTTPException(status_code=500, detail="job_create_failed") from None
|
||||||
|
|
||||||
|
asyncio.create_task(run_analysis_job(job["id"]))
|
||||||
|
return JobCreateResponse(job=job)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""mail_summary backend configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
DEFAULT_DATABASE_URL = "postgresql://mail_summary:strongpassword@localhost:5437/mail_summary"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_backend_env() -> None:
|
||||||
|
"""Load backend/.env. Docker Compose also injects the same file via env_file."""
|
||||||
|
path = BASE_DIR / ".env"
|
||||||
|
if path.is_file():
|
||||||
|
load_dotenv(path)
|
||||||
|
|
||||||
|
|
||||||
|
_load_backend_env()
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL).strip()
|
||||||
|
|
||||||
|
_cors = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:4888")
|
||||||
|
CORS_ORIGINS = [origin.strip() for origin in _cors.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434").rstrip("/")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "gemma4:31b-mlx").strip()
|
||||||
|
LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "300"))
|
||||||
|
LLM_MAX_MAILS = int(os.getenv("LLM_MAX_MAILS", "80"))
|
||||||
|
LLM_BODY_CHARS = int(os.getenv("LLM_BODY_CHARS", "3000"))
|
||||||
|
|
||||||
|
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(BASE_DIR / "uploads"))).resolve()
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
INCOMING_DIR = Path(os.getenv("INCOMING_DIR", str(BASE_DIR.parent / "incoming"))).resolve()
|
||||||
|
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Database helpers."""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""PostgreSQL connection pool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from psycopg_pool import AsyncConnectionPool
|
||||||
|
|
||||||
|
from config import DATABASE_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger("mail_summary")
|
||||||
|
|
||||||
|
_pool: AsyncConnectionPool | None = None
|
||||||
|
_pool_enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
async def init_pool() -> None:
|
||||||
|
global _pool, _pool_enabled
|
||||||
|
if _pool is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pool = AsyncConnectionPool(
|
||||||
|
conninfo=DATABASE_URL,
|
||||||
|
min_size=1,
|
||||||
|
max_size=10,
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
await pool.open()
|
||||||
|
_pool = pool
|
||||||
|
_pool_enabled = True
|
||||||
|
logger.info("PostgreSQL connection pool initialized")
|
||||||
|
except Exception:
|
||||||
|
_pool = None
|
||||||
|
_pool_enabled = False
|
||||||
|
logger.exception("PostgreSQL unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool() -> None:
|
||||||
|
global _pool, _pool_enabled
|
||||||
|
if _pool is None:
|
||||||
|
return
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
_pool_enabled = False
|
||||||
|
logger.info("PostgreSQL connection pool closed")
|
||||||
|
|
||||||
|
|
||||||
|
def pool_enabled() -> bool:
|
||||||
|
return _pool_enabled and _pool is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool() -> AsyncConnectionPool:
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Database pool is not initialized")
|
||||||
|
return _pool
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Persistence for analysis jobs, mails, and weekly reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from db.pool import get_pool, pool_enabled
|
||||||
|
from mail_parser import ParsedMail
|
||||||
|
from queries import Jobs, Mails, Reports, Schema
|
||||||
|
|
||||||
|
logger = logging.getLogger("mail_summary")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_job(row: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"mailbox_scope": row["mailbox_scope"],
|
||||||
|
"date_from": row["date_from"].isoformat() if row["date_from"] else None,
|
||||||
|
"date_to": row["date_to"].isoformat() if row["date_to"] else None,
|
||||||
|
"model": row["model"],
|
||||||
|
"inbox_zip_name": row["inbox_zip_name"],
|
||||||
|
"sent_zip_name": row["sent_zip_name"],
|
||||||
|
"inbox_count": row["inbox_count"],
|
||||||
|
"sent_count": row["sent_count"],
|
||||||
|
"filtered_inbox_count": row["filtered_inbox_count"],
|
||||||
|
"filtered_sent_count": row["filtered_sent_count"],
|
||||||
|
"progress_pct": row.get("progress_pct") or 0,
|
||||||
|
"progress_label": row.get("progress_label") or "",
|
||||||
|
"progress_detail": row.get("progress_detail") or "",
|
||||||
|
"error_message": row["error_message"],
|
||||||
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
|
||||||
|
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_report(row: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"model": row["model"],
|
||||||
|
"scope_note": row["scope_note"],
|
||||||
|
"report": row["report"] or {},
|
||||||
|
"report_markdown": row["report_markdown"],
|
||||||
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_tables() -> None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(Schema.ENSURE_ANALYSIS_JOBS)
|
||||||
|
await cur.execute(Schema.ADD_PROGRESS_PCT)
|
||||||
|
await cur.execute(Schema.ADD_PROGRESS_LABEL)
|
||||||
|
await cur.execute(Schema.ADD_PROGRESS_DETAIL)
|
||||||
|
await cur.execute(Schema.IDX_ANALYSIS_JOBS_CREATED_AT)
|
||||||
|
await cur.execute(Schema.IDX_ANALYSIS_JOBS_STATUS)
|
||||||
|
await cur.execute(Schema.ENSURE_MAILS)
|
||||||
|
await cur.execute(Schema.IDX_MAILS_JOB_ID)
|
||||||
|
await cur.execute(Schema.IDX_MAILS_JOB_MAILED_AT)
|
||||||
|
await cur.execute(Schema.IDX_MAILS_THREAD_KEY)
|
||||||
|
await cur.execute(Schema.IDX_MAILS_MESSAGE_ID)
|
||||||
|
await cur.execute(Schema.ENSURE_WEEKLY_REPORTS)
|
||||||
|
await cur.execute(Schema.IDX_WEEKLY_REPORTS_CREATED_AT)
|
||||||
|
await conn.commit()
|
||||||
|
logger.info("Ensured mail_summary tables")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job(
|
||||||
|
*,
|
||||||
|
mailbox_scope: str,
|
||||||
|
date_from: date,
|
||||||
|
date_to: date,
|
||||||
|
model: str,
|
||||||
|
inbox_zip_name: str | None,
|
||||||
|
sent_zip_name: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise RuntimeError("database_unavailable")
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
Jobs.INSERT,
|
||||||
|
{
|
||||||
|
"mailbox_scope": mailbox_scope,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to,
|
||||||
|
"model": model,
|
||||||
|
"inbox_zip_name": inbox_zip_name,
|
||||||
|
"sent_zip_name": sent_zip_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return _row_to_job(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_job(job_id: int) -> dict[str, Any] | None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return None
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(Jobs.GET_BY_ID, {"job_id": job_id})
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return _row_to_job(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_jobs(limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
if not pool_enabled():
|
||||||
|
return []
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(Jobs.LIST_RECENT, {"limit": limit})
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
return [_row_to_job(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_job(
|
||||||
|
job_id: int,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
inbox_count: int | None = None,
|
||||||
|
sent_count: int | None = None,
|
||||||
|
filtered_inbox_count: int | None = None,
|
||||||
|
filtered_sent_count: int | None = None,
|
||||||
|
progress_pct: int | None = None,
|
||||||
|
progress_label: str | None = None,
|
||||||
|
progress_detail: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
started: bool = False,
|
||||||
|
completed: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return None
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
Jobs.UPDATE,
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": status,
|
||||||
|
"inbox_count": inbox_count,
|
||||||
|
"sent_count": sent_count,
|
||||||
|
"filtered_inbox_count": filtered_inbox_count,
|
||||||
|
"filtered_sent_count": filtered_sent_count,
|
||||||
|
"progress_pct": progress_pct,
|
||||||
|
"progress_label": progress_label,
|
||||||
|
"progress_detail": progress_detail,
|
||||||
|
"error_message": error_message,
|
||||||
|
"set_started": started,
|
||||||
|
"set_completed": completed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return _row_to_job(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_mails(job_id: int, mails: list[ParsedMail]) -> int:
|
||||||
|
if not pool_enabled() or not mails:
|
||||||
|
return 0
|
||||||
|
pool = get_pool()
|
||||||
|
count = 0
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
for mail in mails:
|
||||||
|
await cur.execute(
|
||||||
|
Mails.INSERT,
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"mailbox": mail.mailbox,
|
||||||
|
"message_id": mail.message_id,
|
||||||
|
"thread_key": mail.thread_key,
|
||||||
|
"subject": mail.subject,
|
||||||
|
"from_addr": mail.from_addr,
|
||||||
|
"to_addrs": mail.to_addrs,
|
||||||
|
"cc_addrs": mail.cc_addrs,
|
||||||
|
"mailed_at": mail.mailed_at,
|
||||||
|
"attachment_names": Jsonb(mail.attachment_names),
|
||||||
|
"body_text": mail.body_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
await conn.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def list_mails_for_job(job_id: int) -> list[dict[str, Any]]:
|
||||||
|
if not pool_enabled():
|
||||||
|
return []
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(Mails.LIST_BY_JOB, {"job_id": job_id})
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"mailbox": row["mailbox"],
|
||||||
|
"message_id": row["message_id"],
|
||||||
|
"thread_key": row["thread_key"],
|
||||||
|
"subject": row["subject"],
|
||||||
|
"from_addr": row["from_addr"],
|
||||||
|
"to_addrs": row["to_addrs"],
|
||||||
|
"cc_addrs": row["cc_addrs"],
|
||||||
|
"mailed_at": row["mailed_at"].isoformat() if row["mailed_at"] else None,
|
||||||
|
"attachment_names": row["attachment_names"] or [],
|
||||||
|
"body_text": row["body_text"],
|
||||||
|
"body_purged": row["body_purged"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mail_summary(mail_id: int, summary: dict[str, Any]) -> None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
Mails.UPDATE_SUMMARY,
|
||||||
|
{
|
||||||
|
"mail_id": mail_id,
|
||||||
|
"summary": Jsonb(summary),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_mail_bodies(job_id: int) -> None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(Mails.PURGE_BODIES, {"job_id": job_id})
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_report(job_id: int) -> dict[str, Any] | None:
|
||||||
|
if not pool_enabled():
|
||||||
|
return None
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(Reports.GET_BY_JOB, {"job_id": job_id})
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return _row_to_report(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def save_weekly_report(
|
||||||
|
*,
|
||||||
|
job_id: int,
|
||||||
|
model: str,
|
||||||
|
scope_note: str,
|
||||||
|
report: dict[str, Any],
|
||||||
|
report_markdown: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not pool_enabled():
|
||||||
|
raise RuntimeError("database_unavailable")
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.connection() as conn:
|
||||||
|
async with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
Reports.UPSERT,
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"model": model,
|
||||||
|
"scope_note": scope_note,
|
||||||
|
"report": Jsonb(report),
|
||||||
|
"report_markdown": report_markdown,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
await conn.commit()
|
||||||
|
return _row_to_report(row)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Local Ollama client for mail analysis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
logger = logging.getLogger("mail_summary")
|
||||||
|
|
||||||
|
_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_object(text: str) -> dict[str, Any]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise LlmError("empty_llm_response")
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
match = _JSON_BLOCK_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
raise LlmError("llm_response_not_json")
|
||||||
|
data = json.loads(match.group(0))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise LlmError("llm_response_not_object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_json(
|
||||||
|
*,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
model: str | None = None,
|
||||||
|
temperature: float = 0.2,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
selected = (model or LLM_MODEL).strip() or LLM_MODEL
|
||||||
|
payload = {
|
||||||
|
"model": selected,
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {"temperature": temperature},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
url = f"{LLM_BASE_URL}/api/chat"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SECONDS) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.exception("Ollama request failed")
|
||||||
|
raise LlmError(f"ollama_request_failed: {exc}") from exc
|
||||||
|
|
||||||
|
content = (
|
||||||
|
(body.get("message") or {}).get("content")
|
||||||
|
or body.get("response")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
return extract_json_object(content)
|
||||||
|
|
||||||
|
|
||||||
|
async def ping_llm(model: str | None = None) -> dict[str, Any]:
|
||||||
|
selected = (model or LLM_MODEL).strip() or LLM_MODEL
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
tags = await client.get(f"{LLM_BASE_URL}/api/tags")
|
||||||
|
tags.raise_for_status()
|
||||||
|
data = tags.json()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"ok": False, "error": str(exc), "model": selected}
|
||||||
|
names = [m.get("name", "") for m in data.get("models", [])]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"model": selected,
|
||||||
|
"model_present": any(selected in name or name in selected for name in names),
|
||||||
|
"models": names[:20],
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""Zip(.eml) extraction, parsing, cleaning, and date filtering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.policy
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from email.header import decode_header, make_header
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
|
||||||
|
_SUBJECT_PREFIX_RE = re.compile(
|
||||||
|
r"^\s*((re|fw|fwd|답장|전달|회신)\s*[::]|\[?\s*(re|fw|fwd)\s*\]?\s*:)\s*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_QUOTE_MARKERS = (
|
||||||
|
"-----original message-----",
|
||||||
|
"----- 원본 메시지 -----",
|
||||||
|
"-----원본 메시지-----",
|
||||||
|
"________________________________",
|
||||||
|
"begin forwarded message",
|
||||||
|
"보낸 사람:",
|
||||||
|
"보내는 사람:",
|
||||||
|
"보내는사람:",
|
||||||
|
"보내는사람 :",
|
||||||
|
"from:",
|
||||||
|
)
|
||||||
|
_SIG_MARKERS = (
|
||||||
|
"\n-- \n",
|
||||||
|
"\n--\n",
|
||||||
|
"\n감사합니다.\n",
|
||||||
|
"\nthanks,\n",
|
||||||
|
"\nbest regards,\n",
|
||||||
|
"\ncustomer centric agility\n",
|
||||||
|
"\ntel :",
|
||||||
|
"\ntel:",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedMail:
|
||||||
|
mailbox: str
|
||||||
|
message_id: str
|
||||||
|
thread_key: str
|
||||||
|
subject: str
|
||||||
|
from_addr: str
|
||||||
|
to_addrs: str
|
||||||
|
cc_addrs: str
|
||||||
|
mailed_at: datetime | None
|
||||||
|
attachment_names: list[str] = field(default_factory=list)
|
||||||
|
body_text: str = ""
|
||||||
|
source_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class _HTMLTextExtractor(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._parts: list[str] = []
|
||||||
|
self._skip = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
|
||||||
|
if tag in {"script", "style"}:
|
||||||
|
self._skip = True
|
||||||
|
elif tag in {"br", "p", "div", "tr", "li"}:
|
||||||
|
self._parts.append("\n")
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
if tag in {"script", "style"}:
|
||||||
|
self._skip = False
|
||||||
|
elif tag in {"p", "div", "tr", "li"}:
|
||||||
|
self._parts.append("\n")
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if not self._skip:
|
||||||
|
self._parts.append(data)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return "".join(self._parts)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_mime_header(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(make_header(decode_header(value))).strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_thread_key(subject: str) -> str:
|
||||||
|
text = decode_mime_header(subject)
|
||||||
|
prev = None
|
||||||
|
while prev != text:
|
||||||
|
prev = text
|
||||||
|
text = _SUBJECT_PREFIX_RE.sub("", text).strip()
|
||||||
|
text = re.sub(r"\s+", " ", text).strip().lower()
|
||||||
|
return text or "(no-subject)"
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_text(html: str) -> str:
|
||||||
|
parser = _HTMLTextExtractor()
|
||||||
|
try:
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return re.sub(r"<[^>]+>", " ", html)
|
||||||
|
return parser.text()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_body(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lower = normalized.lower()
|
||||||
|
cut = len(normalized)
|
||||||
|
for marker in _QUOTE_MARKERS:
|
||||||
|
idx = lower.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
cut = min(cut, idx)
|
||||||
|
body = normalized[:cut]
|
||||||
|
for marker in _SIG_MARKERS:
|
||||||
|
idx = body.lower().find(marker.lower())
|
||||||
|
if idx != -1 and idx > 40:
|
||||||
|
body = body[:idx]
|
||||||
|
break
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for line in body.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(">"):
|
||||||
|
continue
|
||||||
|
if re.match(
|
||||||
|
r"^(from|sent|to|cc|subject|보내는\s*사람|보내는사람)\s*:",
|
||||||
|
stripped,
|
||||||
|
re.IGNORECASE,
|
||||||
|
):
|
||||||
|
# Likely start of an inline quoted header block.
|
||||||
|
break
|
||||||
|
if re.match(r"^customer centric agility$", stripped, re.IGNORECASE):
|
||||||
|
break
|
||||||
|
if re.match(r"^tel\s*:", stripped, re.IGNORECASE):
|
||||||
|
break
|
||||||
|
lines.append(line.rstrip())
|
||||||
|
|
||||||
|
cleaned = "\n".join(lines)
|
||||||
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses(msg: EmailMessage, header: str) -> str:
|
||||||
|
raw = msg.get_all(header, [])
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
values = [decode_mime_header(str(item)) for item in raw]
|
||||||
|
return ", ".join(v for v in values if v)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(msg: EmailMessage) -> datetime | None:
|
||||||
|
raw = msg.get("Date")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(raw)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(KST)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_body(msg: EmailMessage) -> str:
|
||||||
|
text_part = ""
|
||||||
|
html_part = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ctype = part.get_content_type()
|
||||||
|
disp = str(part.get("Content-Disposition") or "")
|
||||||
|
if "attachment" in disp.lower():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = part.get_content()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, str):
|
||||||
|
continue
|
||||||
|
if ctype == "text/plain" and not text_part:
|
||||||
|
text_part = payload
|
||||||
|
elif ctype == "text/html" and not html_part:
|
||||||
|
html_part = payload
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
payload = msg.get_content()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
payload = ""
|
||||||
|
if isinstance(payload, str):
|
||||||
|
if msg.get_content_type() == "text/html":
|
||||||
|
html_part = payload
|
||||||
|
else:
|
||||||
|
text_part = payload
|
||||||
|
|
||||||
|
raw = text_part if text_part.strip() else html_to_text(html_part)
|
||||||
|
return clean_body(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_names(msg: EmailMessage) -> list[str]:
|
||||||
|
names: list[str] = []
|
||||||
|
for part in msg.walk():
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
names.append(decode_mime_header(filename))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def parse_eml_bytes(data: bytes, mailbox: str, source_name: str) -> ParsedMail | None:
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(data, policy=email.policy.default)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
subject = decode_mime_header(msg.get("Subject"))
|
||||||
|
message_id = decode_mime_header(msg.get("Message-ID")) or Path(source_name).name
|
||||||
|
return ParsedMail(
|
||||||
|
mailbox=mailbox,
|
||||||
|
message_id=message_id,
|
||||||
|
thread_key=normalize_thread_key(subject),
|
||||||
|
subject=subject,
|
||||||
|
from_addr=_addresses(msg, "From"),
|
||||||
|
to_addrs=_addresses(msg, "To"),
|
||||||
|
cc_addrs=_addresses(msg, "Cc"),
|
||||||
|
mailed_at=_parse_date(msg),
|
||||||
|
attachment_names=_attachment_names(msg),
|
||||||
|
body_text=_extract_body(msg),
|
||||||
|
source_path=source_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_eml_file(path: Path, mailbox: str) -> ParsedMail | None:
|
||||||
|
try:
|
||||||
|
return parse_eml_bytes(path.read_bytes(), mailbox, str(path))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_zip(job_dir: Path, prefix: str) -> Path | None:
|
||||||
|
matches = sorted(job_dir.glob(f"{prefix}_*.zip"))
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
|
||||||
|
def iter_eml_members(zip_path: Path) -> list[zipfile.ZipInfo]:
|
||||||
|
members: list[zipfile.ZipInfo] = []
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = info.filename.replace("\\", "/")
|
||||||
|
if name.startswith("/") or ".." in Path(name).parts:
|
||||||
|
continue
|
||||||
|
if not name.lower().endswith(".eml"):
|
||||||
|
continue
|
||||||
|
members.append(info)
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def load_mailbox_from_zip(
|
||||||
|
zip_path: Path,
|
||||||
|
mailbox: str,
|
||||||
|
*,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
on_progress: Callable[[int, int, int], None] | None = None,
|
||||||
|
) -> tuple[list[ParsedMail], int]:
|
||||||
|
"""Stream-parse .eml entries inside zip. Optionally keep only mails in date range.
|
||||||
|
|
||||||
|
Returns (mails, total_eml_count). Avoids extracting multi-GB archives to disk.
|
||||||
|
on_progress(scanned, total, matched) is called periodically.
|
||||||
|
"""
|
||||||
|
members = iter_eml_members(zip_path)
|
||||||
|
total = len(members)
|
||||||
|
mails: list[ParsedMail] = []
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
if on_progress:
|
||||||
|
on_progress(0, total, 0)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for info in members:
|
||||||
|
scanned += 1
|
||||||
|
name = info.filename.replace("\\", "/")
|
||||||
|
try:
|
||||||
|
with zf.open(info) as src:
|
||||||
|
data = src.read()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
data = b""
|
||||||
|
if data:
|
||||||
|
parsed = parse_eml_bytes(data, mailbox, name)
|
||||||
|
if parsed:
|
||||||
|
keep = True
|
||||||
|
if date_from and date_to:
|
||||||
|
if parsed.mailed_at is None:
|
||||||
|
keep = False
|
||||||
|
else:
|
||||||
|
day = parsed.mailed_at.astimezone(KST).date()
|
||||||
|
keep = date_from <= day <= date_to
|
||||||
|
if keep:
|
||||||
|
mails.append(parsed)
|
||||||
|
|
||||||
|
if on_progress and (scanned == total or scanned % 25 == 0):
|
||||||
|
on_progress(scanned, total, len(mails))
|
||||||
|
|
||||||
|
mails.sort(key=lambda m: m.mailed_at or datetime.min.replace(tzinfo=KST))
|
||||||
|
if on_progress:
|
||||||
|
on_progress(total, total, len(mails))
|
||||||
|
return mails, total
|
||||||
|
|
||||||
|
def filter_by_date(mails: list[ParsedMail], date_from: date, date_to: date) -> list[ParsedMail]:
|
||||||
|
filtered: list[ParsedMail] = []
|
||||||
|
for mail in mails:
|
||||||
|
if mail.mailed_at is None:
|
||||||
|
continue
|
||||||
|
local_day = mail.mailed_at.astimezone(KST).date()
|
||||||
|
if date_from <= local_day <= date_to:
|
||||||
|
filtered.append(mail)
|
||||||
|
filtered.sort(key=lambda m: m.mailed_at or datetime.min.replace(tzinfo=KST))
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_thread(mails: list[ParsedMail]) -> dict[str, list[ParsedMail]]:
|
||||||
|
groups: dict[str, list[ParsedMail]] = {}
|
||||||
|
for mail in mails:
|
||||||
|
groups.setdefault(mail.thread_key, []).append(mail)
|
||||||
|
for key in groups:
|
||||||
|
groups[key].sort(key=lambda m: m.mailed_at or datetime.min.replace(tzinfo=KST))
|
||||||
|
return groups
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
"""End-to-end analysis pipeline: extract → filter → summarize → weekly report."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import LLM_BODY_CHARS, LLM_MAX_MAILS, LLM_MODEL, UPLOAD_DIR
|
||||||
|
from job_repository import (
|
||||||
|
get_job,
|
||||||
|
insert_mails,
|
||||||
|
list_mails_for_job,
|
||||||
|
purge_mail_bodies,
|
||||||
|
save_weekly_report,
|
||||||
|
update_job,
|
||||||
|
update_mail_summary,
|
||||||
|
)
|
||||||
|
from llm_client import LlmError, chat_json
|
||||||
|
from mail_parser import (
|
||||||
|
ParsedMail,
|
||||||
|
find_zip,
|
||||||
|
load_mailbox_from_zip,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("mail_summary")
|
||||||
|
|
||||||
|
THREAD_SYSTEM = """당신은 사내 메일 업무 분석기다.
|
||||||
|
제목이 아니라 본문(Body)을 기준으로 JSON만 출력한다.
|
||||||
|
외부 지식·제목만으로 추측하지 않는다. 본문에 없는 일을 만들지 않는다.
|
||||||
|
|
||||||
|
customer는 은행/고객/거래처 단위 이름이다. 예: 경남은행, 신한은행, 농협, IBK기업은행, 한화손보, 우체국.
|
||||||
|
사내 공지·인사·회계·연구기획 안내는 customer를 "사내"로 둔다.
|
||||||
|
광고·가입·프로모션 메일은 work_relevant=false.
|
||||||
|
|
||||||
|
메일 종류(Kind):
|
||||||
|
- reply(Re/답장): 이번 주 대화·업무로 본다. 본문 신규 내용만 요약.
|
||||||
|
- forward(Fw/전달): 과거 메일을 넘긴 것이다.
|
||||||
|
* 전달된 원본에 적힌 옛 업무(수년 전 견적/구축 등)를 금주 실적으로 쓰지 말 것.
|
||||||
|
* 이번에 한 일이 보이면 그것만. 예: "계약서 법무 검토 요청", "자료 전달".
|
||||||
|
* 단순 전달·참고용이고 신규 행동이 없으면 work_relevant=false.
|
||||||
|
- normal: 본문 기준 일반 요약.
|
||||||
|
|
||||||
|
스키마:
|
||||||
|
{
|
||||||
|
"customer": "경남은행",
|
||||||
|
"title": "짧은 업무 제목",
|
||||||
|
"summary": "한 문장 요약(본문 근거)",
|
||||||
|
"work_items": ["핵심 업무 1줄 (최대 2개, 각 40자 이내)"],
|
||||||
|
"work_relevant": true,
|
||||||
|
"mail_kind": "normal|reply|forward",
|
||||||
|
"horizon": "this_week|next_week|both|unknown",
|
||||||
|
"deadlines": ["날짜 메모"],
|
||||||
|
"people": ["관련자"]
|
||||||
|
}
|
||||||
|
|
||||||
|
horizon 기준:
|
||||||
|
- this_week: 금주 기간에 수행/완료/진행된 일
|
||||||
|
- next_week: 차주 예정·계획·배포 예정
|
||||||
|
- both: 금주 실적과 차주 계획이 함께 있음
|
||||||
|
- unknown: 판단 불가
|
||||||
|
"""
|
||||||
|
|
||||||
|
WEEKLY_SYSTEM = """당신은 사내 주간 업무보고 작성기다.
|
||||||
|
엑셀 주간보고에 붙일 수 있게, 짧고 요약된 JSON만 출력한다.
|
||||||
|
사람 이름 칸은 만들지 않는다. 금주/차주만 만든다.
|
||||||
|
|
||||||
|
작성 규칙:
|
||||||
|
1) 그룹 단위는 고객/은행명이다. 예: 경남은행, 농협, IBK기업은행. 사내 공지는 "사내".
|
||||||
|
2) items는 그 고객에서 이번 주(또는 차주)에 실제로 한 일/할 일만. 제목·옛 FW 원본으로 추측 금지.
|
||||||
|
3) FW/전달 요약이 "과거 견적 제출·구축"처럼 보이면 제외한다. 이번에 "검토 요청/자료 전달"한 경우만 그 행동으로 짧게 남긴다.
|
||||||
|
4) Re/답장은 본문 기준 정상 포함.
|
||||||
|
5) 불릿 1개=한 줄(되도록 40자), 고객당 최대 3개. 중복 제거.
|
||||||
|
6) 광고·뉴스레터 제외. 장황한 규정 문구 제외.
|
||||||
|
7) 금주 → this_week, 차주 → next_week.
|
||||||
|
|
||||||
|
좋은 예:
|
||||||
|
{"customer":"농협","items":["상호 통합UI WAS 분석 자료 송부 (7/16)"]}
|
||||||
|
{"customer":"동아피엠","items":["물품공급 계약서 법무 검토 요청"]}
|
||||||
|
|
||||||
|
나쁜 예:
|
||||||
|
- FW로 넘긴 몇 년 전 견적/구축을 금주 실적으로 적기
|
||||||
|
- 제목만 보고 "견적서 제출"이라고 쓰기
|
||||||
|
|
||||||
|
스키마:
|
||||||
|
{
|
||||||
|
"this_week": [
|
||||||
|
{"customer": "경남은행", "items": ["후면 인자 개발 산출물 작성", "산막공단·초장동 설치 (7/14)"]}
|
||||||
|
],
|
||||||
|
"next_week": [
|
||||||
|
{"customer": "IBK기업은행", "items": ["녹취 동의 가이드 영상 적용 예정 (7/24)"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def scope_note(mailbox_scope: str, inbox_count: int, sent_count: int) -> str:
|
||||||
|
if mailbox_scope == "inbox" or (inbox_count > 0 and sent_count == 0):
|
||||||
|
return (
|
||||||
|
"받은 메일함만 분석되었습니다. 보낸 메일이 포함되지 않아 "
|
||||||
|
"업무 처리 여부와 전체 대화 흐름이 일부 누락될 수 있습니다."
|
||||||
|
)
|
||||||
|
if mailbox_scope == "sent" or (sent_count > 0 and inbox_count == 0):
|
||||||
|
return (
|
||||||
|
"보낸 메일함만 분석되었습니다. 최초 요청 내용과 상대방의 회신이 "
|
||||||
|
"포함되지 않아 일부 업무 배경이 누락될 수 있습니다."
|
||||||
|
)
|
||||||
|
return "받은 메일함과 보낸 메일함을 함께 분석했습니다."
|
||||||
|
|
||||||
|
|
||||||
|
def _trim(text: str, limit: int = LLM_BODY_CHARS) -> str:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 20] + "\n...[truncated]"
|
||||||
|
|
||||||
|
|
||||||
|
# Promotional / SaaS noise — never belongs in a weekly work report.
|
||||||
|
_NOISE_PATTERNS = (
|
||||||
|
re.compile(r"sign\s*up\s*for\s*cursor", re.I),
|
||||||
|
re.compile(r"you've been invited to cursor", re.I),
|
||||||
|
re.compile(r"connect your repos to cursor", re.I),
|
||||||
|
re.compile(r"canceled subscription", re.I),
|
||||||
|
re.compile(r"\bcursor\.com\b", re.I),
|
||||||
|
re.compile(r"\bcursor\.sh\b", re.I),
|
||||||
|
re.compile(r"@cursor\.", re.I),
|
||||||
|
re.compile(r"직장에서\s*copilot", re.I),
|
||||||
|
re.compile(r"copilot을\s*더\s*잘", re.I),
|
||||||
|
re.compile(r"newsletter", re.I),
|
||||||
|
re.compile(r"verify your email", re.I),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_FORWARD_SUBJECT_RE = re.compile(
|
||||||
|
r"^\s*((fw|fwd|전달)\s*[::]|\[?\s*(fw|fwd|전달)\s*\]?\s*:)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_REPLY_SUBJECT_RE = re.compile(
|
||||||
|
r"^\s*((re|답장|회신)\s*[::]|\[?\s*(re|답장|회신)\s*\]?\s*:)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Subject alone suggests a real this-week action (keep FW for LLM).
|
||||||
|
_FORWARD_ACTION_RE = re.compile(
|
||||||
|
r"(검토\s*요청|회신\s*요청|확인\s*요청|승인\s*요청|협조\s*요청)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_kind(subject: str) -> str:
|
||||||
|
text = subject or ""
|
||||||
|
if _FORWARD_SUBJECT_RE.match(text):
|
||||||
|
return "forward"
|
||||||
|
if _REPLY_SUBJECT_RE.match(text):
|
||||||
|
return "reply"
|
||||||
|
return "normal"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise_mail(mail: ParsedMail) -> bool:
|
||||||
|
blob = f"{mail.subject or ''} {mail.from_addr or ''}"
|
||||||
|
return any(pat.search(blob) for pat in _NOISE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_new_body_len(body: str) -> int:
|
||||||
|
"""Length of newly written text only (signature / quoted original stripped)."""
|
||||||
|
text = (body or "").replace("\u200b", "").replace(" ", " ")
|
||||||
|
text = text.replace("\xa0", " ")
|
||||||
|
lower = text.lower()
|
||||||
|
cut = len(text)
|
||||||
|
for marker in (
|
||||||
|
"보내는사람",
|
||||||
|
"보내는 사람",
|
||||||
|
"보낸 사람",
|
||||||
|
"-----original",
|
||||||
|
"begin forwarded",
|
||||||
|
"-----원본",
|
||||||
|
"from:",
|
||||||
|
):
|
||||||
|
idx = lower.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
cut = min(cut, idx)
|
||||||
|
text = text[:cut]
|
||||||
|
lines: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if re.match(r"^customer centric agility$", stripped, re.I):
|
||||||
|
break
|
||||||
|
if re.match(r"^-{5,}", stripped):
|
||||||
|
break
|
||||||
|
if re.match(r"^(tel|fax|mobile|e-?mail)\s*:", stripped, re.I):
|
||||||
|
break
|
||||||
|
# Greeting / name-only lines are not weekly work.
|
||||||
|
if re.fullmatch(r"안녕하세요[.]?", stripped):
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r".*(연구원|책임|수석|팀장)입니다[.]?", stripped):
|
||||||
|
continue
|
||||||
|
lines.append(stripped)
|
||||||
|
return len(" ".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_forward_passthrough(mail: ParsedMail) -> bool:
|
||||||
|
"""FW/전달 with almost no new body — old mail shared as-is, not this week's work."""
|
||||||
|
if _mail_kind(mail.subject) != "forward":
|
||||||
|
return False
|
||||||
|
# Keep only when subject itself is a clear this-week request action.
|
||||||
|
if _FORWARD_ACTION_RE.search(mail.subject or ""):
|
||||||
|
return False
|
||||||
|
return _forward_new_body_len(mail.body_text or "") < 40
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise_thread(thread: list[ParsedMail]) -> bool:
|
||||||
|
if any(_is_noise_mail(mail) for mail in thread):
|
||||||
|
return True
|
||||||
|
# Drop threads that are only empty/short forwards of old mail.
|
||||||
|
return bool(thread) and all(_is_forward_passthrough(mail) for mail in thread)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise_summary(item: dict[str, Any]) -> bool:
|
||||||
|
if item.get("work_relevant") is False:
|
||||||
|
return True
|
||||||
|
blob = " ".join(
|
||||||
|
str(item.get(key) or "")
|
||||||
|
for key in ("customer", "title", "summary")
|
||||||
|
)
|
||||||
|
items = item.get("work_items") or item.get("items") or []
|
||||||
|
if isinstance(items, list):
|
||||||
|
blob = f"{blob} {' '.join(str(x) for x in items)}"
|
||||||
|
return any(pat.search(blob) for pat in _NOISE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_blob(mail: ParsedMail) -> str:
|
||||||
|
mailed = mail.mailed_at.isoformat() if mail.mailed_at else ""
|
||||||
|
attachments = ", ".join(mail.attachment_names) if mail.attachment_names else "(없음)"
|
||||||
|
kind = _mail_kind(mail.subject)
|
||||||
|
body = _trim(mail.body_text)
|
||||||
|
kind_note = {
|
||||||
|
"forward": (
|
||||||
|
"전달(FW) 메일이다. Body는 이번에 새로 쓴 부분만이다. "
|
||||||
|
"원본에 있던 과거 견적/구축 등을 금주 실적으로 쓰지 말고, "
|
||||||
|
"신규 행동(검토 요청·자료 전달 등)만 요약하라. "
|
||||||
|
"신규 행동이 없으면 work_relevant=false."
|
||||||
|
),
|
||||||
|
"reply": "답장(Re) 메일이다. Body 신규 대화 내용만 요약하라.",
|
||||||
|
"normal": "일반 메일이다. Body를 반드시 읽고 요약하라. 제목만으로 업무를 만들지 마라.",
|
||||||
|
}[kind]
|
||||||
|
if not body.strip():
|
||||||
|
body = "(신규 본문 없음 — 제목만으로 업무를 추측하지 말 것)"
|
||||||
|
return (
|
||||||
|
f"[{mail.mailbox}] {mailed}\n"
|
||||||
|
f"Kind: {kind}\n"
|
||||||
|
f"Note: {kind_note}\n"
|
||||||
|
f"From: {mail.from_addr}\n"
|
||||||
|
f"To: {mail.to_addrs}\n"
|
||||||
|
f"Subject: {mail.subject}\n"
|
||||||
|
f"Attachments: {attachments}\n"
|
||||||
|
f"Body:\n{body}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_thread_summary(thread: list[ParsedMail]) -> dict[str, Any]:
|
||||||
|
latest = thread[-1]
|
||||||
|
people = sorted(
|
||||||
|
{
|
||||||
|
p.strip()
|
||||||
|
for mail in thread
|
||||||
|
for p in (mail.from_addr, *mail.to_addrs.split(","))
|
||||||
|
if p and p.strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
title = latest.subject or thread[0].thread_key
|
||||||
|
summary = _trim(latest.body_text, 280) or "(본문 없음)"
|
||||||
|
return {
|
||||||
|
"customer": "기타",
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"work_items": [title] if title else [],
|
||||||
|
"work_relevant": True,
|
||||||
|
"horizon": "unknown",
|
||||||
|
"deadlines": [],
|
||||||
|
"people": people[:8],
|
||||||
|
"heuristic": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_ITEMS_PER_CUSTOMER = 3
|
||||||
|
_MAX_ITEM_CHARS = 48
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_item(text: str) -> str:
|
||||||
|
text = " ".join(str(text).split())
|
||||||
|
if len(text) <= _MAX_ITEM_CHARS:
|
||||||
|
return text
|
||||||
|
return text[: _MAX_ITEM_CHARS - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_side(rows: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
merged: dict[str, list[str]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
customer = str(row.get("customer") or row.get("project") or "기타").strip() or "기타"
|
||||||
|
raw_items = row.get("items") or row.get("bullets") or row.get("work_items") or []
|
||||||
|
if isinstance(raw_items, str):
|
||||||
|
raw_items = [raw_items]
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raw_items = []
|
||||||
|
items = [str(x).strip() for x in raw_items if str(x).strip()]
|
||||||
|
# Legacy fallbacks from older schema
|
||||||
|
if not items:
|
||||||
|
detail = row.get("detail") or row.get("summary") or row.get("title")
|
||||||
|
if detail:
|
||||||
|
items = [str(detail).strip()]
|
||||||
|
if customer not in merged:
|
||||||
|
merged[customer] = []
|
||||||
|
order.append(customer)
|
||||||
|
for item in items:
|
||||||
|
compact = _compact_item(item)
|
||||||
|
if compact and compact not in merged[customer]:
|
||||||
|
merged[customer].append(compact)
|
||||||
|
if len(merged[customer]) >= _MAX_ITEMS_PER_CUSTOMER:
|
||||||
|
break
|
||||||
|
return [
|
||||||
|
{"customer": name, "items": merged[name][:_MAX_ITEMS_PER_CUSTOMER]}
|
||||||
|
for name in order
|
||||||
|
if merged[name]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_weekly_report(report: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
this_week = [
|
||||||
|
row for row in _normalize_side(report.get("this_week")) if not _is_noise_summary(row)
|
||||||
|
]
|
||||||
|
next_week = [
|
||||||
|
row for row in _normalize_side(report.get("next_week")) if not _is_noise_summary(row)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"this_week": this_week,
|
||||||
|
"next_week": next_week,
|
||||||
|
"heuristic": bool(report.get("heuristic")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_weekly(thread_summaries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
this_week: list[dict[str, Any]] = []
|
||||||
|
next_week: list[dict[str, Any]] = []
|
||||||
|
for item in thread_summaries:
|
||||||
|
if _is_noise_summary(item):
|
||||||
|
continue
|
||||||
|
customer = str(item.get("customer") or "기타").strip() or "기타"
|
||||||
|
work_items = item.get("work_items") or []
|
||||||
|
if not isinstance(work_items, list) or not work_items:
|
||||||
|
title = item.get("title") or item.get("summary")
|
||||||
|
work_items = [title] if title else []
|
||||||
|
work_items = [str(x).strip() for x in work_items if str(x).strip()][:2]
|
||||||
|
if not work_items:
|
||||||
|
continue
|
||||||
|
row = {"customer": customer, "items": work_items}
|
||||||
|
horizon = str(item.get("horizon") or "unknown")
|
||||||
|
if horizon in ("next_week",):
|
||||||
|
next_week.append(row)
|
||||||
|
elif horizon == "both":
|
||||||
|
this_week.append(row)
|
||||||
|
next_week.append(row)
|
||||||
|
else:
|
||||||
|
this_week.append(row)
|
||||||
|
return normalize_weekly_report(
|
||||||
|
{"this_week": this_week, "next_week": next_week, "heuristic": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_side_markdown(rows: list[dict[str, Any]]) -> list[str]:
|
||||||
|
if not rows:
|
||||||
|
return ["(해당 항목 없음)", ""]
|
||||||
|
lines: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
customer = row.get("customer") or "기타"
|
||||||
|
lines.append(str(customer))
|
||||||
|
items = row.get("items") or []
|
||||||
|
if not items:
|
||||||
|
lines.append("- (세부 항목 없음)")
|
||||||
|
else:
|
||||||
|
for item in items:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def report_to_markdown(
|
||||||
|
report: dict[str, Any],
|
||||||
|
*,
|
||||||
|
note: str,
|
||||||
|
inbox: int,
|
||||||
|
sent: int,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
next_from: str,
|
||||||
|
next_to: str,
|
||||||
|
) -> str:
|
||||||
|
_ = (note, inbox, sent)
|
||||||
|
report = normalize_weekly_report(report)
|
||||||
|
lines = [
|
||||||
|
f"금주 ({date_from} ~ {date_to})",
|
||||||
|
"",
|
||||||
|
*_format_side_markdown(report["this_week"]),
|
||||||
|
f"차주 ({next_from} ~ {next_to})",
|
||||||
|
"",
|
||||||
|
*_format_side_markdown(report["next_week"]),
|
||||||
|
]
|
||||||
|
return "\n".join(lines).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarize_thread(
|
||||||
|
thread_key: str,
|
||||||
|
thread: list[ParsedMail],
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
user = (
|
||||||
|
f"스레드 키: {thread_key}\n"
|
||||||
|
f"메일 수: {len(thread)}\n"
|
||||||
|
"지시: 각 메일의 Body를 읽고 요약하라. Subject만으로 업무를 만들지 마라. "
|
||||||
|
"FW/전달의 과거 원본 업무를 금주 실적으로 쓰지 마라.\n\n"
|
||||||
|
+ "\n---\n".join(_mail_blob(m) for m in thread)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await chat_json(system=THREAD_SYSTEM, user=user, model=model)
|
||||||
|
data["heuristic"] = False
|
||||||
|
return data
|
||||||
|
except LlmError:
|
||||||
|
logger.warning("Thread LLM summary failed; using heuristic (%s)", thread_key)
|
||||||
|
return _heuristic_thread_summary(thread)
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarize_week(
|
||||||
|
*,
|
||||||
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
|
next_from: str,
|
||||||
|
next_to: str,
|
||||||
|
thread_summaries: list[dict[str, Any]],
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
compact = []
|
||||||
|
for item in thread_summaries:
|
||||||
|
if _is_noise_summary(item):
|
||||||
|
continue
|
||||||
|
compact.append(
|
||||||
|
{
|
||||||
|
"customer": item.get("customer"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"summary": item.get("summary"),
|
||||||
|
"work_items": item.get("work_items"),
|
||||||
|
"mail_kind": item.get("mail_kind"),
|
||||||
|
"horizon": item.get("horizon"),
|
||||||
|
"deadlines": item.get("deadlines"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
f"금주 기간: {date_from} ~ {date_to}\n"
|
||||||
|
f"차주 기간: {next_from} ~ {next_to}\n"
|
||||||
|
f"스레드 수: {len(compact)}\n"
|
||||||
|
f"스레드 요약 JSON:\n{compact}\n\n"
|
||||||
|
"고객/은행 단위로 금주·차주 JSON을 작성하라. "
|
||||||
|
"짧게 요약하고, 고객당 불릿 최대 3개, 광고성 메일은 제외하라."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await chat_json(system=WEEKLY_SYSTEM, user=user, model=model)
|
||||||
|
data["heuristic"] = False
|
||||||
|
return normalize_weekly_report(data)
|
||||||
|
except LlmError:
|
||||||
|
logger.warning("Weekly LLM summary failed; using heuristic")
|
||||||
|
return _heuristic_weekly(thread_summaries)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_progress(
|
||||||
|
job_id: int,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
pct: int,
|
||||||
|
label: str,
|
||||||
|
detail: str = "",
|
||||||
|
inbox_count: int | None = None,
|
||||||
|
sent_count: int | None = None,
|
||||||
|
filtered_inbox_count: int | None = None,
|
||||||
|
filtered_sent_count: int | None = None,
|
||||||
|
started: bool = False,
|
||||||
|
completed: bool = False,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await update_job(
|
||||||
|
job_id,
|
||||||
|
status=status,
|
||||||
|
progress_pct=max(0, min(100, pct)),
|
||||||
|
progress_label=label,
|
||||||
|
progress_detail=detail,
|
||||||
|
inbox_count=inbox_count,
|
||||||
|
sent_count=sent_count,
|
||||||
|
filtered_inbox_count=filtered_inbox_count,
|
||||||
|
filtered_sent_count=filtered_sent_count,
|
||||||
|
started=started,
|
||||||
|
completed=completed,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_analysis_job(job_id: int) -> None:
|
||||||
|
job = await get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
logger.error("Job %s not found", job_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
job_dir = UPLOAD_DIR / str(job_id)
|
||||||
|
model = job.get("model") or LLM_MODEL
|
||||||
|
date_from = date.fromisoformat(job["date_from"])
|
||||||
|
date_to = date.fromisoformat(job["date_to"])
|
||||||
|
scope = job["mailbox_scope"]
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="extracting",
|
||||||
|
pct=3,
|
||||||
|
label="분석 준비 중",
|
||||||
|
detail="업로드된 zip을 확인하고 있습니다.",
|
||||||
|
started=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _report(pct: int, label: str, detail: str, **counts: int) -> None:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
_set_progress(
|
||||||
|
job_id,
|
||||||
|
status="filtering",
|
||||||
|
pct=pct,
|
||||||
|
label=label,
|
||||||
|
detail=detail,
|
||||||
|
**counts,
|
||||||
|
),
|
||||||
|
loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load() -> tuple[list[ParsedMail], list[ParsedMail], int, int]:
|
||||||
|
inbox: list[ParsedMail] = []
|
||||||
|
sent: list[ParsedMail] = []
|
||||||
|
inbox_total = 0
|
||||||
|
sent_total = 0
|
||||||
|
|
||||||
|
do_inbox = scope in ("inbox", "both")
|
||||||
|
do_sent = scope in ("sent", "both")
|
||||||
|
|
||||||
|
if do_inbox:
|
||||||
|
z = find_zip(job_dir, "inbox")
|
||||||
|
if z:
|
||||||
|
last_push = [0.0]
|
||||||
|
|
||||||
|
def on_inbox(scanned: int, total: int, matched: int) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if scanned != total and now - last_push[0] < 1.2:
|
||||||
|
return
|
||||||
|
last_push[0] = now
|
||||||
|
ratio = (scanned / total) if total else 1
|
||||||
|
pct = 8 + int(ratio * 37)
|
||||||
|
_report(
|
||||||
|
pct,
|
||||||
|
"받은 메일함 스캔 중",
|
||||||
|
f"대용량 zip에서 메일을 읽는 중입니다. 검사 {scanned:,}/{total:,} · 기간 매칭 {matched:,}",
|
||||||
|
inbox_count=total,
|
||||||
|
filtered_inbox_count=matched,
|
||||||
|
)
|
||||||
|
|
||||||
|
_report(8, "받은 메일함 스캔 중", "받은 메일 zip 목록을 준비하는 중입니다.")
|
||||||
|
inbox, inbox_total = load_mailbox_from_zip(
|
||||||
|
z,
|
||||||
|
"inbox",
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
on_progress=on_inbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
if do_sent:
|
||||||
|
z = find_zip(job_dir, "sent")
|
||||||
|
if z:
|
||||||
|
last_push = [0.0]
|
||||||
|
|
||||||
|
def on_sent(scanned: int, total: int, matched: int) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if scanned != total and now - last_push[0] < 1.2:
|
||||||
|
return
|
||||||
|
last_push[0] = now
|
||||||
|
ratio = (scanned / total) if total else 1
|
||||||
|
pct = 48 + int(ratio * 32)
|
||||||
|
_report(
|
||||||
|
pct,
|
||||||
|
"보낸 메일함 스캔 중",
|
||||||
|
f"대용량 zip에서 메일을 읽는 중입니다. 검사 {scanned:,}/{total:,} · 기간 매칭 {matched:,}",
|
||||||
|
sent_count=total,
|
||||||
|
filtered_sent_count=matched,
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
)
|
||||||
|
|
||||||
|
_report(
|
||||||
|
48,
|
||||||
|
"보낸 메일함 스캔 중",
|
||||||
|
"보낸 메일 zip 목록을 준비하는 중입니다.",
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
)
|
||||||
|
sent, sent_total = load_mailbox_from_zip(
|
||||||
|
z,
|
||||||
|
"sent",
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
on_progress=on_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return inbox, sent, inbox_total, sent_total
|
||||||
|
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="filtering",
|
||||||
|
pct=6,
|
||||||
|
label="메일 기간 필터링 시작",
|
||||||
|
detail=f"{job['date_from']} ~ {job['date_to']} 구간의 메일만 추립니다. 수 GB zip은 수 분 걸릴 수 있습니다.",
|
||||||
|
)
|
||||||
|
inbox, sent, inbox_total, sent_total = await asyncio.to_thread(_load)
|
||||||
|
inbox = [
|
||||||
|
m for m in inbox if not _is_noise_mail(m) and not _is_forward_passthrough(m)
|
||||||
|
]
|
||||||
|
sent = [
|
||||||
|
m for m in sent if not _is_noise_mail(m) and not _is_forward_passthrough(m)
|
||||||
|
]
|
||||||
|
selected = inbox + sent
|
||||||
|
if len(selected) > LLM_MAX_MAILS:
|
||||||
|
selected = selected[:LLM_MAX_MAILS]
|
||||||
|
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="analyzing",
|
||||||
|
pct=82,
|
||||||
|
label="분석용 메일 저장 중",
|
||||||
|
detail=f"기간 내 메일 {len(selected):,}건을 저장합니다. (전체 검사 받은 {inbox_total:,} / 보낸 {sent_total:,})",
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
sent_count=sent_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
filtered_sent_count=len(sent),
|
||||||
|
)
|
||||||
|
|
||||||
|
await insert_mails(job_id, selected)
|
||||||
|
stored = await list_mails_for_job(job_id)
|
||||||
|
|
||||||
|
by_thread: dict[str, list[ParsedMail]] = defaultdict(list)
|
||||||
|
id_by_message: dict[tuple[str, str], int] = {}
|
||||||
|
original_by_key = {(m.mailbox, m.message_id): m for m in selected}
|
||||||
|
for row in stored:
|
||||||
|
key = (row["mailbox"], row["message_id"] or "")
|
||||||
|
original = original_by_key.get(key)
|
||||||
|
if not original:
|
||||||
|
continue
|
||||||
|
id_by_message[key] = row["id"]
|
||||||
|
by_thread[original.thread_key].append(original)
|
||||||
|
|
||||||
|
thread_items = [
|
||||||
|
(key, thread)
|
||||||
|
for key, thread in by_thread.items()
|
||||||
|
if not _is_noise_thread(thread)
|
||||||
|
]
|
||||||
|
skipped_noise = len(by_thread) - len(thread_items)
|
||||||
|
if skipped_noise:
|
||||||
|
logger.info("Job %s skipped %s noise thread(s)", job_id, skipped_noise)
|
||||||
|
thread_summaries: list[dict[str, Any]] = []
|
||||||
|
total_threads = max(len(thread_items), 1)
|
||||||
|
for idx, (thread_key, thread) in enumerate(thread_items, start=1):
|
||||||
|
pct = 84 + int((idx / total_threads) * 10)
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="analyzing",
|
||||||
|
pct=pct,
|
||||||
|
label="로컬 LLM 스레드 요약 중",
|
||||||
|
detail=f"업무 스레드 {idx}/{len(thread_items)} 분석 중 · {thread_key[:60]}",
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
sent_count=sent_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
filtered_sent_count=len(sent),
|
||||||
|
)
|
||||||
|
summary = await _summarize_thread(thread_key, thread, model)
|
||||||
|
if _is_noise_summary(summary):
|
||||||
|
continue
|
||||||
|
thread_summaries.append(summary)
|
||||||
|
for mail in thread:
|
||||||
|
mail_id = id_by_message.get((mail.mailbox, mail.message_id))
|
||||||
|
if mail_id:
|
||||||
|
await update_mail_summary(mail_id, summary)
|
||||||
|
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="analyzing",
|
||||||
|
pct=96,
|
||||||
|
label="주간 업무보고 작성 중",
|
||||||
|
detail="스레드 요약을 모아 주간 보고 형식으로 정리하고 있습니다.",
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
sent_count=sent_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
filtered_sent_count=len(sent),
|
||||||
|
)
|
||||||
|
next_from_d = date_to + timedelta(days=1)
|
||||||
|
next_to_d = date_to + timedelta(days=7)
|
||||||
|
next_from = next_from_d.isoformat()
|
||||||
|
next_to = next_to_d.isoformat()
|
||||||
|
weekly = await _summarize_week(
|
||||||
|
date_from=job["date_from"],
|
||||||
|
date_to=job["date_to"],
|
||||||
|
next_from=next_from,
|
||||||
|
next_to=next_to,
|
||||||
|
thread_summaries=thread_summaries,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
note = scope_note(scope, len(inbox), len(sent))
|
||||||
|
markdown = report_to_markdown(
|
||||||
|
weekly,
|
||||||
|
note=note,
|
||||||
|
inbox=len(inbox),
|
||||||
|
sent=len(sent),
|
||||||
|
date_from=job["date_from"],
|
||||||
|
date_to=job["date_to"],
|
||||||
|
next_from=next_from,
|
||||||
|
next_to=next_to,
|
||||||
|
)
|
||||||
|
await save_weekly_report(
|
||||||
|
job_id=job_id,
|
||||||
|
model=model,
|
||||||
|
scope_note=note,
|
||||||
|
report=weekly,
|
||||||
|
report_markdown=markdown,
|
||||||
|
)
|
||||||
|
await purge_mail_bodies(job_id)
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="completed",
|
||||||
|
pct=100,
|
||||||
|
label="분석 완료",
|
||||||
|
detail=f"주간 보고 생성 완료 · 스레드 {len(thread_summaries)}개",
|
||||||
|
inbox_count=inbox_total,
|
||||||
|
sent_count=sent_total,
|
||||||
|
filtered_inbox_count=len(inbox),
|
||||||
|
filtered_sent_count=len(sent),
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
logger.info("Job %s completed (%s threads)", job_id, len(thread_summaries))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Job %s failed", job_id)
|
||||||
|
await _set_progress(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
pct=100,
|
||||||
|
label="분석 실패",
|
||||||
|
detail=str(exc)[:300],
|
||||||
|
error_message=str(exc)[:1000],
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Centralized SQL for mail_summary."""
|
||||||
|
|
||||||
|
|
||||||
|
class Schema:
|
||||||
|
ENSURE_ANALYSIS_JOBS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS analysis_jobs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
mailbox_scope TEXT NOT NULL DEFAULT 'both',
|
||||||
|
date_from DATE NOT NULL,
|
||||||
|
date_to DATE NOT NULL,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
inbox_zip_name TEXT,
|
||||||
|
sent_zip_name TEXT,
|
||||||
|
inbox_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sent_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
filtered_inbox_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
filtered_sent_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
progress_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
progress_label TEXT NOT NULL DEFAULT '',
|
||||||
|
progress_detail TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT analysis_jobs_status_chk CHECK (
|
||||||
|
status IN ('pending', 'extracting', 'filtering', 'analyzing', 'completed', 'failed')
|
||||||
|
),
|
||||||
|
CONSTRAINT analysis_jobs_scope_chk CHECK (
|
||||||
|
mailbox_scope IN ('inbox', 'sent', 'both')
|
||||||
|
),
|
||||||
|
CONSTRAINT analysis_jobs_date_chk CHECK (date_from <= date_to)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
ADD_PROGRESS_PCT = """
|
||||||
|
ALTER TABLE analysis_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_pct INTEGER NOT NULL DEFAULT 0
|
||||||
|
"""
|
||||||
|
ADD_PROGRESS_LABEL = """
|
||||||
|
ALTER TABLE analysis_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_label TEXT NOT NULL DEFAULT ''
|
||||||
|
"""
|
||||||
|
ADD_PROGRESS_DETAIL = """
|
||||||
|
ALTER TABLE analysis_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_detail TEXT NOT NULL DEFAULT ''
|
||||||
|
"""
|
||||||
|
IDX_ANALYSIS_JOBS_CREATED_AT = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_created_at
|
||||||
|
ON analysis_jobs (created_at DESC)
|
||||||
|
"""
|
||||||
|
IDX_ANALYSIS_JOBS_STATUS = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_status
|
||||||
|
ON analysis_jobs (status)
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENSURE_MAILS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS mails (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL REFERENCES analysis_jobs(id) ON DELETE CASCADE,
|
||||||
|
mailbox TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
thread_key TEXT,
|
||||||
|
subject TEXT NOT NULL DEFAULT '',
|
||||||
|
from_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
to_addrs TEXT NOT NULL DEFAULT '',
|
||||||
|
cc_addrs TEXT NOT NULL DEFAULT '',
|
||||||
|
mailed_at TIMESTAMPTZ,
|
||||||
|
attachment_names JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
body_text TEXT,
|
||||||
|
body_purged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
summary JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT mails_mailbox_chk CHECK (mailbox IN ('inbox', 'sent'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
IDX_MAILS_JOB_ID = "CREATE INDEX IF NOT EXISTS idx_mails_job_id ON mails (job_id)"
|
||||||
|
IDX_MAILS_JOB_MAILED_AT = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_mails_job_mailed_at ON mails (job_id, mailed_at)"
|
||||||
|
)
|
||||||
|
IDX_MAILS_THREAD_KEY = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_mails_thread_key ON mails (job_id, thread_key)"
|
||||||
|
)
|
||||||
|
IDX_MAILS_MESSAGE_ID = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_mails_message_id ON mails (job_id, message_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
ENSURE_WEEKLY_REPORTS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_reports (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL UNIQUE REFERENCES analysis_jobs(id) ON DELETE CASCADE,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
scope_note TEXT NOT NULL DEFAULT '',
|
||||||
|
report JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
report_markdown TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
IDX_WEEKLY_REPORTS_CREATED_AT = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_reports_created_at
|
||||||
|
ON weekly_reports (created_at DESC)
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Jobs:
|
||||||
|
INSERT = """
|
||||||
|
INSERT INTO analysis_jobs (
|
||||||
|
status, mailbox_scope, date_from, date_to, model,
|
||||||
|
inbox_zip_name, sent_zip_name
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'pending', %(mailbox_scope)s, %(date_from)s, %(date_to)s, %(model)s,
|
||||||
|
%(inbox_zip_name)s, %(sent_zip_name)s
|
||||||
|
)
|
||||||
|
RETURNING id, status, mailbox_scope, date_from, date_to, model,
|
||||||
|
inbox_zip_name, sent_zip_name,
|
||||||
|
inbox_count, sent_count, filtered_inbox_count, filtered_sent_count,
|
||||||
|
progress_pct, progress_label, progress_detail,
|
||||||
|
error_message, created_at, started_at, completed_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
GET_BY_ID = """
|
||||||
|
SELECT id, status, mailbox_scope, date_from, date_to, model,
|
||||||
|
inbox_zip_name, sent_zip_name,
|
||||||
|
inbox_count, sent_count, filtered_inbox_count, filtered_sent_count,
|
||||||
|
progress_pct, progress_label, progress_detail,
|
||||||
|
error_message, created_at, started_at, completed_at
|
||||||
|
FROM analysis_jobs
|
||||||
|
WHERE id = %(job_id)s
|
||||||
|
"""
|
||||||
|
|
||||||
|
LIST_RECENT = """
|
||||||
|
SELECT id, status, mailbox_scope, date_from, date_to, model,
|
||||||
|
inbox_zip_name, sent_zip_name,
|
||||||
|
inbox_count, sent_count, filtered_inbox_count, filtered_sent_count,
|
||||||
|
progress_pct, progress_label, progress_detail,
|
||||||
|
error_message, created_at, started_at, completed_at
|
||||||
|
FROM analysis_jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPDATE = """
|
||||||
|
UPDATE analysis_jobs
|
||||||
|
SET
|
||||||
|
status = COALESCE(%(status)s, status),
|
||||||
|
inbox_count = COALESCE(%(inbox_count)s, inbox_count),
|
||||||
|
sent_count = COALESCE(%(sent_count)s, sent_count),
|
||||||
|
filtered_inbox_count = COALESCE(%(filtered_inbox_count)s, filtered_inbox_count),
|
||||||
|
filtered_sent_count = COALESCE(%(filtered_sent_count)s, filtered_sent_count),
|
||||||
|
progress_pct = COALESCE(%(progress_pct)s, progress_pct),
|
||||||
|
progress_label = COALESCE(%(progress_label)s, progress_label),
|
||||||
|
progress_detail = COALESCE(%(progress_detail)s, progress_detail),
|
||||||
|
error_message = COALESCE(%(error_message)s, error_message),
|
||||||
|
started_at = CASE
|
||||||
|
WHEN %(set_started)s THEN COALESCE(started_at, NOW())
|
||||||
|
ELSE started_at
|
||||||
|
END,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN %(set_completed)s THEN NOW()
|
||||||
|
ELSE completed_at
|
||||||
|
END
|
||||||
|
WHERE id = %(job_id)s
|
||||||
|
RETURNING id, status, mailbox_scope, date_from, date_to, model,
|
||||||
|
inbox_zip_name, sent_zip_name,
|
||||||
|
inbox_count, sent_count, filtered_inbox_count, filtered_sent_count,
|
||||||
|
progress_pct, progress_label, progress_detail,
|
||||||
|
error_message, created_at, started_at, completed_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Mails:
|
||||||
|
INSERT = """
|
||||||
|
INSERT INTO mails (
|
||||||
|
job_id, mailbox, message_id, thread_key, subject,
|
||||||
|
from_addr, to_addrs, cc_addrs, mailed_at,
|
||||||
|
attachment_names, body_text
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%(job_id)s, %(mailbox)s, %(message_id)s, %(thread_key)s, %(subject)s,
|
||||||
|
%(from_addr)s, %(to_addrs)s, %(cc_addrs)s, %(mailed_at)s,
|
||||||
|
%(attachment_names)s, %(body_text)s
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
LIST_BY_JOB = """
|
||||||
|
SELECT id, job_id, mailbox, message_id, thread_key, subject,
|
||||||
|
from_addr, to_addrs, cc_addrs, mailed_at,
|
||||||
|
attachment_names, body_text, body_purged, summary, created_at
|
||||||
|
FROM mails
|
||||||
|
WHERE job_id = %(job_id)s
|
||||||
|
ORDER BY mailed_at NULLS LAST, id
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPDATE_SUMMARY = """
|
||||||
|
UPDATE mails
|
||||||
|
SET summary = %(summary)s
|
||||||
|
WHERE id = %(mail_id)s
|
||||||
|
"""
|
||||||
|
|
||||||
|
PURGE_BODIES = """
|
||||||
|
UPDATE mails
|
||||||
|
SET body_text = NULL, body_purged = TRUE
|
||||||
|
WHERE job_id = %(job_id)s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Reports:
|
||||||
|
GET_BY_JOB = """
|
||||||
|
SELECT id, job_id, model, scope_note, report, report_markdown,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM weekly_reports
|
||||||
|
WHERE job_id = %(job_id)s
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPSERT = """
|
||||||
|
INSERT INTO weekly_reports (job_id, model, scope_note, report, report_markdown)
|
||||||
|
VALUES (
|
||||||
|
%(job_id)s, %(model)s, %(scope_note)s,
|
||||||
|
%(report)s, %(report_markdown)s
|
||||||
|
)
|
||||||
|
ON CONFLICT (job_id) DO UPDATE SET
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
scope_note = EXCLUDED.scope_note,
|
||||||
|
report = EXCLUDED.report,
|
||||||
|
report_markdown = EXCLUDED.report_markdown,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, job_id, model, scope_note, report, report_markdown,
|
||||||
|
created_at, updated_at
|
||||||
|
"""
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
python-multipart>=0.0.12
|
||||||
|
httpx>=0.27.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
psycopg[binary,pool]>=3.2.0
|
||||||
|
eval_type_backport>=0.4.0
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15.6
|
||||||
|
container_name: ${APP_NAME}_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
container_name: ${APP_NAME}_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./backend/logs:/app/logs
|
||||||
|
# Host upload dir (Windows: ./backend/uploads, Linux Synology: set UPLOAD_HOST in .env).
|
||||||
|
- ${UPLOAD_HOST:-./backend/uploads}:/app/uploads
|
||||||
|
# Drop multi-GB mailbox zips here (or point INCOMING_HOST at another folder).
|
||||||
|
- ${INCOMING_HOST:-./incoming}:/app/incoming:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ${APP_NAME}_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-- mail_summary schema.
|
||||||
|
-- Seeds a fresh Postgres container on first boot.
|
||||||
|
-- The backend also runs equivalent idempotent CREATE TABLE IF NOT EXISTS at startup.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS analysis_jobs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending | extracting | filtering | analyzing | completed | failed
|
||||||
|
mailbox_scope TEXT NOT NULL DEFAULT 'both',
|
||||||
|
-- inbox | sent | both
|
||||||
|
date_from DATE NOT NULL,
|
||||||
|
date_to DATE NOT NULL,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
inbox_zip_name TEXT,
|
||||||
|
sent_zip_name TEXT,
|
||||||
|
inbox_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sent_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
filtered_inbox_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
filtered_sent_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
progress_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
progress_label TEXT NOT NULL DEFAULT '',
|
||||||
|
progress_detail TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT analysis_jobs_status_chk CHECK (
|
||||||
|
status IN ('pending', 'extracting', 'filtering', 'analyzing', 'completed', 'failed')
|
||||||
|
),
|
||||||
|
CONSTRAINT analysis_jobs_scope_chk CHECK (
|
||||||
|
mailbox_scope IN ('inbox', 'sent', 'both')
|
||||||
|
),
|
||||||
|
CONSTRAINT analysis_jobs_date_chk CHECK (date_from <= date_to)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_created_at
|
||||||
|
ON analysis_jobs (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_status
|
||||||
|
ON analysis_jobs (status);
|
||||||
|
|
||||||
|
-- Mail metadata + cleaned body for the selected analysis window.
|
||||||
|
-- Prefer storing identifiers/summary over full raw .eml content.
|
||||||
|
CREATE TABLE IF NOT EXISTS mails (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL REFERENCES analysis_jobs(id) ON DELETE CASCADE,
|
||||||
|
mailbox TEXT NOT NULL,
|
||||||
|
-- inbox | sent
|
||||||
|
message_id TEXT,
|
||||||
|
thread_key TEXT,
|
||||||
|
subject TEXT NOT NULL DEFAULT '',
|
||||||
|
from_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
to_addrs TEXT NOT NULL DEFAULT '',
|
||||||
|
cc_addrs TEXT NOT NULL DEFAULT '',
|
||||||
|
mailed_at TIMESTAMPTZ,
|
||||||
|
attachment_names JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
body_text TEXT,
|
||||||
|
body_purged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
summary JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT mails_mailbox_chk CHECK (mailbox IN ('inbox', 'sent'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mails_job_id ON mails (job_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mails_job_mailed_at ON mails (job_id, mailed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mails_thread_key ON mails (job_id, thread_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mails_message_id ON mails (job_id, message_id);
|
||||||
|
|
||||||
|
-- Final weekly work report for a job.
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_reports (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id BIGINT NOT NULL UNIQUE REFERENCES analysis_jobs(id) ON DELETE CASCADE,
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
scope_note TEXT NOT NULL DEFAULT '',
|
||||||
|
report JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
report_markdown TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_reports_created_at
|
||||||
|
ON weekly_reports (created_at DESC);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_APP_NAME=Mail Summary
|
||||||
|
VITE_APP_VERSION=1.0.0
|
||||||
|
VITE_SITE_URL=https://ms.takits.me
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Single .env file (no .env.main / .env.develop split).
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mail Summary</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="메일 요약 · 주간업무보고 도구"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#D60051" />
|
||||||
|
<meta name="application-name" content="Mail Summary" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Mail Summary" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Mail Summary" />
|
||||||
|
<meta property="og:title" content="Mail Summary" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="메일 요약 · 주간업무보고 도구"
|
||||||
|
/>
|
||||||
|
<meta property="og:locale" content="ko_KR" />
|
||||||
|
<meta property="og:url" content="%VITE_SITE_URL%" />
|
||||||
|
<meta property="og:image" content="%VITE_SITE_URL%/og-image.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="1920" />
|
||||||
|
<meta property="og:image:height" content="1080" />
|
||||||
|
<meta property="og:image:alt" content="Mail Summary" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Large mailbox zip backups (multi-GB).
|
||||||
|
client_max_body_size 20g;
|
||||||
|
client_body_timeout 3600s;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Stream upload body to backend instead of buffering in nginx.
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "mail-summary-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
@@ -0,0 +1,636 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createJob, deleteUpload, getHealth, getJob, getReport, stageMailboxZip } from "./api.js";
|
||||||
|
|
||||||
|
const APP_NAME = import.meta.env.VITE_APP_NAME || "Mail Summary";
|
||||||
|
const APP_VERSION = import.meta.env.VITE_APP_VERSION || "1.0.0";
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
pending: "대기 중",
|
||||||
|
extracting: "준비 중",
|
||||||
|
filtering: "메일 스캔 / 기간 필터",
|
||||||
|
analyzing: "LLM 분석",
|
||||||
|
completed: "완료",
|
||||||
|
failed: "실패",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANALYSIS_STEPS = [
|
||||||
|
{ key: "extracting", label: "1. 준비" },
|
||||||
|
{ key: "filtering", label: "2. 메일 스캔·기간 필터" },
|
||||||
|
{ key: "analyzing", label: "3. LLM 요약·보고 작성" },
|
||||||
|
{ key: "completed", label: "4. 완료" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stepState(currentStatus, key) {
|
||||||
|
const order = ["pending", "extracting", "filtering", "analyzing", "completed"];
|
||||||
|
if (currentStatus === "failed") {
|
||||||
|
return key === "extracting" || key === "filtering" || key === "analyzing" ? "done" : "wait";
|
||||||
|
}
|
||||||
|
const cur = order.indexOf(currentStatus);
|
||||||
|
const idx = order.indexOf(key);
|
||||||
|
if (cur < 0) return "wait";
|
||||||
|
if (idx < cur) return "done";
|
||||||
|
if (idx === cur) return "active";
|
||||||
|
return "wait";
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekAgoIso() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 6);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope) {
|
||||||
|
if (scope === "inbox") return "받은 메일함만";
|
||||||
|
if (scope === "sent") return "보낸 메일함만";
|
||||||
|
return "받은 + 보낸 메일함";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySlot() {
|
||||||
|
return { file: null, uploadId: "", progress: 0, status: "idle", error: "" };
|
||||||
|
// status: idle | uploading | done | error
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingText({ text, active }) {
|
||||||
|
const [shown, setShown] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !text) {
|
||||||
|
setShown(text || "");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setShown("");
|
||||||
|
let i = 0;
|
||||||
|
const step = Math.max(1, Math.ceil(text.length / 80));
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
i = Math.min(text.length, i + step);
|
||||||
|
setShown(text.slice(0, i));
|
||||||
|
if (i >= text.length) clearInterval(timer);
|
||||||
|
}, 28);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [text, active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="stream-text">
|
||||||
|
{shown}
|
||||||
|
{active && shown.length < (text || "").length ? <span className="caret" /> : null}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSideText(groups) {
|
||||||
|
if (!groups?.length) return "(해당 항목 없음)";
|
||||||
|
return groups
|
||||||
|
.map((group) => {
|
||||||
|
const customer = group?.customer || "기타";
|
||||||
|
const items = Array.isArray(group?.items) ? group.items.filter(Boolean) : [];
|
||||||
|
const bullets = items.length ? items.map((item) => `- ${item}`).join("\n") : "- (세부 항목 없음)";
|
||||||
|
return `${customer}\n${bullets}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeeklySide({ title, groups, stream }) {
|
||||||
|
const text = formatSideText(groups);
|
||||||
|
return (
|
||||||
|
<div className="weekly-col">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="weekly-body">
|
||||||
|
<StreamingText text={text} active={stream} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path
|
||||||
|
d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M5 12.5 10 17.5 19 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadSlot({ title, slot, onPick, onClear }) {
|
||||||
|
const showBar = slot.status === "uploading" || slot.status === "error";
|
||||||
|
const statusText =
|
||||||
|
slot.status === "uploading"
|
||||||
|
? `업로드 중… ${slot.progress}%`
|
||||||
|
: slot.status === "error"
|
||||||
|
? slot.error || "업로드 실패"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drop-block">
|
||||||
|
<div className={`drop-row ${slot.file ? "has-file" : ""}`}>
|
||||||
|
<label className={`drop ${slot.file ? "has-file" : ""}`}>
|
||||||
|
<span>{title}</span>
|
||||||
|
<p className="file-name">{slot.file ? slot.file.name : "클릭해서 zip 선택"}</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
disabled={slot.status === "uploading"}
|
||||||
|
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{slot.file && slot.status !== "uploading" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-upload"
|
||||||
|
aria-label={`${title} 업로드 삭제`}
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{showBar ? (
|
||||||
|
<div className="upload-bar" aria-hidden="true">
|
||||||
|
<i style={{ width: `${slot.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showBar && statusText ? (
|
||||||
|
<p className={`upload-status ${slot.status === "error" ? "bad" : ""}`}>{statusText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [unlockedStep, setUnlockedStep] = useState(1);
|
||||||
|
const [health, setHealth] = useState(null);
|
||||||
|
const [inbox, setInbox] = useState(emptySlot);
|
||||||
|
const [sent, setSent] = useState(emptySlot);
|
||||||
|
const [dateFrom, setDateFrom] = useState(weekAgoIso);
|
||||||
|
const [dateTo, setDateTo] = useState(todayIso);
|
||||||
|
const [mailboxScope, setMailboxScope] = useState("both");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [job, setJob] = useState(null);
|
||||||
|
const [report, setReport] = useState(null);
|
||||||
|
const [streamSummary, setStreamSummary] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
function goToStep(next) {
|
||||||
|
if (next > unlockedStep) return;
|
||||||
|
if (next === 2) setMailboxScope(inferMailboxScope());
|
||||||
|
setStep(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goHome() {
|
||||||
|
// Soft state reset felt different from a real refresh — hard-navigate home instead.
|
||||||
|
const ids = [inbox.uploadId, sent.uploadId].filter(Boolean);
|
||||||
|
if (ids.length) {
|
||||||
|
await Promise.allSettled(ids.map((id) => deleteUpload(id)));
|
||||||
|
}
|
||||||
|
window.location.assign("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBackToConditions() {
|
||||||
|
setJob(null);
|
||||||
|
setReport(null);
|
||||||
|
setError("");
|
||||||
|
setBusy(false);
|
||||||
|
setStreamSummary(false);
|
||||||
|
setCopied(false);
|
||||||
|
setUnlockedStep(2);
|
||||||
|
setMailboxScope(inferMailboxScope());
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxReady = inbox.status === "done" && Boolean(inbox.uploadId);
|
||||||
|
const sentReady = sent.status === "done" && Boolean(sent.uploadId);
|
||||||
|
const uploading = inbox.status === "uploading" || sent.status === "uploading";
|
||||||
|
const canGoNextFromUpload = (inboxReady || sentReady) && !uploading;
|
||||||
|
|
||||||
|
function inferMailboxScope() {
|
||||||
|
if (inboxReady && sentReady) return "both";
|
||||||
|
if (inboxReady) return "inbox";
|
||||||
|
if (sentReady) return "sent";
|
||||||
|
return "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNextFromUpload() {
|
||||||
|
if (!canGoNextFromUpload) return;
|
||||||
|
setUnlockedStep((prev) => Math.max(prev, 2));
|
||||||
|
setMailboxScope(inferMailboxScope());
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSlot(mailbox) {
|
||||||
|
const slot = mailbox === "inbox" ? inbox : sent;
|
||||||
|
const setSlot = mailbox === "inbox" ? setInbox : setSent;
|
||||||
|
if (slot.uploadId) {
|
||||||
|
try {
|
||||||
|
await deleteUpload(slot.uploadId);
|
||||||
|
} catch {
|
||||||
|
// Local reset still proceeds even if server cleanup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSlot(emptySlot());
|
||||||
|
setUnlockedStep(1);
|
||||||
|
if (step !== 1) setStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePick(mailbox, file) {
|
||||||
|
const setSlot = mailbox === "inbox" ? setInbox : setSent;
|
||||||
|
if (!file) {
|
||||||
|
setSlot(emptySlot());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||||
|
setSlot({
|
||||||
|
file,
|
||||||
|
uploadId: "",
|
||||||
|
progress: 0,
|
||||||
|
status: "error",
|
||||||
|
error: "zip 파일만 업로드할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
setSlot({ file, uploadId: "", progress: 0, status: "uploading", error: "" });
|
||||||
|
setUnlockedStep(1);
|
||||||
|
if (step !== 1) setStep(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await stageMailboxZip(file, mailbox, (pct) => {
|
||||||
|
setSlot((prev) => ({ ...prev, progress: pct, status: "uploading" }));
|
||||||
|
});
|
||||||
|
setSlot({
|
||||||
|
file,
|
||||||
|
uploadId: result.upload_id,
|
||||||
|
progress: 100,
|
||||||
|
status: "done",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setSlot({
|
||||||
|
file,
|
||||||
|
uploadId: "",
|
||||||
|
progress: 0,
|
||||||
|
status: "error",
|
||||||
|
error: err.message || "업로드 실패",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHealth()
|
||||||
|
.then(setHealth)
|
||||||
|
.catch(() => setHealth({ status: "error", database: "down", llm: { ok: false } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job?.id) return undefined;
|
||||||
|
if (job.status === "completed" || job.status === "failed") return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const payload = await getJob(job.id);
|
||||||
|
if (cancelled) return;
|
||||||
|
setJob(payload.job);
|
||||||
|
if (payload.job.status === "failed") {
|
||||||
|
setError(payload.job.error_message || "분석에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err.message || "상태 조회 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [job?.id, job?.status]);
|
||||||
|
|
||||||
|
// Fetch report in a separate effect so polling cleanup can't drop the result
|
||||||
|
// when status flips to completed mid-request.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job?.id || job.status !== "completed" || report) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
getReport(job.id)
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setReport(payload.report);
|
||||||
|
setStreamSummary(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message || "결과 조회 실패");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [job?.id, job?.status, report]);
|
||||||
|
|
||||||
|
const canAnalyze = useMemo(() => {
|
||||||
|
if (!dateFrom || !dateTo || dateFrom > dateTo) return false;
|
||||||
|
if (mailboxScope === "inbox") return inboxReady;
|
||||||
|
if (mailboxScope === "sent") return sentReady;
|
||||||
|
return inboxReady && sentReady;
|
||||||
|
}, [dateFrom, dateTo, mailboxScope, inboxReady, sentReady]);
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
setReport(null);
|
||||||
|
setStreamSummary(false);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("date_from", dateFrom);
|
||||||
|
form.append("date_to", dateTo);
|
||||||
|
form.append("mailbox_scope", mailboxScope);
|
||||||
|
if (inbox.uploadId) form.append("inbox_upload_id", inbox.uploadId);
|
||||||
|
if (sent.uploadId) form.append("sent_upload_id", sent.uploadId);
|
||||||
|
|
||||||
|
const created = await createJob(form);
|
||||||
|
setJob(created.job);
|
||||||
|
setUnlockedStep(3);
|
||||||
|
setStep(3);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "분석 요청에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyReport() {
|
||||||
|
const text = report?.report_markdown?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setError("클립보드 복사에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = report?.report || {};
|
||||||
|
const running = job && !["completed", "failed"].includes(job.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<button type="button" className="brand-mark brand-link" onClick={goHome}>
|
||||||
|
<strong>{APP_NAME}</strong>
|
||||||
|
</button>
|
||||||
|
<div className="live">
|
||||||
|
<span className={`live-item ${health?.database === "up" ? "ok" : "bad"}`}>
|
||||||
|
<i />
|
||||||
|
DB
|
||||||
|
</span>
|
||||||
|
<span className={`live-item ${health?.llm?.ok ? "ok" : "bad"}`}>
|
||||||
|
<i />
|
||||||
|
LLM(on-prem · 30B)
|
||||||
|
</span>
|
||||||
|
<span className="live-item">v{APP_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="hero">
|
||||||
|
<h1>
|
||||||
|
한 주의 메일을 <em>업무보고로</em>
|
||||||
|
</h1>
|
||||||
|
<p className="hero-lead">
|
||||||
|
메일을 외부로 전송하지 않고, 로컬에서 분석해 주간 업무를 자동으로 정리합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="layout">
|
||||||
|
<nav className="steps" aria-label="진행 단계">
|
||||||
|
<button type="button" className={step === 1 ? "active" : ""} onClick={() => goToStep(1)}>
|
||||||
|
01 업로드
|
||||||
|
<small>mailbox zip</small>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={step === 2 ? "active" : ""}
|
||||||
|
onClick={() => goToStep(2)}
|
||||||
|
disabled={unlockedStep < 2}
|
||||||
|
>
|
||||||
|
02 조건
|
||||||
|
<small>range & scope</small>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={step === 3 ? "active" : ""}
|
||||||
|
onClick={() => goToStep(3)}
|
||||||
|
disabled={unlockedStep < 3 || !job}
|
||||||
|
>
|
||||||
|
03 결과
|
||||||
|
<small>weekly report</small>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="main">
|
||||||
|
{error ? <div className="error">{error}</div> : null}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<section className="stage" key="step-1">
|
||||||
|
<div className="stage-head">
|
||||||
|
<h2>메일 백업 업로드</h2>
|
||||||
|
<p>받은함 / 보낸함 각각 .zip 파일을 올려주세요. 업로드가 끝난 뒤에 다음 단계로 갈 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="drop-grid">
|
||||||
|
<UploadSlot
|
||||||
|
title="받은 메일함"
|
||||||
|
slot={inbox}
|
||||||
|
onPick={(file) => handlePick("inbox", file)}
|
||||||
|
onClear={() => clearSlot("inbox")}
|
||||||
|
/>
|
||||||
|
<UploadSlot
|
||||||
|
title="보낸 메일함"
|
||||||
|
slot={sent}
|
||||||
|
onPick={(file) => handlePick("sent", file)}
|
||||||
|
onClear={() => clearSlot("sent")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent"
|
||||||
|
onClick={goNextFromUpload}
|
||||||
|
disabled={!canGoNextFromUpload}
|
||||||
|
>
|
||||||
|
다음 단계
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<section className="stage" key="step-2">
|
||||||
|
<div className="stage-head">
|
||||||
|
<h2>분석 조건</h2>
|
||||||
|
<p>기간과 메일함 범위를 정한 뒤 분석을 시작합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="field">
|
||||||
|
<span>시작일</span>
|
||||||
|
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>종료일</span>
|
||||||
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>분석 범위</span>
|
||||||
|
<select value={mailboxScope} onChange={(e) => setMailboxScope(e.target.value)}>
|
||||||
|
<option value="both" disabled={!inboxReady || !sentReady}>
|
||||||
|
받은 + 보낸 (권장)
|
||||||
|
</option>
|
||||||
|
<option value="inbox" disabled={!inboxReady}>
|
||||||
|
받은 메일함만
|
||||||
|
</option>
|
||||||
|
<option value="sent" disabled={!sentReady}>
|
||||||
|
보낸 메일함만
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" onClick={() => setStep(1)}>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent"
|
||||||
|
disabled={!canAnalyze || busy}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{busy ? "작업 생성 중…" : "분석 시작"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && job && (
|
||||||
|
<section className="stage" key="step-3">
|
||||||
|
<div className="stage-head">
|
||||||
|
<h2>분석 결과</h2>
|
||||||
|
<div className="meta-row">
|
||||||
|
<span>{scopeLabel(job.mailbox_scope)}</span>
|
||||||
|
<span>
|
||||||
|
{job.date_from} ~ {job.date_to}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
받은 {job.filtered_inbox_count ?? 0} / 보낸 {job.filtered_sent_count ?? 0}
|
||||||
|
</span>
|
||||||
|
<span>{STATUS_LABEL[job.status] || job.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<div className="progress analysis-progress">
|
||||||
|
<div className="analysis-progress-head">
|
||||||
|
<strong>
|
||||||
|
<span className="dot" />
|
||||||
|
job #{job.id} 분석 진행 중
|
||||||
|
</strong>
|
||||||
|
<span>{Math.max(0, Math.min(100, job.progress_pct || 0))}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="upload-bar analysis-bar" aria-hidden="true">
|
||||||
|
<i style={{ width: `${Math.max(2, Math.min(100, job.progress_pct || 0))}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="progress-label">{job.progress_label || STATUS_LABEL[job.status] || job.status}</p>
|
||||||
|
<p className="progress-detail">
|
||||||
|
{job.progress_detail ||
|
||||||
|
"대용량 메일 zip을 검사하는 중입니다. 완료될 때까지 이 화면에 머물러 주세요."}
|
||||||
|
</p>
|
||||||
|
<ul className="phase-list">
|
||||||
|
{ANALYSIS_STEPS.map((item) => {
|
||||||
|
const state = stepState(job.status, item.key);
|
||||||
|
return (
|
||||||
|
<li key={item.key} className={state}>
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="progress-counts">
|
||||||
|
검사 기준 메일 수 · 받은 {job.inbox_count ?? 0} / 보낸 {job.sent_count ?? 0}
|
||||||
|
{" · "}
|
||||||
|
기간 매칭 · 받은 {job.filtered_inbox_count ?? 0} / 보낸 {job.filtered_sent_count ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{report ? (
|
||||||
|
<>
|
||||||
|
<div className="weekly-panel">
|
||||||
|
<div className="weekly-panel-head">
|
||||||
|
<h3>주간 업무보고</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`icon-copy ${copied ? "copied" : ""}`}
|
||||||
|
onClick={copyReport}
|
||||||
|
disabled={!report.report_markdown}
|
||||||
|
aria-label={copied ? "복사됨" : "보고서 복사"}
|
||||||
|
title={copied ? "복사됨" : "복사"}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="weekly-table">
|
||||||
|
<WeeklySide title="금주" groups={data.this_week} stream={streamSummary} />
|
||||||
|
<WeeklySide title="차주" groups={data.next_week} stream={streamSummary} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="accent" onClick={goBackToConditions}>
|
||||||
|
조건으로 돌아가기
|
||||||
|
</button>
|
||||||
|
<button type="button" className="primary" onClick={goHome}>
|
||||||
|
새 분석
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!running && (
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="accent" onClick={goBackToConditions}>
|
||||||
|
조건으로 돌아가기
|
||||||
|
</button>
|
||||||
|
<button type="button" className="primary" onClick={goHome}>
|
||||||
|
새 분석
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${path}`, options);
|
||||||
|
} catch {
|
||||||
|
throw new Error("서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data = {};
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = { detail: text.slice(0, 200) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 413) {
|
||||||
|
throw new Error("파일이 너무 큽니다. 업로드 제한을 확인해 주세요.");
|
||||||
|
}
|
||||||
|
const detail = data.detail || response.statusText || "request_failed";
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth() {
|
||||||
|
return request("/api/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(jobId) {
|
||||||
|
return request(`/api/jobs/${jobId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReport(jobId) {
|
||||||
|
return request(`/api/jobs/${jobId}/report`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJob(formData) {
|
||||||
|
return request("/api/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to stage a file that already exists on the server incoming mount. */
|
||||||
|
export async function resolveUpload(mailbox, filename, sizeBytes) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("mailbox", mailbox);
|
||||||
|
form.append("filename", filename);
|
||||||
|
form.append("size_bytes", String(sizeBytes));
|
||||||
|
const response = await fetch(`${API_BASE}/api/uploads/resolve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.detail || "resolve_failed");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload zip with progress via XHR. */
|
||||||
|
export function uploadZip(file, mailbox, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("mailbox", mailbox);
|
||||||
|
form.append("file", file);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) return;
|
||||||
|
const pct = Math.max(1, Math.min(99, Math.round((event.loaded / event.total) * 100)));
|
||||||
|
onProgress?.(pct);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText || "{}");
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
onProgress?.(100);
|
||||||
|
resolve(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (xhr.status === 413) {
|
||||||
|
reject(new Error("파일이 너무 큽니다. 업로드 제한을 확인해 주세요."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(data.detail || `upload_failed (${xhr.status})`));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error("업로드 중 네트워크 오류가 발생했습니다."));
|
||||||
|
xhr.open("POST", `${API_BASE}/api/uploads`);
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage a mailbox zip:
|
||||||
|
* 1) resolve against server incoming mount (no re-upload)
|
||||||
|
* 2) otherwise upload with real progress
|
||||||
|
*/
|
||||||
|
export async function stageMailboxZip(file, mailbox, onProgress) {
|
||||||
|
onProgress?.(2);
|
||||||
|
const resolved = await resolveUpload(mailbox, file.name, file.size);
|
||||||
|
if (resolved) {
|
||||||
|
for (const pct of [25, 55, 80, 100]) {
|
||||||
|
onProgress?.(pct);
|
||||||
|
await sleep(70);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return uploadZip(file, mailbox, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUpload(uploadId) {
|
||||||
|
if (!uploadId) return { ok: true };
|
||||||
|
return request(`/api/uploads/${uploadId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #121417;
|
||||||
|
--muted: #5c6570;
|
||||||
|
--line: #cfd6de;
|
||||||
|
--line-strong: #121417;
|
||||||
|
--surface: rgba(255, 255, 255, 0.58);
|
||||||
|
--accent: #d60051;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--highlight: #d6ff3f;
|
||||||
|
--signal: #d60051;
|
||||||
|
--ok: #1f8a4c;
|
||||||
|
--danger: #c0392b;
|
||||||
|
--radius: 2px;
|
||||||
|
--font-display: "Syne", "IBM Plex Sans KR", sans-serif;
|
||||||
|
--font-body: "IBM Plex Sans KR", "Apple SD Gothic Neo", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background-color: #e7ebf0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 50% at 8% -12%, rgba(214, 0, 81, 0.16), transparent 55%),
|
||||||
|
radial-gradient(ellipse 55% 40% at 100% 0%, rgba(214, 0, 81, 0.08), transparent 50%),
|
||||||
|
linear-gradient(165deg, #f7f5f6 0%, #ebe7ea 48%, #e4e0e4 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(1080px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.75rem 0 4.5rem;
|
||||||
|
animation: rise 0.55s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stepIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseDot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.35);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes barFlow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.brand-link {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.brand-link:hover {
|
||||||
|
transform: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.55rem, 3.4vw, 2.15rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-item i {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-item.ok i {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-item.bad i {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.75rem;
|
||||||
|
border-bottom: 2px solid var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(2.4rem, 6vw, 4.1rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 em {
|
||||||
|
font-style: normal;
|
||||||
|
background: linear-gradient(120deg, transparent 0%, var(--highlight) 0%);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 0.35em;
|
||||||
|
background-position: 0 88%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.7rem 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button:hover:not(:disabled) {
|
||||||
|
color: var(--ink);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button.active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-left-color: var(--signal);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
animation: stepIn 0.35s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-head {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-head h2 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.55rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-head p,
|
||||||
|
.hint,
|
||||||
|
.meta,
|
||||||
|
.file-name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
padding: 1.15rem 1.2rem;
|
||||||
|
border: 1.5px dashed var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop:hover,
|
||||||
|
.drop:focus-within {
|
||||||
|
border-color: rgba(214, 0, 81, 0.55);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(214, 0, 81, 0.45);
|
||||||
|
background: rgba(214, 0, 81, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop.has-file:hover,
|
||||||
|
.drop.has-file:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 2.6rem;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
column-gap: 0.55rem;
|
||||||
|
row-gap: 0.55rem;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row .drop {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* X 없을 때 메일함이 전체 너비(메일함+X와 동일)를 쓰도록 */
|
||||||
|
.drop-row:not(:has(.clear-upload)) .drop {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row .clear-upload {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row .upload-bar {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row .upload-status {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-row:not(:has(.clear-upload)) .upload-bar,
|
||||||
|
.drop-row:not(:has(.clear-upload)) .upload-status {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop .file-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-upload {
|
||||||
|
align-self: stretch;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1.5px solid var(--ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-upload:hover:not(:disabled) {
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-text {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.5em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-text .caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55ch;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.1ch;
|
||||||
|
background: var(--accent);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
animation: pulseDot 0.9s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-bar {
|
||||||
|
height: 0.45rem;
|
||||||
|
border: 1px solid rgba(214, 0, 81, 0.22);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-bar > i {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--accent);
|
||||||
|
transition: width 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-bar > i::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.45) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: barFlow 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop span {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="date"],
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 0.85rem;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
color: var(--ink);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="date"]:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1.5px solid var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.78rem 1.05rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.accent {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.accent:hover:not(:disabled) {
|
||||||
|
background: #b80046;
|
||||||
|
border-color: #b80046;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
background: rgba(192, 57, 43, 0.08);
|
||||||
|
color: var(--danger);
|
||||||
|
animation: stepIn 0.25s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 1rem 0 1.4rem;
|
||||||
|
padding: 1.1rem 1.15rem;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(231, 235, 240, 0.72);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
margin-right: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulseDot 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-progress {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-progress-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-bar {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-detail,
|
||||||
|
.progress-counts {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: rgba(92, 101, 112, 0.55);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.45rem;
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(92, 101, 112, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li.active {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li.active::before {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(214, 0, 81, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li.done {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-list li.done::before {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin: 0.35rem 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-note {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-panel {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-copy {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-copy:hover:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-copy.copied {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
border-top: 1.5px solid var(--ink);
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-col {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1rem 1.1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-col + .weekly-col {
|
||||||
|
border-left: 1.5px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-col h3 {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-body {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-body .stream-text {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.hero,
|
||||||
|
.layout,
|
||||||
|
.row,
|
||||||
|
.weekly-table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-col + .weekly-col {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1.5px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
position: static;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button {
|
||||||
|
border-left: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 0.65rem 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button.active {
|
||||||
|
border-bottom-color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps button small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user