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