Frist commit

This commit is contained in:
2026-07-19 23:04:16 +09:00
commit f093d41f60
33 changed files with 4077 additions and 0 deletions
+308
View File
@@ -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)