Browse Source

新增日志+针对请求体连接进行处理

wangxi 1 week ago
parent
commit
be1efa5e1a

+ 24 - 0
README.md

@@ -116,6 +116,25 @@ sh deploy.sh restart    # 停止后重新后台启动
 
 
 日志位于 `output/api.log`,PID 位于 `output/api.pid`。
 日志位于 `output/api.log`,PID 位于 `output/api.pid`。
 
 
+请求审计日志位于 `output/requests.log`,UTF-8 JSONL 格式,每行一条事件,追加写入。
+记录原始 JSON 请求体、接口响应状态码及 JSON 正文,以及每次回调的地址、载荷、
+HTTP 状态码、响应正文或异常;通过 `request_id` 关联,成功创建任务时它也是任务 ID。
+校验失败的请求也记录。multipart 记录表单字段和文件名/大小/类型,不记录文件二进制。
+
+`processing_request` 在校验完成、提交后台任务之前记录最终处理参数:`body` 中为复制到
+任务目录后的实际文件绝对路径、规范化回调地址、txbId 和生效的清理开关;两个路径模式
+标记均为 true,表示这些最终路径已经是绝对路径。JSON 模式额外通过 `source_paths`
+记录删除 static、目录拼接后的源文件绝对路径,便于对照原始请求。CLA_FILE 仅保存,
+不参与生成,日志通过 `clarification_used_in_pipeline=false` 明确标注。
+
+下载响应仅记录状态码,不记录 DOCX 内容。日志不随任务中间文件清理,重启后仍保留。
+回调 HTTP 200 的业务错误正文会原样记录,原有按 HTTP 状态判断投递成功的规则不变。
+日志包含请求中的路径和回调信息,仅保存在服务器,按运维要求限制访问并定期归档。
+
+```bash
+tail -f output/requests.log
+```
+
 可用环境变量:`MODEL_REPO`(默认
 可用环境变量:`MODEL_REPO`(默认
 `Maiteka/gpt2-chinese-cluecorpussmall-onnx`)、`MODEL_DIR`(默认
 `Maiteka/gpt2-chinese-cluecorpussmall-onnx`)、`MODEL_DIR`(默认
 `models/gpt2-chinese-cluecorpussmall-onnx`)、`UV_CACHE_DIR`(默认
 `models/gpt2-chinese-cluecorpussmall-onnx`)、`UV_CACHE_DIR`(默认
@@ -195,6 +214,11 @@ curl.exe -X POST "http://127.0.0.1:8000/api/v1/final-review" `
 
 
 ### REFERENCE_BID 路径处理
 ### REFERENCE_BID 路径处理
 
 
+JSON 模式的 `TENDER_FILE`、`PROCUREMENT_FILE`、`REFERENCE_BID`、`CLA_FILE` 在路径
+拼接或绝对读取之前,统一删除独立的 `static/`(或 `static\`)目录段。例如
+`static/file/example.docx` 变成 `file/example.docx`。保留其他目录、文件名和绝对路径根;
+不修改配置目录本身,`mystatic/`、`static.docx` 不受影响。multipart 上传不做路径清理。
+
 JSON 模式下:
 JSON 模式下:
 
 
 - `REFERENCE_BID` 为绝对路径时直接使用。
 - `REFERENCE_BID` 为绝对路径时直接使用。

+ 1 - 0
pyproject.toml

@@ -35,6 +35,7 @@ py-modules = [
     "context_search",
     "context_search",
     "api_worker",
     "api_worker",
     "http_api",
     "http_api",
+    "request_logging",
     "main",
     "main",
     "models",
     "models",
     "workflow",
     "workflow",

+ 1 - 6
scripts/test_step4.py

@@ -205,12 +205,7 @@ def _build_report(outline, analysis=None):
             errors.append(f"模板章块顺序错误: {chapter.id} {chapter.title}: {kinds}")
             errors.append(f"模板章块顺序错误: {chapter.id} {chapter.title}: {kinds}")
 
 
     records = [_chapter_record(chapter, entry_counts) for chapter in outline.chapters]
     records = [_chapter_record(chapter, entry_counts) for chapter in outline.chapters]
-    for record in records:
-        if record["unresolved_placeholders"]:
-            errors.append(
-                f"章节产物仍有未解析占位符: {record['id']} {record['title']}: "
-                + ", ".join(record["unresolved_placeholders"][:10])
-            )
+    # 残留占位符保留在章节报告中供人工检查,不阻断后续审核和 DOCX 导出。
     project_fields = dict(
     project_fields = dict(
         getattr(analysis, "project_fields", {}) or {}
         getattr(analysis, "project_fields", {}) or {}
     ) if analysis is not None else {}
     ) if analysis is not None else {}

+ 8 - 0
scripts/tests/test_api.py

@@ -135,6 +135,11 @@ class ApiWorkerTests(unittest.TestCase):
 
 
 class FinalReviewApiTests(unittest.TestCase):
 class FinalReviewApiTests(unittest.TestCase):
     def setUp(self):
     def setUp(self):
+        log_dir = tempfile.TemporaryDirectory()
+        self.addCleanup(log_dir.cleanup)
+        log_patch = patch("request_logging.LOG_PATH", Path(log_dir.name) / "requests.log")
+        log_patch.start()
+        self.addCleanup(log_patch.stop)
         with http_api._JOBS_LOCK:
         with http_api._JOBS_LOCK:
             http_api._JOBS.clear()
             http_api._JOBS.clear()
 
 
@@ -731,6 +736,9 @@ class FinalReviewApiTests(unittest.TestCase):
         class Response:
         class Response:
             status = 200
             status = 200
 
 
+            def read(self):
+                return b'{"code":200}'
+
             def __enter__(self):
             def __enter__(self):
                 return self
                 return self
 
 

+ 173 - 0
scripts/tests/test_request_logging.py

@@ -0,0 +1,173 @@
+from io import BytesIO
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+from urllib.error import HTTPError, URLError
+
+from fastapi.testclient import TestClient
+import http_api
+import request_logging
+from scripts.tests.test_api import RecordingExecutor, _write_json_input_files, _valid_files, _write_docx
+
+
+class RequestLoggingTests(unittest.TestCase):
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temp.cleanup)
+        self.root = Path(self.temp.name)
+        self.log = self.root / "requests.log"
+        self.patch = patch.object(request_logging, "LOG_PATH", self.log)
+        self.patch.start()
+        self.addCleanup(self.patch.stop)
+
+    def events(self):
+        return [json.loads(line) for line in self.log.read_text(encoding="utf-8").splitlines()]
+
+    def test_static_directory_only_preserves_root_and_filename(self):
+        for source, expected in [
+            ("static/file/a.docx", "file/a.docx"),
+            ("/static/file/static/a.docx", "/file/a.docx"),
+            (r"C:\static\file\a.docx", r"C:\file\a.docx"),
+            ("/data/static//file/a.docx", "/data/file/a.docx"),
+            ("/data/mystatic/static.docx", "/data/mystatic/static.docx"),
+            ("//server/share/static/a.docx", "//server/share/a.docx"),
+        ]:
+            with self.subTest(source=source):
+                self.assertEqual(http_api._strip_static_directory(source), expected)
+
+    def test_four_paths_cleaned_and_original_request_logged_for_both_modes(self):
+        tender, procurement, reference = _write_json_input_files(self.root)
+        cla = self.root / "clarification.docx"
+        cla.write_bytes(b"optional")
+        for absolute in (True, False):
+            with self.subTest(absolute=absolute):
+                paths = dict(zip(("TENDER_FILE", "PROCUREMENT_FILE", "REFERENCE_BID", "CLA_FILE"),
+                                 (tender, procurement, reference, cla)))
+                payload = {key: str(path.parent / "static" / path.name) if absolute else "static/" + path.name
+                           for key, path in paths.items()}
+                payload.update(CALLBACK_URL="http://localhost/callback", txbId="TEST",
+                               TPC_IS_ABSOLUTE=absolute, REFERENCE_IS_ABSOLUTE=absolute)
+                executor = RecordingExecutor()
+                with patch.object(http_api, "API_WORK_ROOT", self.root / "jobs"), \
+                     patch.object(http_api, "TPC_DIR", str(self.root)), \
+                     patch.object(http_api, "REFERENCE_DIR", str(self.root)), \
+                     patch.object(http_api, "_JOB_EXECUTOR", executor):
+                    response = TestClient(http_api.app).post("/api/v1/final-review", json=payload)
+                self.assertEqual(response.status_code, 202, response.text)
+                job = executor.submitted[0][1][0]
+                self.assertEqual(set(job["files"]), set(paths))
+                records = [e for e in self.events() if e["request_id"] == response.json()["request_id"]]
+                self.assertEqual(records[0]["body"], payload)
+                effective = next(e for e in records if e["event"] == "processing_request")
+                self.assertEqual(effective["source_paths"], {key: str(path.resolve()) for key, path in paths.items()})
+                self.assertEqual(effective["body"]["TENDER_FILE"], str(job["tender_path"].resolve()))
+                self.assertTrue(effective["body"]["TPC_IS_ABSOLUTE"])
+                self.assertTrue(effective["body"]["CLEAN_INTERMEDIATE"])
+                self.assertEqual(effective["body"]["CALLBACK_URL"], "http://localhost/callback")
+                self.assertEqual(records[-1]["body"], response.json())
+                self.assertEqual(records[-1]["status"], 202)
+
+    def test_rejected_and_invalid_json_requests_are_logged(self):
+        client = TestClient(http_api.app)
+        for body, status in [("{broken", 400), ('{"txbId":"TEST"}', 422)]:
+            response = client.post("/api/v1/final-review", content=body,
+                                   headers={"Content-Type": "application/json"})
+            self.assertEqual(response.status_code, status)
+            records = self.events()[-2:]
+            self.assertEqual(records[0]["request_id"], records[1]["request_id"])
+            self.assertEqual(records[1]["body"], response.json())
+        self.assertFalse(any(e["event"] == "processing_request" for e in self.events()))
+
+    def test_multipart_records_fields_and_file_metadata_without_binary(self):
+        with patch.object(http_api, "API_WORK_ROOT", self.root / "jobs"), \
+             patch.object(http_api, "_JOB_EXECUTOR", RecordingExecutor()):
+            response = TestClient(http_api.app).post("/api/v1/final-review",
+                data={"CALLBACK_URL": "http://localhost/callback", "txbId": "TEST"},
+                files=_valid_files())
+        self.assertEqual(response.status_code, 202)
+        record = next(e for e in self.events() if e["event"] == "request")
+        fields = {entry["name"]: entry["value"] for entry in record["body"]}
+        self.assertEqual(fields["txbId"], "TEST")
+        self.assertGreater(fields["TENDER_FILE"]["size"], 0)
+        self.assertNotIn("%PDF", self.log.read_text(encoding="utf-8"))
+
+    def test_callback_http_error_retry_and_business_response_logged(self):
+        class Response(BytesIO):
+            status = 200
+        responses = [HTTPError("http://localhost", 503, "unavailable", {}, BytesIO(b"busy")),
+                     Response(b'{"code":400,"message":"unknown id"}')]
+        with patch.object(http_api, "urlopen", side_effect=responses), \
+             patch.object(http_api.time, "sleep"), patch.object(http_api, "CALLBACK_MAX_RETRIES", 2):
+            http_api._send_callback("http://localhost/callback", {"request_id": "TEST"})
+        records = self.events()
+        self.assertEqual([e["event"] for e in records],
+                         ["callback_request", "callback_response"] * 2)
+        self.assertEqual(records[1]["status"], 503)
+        self.assertEqual(records[1]["response_body"], "busy")
+        self.assertEqual(records[3]["response_body"]["code"], 400)
+        self.assertEqual(records[3]["attempt"], 2)
+
+    def test_callback_network_failure_logged(self):
+        with patch.object(http_api, "urlopen", side_effect=URLError("offline")), \
+             patch.object(http_api, "CALLBACK_MAX_RETRIES", 1):
+            with self.assertRaises(RuntimeError):
+                http_api._send_callback("http://localhost/callback", {"request_id": "TEST"})
+        self.assertIn("offline", self.events()[-1]["error"])
+
+    def test_log_write_failure_does_not_fail_request(self):
+        with patch.object(request_logging, "LOG_PATH", self.root), \
+             self.assertLogs("request_logging", level="ERROR"):
+            self.assertEqual(TestClient(http_api.app).get("/health").status_code, 200)
+
+    def test_local_http_callback_and_job_cleanup_keep_correlated_log(self):
+        received = []
+
+        class Handler(BaseHTTPRequestHandler):
+            def do_POST(self):
+                received.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
+                self.send_response(200)
+                self.end_headers()
+                self.wfile.write(b'{"code":200}')
+
+            def log_message(self, *_args):
+                pass
+
+        server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+        thread = threading.Thread(target=server.serve_forever, daemon=True)
+        thread.start()
+        try:
+            executor = RecordingExecutor()
+            with patch.object(http_api, "API_WORK_ROOT", self.root / "jobs"), \
+                 patch.object(http_api, "_JOB_EXECUTOR", executor), \
+                 patch.object(http_api, "_run_pipeline_worker",
+                              side_effect=lambda _a, _b, _c, output, _log: _write_docx(output)):
+                client = TestClient(http_api.app)
+                response = client.post("/api/v1/final-review", files=_valid_files(),
+                    data={"CALLBACK_URL": f"http://127.0.0.1:{server.server_port}/callback", "txbId": "TEST"})
+                self.assertEqual(response.status_code, 202)
+                function, args = executor.submitted[0]
+                function(*args)
+                job_id = response.json()["request_id"]
+                self.assertTrue(client.get(f"/api/v1/jobs/{job_id}").json()["callback_delivered"])
+                self.assertEqual(client.get(f"/api/v1/jobs/{job_id}/file").status_code, 200)
+            self.assertEqual(received[0]["request_id"], job_id)
+            self.assertFalse(args[0]["job_dir"].exists())
+            callback = next(e for e in self.events() if e["event"] == "callback_response")
+            self.assertEqual(callback["request_id"], job_id)
+            self.assertEqual(callback["response_body"], {"code": 200})
+            self.assertEqual(self.events()[-1]["body"], "[non-JSON response omitted]")
+        finally:
+            server.shutdown()
+            server.server_close()
+            thread.join()
+
+    def test_early_server_failure_still_logs_original_json(self):
+        with patch.object(http_api, "TEMPLATE_PATH", self.root / "missing.docx"):
+            response = TestClient(http_api.app).post("/api/v1/final-review", json={"txbId": "TEST"})
+        self.assertEqual(response.status_code, 500)
+        self.assertEqual(self.events()[0]["body"], {"txbId": "TEST"})
+        self.assertEqual(self.events()[-1]["status"], 500)

+ 10 - 7
scripts/tests/test_step4_reporting.py

@@ -140,11 +140,12 @@ class Step4ReportingTests(unittest.TestCase):
         self.assertFalse(report["passed"])
         self.assertFalse(report["passed"])
         self.assertIn("直接落点缺少补充正文", report["errors"][0])
         self.assertIn("直接落点缺少补充正文", report["errors"][0])
 
 
-    def test_unresolved_placeholder_in_chapter_artifact_fails_gate(self):
+    def test_unresolved_placeholders_are_reported_without_failing_gate(self):
         with tempfile.TemporaryDirectory() as directory:
         with tempfile.TemporaryDirectory() as directory:
             artifact_path = os.path.join(directory, "chapter.docx")
             artifact_path = os.path.join(directory, "chapter.docx")
             document = Document()
             document = Document()
-            document.add_paragraph("项目编号:%%项目编号%%")
+            document.add_paragraph("%%报价汇总表%%")
+            document.add_table(rows=1, cols=1).cell(0, 0).text = "%%服务要求%%"
             document.save(artifact_path)
             document.save(artifact_path)
             chapter = Chapter(
             chapter = Chapter(
                 id="1",
                 id="1",
@@ -156,11 +157,13 @@ class Step4ReportingTests(unittest.TestCase):
                 chapters=[chapter],
                 chapters=[chapter],
             ))
             ))
 
 
-        self.assertFalse(report["passed"])
-        self.assertTrue(any(
-            "章节产物仍有未解析占位符" in error
-            for error in report["errors"]
-        ))
+        self.assertTrue(report["passed"], report["errors"])
+        self.assertEqual(report["errors"], [])
+        self.assertEqual(
+            report["chapters"][0]["unresolved_placeholders"],
+            ["%%报价汇总表%%", "%%服务要求%%"],
+            "残留占位符应保留在报告中,但不得重新加入阻断 errors。",
+        )
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 57 - 1
src/http_api.py

@@ -14,6 +14,7 @@ import sys
 import threading
 import threading
 import time
 import time
 from urllib.parse import urlsplit
 from urllib.parse import urlsplit
+from urllib.error import HTTPError
 from urllib.request import Request as UrlRequest, urlopen
 from urllib.request import Request as UrlRequest, urlopen
 from uuid import uuid4
 from uuid import uuid4
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
@@ -22,6 +23,7 @@ import zipfile
 from dotenv import load_dotenv
 from dotenv import load_dotenv
 from fastapi import FastAPI, HTTPException, Request, UploadFile
 from fastapi import FastAPI, HTTPException, Request, UploadFile
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
+from request_logging import RequestLogMiddleware, decode_body, write_event
 
 
 
 
 PROJECT_ROOT = Path(__file__).resolve().parent.parent
 PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -66,6 +68,9 @@ app = FastAPI(
 )
 )
 
 
 
 
+app.add_middleware(RequestLogMiddleware)
+
+
 def _require_suffix(upload: UploadFile, field_name: str, expected: str) -> str:
 def _require_suffix(upload: UploadFile, field_name: str, expected: str) -> str:
     suffix = Path(upload.filename or "").suffix.lower()
     suffix = Path(upload.filename or "").suffix.lower()
     if suffix != expected:
     if suffix != expected:
@@ -210,6 +215,7 @@ def _parse_path_is_absolute(value, field_name: str) -> bool:
 
 
 def _resolve_reference_path(reference_value: str, is_absolute: bool) -> str:
 def _resolve_reference_path(reference_value: str, is_absolute: bool) -> str:
     """按配置顺序查找相对参考投书,绝对路径模式原样返回。"""
     """按配置顺序查找相对参考投书,绝对路径模式原样返回。"""
+    reference_value = _strip_static_directory(reference_value)
     if is_absolute:
     if is_absolute:
         return reference_value
         return reference_value
     reference_dirs = [
     reference_dirs = [
@@ -234,8 +240,14 @@ def _resolve_reference_path(reference_value: str, is_absolute: bool) -> str:
     )
     )
 
 
 
 
+def _strip_static_directory(value: str) -> str:
+    # Match directory components only, preserving roots and file names.
+    return re.sub(r"(?<![^/\\])static[/\\]+", "", value)
+
+
 def _resolve_tpc_path(path_value: str, is_absolute: bool, field_name: str) -> str:
 def _resolve_tpc_path(path_value: str, is_absolute: bool, field_name: str) -> str:
     """TPC 相对路径与统一根目录拼接;绝对路径模式原样返回。"""
     """TPC 相对路径与统一根目录拼接;绝对路径模式原样返回。"""
+    path_value = _strip_static_directory(path_value)
     if is_absolute:
     if is_absolute:
         return path_value
         return path_value
     if not TPC_DIR:
     if not TPC_DIR:
@@ -382,6 +394,9 @@ def _send_callback(callback_url: str, payload: dict) -> None:
     body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
     body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
     last_error: Exception | None = None
     last_error: Exception | None = None
     for attempt in range(1, CALLBACK_MAX_RETRIES + 1):
     for attempt in range(1, CALLBACK_MAX_RETRIES + 1):
+        fields = {"request_id": payload.get("request_id"), "url": callback_url,
+                  "attempt": attempt, "body": payload}
+        write_event("callback_request", **fields)
         request = UrlRequest(
         request = UrlRequest(
             callback_url,
             callback_url,
             data=body,
             data=body,
@@ -390,10 +405,21 @@ def _send_callback(callback_url: str, payload: dict) -> None:
         )
         )
         try:
         try:
             with urlopen(request, timeout=CALLBACK_TIMEOUT_SECONDS) as response:
             with urlopen(request, timeout=CALLBACK_TIMEOUT_SECONDS) as response:
+                write_event("callback_response", **fields, status=response.status,
+                            response_body=decode_body(response.read()))
                 if 200 <= response.status < 300:
                 if 200 <= response.status < 300:
                     return
                     return
                 raise RuntimeError(f"回调返回 HTTP {response.status}")
                 raise RuntimeError(f"回调返回 HTTP {response.status}")
         except Exception as exc:
         except Exception as exc:
+            if isinstance(exc, HTTPError):
+                try:
+                    response_body = decode_body(exc.read())
+                finally:
+                    exc.close()
+                write_event("callback_response", **fields, status=exc.code,
+                            response_body=response_body, error=str(exc))
+            else:
+                write_event("callback_error", **fields, error=str(exc))
             last_error = exc
             last_error = exc
             if attempt < CALLBACK_MAX_RETRIES:
             if attempt < CALLBACK_MAX_RETRIES:
                 time.sleep(min(2 ** (attempt - 1), 5))
                 time.sleep(min(2 ** (attempt - 1), 5))
@@ -595,6 +621,14 @@ async def generate_final_review(
 
 
     if is_multipart:
     if is_multipart:
         form = await request.form()
         form = await request.form()
+        request.state.audit_form = [
+            {"name": name, "value": {"filename": value.filename,
+             "size": value.size, "content_type": value.content_type}
+             if hasattr(value, "filename") else value}
+            for name, value in form.multi_items()
+        ]
+        write_event("request_form", request_id=request.state.audit_id,
+                    body=request.state.audit_form)
         callback_url = _normalize_callback_url(
         callback_url = _normalize_callback_url(
             _required_form_value(form, "CALLBACK_URL")
             _required_form_value(form, "CALLBACK_URL")
         )
         )
@@ -660,7 +694,7 @@ async def generate_final_review(
         _require_suffix_from_string(reference_suffix, "REFERENCE_BID", ".docx")
         _require_suffix_from_string(reference_suffix, "REFERENCE_BID", ".docx")
 
 
     API_WORK_ROOT.mkdir(parents=True, exist_ok=True)
     API_WORK_ROOT.mkdir(parents=True, exist_ok=True)
-    request_id = uuid4().hex
+    request_id = request.state.audit_id
     request_root = API_WORK_ROOT / request_id
     request_root = API_WORK_ROOT / request_id
     job_dir = request_root / "work"
     job_dir = request_root / "work"
     job_dir.mkdir(parents=True, exist_ok=True)
     job_dir.mkdir(parents=True, exist_ok=True)
@@ -756,6 +790,28 @@ async def generate_final_review(
         }
         }
         with _JOBS_LOCK:
         with _JOBS_LOCK:
             _JOBS[request_id] = dict(job)
             _JOBS[request_id] = dict(job)
+        write_event(
+            "processing_request", request_id=request_id,
+            body={
+                "CALLBACK_URL": callback_url,
+                "txbId": txb_id,
+                "TENDER_FILE": str(tender_path.resolve()),
+                "PROCUREMENT_FILE": str(procurement_path.resolve()),
+                "REFERENCE_BID": str(reference_path.resolve()),
+                "CLA_FILE": str(clarification_path.resolve()) if "CLA_FILE" in files else "",
+                "TPC_IS_ABSOLUTE": True,
+                "REFERENCE_IS_ABSOLUTE": True,
+                "CLEAN_INTERMEDIATE": clean_intermediate,
+            },
+            source_paths=None if is_multipart else {
+                "TENDER_FILE": str(Path(tender_source).resolve()),
+                "PROCUREMENT_FILE": str(Path(procurement_source).resolve()),
+                "REFERENCE_BID": str(Path(reference_source).resolve()),
+                "CLA_FILE": str(Path(cla_source).resolve()) if cla_source else "",
+            },
+            output_path=output_path,
+            clarification_used_in_pipeline=False,
+        )
         _JOB_EXECUTOR.submit(_process_job, job)
         _JOB_EXECUTOR.submit(_process_job, job)
     except HTTPException:
     except HTTPException:
         shutil.rmtree(request_root, ignore_errors=True)
         shutil.rmtree(request_root, ignore_errors=True)

+ 95 - 0
src/request_logging.py

@@ -0,0 +1,95 @@
+"""Append-only JSONL audit events, independent of per-job cleanup."""
+
+from datetime import datetime, timezone
+import json
+import logging
+from pathlib import Path
+import threading
+from uuid import uuid4
+
+
+LOG_PATH = Path(__file__).resolve().parent.parent / "output" / "requests.log"
+_LOCK = threading.Lock()
+
+
+def write_event(event: str, **fields) -> None:
+    record = {"time": datetime.now(timezone.utc).isoformat(), "event": event, **fields}
+    try:
+        with _LOCK:
+            LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
+            with LOG_PATH.open("a", encoding="utf-8") as stream:
+                stream.write(json.dumps(record, ensure_ascii=False) + "\n")
+    except OSError:
+        logging.getLogger(__name__).exception("Cannot write request log")
+
+
+def decode_body(body: bytes):
+    text = body.decode("utf-8", errors="replace")
+    try:
+        return json.loads(text)
+    except ValueError:
+        return text
+
+
+class RequestLogMiddleware:
+    def __init__(self, app):
+        self.app = app
+
+    async def __call__(self, scope, receive, send):
+        if scope["type"] != "http":
+            return await self.app(scope, receive, send)
+        state = scope.setdefault("state", {})
+        request_id = state["audit_id"] = uuid4().hex
+        headers = dict(scope.get("headers", []))
+        multipart = b"multipart/form-data" in headers.get(b"content-type", b"").lower()
+        request_body = bytearray()
+        response_body = bytearray()
+        status = 500
+        response_json = False
+        request_logged = False
+
+        def log_request():
+            nonlocal request_logged
+            if not request_logged:
+                write_event("request", request_id=request_id, method=scope["method"],
+                            path=scope["path"], query=scope.get("query_string", b"").decode("utf-8", errors="replace"),
+                            body=state.get("audit_form", {"format": "multipart"}) if multipart else decode_body(bytes(request_body)))
+                request_logged = True
+
+        buffered = []
+        if not multipart:
+            while True:
+                message = await receive()
+                buffered.append(message)
+                if message["type"] != "http.request":
+                    break
+                request_body.extend(message.get("body", b""))
+                if not message.get("more_body", False):
+                    break
+            log_request()
+        pending = iter(buffered)
+
+        async def read():
+            message = next(pending, None)
+            return message if message is not None else await receive()
+
+        async def write(message):
+            nonlocal status, response_json
+            if message["type"] == "http.response.start":
+                log_request()
+                status = message["status"]
+                response_json = b"application/json" in dict(message.get("headers", [])).get(b"content-type", b"")
+            elif message["type"] == "http.response.body" and response_json:
+                response_body.extend(message.get("body", b""))
+            await send(message)
+
+        try:
+            await self.app(scope, read, write)
+        except Exception as exc:
+            log_request()
+            write_event("response", request_id=request_id, status=500,
+                        body="Internal Server Error", error=str(exc))
+            raise
+        else:
+            write_event("response", request_id=request_id, status=status,
+                        body=decode_body(bytes(response_body)) if response_json else "[non-JSON response omitted]")