Spaces:
Paused
Paused
| """OpenRouter-backed AI rewriter used by the Stage 1 dataset builder. | |
| Given a human-authored German paragraph, :class:`AI_Version_Generator` | |
| asks an ``openai/gpt-5.4`` deployment on OpenRouter to produce an | |
| AI-styled rewrite that must preserve every citation verbatim. The | |
| result is a tuple ``(ai_text, citations, attempts)`` consumed by | |
| ``scripts/build_dataset_v2.py``. | |
| Citation detection is delegated to | |
| :mod:`training.data.citation_utils` — this module never redefines the | |
| regex, so the TS-equivalent invariant documented in ``citation_utils`` | |
| continues to hold for the dataset pipeline. | |
| Retry policy (Requirement 1.10) | |
| ------------------------------- | |
| * Maximum 3 HTTP attempts per paragraph. | |
| * Retries fire on any of: connect/read timeout, generic transport | |
| error, HTTP 5xx, HTTP 429 (rate limited), or an empty ``content`` | |
| field in the OpenAI-style response body. | |
| * 4xx responses other than 429 are treated as unrecoverable | |
| (bad request, invalid key, insufficient credits, etc.) and short- | |
| circuit to ``None`` immediately. | |
| * Backoff between attempts is ``2 ** attempt`` seconds, so 2s after | |
| attempt 1, 4s after attempt 2, 8s after attempt 3 — matching the | |
| "2-4-8" pattern in design.md. | |
| The class is usable either manually (``gen = AI_Version_Generator(key); | |
| ; gen.close()``) or as a context manager | |
| (``with AI_Version_Generator(key) as gen: ...``). | |
| Relates to requirements 1.4, 1.10 and 2.1 of spec | |
| ``grpo-humanizer-training-v2``. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import time | |
| from dataclasses import dataclass | |
| from typing import Any | |
| import httpx | |
| from training.data.citation_utils import extract_citations | |
| __all__ = [ | |
| "AI_MODEL", | |
| "AIVersionResult", | |
| "AI_Version_Generator", | |
| ] | |
| logger = logging.getLogger(__name__) | |
| #: OpenRouter model identifier used for all AI-version generations. | |
| #: Fixed by Requirement 1.4 — do not parameterise without updating the | |
| #: spec first. | |
| AI_MODEL: str = "openai/gpt-5.4" | |
| #: OpenRouter chat completions endpoint (OpenAI-compatible). | |
| _OPENROUTER_URL: str = "https://openrouter.ai/api/v1/chat/completions" | |
| #: Maximum number of HTTP attempts per paragraph (Requirement 1.10). | |
| _MAX_ATTEMPTS: int = 3 | |
| class AIVersionResult: | |
| """Outcome of a successful OpenRouter generation. | |
| Attributes | |
| ---------- | |
| ai_text: | |
| The AI-rewritten paragraph, stripped of leading/trailing | |
| whitespace. Guaranteed non-empty (empty responses trigger a | |
| retry). | |
| citations: | |
| Citations extracted from the *original* human text — i.e. | |
| the citations OpenRouter was instructed to preserve. Not the | |
| citations found in ``ai_text``. Callers that need to validate | |
| citation preservation should run | |
| :func:`training.data.citation_utils.passes_citation_check` | |
| against this list via a fresh extraction of ``ai_text``. | |
| attempts: | |
| Number of HTTP attempts made to obtain the result. ``1`` when | |
| the first request succeeded; up to ``3`` when earlier attempts | |
| were retried per Requirement 1.10. | |
| """ | |
| ai_text: str | |
| citations: list[str] | |
| attempts: int | |
| class AI_Version_Generator: | |
| """Thin synchronous OpenRouter client for AI-version generation. | |
| One instance wraps a single :class:`httpx.Client` with the required | |
| authorization header. The client is reused across many | |
| ``generate`` calls so HTTP keep-alive reduces per-request overhead. | |
| Always close the client via :meth:`close` or the context-manager | |
| protocol when done. | |
| Parameters | |
| ---------- | |
| api_key: | |
| OpenRouter API key (``sk-or-v1-…``). Sent in the | |
| ``Authorization: Bearer`` header on every request. | |
| timeout: | |
| Per-request timeout in seconds, passed straight to | |
| :class:`httpx.Client`. Default ``60.0`` matches design.md. | |
| """ | |
| def __init__(self, api_key: str, timeout: float = 60.0) -> None: | |
| self._client: httpx.Client = httpx.Client( | |
| timeout=timeout, | |
| headers={ | |
| "Authorization": f"Bearer {api_key}", | |
| "HTTP-Referer": "https://github.com/LevArtesa/ghostwriter", | |
| "Content-Type": "application/json", | |
| }, | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Lifecycle | |
| # ------------------------------------------------------------------ | |
| def close(self) -> None: | |
| """Close the underlying HTTP client. Safe to call multiple times.""" | |
| self._client.close() | |
| def __enter__(self) -> "AI_Version_Generator": | |
| return self | |
| def __exit__(self, exc_type, exc, tb) -> None: | |
| self.close() | |
| # ------------------------------------------------------------------ | |
| # Prompt building | |
| # ------------------------------------------------------------------ | |
| def _build_messages( | |
| self, human_text: str, citations: list[str] | |
| ) -> list[dict[str, str]]: | |
| """Build the OpenAI-style chat messages for one paragraph. | |
| The system prompt instructs GPT-5.4 (in German) to rewrite the | |
| paragraph in a distinctly AI-typical style while copy-pasting | |
| every listed citation verbatim. When ``citations`` is empty we | |
| still emit a non-empty fallback token so the model sees a | |
| well-formed list. The fallback text (``"(нет цитат)"``) comes | |
| from design.md verbatim — its Russian wording is harmless | |
| because the model is instructed to preserve *only* citations | |
| from the list, and an empty Russian tag signals "nothing to | |
| preserve". | |
| """ | |
| citation_list = ( | |
| "\n".join(f"- {c}" for c in citations) if citations else "(нет цитат)" | |
| ) | |
| system_content = ( | |
| "Du bist ein KI-Textgenerator, der akademische Texte im " | |
| "typischen KI-Stil produziert: gleichmäßige Satzlänge, " | |
| "formelhafte Konnektoren (\"darüber hinaus\", \"in diesem " | |
| "Zusammenhang\"), abstrakte Verben, Redundanz. Deutsch.\n\n" | |
| "WICHTIG: Übernimm ALLE folgenden Zitate DORTGENAU " | |
| "(copy-paste). Verändere keine Autorennamen, Jahreszahlen, " | |
| "Seitenzahlen:\n" | |
| f"{citation_list}\n\n" | |
| "Gib NUR den umgeschriebenen Text zurück." | |
| ) | |
| user_content = ( | |
| "Schreibe den folgenden Absatz im KI-Stil neu:\n\n" + human_text | |
| ) | |
| return [ | |
| {"role": "system", "content": system_content}, | |
| {"role": "user", "content": user_content}, | |
| ] | |
| # ------------------------------------------------------------------ | |
| # Main entry point | |
| # ------------------------------------------------------------------ | |
| def generate(self, human_text: str) -> AIVersionResult | None: | |
| """Request an AI-styled rewrite from OpenRouter. | |
| Parameters | |
| ---------- | |
| human_text: | |
| Source paragraph in German. Citations are extracted up- | |
| front and forwarded to the model so it can preserve them | |
| verbatim. | |
| Returns | |
| ------- | |
| AIVersionResult | None | |
| The rewrite and metadata on success, or ``None`` when all | |
| three retry attempts fail. A ``None`` return is logged at | |
| WARNING level with the last observed error so callers can | |
| surface it in checkpoint status. | |
| """ | |
| citations = extract_citations(human_text) | |
| messages = self._build_messages(human_text, citations) | |
| payload: dict[str, Any] = { | |
| "model": AI_MODEL, | |
| "messages": messages, | |
| "temperature": 0.7, | |
| "top_p": 1.0, | |
| } | |
| last_error: str | None = None | |
| for attempt in range(1, _MAX_ATTEMPTS + 1): | |
| retryable, error_msg, ai_text = self._attempt_once(payload) | |
| if ai_text is not None: | |
| # Success path. `attempt` is the final attempt count. | |
| return AIVersionResult( | |
| ai_text=ai_text, | |
| citations=citations, | |
| attempts=attempt, | |
| ) | |
| # Failure path. Remember the reason for the final warning | |
| # log, then decide whether to keep retrying. | |
| last_error = error_msg | |
| if not retryable: | |
| logger.warning( | |
| "OpenRouter: non-retryable error on attempt %d: %s", | |
| attempt, | |
| error_msg, | |
| ) | |
| return None | |
| # Exponential backoff: 2s after attempt 1, 4s after | |
| # attempt 2, 8s after attempt 3. The last sleep is only | |
| # executed if another attempt would follow it; after | |
| # the final attempt we fall through to the warning and | |
| # return None. | |
| backoff = 2 ** attempt | |
| if attempt < _MAX_ATTEMPTS: | |
| logger.info( | |
| "OpenRouter: retrying after attempt %d (%s); " | |
| "sleeping %ds", | |
| attempt, | |
| error_msg, | |
| backoff, | |
| ) | |
| time.sleep(backoff) | |
| logger.warning( | |
| "OpenRouter: giving up after %d attempts, last error: %s", | |
| _MAX_ATTEMPTS, | |
| last_error, | |
| ) | |
| return None | |
| # ------------------------------------------------------------------ | |
| # Single-attempt helper | |
| # ------------------------------------------------------------------ | |
| def _attempt_once( | |
| self, payload: dict[str, Any] | |
| ) -> tuple[bool, str | None, str | None]: | |
| """Run a single HTTP attempt. | |
| Returns a 3-tuple ``(retryable, error_msg, ai_text)``: | |
| * On success, ``ai_text`` is the stripped content and | |
| ``error_msg`` is ``None``. | |
| * On retryable failure (timeout, transport error, 5xx, 429, | |
| empty content), ``retryable`` is ``True`` and ``ai_text`` is | |
| ``None``. | |
| * On unrecoverable failure (4xx other than 429, malformed | |
| JSON, unexpected shape), ``retryable`` is ``False``. | |
| """ | |
| try: | |
| response = self._client.post(_OPENROUTER_URL, json=payload) | |
| except httpx.TimeoutException as exc: | |
| return True, f"timeout: {exc!r}", None | |
| except httpx.RequestError as exc: | |
| return True, f"request error: {exc!r}", None | |
| status = response.status_code | |
| if status >= 500 or status == 429: | |
| return True, f"HTTP {status}", None | |
| if status >= 400: | |
| # 4xx other than 429 — auth / bad request. Surface body | |
| # snippet (truncated) in the error message so operators | |
| # can diagnose without retrying pointlessly. | |
| body_snippet = response.text[:200] | |
| return False, f"HTTP {status}: {body_snippet}", None | |
| try: | |
| data = response.json() | |
| except ValueError as exc: | |
| # 2xx with non-JSON body — treat as unrecoverable, the | |
| # endpoint is misbehaving in a way retries won't fix. | |
| return False, f"invalid JSON: {exc!r}", None | |
| try: | |
| content = data["choices"][0]["message"]["content"] | |
| except (KeyError, IndexError, TypeError) as exc: | |
| return False, f"unexpected response shape: {exc!r}", None | |
| if not isinstance(content, str): | |
| return False, f"non-string content: {type(content).__name__}", None | |
| ai_text = content.strip() | |
| if not ai_text: | |
| # Empty content is treated as retryable per task contract | |
| # — a transient quirk of the upstream model rather than a | |
| # permanent misconfiguration. | |
| return True, "empty content", None | |
| return True, None, ai_text | |