diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0ffe11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["backend"] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13.3"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: | + backend/requirements.txt + backend/requirements-dev.txt + backend/pyproject.toml + + - name: Install deps + working-directory: backend + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + + - name: Lint (pylint) + run: | + pylint ./backend + + - name: Test (pytest) + run: | + pytest -q + + diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml deleted file mode 100644 index a479787..0000000 --- a/.github/workflows/pylint.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Pylint - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.13.3"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint - - name: Analysing the code with pylint - run: | - pylint $(git ls-files '*.py') diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..cc5c502 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# デフォルトの無視対象ファイル +/shelf/ +/workspace.xml +# エディターベースの HTTP クライアントリクエスト +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..93cee84 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e036dbb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pre-21.iml b/.idea/pre-21.iml new file mode 100644 index 0000000..78c542f --- /dev/null +++ b/.idea/pre-21.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend/.github/workflows/ci.yml b/backend/.github/workflows/ci.yml new file mode 100644 index 0000000..0f4fc20 --- /dev/null +++ b/backend/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13.3" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: scripts/test.sh + + - name: Run pylint + env: + PYLINTHOME: .pylint.d + run: pylint app diff --git a/backend/.pylint.d/app_1.stats b/backend/.pylint.d/app_1.stats new file mode 100644 index 0000000..49c7ba9 Binary files /dev/null and b/backend/.pylint.d/app_1.stats differ diff --git a/backend/.pylintrc b/backend/.pylintrc new file mode 100644 index 0000000..12f5cc6 --- /dev/null +++ b/backend/.pylintrc @@ -0,0 +1,13 @@ +[MASTER] +ignore-paths=.venv + +[MESSAGES CONTROL] +disable= + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + broad-except, + too-few-public-methods + +[FORMAT] +max-line-length=100 diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..26f7f84 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,41 @@ +# Repository Guidelines + +## プロジェクト構成とモジュール +- `app/`: FastAPIアプリ本体。API (`app/main.py`)、モデル、各サービス。 +- `test/`: pytestのテスト一式(例: `test/test_main.py`, `test/test_store.py`)。 +- `templates/`: PDF生成に使うJinja2テンプレート(`templates/index.html.j2`)。 +- `scripts/`: 開発/運用向けスクリプト(`scripts/dev.sh`, `scripts/test.sh`, `scripts/print_pdf.sh`)。 +- `data/`: 実行時に生成されるジョブ状態や成果物(JSON、PDF)。 +- `font/`: PDFレンダリング用フォント(`font/ipamjm.ttf`)。 +- `output/`: 生成物置き場。必要性が明示されない限り一時ファイル扱い。 + +## ビルド/テスト/開発コマンド +- `pip install -r requirements.txt`: 依存関係のインストール。 +- `scripts/dev.sh`: リロード付きでFastAPIを起動(`uvicorn app.main:app`)。 +- `scripts/test.sh`: pytestでテスト実行(`pytest -q`)。 +- `scripts/print_pdf.sh [copies] [printer]`: CUPSの`lp`でPDF印刷。 + +## コーディング規約と命名 +- Python、インデントは4スペース、PEP 8準拠を意識。 +- 関数/変数は`snake_case`、クラスは`PascalCase`。 +- 可能な範囲で型ヒントを付与(`app/store.py`参照)。 +- APIエンドポイントは`app/main.py`に集約し、追加サービスは`app/`配下へ。 + +## テスト方針 +- フレームワーク: pytest。 +- 命名規則: ファイルは`test_*.py`、関数は`test_*`。 +- 実行方法: `scripts/test.sh`または`pytest -q`。 +- 新規APIやジョブの状態遷移にはテスト追加を推奨。 + +## コミット & PR ガイドライン +- 履歴は説明的な文体(日本語が多め)。同じトーンで具体的に書く。 +- PRには概要、実行したテスト、設定変更や必要な環境変数を記載。 + +## 設定と環境 +- ローカル開発では`.env`を`app/config.py`が自動読み込み。 +- 主要変数: `GEMINI_API_KEY`, `GEMINI_MODEL`, `BASE_URL`, `PRINTER_NAME`, `DATA_DIR`, `JOBS_DIR`, `IDEM_DIR`, `ARTIFACTS_DIR`。 + +## gemini_transformの実装方針 +- ESP32から0か1のデータ5個送信されます。それに基づいて場合分けしてプロンプトを変える処理をしたいです +- 最終的にそのgemini apiにデータを送信して結果をjson形式で返します +- 整合性についてはほかファイルの仕様を優先して取ってください \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e2b1156 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,20 @@ +import os +from pydantic import BaseModel +from dotenv import load_dotenv + +# .env を自動読み込み(ローカル開発向け) +load_dotenv() + +class Settings(BaseModel): + gemini_api_key: str | None = os.getenv("GEMINI_API_KEY") + gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") + base_url: str = os.getenv("BASE_URL", "http://localhost:8000") + printer_name: str | None = os.getenv("PRINTER_NAME") or None + + data_dir: str = os.getenv("DATA_DIR", "data") + jobs_dir: str = os.getenv("JOBS_DIR", "data/jobs") + idem_dir: str = os.getenv("IDEM_DIR", "data/idem") + llm_dir: str = os.getenv("LLM_DIR", "data/llm") + artifacts_dir: str = os.getenv("ARTIFACTS_DIR", "data/artifacts") + +settings = Settings() diff --git a/backend/app/design_tool.py b/backend/app/design_tool.py new file mode 100644 index 0000000..fb82e4e --- /dev/null +++ b/backend/app/design_tool.py @@ -0,0 +1,54 @@ +import argparse +import json +import os +from jinja2 import Environment, FileSystemLoader, select_autoescape, TemplateNotFound +from app.config import settings + + +def generate_preview(job_id: str) -> str | None: + loader = FileSystemLoader("templates") + env = Environment( + loader=loader, + autoescape=select_autoescape(["html", "xml"]), + ) + + llm_path = os.path.join(settings.llm_dir, f"{job_id}.json") + if not os.path.exists(llm_path): + print(f"エラー: LLMデータが見つかりません: {llm_path}") + return None + + try: + template = env.get_template("default.html.j2") + except TemplateNotFound as e: + print(f"エラー: テンプレートが見つかりません。\n詳細: {e}") + return None + + try: + with open(llm_path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + print(f"エラー: JSONの読み込みに失敗しました。\n詳細: {e}") + return None + + html_str = template.render(**data) + + output_dir = os.path.join(settings.data_dir, "html") + os.makedirs(output_dir, exist_ok=True) + output_filename = os.path.join(output_dir, f"{job_id}.html") + with open(output_filename, "w", encoding="utf-8") as f: + f.write(html_str) + + print("-" * 30) + print(f"成功! '{output_filename}' を作成しました。") + print("このファイルをブラウザで開いてデザインを確認してください。") + print("-" * 30) + return output_filename + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="LLM JSONをdefault.html.j2に埋め込み、HTMLを生成します。" + ) + parser.add_argument("job_id", help="LLM JSONのjob_id (llm/{job_id}.json)") + args = parser.parse_args() + generate_preview(args.job_id) diff --git a/backend/app/gemini_client.py b/backend/app/gemini_client.py new file mode 100644 index 0000000..fb72ee3 --- /dev/null +++ b/backend/app/gemini_client.py @@ -0,0 +1,108 @@ +import json +import re +from typing import Iterable + +from google import genai + +from .config import settings +from .models import PrintDoc + +class LLMError(Exception): + """Raised for Gemini/LLM related failures.""" + +def _get_client() -> genai.Client: + api_key = settings.gemini_api_key + if not api_key: + raise LLMError("GEMINI_API_KEY is not configured") + return genai.Client(api_key=api_key) + +def _parse_bits(payload: str) -> list[int]: + raw = payload.strip() + if not raw: + raise LLMError("payload is empty") + + if raw.startswith("["): + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise LLMError("payload JSON parse failed") from exc + if isinstance(data, list): + bits = [int(v) for v in data] + else: + raise LLMError("payload JSON must be a list") + else: + bits = [int(v) for v in re.findall(r"[01]", raw)] + + if len(bits) != 5 or any(v not in (0, 1) for v in bits): + raise LLMError("payload must contain exactly five 0/1 values") + return bits + +def _topic_index(bits: Iterable[int]) -> int: + b2, b3 = list(bits)[2:4] + return (b2 << 1) | b3 + +def _build_prompt(bits: list[int]) -> str: + tone = "男" if bits[0] else "女" + length = "和" if bits[1] else "現代" + topics = [ + "ファンタジー", + "外国語由来の言葉", + "事象", + "アニメ", + ] + topic = topics[_topic_index(bits)] + include_bullets = bool(bits[4]) + + bullets_rule = "漢字表記のキラキラネームを生成する" if include_bullets else "箇条書きは空配列にする" + return ( + "あなたは赤子に名前をつける親です。次の条件でJSONのみを出力してください。\n" + "出力フォーマットは {\"name\": string, \"ruby\": string} です。\n" + "nameは漢字表記です。\n" + "subnameはnameの読み仮名(ひらがな)です。\n" + "コードフェンスや説明文は不要です。\n" + f"トーン: {tone}\n" + f"長さ: {length}\n" + f"テーマ: {topic}\n" + f"箇条書き: {bullets_rule}\n" + ) + +def _extract_json(text: str) -> dict: + try: + return json.loads(text) + except json.JSONDecodeError: + match = re.search(r"\{.*\}", text, re.S) + if not match: + raise + return json.loads(match.group(0)) + +def gemini_transform(payload: str) -> tuple[PrintDoc, dict]: + """ + Transform input payload via Gemini into a PrintDoc. + """ + bits = _parse_bits(payload) + prompt = _build_prompt(bits) + client = _get_client() + + try: + response = client.models.generate_content( + model=settings.gemini_model, + contents=prompt, + ) + except Exception as exc: + raise LLMError(f"Gemini request failed: {exc}") from exc + + text = getattr(response, "text", None) + if not text: + raise LLMError("Gemini returned empty response") + + try: + data = _extract_json(text) + except json.JSONDecodeError as exc: + raise LLMError("Gemini response is not valid JSON") from exc + + try: + doc = PrintDoc.model_validate(data) + except Exception as exc: + raise LLMError(f"Gemini response validation failed: {exc}") from exc + + return doc, data diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5b36607 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,93 @@ +import uuid +import logging +import os +from fastapi import FastAPI, BackgroundTasks, HTTPException +from fastapi.responses import FileResponse +from .models import PrintRequest, JobStatus +from .store import create_or_get_job, write_job, read_job, write_llm_result +from .gemini_client import gemini_transform, LLMError +from .render import render_pdf +from . import print_service +from .config import settings + +app = FastAPI() + +@app.get("/health") +def health(): + return {"ok": True} + +@app.post("/v1/print", response_model=dict) +def create_print(req: PrintRequest, bg: BackgroundTasks): + new_job_id = str(uuid.uuid4()) + job_id, created = create_or_get_job(req.device_id, req.idempotency_key, new_job_id) + + if not created: + # 既存ジョブを返す(再送でも二重印刷しない) + try: + job = read_job(job_id) + return {"job_id": job_id, "status": job["status"]} + except FileNotFoundError as exc: + # 参照壊れはレアだが、ここでは作り直さずエラーにする + raise HTTPException( + status_code=409, + detail="Idempotency map exists but job missing", + ) from exc + + write_job(job_id, "RECEIVED") + write_job(job_id, "QUEUED") + bg.add_task(process_job, job_id, req) + return {"job_id": job_id, "status": "QUEUED"} + +@app.get("/v1/jobs/{job_id}", response_model=JobStatus) +def get_job(job_id: str): + try: + job = read_job(job_id) + return JobStatus(**job) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="job not found") from exc + +@app.get("/v1/download/{job_id}") +def download_pdf(job_id: str): + try: + job = read_job(job_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="job not found") from exc + + artifact_path = job.get("artifact_path") + if not artifact_path: + artifact_path = os.path.join(settings.artifacts_dir, f"{job_id}.pdf") + + if not os.path.exists(artifact_path): + raise HTTPException(status_code=404, detail="pdf not found") + + return FileResponse( + artifact_path, + media_type="application/pdf", + filename=f"{job_id}.pdf", + ) + +def process_job(job_id: str, req: PrintRequest) -> None: + try: + write_job(job_id, "LLM_PROCESSING") + doc, llm_data = gemini_transform(req.payload) + write_llm_result(job_id, llm_data) + + write_job(job_id, "RENDERING") + pdf_path = render_pdf(job_id, req.template_id, doc) + + write_job(job_id, "PRINTING", artifact_path=pdf_path) + print_service.print_pdf(pdf_path, req.copies) + + write_job(job_id, "PRINTED", artifact_path=pdf_path) + + except Exception as e: + # ログにフルスタックを残してデバッグしやすくする + logging.exception("Job %s failed", job_id) + # 例外型に基づいてステータスを決定する + if isinstance(e, print_service.PrintError): + status = "PRINT_FAILED" + elif isinstance(e, LLMError): + status = "LLM_FAILED" + else: + status = "RENDER_FAILED" + write_job(job_id, status, error={"message": str(e)}) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..4bf4295 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,32 @@ +from typing import Literal +from pydantic import BaseModel, Field + +class PrintRequest(BaseModel): + device_id: str = Field(default="esp32") + idempotency_key: str = Field(min_length=8, max_length=200) + payload: str = Field(min_length=1, max_length=4000) + template_id: str = Field(default="default") + copies: int = Field(default=1, ge=1, le=20) + +class JobStatus(BaseModel): + job_id: str + status: Literal[ + "RECEIVED", + "QUEUED", + "LLM_PROCESSING", + "LLM_FAILED", + "RENDERING", + "RENDER_FAILED", + "PRINTING", + "PRINT_FAILED", + "PRINTED", + ] + + error: dict | None = None + artifact_path: str | None = None + updated_at: str + +class PrintDoc(BaseModel): + name: str = Field(description="キラキラネーム") + ruby: str = Field(description="キラキラネームの読み仮名(ひらがな)") + #bullets: list[str] = Field(default_factory=list, description="箇条書き(0個以上)") diff --git a/backend/app/print_service.py b/backend/app/print_service.py new file mode 100644 index 0000000..8a74ec8 --- /dev/null +++ b/backend/app/print_service.py @@ -0,0 +1,22 @@ +import subprocess +from subprocess import CalledProcessError +from .config import settings + +class PrintError(Exception): + """Raised when printing fails.""" + +def print_pdf(pdf_path: str, copies: int = 1) -> None: + """ + Print the PDF file at pdf_path copies times using scripts/print_pdf.sh. + """ + printer = settings.printer_name or "" + cmd = ["bash", "scripts/print_pdf.sh", pdf_path, str(copies), printer] + # helpful debug output + print("Running print command:", cmd) + try: + subprocess.run(cmd, check=True, text=True, capture_output=True) + except CalledProcessError as e: + raise PrintError(f"Print failed: {e.stderr}") from e + + + diff --git a/backend/app/render.py b/backend/app/render.py new file mode 100644 index 0000000..355c8d0 --- /dev/null +++ b/backend/app/render.py @@ -0,0 +1,55 @@ +import json +import os +from jinja2 import Environment, FileSystemLoader, select_autoescape, TemplateNotFound +try: + from weasyprint import HTML # type: ignore +except Exception: # 環境に weasyprint 依存がない場合に備える + HTML = None # type: ignore +from .config import settings +from .models import PrintDoc + +_env = Environment( + loader=FileSystemLoader("templates"), + autoescape=select_autoescape(["html", "xml"]), +) + + +class RenderError(Exception): + """Raised when rendering (templating/PDF) fails.""" + +def render_pdf(job_id: str, template_id: str, doc: PrintDoc) -> str: + template_name = f"{template_id}.html.j2" + try: + template = _env.get_template(template_name) + except TemplateNotFound as e: + raise RenderError(f"template not found: {template_name}") from e + + html_str = template.render( + name=doc.name, + ruby = doc.ruby, + + ) + + os.makedirs(settings.artifacts_dir, exist_ok=True) + pdf_path = os.path.join(settings.artifacts_dir, f"{job_id}.pdf") + + if HTML is None: + # テスト環境や最小依存環境では weasyprint がないことがある + raise RenderError("WeasyPrint not available") + + try: + HTML(string=html_str, base_url=os.getcwd()).write_pdf(pdf_path) + except Exception as e: + # WeasyPrint の失敗をレンダリングエラーとして包む + raise RenderError(f"pdf generation failed: {e}") from e + return pdf_path + + +# デバッグ用 +if __name__ == "__main__": + with open("data/idem/print_doc_sample_simple.json", "r", encoding="utf-8") as f: + data = json.load(f) + + demo_doc = PrintDoc.model_validate(data) + demo_pdf_path = render_pdf("1", "index", demo_doc) + print("PDF generated:", demo_pdf_path) diff --git a/backend/app/store.py b/backend/app/store.py new file mode 100644 index 0000000..42c58d8 --- /dev/null +++ b/backend/app/store.py @@ -0,0 +1,84 @@ +import hashlib +import json +import os +import time +from typing import Any +from .config import settings + +def _ensure_dirs() -> None: + for d in [settings.jobs_dir, settings.idem_dir, settings.llm_dir, settings.artifacts_dir]: + os.makedirs(d, exist_ok=True) + +def _atomic_write_json(path: str, obj: dict[str, Any]) -> None: + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(obj, f, ensure_ascii=False, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + +def idem_key_to_filename(device_id: str, idempotency_key: str) -> str: + h = hashlib.sha256(f"{device_id}:{idempotency_key}".encode("utf-8")).hexdigest() + return os.path.join(settings.idem_dir, f"{h}.json") + +def job_path(job_id: str) -> str: + return os.path.join(settings.jobs_dir, f"{job_id}.json") + +def llm_path(job_id: str) -> str: + return os.path.join(settings.llm_dir, f"{job_id}.json") + +def create_or_get_job(device_id: str, idempotency_key: str, new_job_id: str) -> tuple[str, bool]: + """ + returns: (job_id, created_new) + """ + _ensure_dirs() + idem_path = idem_key_to_filename(device_id, idempotency_key) + + if os.path.exists(idem_path): + with open(idem_path, "r", encoding="utf-8") as f: + job_id = json.load(f)["job_id"] + return job_id, False + + # 排他作成(存在したら誰かが先に作った) + try: + fd = os.open(idem_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"job_id": new_job_id}, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + return new_job_id, True + except FileExistsError: + with open(idem_path, "r", encoding="utf-8") as f: + job_id = json.load(f)["job_id"] + return job_id, False + +def write_job( + job_id: str, + status: str, + *, + error: dict | None = None, + artifact_path: str | None = None, +) -> None: + _ensure_dirs() + now = time.strftime("%Y-%m-%dT%H:%M:%S%z") + path = job_path(job_id) + obj = { + "job_id": job_id, + "status": status, + "error": error, + "artifact_path": artifact_path, + "updated_at": now, + } + _atomic_write_json(path, obj) + +def write_llm_result(job_id: str, data: dict[str, Any]) -> None: + _ensure_dirs() + path = llm_path(job_id) + _atomic_write_json(path, data) + +def read_job(job_id: str) -> dict: + path = job_path(job_id) + if not os.path.exists(path): + raise FileNotFoundError(job_id) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/backend/data/artifacts/1.pdf b/backend/data/artifacts/1.pdf new file mode 100644 index 0000000..c381316 Binary files /dev/null and b/backend/data/artifacts/1.pdf differ diff --git a/backend/data/idem/print_doc_sample_simple.json b/backend/data/idem/print_doc_sample_simple.json new file mode 100644 index 0000000..c4cf269 --- /dev/null +++ b/backend/data/idem/print_doc_sample_simple.json @@ -0,0 +1,9 @@ +{ + "title": "印刷デバッグ:サンプル(シンプル)", + "body": "これはWeasyPrintのPDF変換デバッグ用データです。\n\n- 改行\n- 句読点(、。)\n- 英数字 (ABC 123)\n\nこのままテンプレートに流し込んでPDF化できればOK。", + "bullets": [ + "本文に改行が含まれること", + "箇条書きがレンダリングできること", + "日本語フォントが豆腐にならないこと" + ] +} \ No newline at end of file diff --git a/backend/data/llm/1.json b/backend/data/llm/1.json new file mode 100644 index 0000000..c4cf269 --- /dev/null +++ b/backend/data/llm/1.json @@ -0,0 +1,9 @@ +{ + "title": "印刷デバッグ:サンプル(シンプル)", + "body": "これはWeasyPrintのPDF変換デバッグ用データです。\n\n- 改行\n- 句読点(、。)\n- 英数字 (ABC 123)\n\nこのままテンプレートに流し込んでPDF化できればOK。", + "bullets": [ + "本文に改行が含まれること", + "箇条書きがレンダリングできること", + "日本語フォントが豆腐にならないこと" + ] +} \ No newline at end of file diff --git a/backend/data/test.json b/backend/data/test.json new file mode 100644 index 0000000..5e9fef0 --- /dev/null +++ b/backend/data/test.json @@ -0,0 +1,3 @@ +{ + "name":"理人" +} \ No newline at end of file diff --git a/backend/design_preview.html b/backend/design_preview.html new file mode 100644 index 0000000..aa91350 --- /dev/null +++ b/backend/design_preview.html @@ -0,0 +1,141 @@ + + + + + 命名書 + + + + +
+ +
+ 令和七年十二月二十日 +
+ +
+ 命名 +
+ +
+ 夜空 +
+ +
+ よぞら +
+ +
+ + + \ No newline at end of file diff --git a/backend/font/ipamjm.ttf b/backend/font/ipamjm.ttf new file mode 100755 index 0000000..d9602cb Binary files /dev/null and b/backend/font/ipamjm.ttf differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..df484ed --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +dotenv +fastapi[all] +google.generativeai +jinja2 +pydantic +pylint +pypdf +pytest +python-dotenv +python-multipart +uvicorn[all] +weasyprint \ No newline at end of file diff --git a/backend/scripts/dev.sh b/backend/scripts/dev.sh new file mode 100755 index 0000000..c7be7d1 --- /dev/null +++ b/backend/scripts/dev.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# HOST と PORT は環境変数で上書き可能 +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8000}" + +if command -v uvicorn >/dev/null 2>&1; then + exec uvicorn app.main:app --reload --host "$HOST" --port "$PORT" +else + echo "uvicorn not found. Install deps: pip install -r requirements.txt" >&2 + exit 1 +fi + diff --git a/backend/scripts/print_pdf.sh b/backend/scripts/print_pdf.sh new file mode 100755 index 0000000..9dea07e --- /dev/null +++ b/backend/scripts/print_pdf.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +PDF_PATH="${1:?pdf path required}" +COPIES="${2:-1}" +PRINTER_NAME="${3:-}" + +if command -v lp >/dev/null 2>&1; then + if [[ -n "${PRINTER_NAME}" ]]; then + lp -d "${PRINTER_NAME}" -n "${COPIES}" "${PDF_PATH}" + else + lp -n "${COPIES}" "${PDF_PATH}" + fi +else + echo "lp command not found (CUPS). Install/configure CUPS." >&2 + exit 1 +fi diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh new file mode 100755 index 0000000..8234f3f --- /dev/null +++ b/backend/scripts/test.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v pytest >/dev/null 2>&1; then + exec pytest -q "$@" +else + echo "pytest not found. Install deps: pip install -r requirements.txt" >&2 + exit 1 +fi \ No newline at end of file diff --git a/backend/templates/default.html.j2 b/backend/templates/default.html.j2 new file mode 100644 index 0000000..d5c81f9 --- /dev/null +++ b/backend/templates/default.html.j2 @@ -0,0 +1,148 @@ + + + + + + {{ title | default("命名書") }} + + + + + +
+
+ +
+ +
+ {% if ruby %} +
{{ ruby }}
+ {% endif %} +
{{ name | default("名前") }}
+
+ +
+
+
{{ date | default("令和7年") }}
+
{{ issuer | default("") }}
+
+
+ +
+
+ + diff --git a/backend/templates/static/Tamanegi.ttf b/backend/templates/static/Tamanegi.ttf new file mode 100644 index 0000000..b0b2b6d Binary files /dev/null and b/backend/templates/static/Tamanegi.ttf differ diff --git a/backend/templates/static/style.css b/backend/templates/static/style.css new file mode 100644 index 0000000..68c9099 --- /dev/null +++ b/backend/templates/static/style.css @@ -0,0 +1,108 @@ +/* ========================================= + 設定変数 (CSS Custom Properties) + チーム開発で変更頻度が高い値をここで一元管理します + ========================================= */ +:root { + /* 用紙設定: A4サイズ */ + --paper-width: 210mm; + --paper-height: 297mm; + + /* 色設定 */ + --color-bg-app: #555; + --color-paper: #ffffff; + --color-debug-border: #ccc; + + /* フォントサイズ */ + --fs-base: 24pt; + --fs-date: 50px; + --fs-label: 100px; + --fs-name: 180pt; + --fs-ruby: 70px; +} + +/* ========================================= + ベーススタイル + ========================================= */ +body { + margin: 0; + padding: 20px; + background-color: var(--color-bg-app); +} + +/* 開発用デバッグスタイル + ※ 要素の領域を可視化するための点線枠 + */ +.debug-box { + border: 1px dashed var(--color-debug-border); + padding: 10px; + margin-left: 20px; /* 元のコードの仕様維持:絶対配置要素に対するオフセット */ +} + +/* ========================================= + コンポーネント: 命名書 (Naming Sheet) + ========================================= */ + +/* 用紙のコンテナ */ +.naming-sheet { + /* レイアウト: A4中央配置 */ + position: relative; + width: var(--paper-width); + height: var(--paper-height); + margin: 0 auto; + + /* 装飾 */ + background-color: var(--color-paper); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + + /* 文字設定: 縦書き */ + writing-mode: vertical-rl; + text-orientation: upright; + font-family: serif; +} + +/* 要素共通スタイル (Element) + 用紙内の各テキスト要素に適用 + */ +.naming-sheet__item { + position: absolute; + font-size: var(--fs-base); + white-space: nowrap; +} + +/* ========================================= + 各要素の配置 (Modifiers) + ========================================= */ + +/* 1. 日付 */ +.naming-sheet__item--date { + left: 5%; + top: 5%; + font-size: var(--fs-date); +} + +/* 2. 「命名」ラベル */ +.naming-sheet__item--label { + /* 横方向:親の50%位置を基準に、自身の50%分戻して中央揃え */ + left: 50%; + transform: translateX(-50%); + + top: 5%; + font-size: var(--fs-label); +} + +/* 3. 名前(漢字) */ +.naming-sheet__item--name { + left: 50%; + transform: translateX(-50%); + + /* 注意: vhはビューポートの高さ依存です。印刷時は挙動が変わる可能性があります */ + top: 20vh; + font-size: var(--fs-name); +} + +/* 4. 名前(ルビ) */ +.naming-sheet__item--ruby { + right: 10%; + top: 23vh; + font-size: var(--fs-ruby); +} diff --git a/backend/test/conftest.py b/backend/test/conftest.py new file mode 100644 index 0000000..f687bf3 --- /dev/null +++ b/backend/test/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +# プロジェクトルート(tests の親=backend)を import パスに追加 +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/backend/test/test_api.py b/backend/test/test_api.py new file mode 100644 index 0000000..83e416a --- /dev/null +++ b/backend/test/test_api.py @@ -0,0 +1,80 @@ +import time +from fastapi.testclient import TestClient + +from app.main import app +from app.store import write_job +from app.config import settings + + +def test_print_flow_and_idempotency(tmp_path, monkeypatch): + # ランタイムディレクトリを一時領域へ + monkeypatch.setattr(settings, "jobs_dir", str(tmp_path / "jobs"), raising=False) + monkeypatch.setattr(settings, "idem_dir", str(tmp_path / "idem"), raising=False) + monkeypatch.setattr(settings, "artifacts_dir", str(tmp_path / "artifacts"), raising=False) + + # process_job を同期・高速なダミーに差し替え(バックグラウンドタスクの完了を容易に) + import app.main as m + + def fake_process(job_id, req): + write_job(job_id, "LLM_PROCESSING") + write_job(job_id, "RENDERING") + # ダミーの成果物パス + fake_pdf = str((tmp_path / "artifacts" / f"{job_id}.pdf").resolve()) + write_job(job_id, "PRINTING", artifact_path=fake_pdf) + write_job(job_id, "PRINTED", artifact_path=fake_pdf) + + monkeypatch.setattr(m, "process_job", fake_process, raising=True) + + client = TestClient(app) + + body = { + "device_id": "devA", + "idempotency_key": "idem-12345678", + "payload": "Hello", + "template_id": "index", + "copies": 1, + } + + # 1回目: ジョブ作成 + r = client.post("/v1/print", json=body) + assert r.status_code == 200 + data = r.json() + job_id = data["job_id"] + assert data["status"] in {"QUEUED", "PRINTED"} + + # バックグラウンドタスクの完了を待機(最大 1 秒) + for _ in range(100): + jr = client.get(f"/v1/jobs/{job_id}") + assert jr.status_code == 200 + if jr.json()["status"] == "PRINTED": + break + time.sleep(0.01) + else: + assert False, "job did not reach PRINTED in time" + + # 2回目: 同じ idempotency_key で再送 → 既存ジョブを返す + r2 = client.post("/v1/print", json=body) + assert r2.status_code == 200 + data2 = r2.json() + assert data2["job_id"] == job_id + assert data2["status"] == "PRINTED" + + +def test_download_pdf(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "jobs_dir", str(tmp_path / "jobs"), raising=False) + monkeypatch.setattr(settings, "idem_dir", str(tmp_path / "idem"), raising=False) + monkeypatch.setattr(settings, "artifacts_dir", str(tmp_path / "artifacts"), raising=False) + + job_id = "job-123" + pdf_path = tmp_path / "artifacts" / f"{job_id}.pdf" + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_bytes = b"%PDF-1.4\n%fake\n" + pdf_path.write_bytes(pdf_bytes) + + write_job(job_id, "PRINTED", artifact_path=str(pdf_path)) + + client = TestClient(app) + r = client.get(f"/v1/download/{job_id}") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("application/pdf") + assert r.content == pdf_bytes diff --git a/backend/test/test_main.py b/backend/test/test_main.py new file mode 100644 index 0000000..c556bb5 --- /dev/null +++ b/backend/test/test_main.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app.main import app + + +def test_health_ok(): + client = TestClient(app) + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"ok": True} + diff --git a/backend/test/test_print_service.py b/backend/test/test_print_service.py new file mode 100644 index 0000000..f9f5642 --- /dev/null +++ b/backend/test/test_print_service.py @@ -0,0 +1,44 @@ +import types +import subprocess +import pytest + +from app.print_service import print_pdf, PrintError +from app import print_service as ps + + +def test_print_pdf_calls_script_with_printer(monkeypatch, tmp_path): + # プリンタ名を設定 + monkeypatch.setattr(ps.settings, "printer_name", "PRN001", raising=False) + + called = {} + + def fake_run(cmd, check, text, capture_output): + called["cmd"] = cmd + # 成功として何も返さない(呼び出し側は値を使用しない) + return types.SimpleNamespace() + + monkeypatch.setattr(subprocess, "run", fake_run) + + pdf = str(tmp_path / "sample.pdf") + print_pdf(pdf, 2) + + assert called["cmd"] == [ + "bash", + "scripts/print_pdf.sh", + pdf, + "2", + "PRN001", + ] + + +def test_print_pdf_raises_on_failure(monkeypatch, tmp_path): + monkeypatch.setattr(ps.settings, "printer_name", "PRN001", raising=False) + + def fail_run(cmd, check, text, capture_output): + raise subprocess.CalledProcessError(returncode=1, cmd=cmd, stderr="boom") + + monkeypatch.setattr(subprocess, "run", fail_run) + + with pytest.raises(PrintError): + print_pdf(str(tmp_path / "x.pdf"), 1) + diff --git a/backend/test/test_render.py b/backend/test/test_render.py new file mode 100644 index 0000000..ae769f7 --- /dev/null +++ b/backend/test/test_render.py @@ -0,0 +1,50 @@ +import os +from app.render import render_pdf, RenderError, HTML as REAL_HTML +from app.models import PrintDoc +from app.config import settings +import types + + +def test_render_missing_template_raises(tmp_path, monkeypatch): + # 出力先を一時ディレクトリに変更 + monkeypatch.setattr(settings, "artifacts_dir", str(tmp_path), raising=False) + + doc = PrintDoc(title="t", body="b", bullets=["x"]) + with raise_render_error(): + render_pdf("job-1", "__no_such_template__", doc) + + +def test_render_success_creates_pdf(tmp_path, monkeypatch): + # 出力先を一時ディレクトリに変更 + monkeypatch.setattr(settings, "artifacts_dir", str(tmp_path), raising=False) + + # WeasyPrint の HTML.write_pdf を差し替えて副作用を軽量化 + class FakeHTML: + def __init__(self, *args, **kwargs): + pass + + def write_pdf(self, pdf_path): + # 生成物として空ファイルを作る + with open(pdf_path, "wb") as f: + f.write(b"") + + from app import render as r + monkeypatch.setattr(r, "HTML", FakeHTML, raising=True) + + doc = PrintDoc(title="Title", body="Body", bullets=["a", "b"]) + out = render_pdf("job-xyz", "index", doc) + assert os.path.exists(out) + + +# ヘルパ: RenderError を期待する with 構文 +from contextlib import contextmanager + + +@contextmanager +def raise_render_error(): + try: + yield + assert False, "RenderError was not raised" + except RenderError: + pass + diff --git a/backend/test/test_store.py b/backend/test/test_store.py new file mode 100644 index 0000000..22f2229 --- /dev/null +++ b/backend/test/test_store.py @@ -0,0 +1,41 @@ +import os +from app.store import create_or_get_job, write_job, read_job +from app.config import settings + + +def test_create_or_get_job_idempotency(tmp_path, monkeypatch): + # 一時ディレクトリに切替 + jobs = tmp_path / "jobs" + idem = tmp_path / "idem" + arts = tmp_path / "artifacts" + monkeypatch.setattr(settings, "jobs_dir", str(jobs), raising=False) + monkeypatch.setattr(settings, "idem_dir", str(idem), raising=False) + monkeypatch.setattr(settings, "artifacts_dir", str(arts), raising=False) + + job_id, created = create_or_get_job("dev1", "key-12345678", "job-1") + assert created is True + assert job_id == "job-1" + + # 同じキーでもう一度: 既存の job が返る + job_id2, created2 = create_or_get_job("dev1", "key-12345678", "job-2") + assert created2 is False + assert job_id2 == job_id + + +def test_write_and_read_job(tmp_path, monkeypatch): + jobs = tmp_path / "jobs" + idem = tmp_path / "idem" + arts = tmp_path / "artifacts" + monkeypatch.setattr(settings, "jobs_dir", str(jobs), raising=False) + monkeypatch.setattr(settings, "idem_dir", str(idem), raising=False) + monkeypatch.setattr(settings, "artifacts_dir", str(arts), raising=False) + + job_id, _ = create_or_get_job("devX", "key-abcdefgh", "job-xyz") + write_job(job_id, "RECEIVED") + + obj = read_job(job_id) + assert obj["job_id"] == job_id + assert obj["status"] == "RECEIVED" + assert obj["error"] is None + assert "updated_at" in obj + diff --git a/data/artifacts/1b6c6b08-d7f1-4d4f-ba3b-9c4a3a586ddc.pdf b/data/artifacts/1b6c6b08-d7f1-4d4f-ba3b-9c4a3a586ddc.pdf new file mode 100644 index 0000000..caa16cf Binary files /dev/null and b/data/artifacts/1b6c6b08-d7f1-4d4f-ba3b-9c4a3a586ddc.pdf differ diff --git a/data/artifacts/b995a2be-5114-494a-9705-6a81c4202127.pdf b/data/artifacts/b995a2be-5114-494a-9705-6a81c4202127.pdf new file mode 100644 index 0000000..af63ba7 Binary files /dev/null and b/data/artifacts/b995a2be-5114-494a-9705-6a81c4202127.pdf differ diff --git a/data/artifacts/d2b8e822-5ed9-4799-b03f-cbafb51e5257.pdf b/data/artifacts/d2b8e822-5ed9-4799-b03f-cbafb51e5257.pdf new file mode 100644 index 0000000..1bf26ec Binary files /dev/null and b/data/artifacts/d2b8e822-5ed9-4799-b03f-cbafb51e5257.pdf differ