From 407e55d2147f05bb506e7fa73e02e177ed4ba7af Mon Sep 17 00:00:00 2001 From: root Date: Wed, 5 Aug 2026 06:19:40 +0000 Subject: [PATCH] fix: pass Korean inputs through GPU promptd --- README.md | 18 ++++++++-- app/main.py | 77 ++++++++++++++++++++++++++-------------- tests/test_app.py | 89 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 139 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index fc3314a..96d7d85 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,19 @@ curl "$SERVICE_URL/jobs/claim?worker_id=gpu-1" \ -H "Authorization: Bearer $WORKER_TOKEN" ``` -The claim response contains exactly `id`, `workflow`, and `inputs`. Upload one or more generated images with a JSON-string `meta` field; `image_0` is required: +The claim response contains exactly `id`, `workflow`, and `inputs`. Preview inputs +omit `prompt` entirely and contain the submitted `title` and `keywords` unchanged +(including Korean), plus `style`, `seed`, `prefix`, dimensions, `format`, `quality`, +and `steps`. The GPU worker is responsible for rendering an English prompt. + +Upload one or more generated images with a JSON-string `meta` field; `image_0` is +required. For previews, `meta.prompt` must be a non-empty string containing the +actual English prompt used by the GPU: ```bash curl -X POST "$SERVICE_URL/jobs/JOB_ID/result" \ -H "Authorization: Bearer $WORKER_TOKEN" \ - -F 'meta={"renderer":"comfyui"}' \ + -F 'meta={"renderer":"comfyui","prompt":"Exact prompt used by the GPU"}' \ -F image_0=@preview.webp \ -F image_1=@alternate.webp ``` @@ -81,6 +88,13 @@ curl -X POST "$SERVICE_URL/api/covers/REQUEST_ID/select" \ -d '{"style_id":"watercolour"}' ``` +Selection returns HTTP 409 until that style's preview is done and its rendered +prompt has been stored. Final inputs contain that prompt byte-for-byte, omit +`title` and `keywords`, and reuse the selected preview's exact seed. + +At startup, existing SQLite databases are migrated in place by adding the nullable +`jobs.rendered_prompt` column when absent; existing rows and files are preserved. + Previews request 512×768 WebP at quality 82. Final jobs request 1795×2657 WebP at quality 92. Both use generation dimensions 1024×1536 and four steps. ## Verification diff --git a/app/main.py b/app/main.py index 43b9768..fc6e9bc 100644 --- a/app/main.py +++ b/app/main.py @@ -80,6 +80,7 @@ class Failure(StrictModel): style_id TEXT NOT NULL, kind TEXT NOT NULL, workflow TEXT NOT NULL, inputs TEXT NOT NULL, status TEXT NOT NULL, worker_id TEXT, file TEXT, error TEXT, claimed_at TEXT, attempts INTEGER NOT NULL DEFAULT 0, + rendered_prompt TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS jobs_queue ON jobs(status, created_at); @@ -99,24 +100,18 @@ def _connect(app: FastAPI) -> sqlite3.Connection: return connection -def _prompt(title: str, keywords: list[str]) -> str: - lowered_title = title.strip().lower() - clean = [ - item.strip() - for item in keywords - if item.strip() - and item.isascii() - and lowered_title not in item.strip().lower() - and item.strip().lower() not in lowered_title - ] - scene = ", ".join(clean) if clean and len(clean) == len(keywords) else "a quiet landscape with balanced natural forms and soft atmospheric light" - return f"Wordless book cover artwork, no typography. Scene: {scene}. No text, letters, logo, watermark, or signature." - - -def _inputs(prompt: str, style_id: str, seed: int, kind: str, prefix: str) -> dict[str, Any]: +def _inputs( + style_id: str, + seed: int, + kind: str, + prefix: str, + *, + title: str | None = None, + keywords: list[str] | None = None, + prompt: str | None = None, +) -> dict[str, Any]: final = kind == "final" - return { - "prompt": prompt, + inputs = { "style": STYLES[style_id]["prompt"], "seed": seed, "prefix": prefix, @@ -128,6 +123,12 @@ def _inputs(prompt: str, style_id: str, seed: int, kind: str, prefix: str) -> di "width": WIDTH, "height": HEIGHT, } + if final: + inputs["prompt"] = prompt + else: + inputs["title"] = title + inputs["keywords"] = keywords + return inputs def worker_auth(authorization: Annotated[str | None, Header()] = None) -> None: @@ -152,6 +153,9 @@ async def lifespan(app: FastAPI): with _connect(app) as connection: connection.execute("PRAGMA journal_mode = WAL") connection.executescript(SCHEMA) + columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")} + if "rendered_prompt" not in columns: + connection.execute("ALTER TABLE jobs ADD COLUMN rendered_prompt TEXT") yield app = FastAPI(title="GPU cover worker service", lifespan=lifespan) @@ -170,17 +174,19 @@ def styles() -> dict: @app.post("/api/covers") def create_cover(body: CoverCreate) -> dict: request_id, created_at = str(uuid.uuid4()), now() - prompt = _prompt(body.title, body.keywords) seed = secrets.randbelow(2_000_000_000) with _connect(app) as connection: connection.execute("BEGIN IMMEDIATE") connection.execute( "INSERT INTO requests(id,title,keywords,prompt,seed,created_at) VALUES(?,?,?,?,?,?)", - (request_id, body.title, json.dumps(body.keywords, ensure_ascii=False), prompt, seed, created_at), + (request_id, body.title, json.dumps(body.keywords, ensure_ascii=False), "", seed, created_at), ) for index, style_id in enumerate(STYLES): job_id = str(uuid.uuid4()) - inputs = _inputs(prompt, style_id, seed + index, "preview", f"{request_id}_{style_id}_preview") + inputs = _inputs( + style_id, seed + index, "preview", f"{request_id}_{style_id}_preview", + title=body.title, keywords=body.keywords, + ) connection.execute( """INSERT INTO jobs(id,request_id,style_id,kind,workflow,inputs,status,created_at) VALUES(?,?,?,'preview','cover',?,'queued',?)""", @@ -225,12 +231,22 @@ def get_cover(request_id: str) -> dict: def select_cover(request_id: str, body: Selection) -> dict: with _connect(app) as connection: connection.execute("BEGIN IMMEDIATE") - stored = connection.execute("SELECT prompt,seed FROM requests WHERE id=?", (request_id,)).fetchone() + stored = connection.execute("SELECT seed FROM requests WHERE id=?", (request_id,)).fetchone() if stored is None: raise HTTPException(404, "cover request not found") - index = list(STYLES).index(body.style_id) + preview = connection.execute( + """SELECT status,rendered_prompt,inputs FROM jobs + WHERE request_id=? AND kind='preview' AND style_id=?""", + (request_id, body.style_id), + ).fetchone() + if preview is None or preview["status"] != "done" or not preview["rendered_prompt"]: + raise HTTPException(409, "selected preview is not done with a rendered prompt") + preview_seed = json.loads(preview["inputs"])["seed"] job_id, created_at = str(uuid.uuid4()), now() - inputs = _inputs(stored["prompt"], body.style_id, stored["seed"] + index, "final", f"{request_id}_{body.style_id}_final") + inputs = _inputs( + body.style_id, preview_seed, "final", f"{request_id}_{body.style_id}_final", + prompt=preview["rendered_prompt"], + ) connection.execute( """INSERT INTO jobs(id,request_id,style_id,kind,workflow,inputs,status,created_at) VALUES(?,?,?,'final','cover',?,'queued',?)""", @@ -285,7 +301,7 @@ async def job_result(job_id: str, request: Request) -> dict: if not isinstance(meta_value, str): raise HTTPException(422, "meta must be a JSON string") try: - json.loads(meta_value) + meta = json.loads(meta_value) except json.JSONDecodeError as exc: raise HTTPException(422, "meta must be valid JSON") from exc uploads: list[tuple[str, Any]] = [] @@ -295,11 +311,16 @@ async def job_result(job_id: str, request: Request) -> dict: if not any(key == "image_0" for key, _ in uploads): raise HTTPException(422, "image_0 is required") with _connect(app) as connection: - row = connection.execute("SELECT status FROM jobs WHERE id=?", (job_id,)).fetchone() + row = connection.execute("SELECT status,kind FROM jobs WHERE id=?", (job_id,)).fetchone() if row is None: raise HTTPException(404, "job not found") if row["status"] != "running": raise HTTPException(409, "job is not running") + rendered_prompt = meta.get("prompt") if isinstance(meta, dict) else None + if row["kind"] == "preview" and ( + not isinstance(rendered_prompt, str) or not rendered_prompt.strip() + ): + raise HTTPException(422, "preview meta.prompt must be a non-empty string") first_url = None for key, upload in uploads: content = upload.read() @@ -315,7 +336,11 @@ async def job_result(job_id: str, request: Request) -> dict: first_url = f"{public_base_path}/files/{filename}" with _connect(app) as connection: connection.execute("BEGIN IMMEDIATE") - connection.execute("UPDATE jobs SET status='done',file=?,error=NULL WHERE id=? AND status='running'", (first_url, job_id)) + connection.execute( + """UPDATE jobs SET status='done',file=?,error=NULL,rendered_prompt=? + WHERE id=? AND status='running'""", + (first_url, rendered_prompt, job_id), + ) connection.commit() return {"id": job_id, "status": "done", "file": first_url} diff --git a/tests/test_app.py b/tests/test_app.py index 94f8b4b..e9b63b7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -76,19 +76,17 @@ def test_create_accepts_exact_schema(client, bad): assert client.post("/api/covers", json=bad).status_code == 422 -def test_prompt_is_ascii_english_and_does_not_leak_non_ascii_input(client): - response = create_cover(client, title="달빛 고양이", keywords=["숲", "달빛"]) +def test_korean_input_is_stored_exactly_and_preview_claim_omits_prompt(client): + response = create_cover(client, title=" 달빛 고양이 ", keywords=["숲", "달빛", "고요한 밤"]) assert response.status_code == 200 db = sqlite3.connect(os.environ["DATABASE_PATH"]) - prompt = db.execute("SELECT prompt FROM requests").fetchone()[0] - assert prompt.isascii() - assert "달빛 고양이" not in prompt and "숲" not in prompt and "달빛" not in prompt - assert "moon" not in prompt.lower() and "cat" not in prompt.lower() - assert "generic" not in prompt.lower() - - create_cover(client, title="Secret Forest", keywords=["Secret Forest"]) - prompts = [row[0] for row in db.execute("SELECT prompt FROM requests ORDER BY rowid")] - assert "secret forest" not in prompts[-1].lower() + stored = db.execute("SELECT title,keywords FROM requests").fetchone() + assert stored[0] == " 달빛 고양이 " + assert json.loads(stored[1]) == ["숲", "달빛", "고요한 밤"] + inputs = claim(client).json()["inputs"] + assert "prompt" not in inputs + assert inputs["title"] == " 달빛 고양이 " + assert inputs["keywords"] == ["숲", "달빛", "고요한 밤"] def test_claim_exact_payload_atomic_and_records_worker(client): @@ -105,7 +103,7 @@ def get_claim(index): for job in jobs: assert set(job) == {"id", "workflow", "inputs"} assert set(job["inputs"]) == { - "prompt", "style", "seed", "prefix", "out_width", "out_height", "format", + "title", "keywords", "style", "seed", "prefix", "out_width", "out_height", "format", "quality", "steps", "width", "height", } db = sqlite3.connect(os.environ["DATABASE_PATH"]) @@ -153,7 +151,8 @@ def test_get_progress_result_upload_and_final_selection(client): assert all(set(item) == {"style_id", "label", "status", "url"} for item in initial["candidates"]) first = claim(client).json() - meta = {"renderer": "test", "images": 1} + rendered_prompt = "Moonlit forest — exact GPU prompt, punctuation preserved." + meta = {"renderer": "test", "images": 1, "prompt": rendered_prompt} done = client.post( f"/jobs/{first['id']}/result", data={"meta": json.dumps(meta)}, @@ -173,7 +172,8 @@ def test_get_progress_result_upload_and_final_selection(client): ) assert selected.status_code == 200 final_job = claim(client, "final-worker").json() - assert final_job["inputs"]["prompt"] == first["inputs"]["prompt"] + assert final_job["inputs"]["prompt"] == rendered_prompt + assert "title" not in final_job["inputs"] and "keywords" not in final_job["inputs"] assert final_job["inputs"]["seed"] == first["inputs"]["seed"] assert (final_job["inputs"]["out_width"], final_job["inputs"]["out_height"]) == (1795, 2657) assert (final_job["inputs"]["width"], final_job["inputs"]["height"]) == (1024, 1536) @@ -208,7 +208,8 @@ def test_final_status_controls_request_status_when_final_exists(client): request_id = create_cover(client).json()["request_id"] with sqlite3.connect(os.environ["DATABASE_PATH"]) as db: db.execute( - "UPDATE jobs SET status='done' WHERE request_id=? AND kind='preview'", + """UPDATE jobs SET status='done',rendered_prompt='completed prompt' + WHERE request_id=? AND kind='preview' AND style_id='watercolour'""", (request_id,), ) client.post(f"/api/covers/{request_id}/select", json={"style_id": "watercolour"}) @@ -232,7 +233,7 @@ def test_public_base_path_prefixes_urls_but_static_route_stays_internal(tmp_path request_id = create_cover(prefixed).json()["request_id"] job = claim(prefixed).json() result = prefixed.post( - f"/jobs/{job['id']}/result", data={"meta": "{}"}, + f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": "rendered"})}, files={"image_0": ("preview.webp", io.BytesIO(b"prefixed"), "image/webp")}, ).json() expected = f"/image/files/{job['id']}_image_0.webp" @@ -255,7 +256,7 @@ def test_result_requires_meta_and_image_zero_but_accepts_multiple_images(client) files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")}, ).status_code == 422 response = client.post( - f"/jobs/{job['id']}/result", data={"meta": "{}"}, + f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": "rendered"})}, files=[ ("image_0", ("a.webp", io.BytesIO(b"a"), "image/webp")), ("image_1", ("b.webp", io.BytesIO(b"b"), "image/webp")), @@ -264,6 +265,60 @@ def test_result_requires_meta_and_image_zero_but_accepts_multiple_images(client) assert response.status_code == 200 +def test_preview_result_requires_non_empty_prompt_and_persists_it(client): + create_cover(client) + job = claim(client).json() + for meta in ({}, {"prompt": ""}, {"prompt": " "}, {"prompt": 12}): + response = client.post( + f"/jobs/{job['id']}/result", data={"meta": json.dumps(meta)}, + files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")}, + ) + assert response.status_code == 422 + prompt = "Exact rendered prompt 한 글자도 바꾸지 않음" + response = client.post( + f"/jobs/{job['id']}/result", data={"meta": json.dumps({"prompt": prompt})}, + files={"image_0": ("x.webp", io.BytesIO(b"x"), "image/webp")}, + ) + assert response.status_code == 200 + with sqlite3.connect(os.environ["DATABASE_PATH"]) as db: + assert db.execute("SELECT rendered_prompt FROM jobs WHERE id=?", (job["id"],)).fetchone()[0] == prompt + + +def test_selection_requires_done_preview_with_rendered_prompt(client): + request_id = create_cover(client).json()["request_id"] + endpoint = f"/api/covers/{request_id}/select" + assert client.post(endpoint, json={"style_id": "watercolour"}).status_code == 409 + with sqlite3.connect(os.environ["DATABASE_PATH"]) as db: + db.execute( + "UPDATE jobs SET status='done' WHERE request_id=? AND style_id='watercolour' AND kind='preview'", + (request_id,), + ) + assert client.post(endpoint, json={"style_id": "watercolour"}).status_code == 409 + + +def test_lifespan_migrates_existing_database_without_data_loss(tmp_path, monkeypatch): + database = tmp_path / "legacy.db" + with sqlite3.connect(database) as db: + db.executescript(""" + CREATE TABLE requests (id TEXT PRIMARY KEY, title TEXT NOT NULL, keywords TEXT NOT NULL, + prompt TEXT NOT NULL, seed INTEGER NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE jobs (id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES requests(id), + style_id TEXT NOT NULL, kind TEXT NOT NULL, workflow TEXT NOT NULL, inputs TEXT NOT NULL, + status TEXT NOT NULL, worker_id TEXT, file TEXT, error TEXT, claimed_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL); + INSERT INTO requests VALUES ('r','기존 제목','["키워드"]','legacy',7,'now'); + INSERT INTO jobs VALUES ('j','r','watercolour','preview','cover','{}','done',NULL, + '/files/old.webp',NULL,NULL,1,'now'); + """) + monkeypatch.setenv("DATABASE_PATH", str(database)) + monkeypatch.setenv("FILES_DIR", str(tmp_path / "files")) + with TestClient(create_app()): + pass + with sqlite3.connect(database) as db: + assert "rendered_prompt" in {row[1] for row in db.execute("PRAGMA table_info(jobs)")} + assert db.execute("SELECT title,file FROM requests JOIN jobs ON jobs.request_id=requests.id").fetchone() == ("기존 제목", "/files/old.webp") + + def test_failure_retryable_policy_and_exact_json(client): create_cover(client) job = claim(client).json()