From 181f84d372bb9d50451c3ff85a5019721a81d1e6 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 8 Sep 2026 10:53:06 +0300 Subject: [PATCH] fixed proxy --- web/backend/app/services/espd/ro_ai.py | 68 +++++++----- web/backend/app/services/llm.py | 140 +++++++++++++++++-------- 2 files changed, 137 insertions(+), 71 deletions(-) diff --git a/web/backend/app/services/espd/ro_ai.py b/web/backend/app/services/espd/ro_ai.py index 2fc811a..34dbbcf 100644 --- a/web/backend/app/services/espd/ro_ai.py +++ b/web/backend/app/services/espd/ro_ai.py @@ -1,16 +1,33 @@ -"""Generate «Руководство оператора» text via OpenRouter.""" +"""Generate «Руководство оператора» text via the same OpenRouter stack as ESKD chat.""" from __future__ import annotations from pathlib import Path from app.core.config import get_settings +from app.services.llm import openrouter_chat_sync EXAMPLE_PATH = Path(__file__).resolve().parents[2] / "espd_templates" / "ro_mat.txt" -SYSTEM_PROMPT = """Ты — технический писатель. Твоя задача: по краткому описанию программы составить текст «Руководство оператора» по ГОСТ ЕСПД (ГОСТ 19.503-79 и др.). -Структура документа: 1) Назначение программы; 2) Условия выполнения программы; 3) Установка программы; 4) Работа с программой. -Пиши строго по образцу: тот же стиль, нумерация разделов и подпунктов (1.1, 1.2, 2.1, 3.1.1 и т.д.), без лишних пояснений и без упоминания ИИ.""" +SYSTEM_PROMPT = """Ты технический писатель по ГОСТ ЕСПД (ЕСПД: ГОСТ 19.101, ГОСТ 19.105, ГОСТ 19.503-79 и связанные). + +Твоя единственная задача — по описанию программы составить полный текст документа +«Руководство оператора» на русском языке. + +Обязательная структура (нумерация разделов и подпунктов как в образце): +1. Назначение программы +2. Условия выполнения программы +3. Установка программы +4. Работа с программой + +Правила: +- Пиши деловым техническим стилем, без воды и без обращений к читателю в разговорной форме. +- Не упоминай ИИ, LLM, промпты, OpenRouter и процесс генерации. +- Не используй markdown (без #, **, списков с "-"), только обычный текст с нумерацией 1., 1.1, 1.1.1. +- Не добавляй титульный лист, оглавление, «Введение» вне структуры и служебные комментарии. +- Если в описании не хватает деталей — формулируй разумные типовые требования (ОС, права, шаги установки/запуска), не выдумывай конкретные секретные URL/пароли. +- Выход: только готовый текст руководства, без предисловий вроде «Вот документ:». +""" def _load_example() -> str: @@ -21,48 +38,45 @@ def _load_example() -> str: def generate_ro_content(progname: str, decimal: str, user_description: str) -> str: settings = get_settings() - api_key = (settings.openrouter_api_key or "").strip() - if not api_key: + if not (settings.openrouter_api_key or "").strip(): return "[Ошибка: не задан OPENROUTER_API_KEY.]" example = _load_example() + sample = ( + example[:12000] + if example + else ( + "1. Назначение программы\n" + "1.1. …\n" + "2. Условия выполнения программы\n" + "3. Установка программы\n" + "4. Работа с программой" + ) + ) user_prompt = f"""Составь полный текст «Руководство оператора» по ГОСТ ЕСПД. Данные документа: -- Обозначение (децимальный номер): {decimal} -- Название программы: {progname} +- Обозначение (децимальный номер): {decimal or "—"} +- Название программы: {progname or "—"} Описание от заказчика (что за программа, для какой системы, что должна делать): -{user_description} +{user_description.strip() or "Описание не предоставлено — составь типовое руководство по названию и обозначению."} -Образец оформления и стиля (придерживайся этой структуры и нумерации): +Образец оформления и стиля (придерживайся структуры и нумерации): --- -{example[:12000] if example else "Структура: 1. Назначение программы; 2. Условия выполнения; 3. Установка; 4. Работа с программой."} +{sample} --- -Выведи только текст руководства, без предисловий.""" +Выведи только текст руководства.""" try: - from openai import OpenAI - - client = OpenAI( - base_url=settings.openrouter_base_url.rstrip("/"), - api_key=api_key, - default_headers={ - "Authorization": f"Bearer {api_key}", - "HTTP-Referer": "https://gostgenerator.local", - "X-Title": "GostGenerator Web ESPD", - }, - ) - response = client.chat.completions.create( - model=settings.openrouter_model or "deepseek/deepseek-chat", - messages=[ + text = openrouter_chat_sync( + [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.4, max_tokens=4000, ) - text = response.choices[0].message.content or "" return text.strip() except Exception as e: return f"[Ошибка генерации: {e}]" diff --git a/web/backend/app/services/llm.py b/web/backend/app/services/llm.py index 2822105..5d93051 100644 --- a/web/backend/app/services/llm.py +++ b/web/backend/app/services/llm.py @@ -37,6 +37,101 @@ TABLE_TYPE_LABELS = { "simple_list": "Бланк заказа", } + +def _openrouter_proxy_url() -> str | None: + settings = get_settings() + proxy = (settings.openrouter_proxy or "").strip() or None + if proxy and "://" not in proxy: + proxy = f"socks5://{proxy}" + return proxy + + +def openrouter_headers() -> dict[str, str]: + settings = get_settings() + return { + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://gostgenerator.local", + "X-Title": "GostGenerator Web", + } + + +def openrouter_chat_url() -> str: + return get_settings().openrouter_base_url.rstrip("/") + "/chat/completions" + + +async def openrouter_chat( + messages: list[dict[str, str]], + *, + temperature: float = 0.2, + max_tokens: int = 2000, +) -> str: + """Shared OpenRouter chat/completions call (same key, proxy, model as ESKD chat).""" + settings = get_settings() + if not settings.openrouter_api_key: + raise RuntimeError("OpenRouter API key не настроен (OPENROUTER_API_KEY).") + + payload = { + "model": settings.openrouter_model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + client_kwargs: dict[str, Any] = {"timeout": 120.0} + proxy = _openrouter_proxy_url() + if proxy: + client_kwargs["proxy"] = proxy + + async with httpx.AsyncClient(**client_kwargs) as client: + resp = await client.post( + openrouter_chat_url(), + headers=openrouter_headers(), + json=payload, + ) + if resp.status_code >= 400: + detail = resp.text[:500] + raise RuntimeError(f"OpenRouter HTTP {resp.status_code}: {detail}") + data = resp.json() + + return data["choices"][0]["message"]["content"] or "" + + +def openrouter_chat_sync( + messages: list[dict[str, str]], + *, + temperature: float = 0.2, + max_tokens: int = 2000, +) -> str: + """Sync variant for non-async endpoints (same transport as ESKD chat).""" + settings = get_settings() + if not settings.openrouter_api_key: + raise RuntimeError("OpenRouter API key не настроен (OPENROUTER_API_KEY).") + + payload = { + "model": settings.openrouter_model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + client_kwargs: dict[str, Any] = {"timeout": 120.0} + proxy = _openrouter_proxy_url() + if proxy: + client_kwargs["proxy"] = proxy + + with httpx.Client(**client_kwargs) as client: + resp = client.post( + openrouter_chat_url(), + headers=openrouter_headers(), + json=payload, + ) + if resp.status_code >= 400: + detail = resp.text[:500] + raise RuntimeError(f"OpenRouter HTTP {resp.status_code}: {detail}") + data = resp.json() + + return data["choices"][0]["message"]["content"] or "" + + def estimate_tokens(text: str) -> int: """Rough token estimate (works for mixed RU/EN).""" if not text: @@ -159,36 +254,8 @@ async def chat_edit_table( hist.append({"role": "user", "content": message}) messages = build_llm_messages(table_type, rows, hist) - context = build_context_usage(messages) + content = await openrouter_chat(messages, temperature=0.2, max_tokens=2000) - payload = { - "model": settings.openrouter_model, - "messages": messages, - "temperature": 0.2, - "max_tokens": 2000, - } - headers = { - "Authorization": f"Bearer {settings.openrouter_api_key}", - "Content-Type": "application/json", - "HTTP-Referer": "https://gostgenerator.local", - "X-Title": "GostGenerator Web", - } - url = settings.openrouter_base_url.rstrip("/") + "/chat/completions" - - proxy = (settings.openrouter_proxy or "").strip() or None - if proxy and "://" not in proxy: - proxy = f"socks5://{proxy}" - - client_kwargs: dict[str, Any] = {"timeout": 90.0} - if proxy: - client_kwargs["proxy"] = proxy - - async with httpx.AsyncClient(**client_kwargs) as client: - resp = await client.post(url, headers=headers, json=payload) - resp.raise_for_status() - data = resp.json() - - content = data["choices"][0]["message"]["content"] parsed = _extract_json(content) edits = parsed.get("edits") or [] norm_edits = [] @@ -204,22 +271,7 @@ async def chat_edit_table( } ) - # include assistant reply in usage estimate context = build_context_usage(messages + [{"role": "assistant", "content": content}]) - usage = data.get("usage") or {} - if usage.get("prompt_tokens"): - prompt_t = int(usage["prompt_tokens"]) - completion_t = int(usage.get("completion_tokens") or 0) - total_t = int(usage.get("total_tokens") or (prompt_t + completion_t)) - limit = settings.llm_context_limit - context = { - "used": total_t, - "limit": limit, - "percent": min(100.0, round(100.0 * total_t / limit, 1)) if limit else 0.0, - "prompt_tokens": prompt_t, - "completion_tokens": completion_t, - } - return {"reply": parsed.get("reply") or "", "edits": norm_edits, "context": context}