{"openapi":"3.1.0","info":{"title":"Aulect API","description":"Article scraping, merging, and evaluation pipeline.\n\n### Authentication\n\n- **Browser / SPA:** Google OAuth sets the `mum_session` cookie.\n- **Scripts / Swagger:** Set `ADMIN_API_KEY` and `ADMIN_API_USER_EMAIL` in the\n  environment, then send `Authorization: Bearer <ADMIN_API_KEY>` (Swagger:\n  **Authorize** → HTTP Bearer). Requires a matching row in `users` (sign in\n  once with that Google account) and the email in `ADMIN_EMAILS` (or\n  `is_admin=true` in the database).\n\n### Lecture Refiner (LangGraph)\n\nTag **refiner** groups three endpoints that drive an automated\njudge↔revise loop on top of an already-merged lecture. The graph\nruns as a background thread, persists every iteration to\n`lecture_revisions`, and overwrites `lectures.body` with the best\nrevision so `GET /pipeline/{run_id}/lecture` returns the refined\ntext without any frontend changes. Tunables: `REFINER_MAX_ITERATIONS`,\n`REFINER_TARGET_SCORE`, `REFINER_MODEL`. Optional LangSmith tracing\nvia `LANGSMITH_API_KEY` + `LANGSMITH_PROJECT`.","version":"0.1.0"},"paths":{"/auth/google/login":{"get":{"tags":["auth"],"summary":"Google Login","description":"Redirect the browser to Google's authorization endpoint.","operationId":"google_login_auth_google_login_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/google/callback":{"get":{"tags":["auth"],"summary":"Google Callback","description":"Handle Google OAuth callback — exchange code for tokens, verify, upsert user.","operationId":"google_callback_auth_google_callback_get","parameters":[{"name":"code","in":"query","required":false,"schema":{"type":"string","title":"Code"}},{"name":"state","in":"query","required":false,"schema":{"type":"string","title":"State"}},{"name":"error","in":"query","required":false,"schema":{"type":"string","title":"Error"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/me":{"get":{"tags":["auth"],"summary":"Get Me","operationId":"get_me_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/logout":{"post":{"tags":["auth"],"summary":"Logout","operationId":"logout_auth_logout_post","responses":{"204":{"description":"Successful Response"}}}},"/health":{"get":{"tags":["health"],"summary":"Health","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/readiness":{"get":{"tags":["health"],"summary":"Readiness","operationId":"readiness_readiness_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/pipeline/sources":{"get":{"tags":["pipeline"],"summary":"List article sources enabled on this deployment","description":"Sources the dashboard should show in its picker.\n\nEach entry includes the slug we expect on `/pipeline/run`, a display\nname, and whatever filters the source supports (so the dashboard\ncan render the right secondary dropdown without hard-coding source\nknowledge). Sources whose feature flag is off appear nowhere — the\ncode path stays in the repo but is invisible until re-enabled.","operationId":"list_sources_pipeline_sources_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/pipeline/topics":{"get":{"tags":["pipeline"],"summary":"List OECD topic catalogs accepted by /pipeline/run (CyberLeninka)","description":"Return the OECD field-of-science catalogs CyberLeninka supports.\n\nReturns an empty list when CyberLeninka is disabled by feature flag,\nso the dashboard can keep a single code path that asks the server\nwhat's available and shows it.","operationId":"list_oecd_topics_pipeline_topics_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/pipeline/":{"get":{"tags":["pipeline"],"summary":"List recent pipeline runs","description":"List recent pipeline runs.\n\n- Regular users: see only their own runs, capped at HISTORY_LIMIT\n  (older runs are pruned whenever a new run is started anyway).\n- Admin users (`is_admin=true`): see runs from every user, with the\n  cap raised to ADMIN_HISTORY_LIMIT, and each item annotated with\n  `user_id` / `user_email` / `user_name` so it's obvious who started\n  each run. Use this from Swagger to operate the system globally.","operationId":"list_pipeline_runs_pipeline__get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":10,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/run":{"post":{"tags":["pipeline"],"summary":"Start Pipeline","description":"Start a new pipeline run. Executes in background, returns immediately.","operationId":"start_pipeline_pipeline_run_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PipelineRunRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PipelineRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}":{"get":{"tags":["pipeline"],"summary":"Get Pipeline Run","description":"Get pipeline run status and step summaries (no large text blobs).","operationId":"get_pipeline_run_pipeline__run_id__get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/lecture":{"get":{"tags":["pipeline"],"summary":"Get Lecture Text","description":"Download the final lecture as plain text.","operationId":"get_lecture_text_pipeline__run_id__lecture_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/lecture.docx":{"get":{"tags":["pipeline"],"summary":"Get Lecture Docx","description":"Download the lecture as a Word document (Times New Roman 12pt).\n\nMath formulas are embedded as inline PNGs at the position of each\n`[[FORMULA:f-id]]` marker in the lecture body. The cropped images\nwere prepared in the classify step (GROBID coords + PyMuPDF crop)\nand stored in `lecture.formulas`; here we just decode the base64\npayload and let python-docx wire the image into the paragraph.","operationId":"get_lecture_docx_pipeline__run_id__lecture_docx_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/evaluate":{"post":{"tags":["pipeline"],"summary":"Start Evaluation","description":"Run evaluation for this pipeline run (always uses LLM). Requires merge step completed.\nEvaluation runs in background; poll GET /pipeline/{run_id}/evaluation for results.","operationId":"start_evaluation_pipeline__run_id__evaluate_post","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PipelineRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/evaluation":{"get":{"tags":["pipeline"],"summary":"Get Evaluation","description":"Get the full evaluation results: stats, LLM judge scores, and fact coverage.","operationId":"get_evaluation_pipeline__run_id__evaluation_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/articles":{"get":{"tags":["pipeline"],"summary":"Get Run Articles","description":"List articles for a pipeline run. Body is omitted by default to keep responses small.","operationId":"get_run_articles_pipeline__run_id__articles_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}},{"name":"include_body","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Body"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/resume":{"post":{"tags":["pipeline"],"summary":"Resume Pipeline Run","description":"Resume a failed pipeline run from its last checkpoint.\n\nCancelled runs are also resumable — cancellation is treated as a\ndeliberate, recoverable interruption rather than a hard failure.","operationId":"resume_pipeline_run_pipeline__run_id__resume_post","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PipelineRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/cancel":{"post":{"tags":["pipeline"],"summary":"Stop a pipeline run","description":"Stop a pending or running pipeline run.\n\nCancellation is **cooperative**: the runner finishes whatever step is\ncurrently executing (so partial work isn't thrown away), then stops\nbefore starting the next step. As a result the call returns\nimmediately with `status=\"cancelled\"`, but the background thread may\nstill be wrapping up the in-flight step for a few seconds afterwards.\n\nCancelled runs are resumable — call POST /pipeline/{run_id}/resume to\npick up from the last checkpoint.","operationId":"cancel_pipeline_run_pipeline__run_id__cancel_post","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PipelineRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/refine":{"post":{"tags":["refiner"],"summary":"Start the Lecture Refiner","description":"Start the LangGraph judge↔revise loop for an already-merged lecture.\n\nBehaviour:\n\n* **Defaults**: `max_iterations` and `target_score` fall back to\n  `REFINER_MAX_ITERATIONS` / `REFINER_TARGET_SCORE` when omitted.\n* **Resume semantics**: re-calling on the same `run_id` continues the\n  previous LangGraph thread (the checkpointer is keyed by `run_id`).\n* **Termination**: the loop ends as soon as the average judge score\n  crosses `target_score`, OR the iteration counter reaches\n  `max_iterations`, whichever comes first.\n* **Side effects**: each iteration is persisted to `lecture_revisions`.\n  The best revision (highest avg judge score) overwrites\n  `lectures.body` so existing GET /pipeline/{run_id}/lecture returns\n  the refined text without any frontend changes.\n\nRequires the merge step to have completed for this run (see 400).","operationId":"start_refine_pipeline__run_id__refine_post","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"requestBody":{"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RefineRequest"},{"type":"null"}],"title":"Req"}}}},"responses":{"202":{"description":"Refiner kicked off; poll `/refine/status` for progress.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefineStartedResponse"}}}},"400":{"description":"Bad request — see `detail.code` for the machine-readable error.","content":{"application/json":{"example":{"detail":{"code":"merge_step_not_completed","message":"Merge step not found or not completed. Run merge before refining."}}}}},"404":{"description":"Pipeline run or lecture not found for this user.","content":{"application/json":{"example":{"detail":{"code":"pipeline_run_not_found","message":"Pipeline run not found"}}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/refine/status":{"get":{"tags":["refiner"],"summary":"Poll the Lecture Refiner status","description":"Snapshot of the refiner thread for this run.\n\nThe `history` array is rebuilt from the `lecture_revisions` table\non every call, so it survives API restarts. The in-memory fields\n(`status`, `iteration`, `done_reason`, `trace_id`) come from a\nprocess-local registry — `status=\"unknown\"` means the API has no\nrecord of the refiner ever running for this run (e.g. it was\nrestarted after the last refine).","operationId":"refine_status_pipeline__run_id__refine_status_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Current refiner thread snapshot + history of judge scores.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefineStatusResponse"}}}},"404":{"description":"Pipeline run or lecture not found for this user.","content":{"application/json":{"example":{"detail":{"code":"pipeline_run_not_found","message":"Pipeline run not found"}}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/pipeline/{run_id}/refinements":{"get":{"tags":["refiner"],"summary":"List every persisted lecture revision","description":"Every `LectureRevision` row for this run, oldest first.\n\n`iteration=0` is the original merged lecture, scored before any\nrevision happened. Each subsequent row is the rewritten version\nproduced at that iteration plus the judge result it was based on.","operationId":"refinements_list_pipeline__run_id__refinements_get","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Refinements ordered oldest first (iteration ascending).","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RefinementSummary"},"title":"Response Refinements List Pipeline  Run Id  Refinements Get"}}}},"404":{"description":"Pipeline run or lecture not found for this user.","content":{"application/json":{"example":{"detail":{"code":"pipeline_run_not_found","message":"Pipeline run not found"}}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/support/contact":{"post":{"tags":["support"],"summary":"Submit Support","description":"Receive a support request and email it to the configured support address.\n\nValidates that the text is non-empty and ≤ 200 chars. Prefers Resend\n(HTTP) when configured, otherwise falls back to SMTP.","operationId":"submit_support_support_contact_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"PipelineRunRequest":{"properties":{"search_query":{"anyOf":[{"type":"string","maxLength":4000},{"type":"null"}],"title":"Search Query"},"max_articles":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Articles"},"source":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Source"},"oecd_topic":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"title":"Oecd Topic"},"arxiv_category":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Arxiv Category"}},"type":"object","title":"PipelineRunRequest"},"PipelineRunResponse":{"properties":{"id":{"type":"string","title":"Id"},"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"}},"type":"object","required":["id","status","message"],"title":"PipelineRunResponse"},"RefineHistoryEntry":{"properties":{"iteration":{"type":"integer","title":"Iteration"},"avg_score":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Score"},"fact_coverage":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Fact Coverage"}},"type":"object","required":["iteration"],"title":"RefineHistoryEntry"},"RefineRequest":{"properties":{"max_iterations":{"anyOf":[{"type":"integer","maximum":10.0,"minimum":1.0},{"type":"null"}],"title":"Max Iterations","description":"Maximum number of judge↔revise cycles. Defaults to REFINER_MAX_ITERATIONS (typically 3)."},"target_score":{"anyOf":[{"type":"number","maximum":10.0,"minimum":0.0},{"type":"null"}],"title":"Target Score","description":"Average judge score (1..10, see prompts/eval_criteria.txt) at which the refiner stops early. Defaults to REFINER_TARGET_SCORE (typically 8.5)."}},"type":"object","title":"RefineRequest","description":"Optional knobs; both default to the values in `Settings`.\n\nSend an empty body (or omit the body entirely) to use\nREFINER_MAX_ITERATIONS / REFINER_TARGET_SCORE from the environment.","example":{"max_iterations":3,"target_score":8.5}},"RefineStartedResponse":{"properties":{"thread_id":{"type":"string","title":"Thread Id","description":"LangGraph thread id (equals run_id)."},"status":{"type":"string","title":"Status","description":"Always `running` on a successful start."},"message":{"type":"string","title":"Message"}},"type":"object","required":["thread_id","status","message"],"title":"RefineStartedResponse","description":"Response from `POST /pipeline/{run_id}/refine`.","example":{"message":"Refiner started. Poll GET /pipeline/{run_id}/refine/status for progress.","status":"running","thread_id":"11111111-1111-1111-1111-111111111111"}},"RefineStatusResponse":{"properties":{"thread_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thread Id"},"status":{"type":"string","title":"Status","description":"One of `running`, `completed`, `failed`, `unknown`."},"iteration":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Iteration"},"history":{"items":{"$ref":"#/components/schemas/RefineHistoryEntry"},"type":"array","title":"History"},"done_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Done Reason","description":"Why the loop ended — `target_reached`, `max_iters`, `judge_unscored`, or null while still running."},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"updated_at":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["status"],"title":"RefineStatusResponse","description":"Response from `GET /pipeline/{run_id}/refine/status`.\n\n`status=\"unknown\"` means we have no in-process record of the\nrefiner ever running for this run (e.g. the API was restarted\nafter the last refine completed). Past iterations are still\nvisible via GET /refinements because they live in Postgres.","example":{"done_reason":"target_reached","history":[{"avg_score":7.2,"fact_coverage":72.0,"iteration":0},{"avg_score":8.1,"fact_coverage":84.0,"iteration":1},{"avg_score":8.6,"fact_coverage":92.0,"iteration":2}],"iteration":2,"status":"completed","thread_id":"11111111-1111-1111-1111-111111111111","trace_id":"f08b2205ce2cfbbb5a317af389ffc506","updated_at":1746957600.0}},"RefinementSummary":{"properties":{"id":{"type":"string","title":"Id"},"iteration":{"type":"integer","title":"Iteration"},"size_chars":{"type":"integer","title":"Size Chars"},"avg_judge_score":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Judge Score"},"fact_coverage_percent":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Fact Coverage Percent"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["id","iteration","size_chars"],"title":"RefinementSummary","description":"One row from `GET /pipeline/{run_id}/refinements`.","example":{"avg_judge_score":8.1,"created_at":"2026-05-11T10:55:13.420000+00:00","fact_coverage_percent":84.0,"id":"22222222-2222-2222-2222-222222222222","iteration":1,"size_chars":14820}},"SupportRequest":{"properties":{"message":{"type":"string","maxLength":250,"minLength":1,"title":"Message"}},"type":"object","required":["message"],"title":"SupportRequest"},"SupportResponse":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"type":"string","title":"Message"}},"type":"object","required":["ok","message"],"title":"SupportResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"AdminApiBearer":{"type":"http","scheme":"bearer","description":"Value of the `ADMIN_API_KEY` environment variable (not a JWT)."}}}}