diff --git a/.helm/templates/admin/deployment.yaml b/.helm/templates/admin/deployment.yaml index c1d7b1c..588ab3c 100644 --- a/.helm/templates/admin/deployment.yaml +++ b/.helm/templates/admin/deployment.yaml @@ -27,8 +27,9 @@ spec: - sh - -c - | - mkdir -p /data/output /data/logs /data/posts + mkdir -p /data/output /data/logs /data/posts /data/prompts test -f /data/mkdocs.yml || cp /app/mkdocs-default.yml /data/mkdocs.yml + cp -n /app/prompts/*.txt /data/prompts/ 2>/dev/null || true volumeMounts: - name: data mountPath: /data diff --git a/admin/Dockerfile b/admin/Dockerfile index 162a281..70a5130 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -5,7 +5,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONPATH=/app/generator \ CONFIG_PATH=/data/config.yml \ OUTPUT_DIR=/data/output \ - PROMPTS_DIR=/app/prompts + PROMPTS_DIR=/data/prompts RUN apt-get update && apt-get install -y --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* \ @@ -30,4 +30,5 @@ EXPOSE 8501 CMD ["streamlit", "run", "admin/admin_app.py", \ "--server.port=8501", \ "--server.address=0.0.0.0", \ - "--server.headless=true"] + "--server.headless=true", \ + "--client.toolbarMode=minimal"] diff --git a/admin/admin_app.py b/admin/admin_app.py index 4ec9605..2d27631 100644 --- a/admin/admin_app.py +++ b/admin/admin_app.py @@ -15,17 +15,19 @@ pages = { "Генерация": [ - st.Page("pages/generation.py", title="Генерация", icon=":material/play_circle:") + st.Page("pages/generation.py", title="Запуск", icon=":material/play_circle:"), + st.Page("pages/schedule.py", title="Расписание", icon=":material/schedule:"), + st.Page("pages/logs.py", title="Логи", icon=":material/terminal:"), ], "Контент": [ st.Page("pages/posts.py", title="Посты", icon=":material/article:"), - st.Page("pages/settings.py", title="Настройки", icon=":material/tune:"), - st.Page("pages/config_editor.py", title="Конфиг YAML", icon=":material/code:"), st.Page("pages/mkdocs_editor.py", title="Блог", icon=":material/web:"), ], - "Система": [ - st.Page("pages/schedule.py", title="Расписание", icon=":material/schedule:"), - st.Page("pages/logs.py", title="Логи", icon=":material/terminal:"), + "Настройки": [ + st.Page("pages/settings.py", title="Основные", icon=":material/tune:"), + st.Page("pages/repos.py", title="Репозитории", icon=":material/folder_copy:"), + st.Page("pages/prompts.py", title="Промпты", icon=":material/edit_note:"), + st.Page("pages/config_editor.py", title="Конфиг YAML", icon=":material/code:"), ], } diff --git a/admin/pages/prompts.py b/admin/pages/prompts.py new file mode 100644 index 0000000..b987ce5 --- /dev/null +++ b/admin/pages/prompts.py @@ -0,0 +1,65 @@ +"""Страница редактирования промптов для LLM.""" + +from pathlib import Path + +import streamlit as st + +from scheduler import DATA_DIR + +# Промпты на PVC (редактируемые), фолбэк — встроенные в образ +PROMPTS_DIR = DATA_DIR / "prompts" +DEFAULT_PROMPTS_DIR = Path("/app/prompts") + +PROMPT_FILES = { + "analyze_repo.txt": "Анализ репозитория", + "summarize_file.txt": "Суммаризация большого файла", + "synthesize.txt": "Синтез фич из нескольких репозиториев", + "generate_post.txt": "Генерация поста", + "review_post.txt": "Ревью поста", +} + +st.header("Промпты") +st.caption( + "Шаблоны запросов к LLM." + " Переменные в `{фигурных скобках}` подставляются автоматически." +) + + +def _read_prompt(filename: str) -> str: + pvc_path = PROMPTS_DIR / filename + if pvc_path.exists(): + return pvc_path.read_text(encoding="utf-8") + default_path = DEFAULT_PROMPTS_DIR / filename + if default_path.exists(): + return default_path.read_text(encoding="utf-8") + return "" + + +def _save_prompt(filename: str, content: str) -> None: + PROMPTS_DIR.mkdir(parents=True, exist_ok=True) + (PROMPTS_DIR / filename).write_text(content, encoding="utf-8") + + +tabs = st.tabs(list(PROMPT_FILES.values())) + +for tab, (filename, label) in zip(tabs, PROMPT_FILES.items()): + with tab: + current = _read_prompt(filename) + edited = st.text_area( + filename, value=current, height=300, key=f"prompt_{filename}" + ) + + col1, col2 = st.columns([1, 4]) + with col1: + if st.button("Сохранить", key=f"save_{filename}", type="primary"): + _save_prompt(filename, edited) + st.success("Сохранено!") + with col2: + if st.button("Сбросить к дефолту", key=f"reset_{filename}"): + default = DEFAULT_PROMPTS_DIR / filename + if default.exists(): + _save_prompt(filename, default.read_text(encoding="utf-8")) + st.success("Сброшено к значению по умолчанию!") + st.rerun() + else: + st.warning("Дефолтный промпт не найден") diff --git a/admin/pages/repos.py b/admin/pages/repos.py new file mode 100644 index 0000000..0923808 --- /dev/null +++ b/admin/pages/repos.py @@ -0,0 +1,193 @@ +"""Страница управления репозиториями.""" + +import streamlit as st +import yaml + +from scheduler import DATA_DIR + +CONFIG_PATH = DATA_DIR / "config.yml" + +st.header("Репозитории") + + +def _load_config() -> dict: + if CONFIG_PATH.exists(): + return yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8")) or {} + return {} + + +def _save_config(data: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text( + yaml.dump(data, allow_unicode=True, default_flow_style=False), + encoding="utf-8", + ) + + +def _lines_to_list(text: str) -> list[str]: + return [line.strip() for line in text.strip().splitlines() if line.strip()] + + +def _render_repo(repo: dict, idx: int) -> dict | None: + """Рендерит форму репозитория. Возвращает None если удалён.""" + name = repo.get("name") or repo.get("url", f"repo-{idx}") + with st.expander(f"**{name}** — {repo.get('url', '')}", expanded=False): + col1, col2 = st.columns([4, 1]) + with col1: + url = st.text_input( + "URL *", + value=repo.get("url", ""), + key=f"r{idx}_url", + help="HTTPS-ссылка на git-репозиторий", + ) + with col2: + if st.button("Удалить", key=f"r{idx}_del", type="secondary"): + return None + + col1, col2, col3 = st.columns(3) + with col1: + rname = st.text_input( + "Имя", + value=repo.get("name", ""), + key=f"r{idx}_n", + help="Отображаемое имя. Если пусто — берётся из URL", + ) + with col2: + branch = st.text_input( + "Ветка", value=repo.get("branch", "main"), key=f"r{idx}_br" + ) + with col3: + token = st.text_input( + "Токен", + value=repo.get("token", ""), + type="password", + key=f"r{idx}_tok", + help="Персональный токен для этого репозитория. " + "Перекрывает глобальный Git Token", + ) + + col1, col2 = st.columns(2) + with col1: + modes = ["period", "tag"] + mode_idx = modes.index(repo.get("diff_mode", "period")) + diff_mode = st.selectbox( + "Режим diff", + options=modes, + index=mode_idx, + key=f"r{idx}_dm", + help="period — diff за N дней. " + "tag — diff между git-тегами (релизами)", + ) + with col2: + lookback = st.number_input( + "Глубина поиска тега (периодов)", + value=repo.get("tag_lookback_periods", 1), + min_value=1, + max_value=10, + key=f"r{idx}_lb", + disabled=diff_mode != "tag", + help="Сколько периодов назад искать базовый тег. " + "Увеличьте, если между релизами большие паузы", + ) + + col1, col2, col3 = st.columns(3) + with col1: + r_noise = st.text_area( + "Шум", + value="\n".join(repo.get("noise_patterns", [])), + height=100, + key=f"r{idx}_np", + help="Дополнительные паттерны к глобальным. " "По одному на строку", + ) + with col2: + r_prio = st.text_area( + "Приоритетные", + value="\n".join(repo.get("priority_patterns", [])), + height=100, + key=f"r{idx}_pp", + ) + with col3: + r_sec = st.text_area( + "Вторичные", + value="\n".join(repo.get("secondary_patterns", [])), + height=100, + key=f"r{idx}_sp", + ) + + if not url: + st.warning("URL — обязательное поле") + + result: dict[str, object] = { + "url": url, + "branch": branch, + "diff_mode": diff_mode, + } + if rname: + result["name"] = rname + if token: + result["token"] = token + if diff_mode == "tag": + result["tag_lookback_periods"] = lookback + np = _lines_to_list(r_noise) + pp = _lines_to_list(r_prio) + sp = _lines_to_list(r_sec) + if np: + result["noise_patterns"] = np + if pp: + result["priority_patterns"] = pp + if sp: + result["secondary_patterns"] = sp + return result + + +config = _load_config() +repos: list[dict] = config.get("repos", []) + +updated: list[dict] = [] +for i, repo in enumerate(repos): + result = _render_repo(repo, i) + if result is not None: + updated.append(result) + +# --- Add new --- +st.divider() +with st.expander("Добавить репозиторий", icon=":material/add:"): + new_url = st.text_input( + "URL *", key="new_url", placeholder="https://github.com/..." + ) + col1, col2, col3 = st.columns(3) + with col1: + new_name = st.text_input("Имя", key="new_name") + with col2: + new_branch = st.text_input("Ветка", value="main", key="new_branch") + with col3: + new_token = st.text_input("Токен", type="password", key="new_token") + + if st.button("Добавить", type="primary"): + if not new_url: + st.error("URL — обязательное поле") + else: + new_repo: dict[str, object] = { + "url": new_url, + "branch": new_branch, + } + if new_name: + new_repo["name"] = new_name + if new_token: + new_repo["token"] = new_token + updated.append(new_repo) + config["repos"] = updated + _save_config(config) + st.success("Репозиторий добавлен!") + st.rerun() + +# --- Save all --- +st.divider() +if st.button("Сохранить изменения", type="primary", use_container_width=True): + invalid = [r for r in updated if not r.get("url")] + if invalid: + st.error("У всех репозиториев должен быть указан URL") + else: + config["repos"] = updated + _save_config(config) + st.success("Репозитории сохранены!") diff --git a/admin/pages/settings.py b/admin/pages/settings.py index b195b92..8aeb59a 100644 --- a/admin/pages/settings.py +++ b/admin/pages/settings.py @@ -6,6 +6,7 @@ from scheduler import DATA_DIR CONFIG_PATH = DATA_DIR / "config.yml" +MKDOCS_PATH = DATA_DIR / "mkdocs.yml" LANGUAGES = ["ru", "en", "de", "fr", "es", "zh", "ja", "ko"] MODEL_DEFAULTS = { @@ -17,38 +18,75 @@ st.header("Настройки") -def _load_config() -> dict: - if CONFIG_PATH.exists(): - return yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8")) or {} +def _load_yaml(path): # type: ignore[no-untyped-def] + if path.exists(): + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} return {} -def _save_config(data: dict) -> None: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text( +def _save_yaml(path, data): # type: ignore[no-untyped-def] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( yaml.dump(data, allow_unicode=True, default_flow_style=False), encoding="utf-8", ) -config = _load_config() +def _lines_to_list(text: str) -> list[str]: + return [line.strip() for line in text.strip().splitlines() if line.strip()] + + +config = _load_yaml(CONFIG_PATH) # --- API --- st.subheader("API") col1, col2 = st.columns(2) with col1: llm_api_key = st.text_input( - "LLM API Key", value=config.get("llm_api_key", ""), type="password" + "LLM API Key *", + value=config.get("llm_api_key", ""), + type="password", + help="Ключ от OpenAI-совместимого провайдера (OpenRouter, Ollama и др.)", ) with col2: llm_base_url = st.text_input( - "LLM Base URL", + "LLM Base URL *", value=config.get("llm_base_url", "https://openrouter.ai/api/v1"), + help="Базовый URL API. Поддерживается любой OpenAI-совместимый провайдер", ) git_token = st.text_input( - "Git Token (глобальный)", value=config.get("git_token", ""), type="password" + "Git Token (глобальный)", + value=config.get("git_token", ""), + type="password", + help="Токен по умолчанию для всех репозиториев. Можно переопределить per-repo", ) +# --- Site --- +st.subheader("Сайт") +post_cfg = config.get("post", {}) +col1, col2, col3 = st.columns([2, 3, 1]) +with col1: + site_name = st.text_input( + "Название сайта *", + value=post_cfg.get("site_name", "My Project"), + help="Используется в заголовке поста и в MkDocs", + ) +with col2: + site_desc = st.text_input( + "Описание сайта", + value=post_cfg.get("site_description", ""), + help="Описание для MkDocs (мета-тег description)", + ) +with col3: + lang_value = post_cfg.get("language", "en") + lang_idx = LANGUAGES.index(lang_value) if lang_value in LANGUAGES else 0 + language = st.selectbox( + "Язык", + options=LANGUAGES, + index=lang_idx, + help="Язык генерируемых постов и интерфейса MkDocs", + ) + # --- General --- st.subheader("Общие") col1, col2, col3, col4 = st.columns(4) @@ -58,20 +96,27 @@ def _save_config(data: dict) -> None: value=config.get("period_days", 7), min_value=1, max_value=90, + help="За сколько дней собирать git diff", ) with col2: - timezone = st.text_input("Часовой пояс", value=config.get("timezone", "UTC")) + timezone = st.text_input( + "Часовой пояс", + value=config.get("timezone", "UTC"), + help="Для даты в имени файла поста (напр. Europe/Moscow)", + ) with col3: max_review = st.number_input( "Макс. итераций ревью", value=config.get("max_review_iterations", 3), min_value=0, max_value=10, + help="Сколько раз ревьювер может отправить пост на доработку", ) with col4: log_cleanup = st.checkbox( "Очистка логов", value=config.get("log_cleanup_enabled", False), + help="Автоматически удалять старые логи генерации", ) log_retention = st.number_input( "Хранить логи (дни)", @@ -81,31 +126,64 @@ def _save_config(data: dict) -> None: disabled=not log_cleanup, ) -# --- Post --- -st.subheader("Пост") -post_cfg = config.get("post", {}) -col1, col2 = st.columns(2) +# --- Global filters --- +st.subheader("Глобальные фильтры файлов") +col1, col2, col3 = st.columns(3) with col1: - site_name = st.text_input( - "Название сайта", value=post_cfg.get("site_name", "My Project") + noise = st.text_area( + "Шум", + value="\n".join(config.get("noise_patterns", [])), + height=120, + help="Glob-паттерны файлов, полностью исключённых из анализа " + "(lock-файлы, сборки, кэши). По одному на строку", ) with col2: - lang_value = post_cfg.get("language", "en") - lang_idx = LANGUAGES.index(lang_value) if lang_value in LANGUAGES else 0 - language = st.selectbox("Язык", options=LANGUAGES, index=lang_idx) + priority = st.text_area( + "Приоритетные", + value="\n".join(config.get("priority_patterns", [])), + height=120, + help="Файлы, попадающие в diff первыми " + "(основной код: app/**, lib/**, src/**)", + ) +with col3: + secondary = st.text_area( + "Вторичные", + value="\n".join(config.get("secondary_patterns", [])), + height=120, + help="Файлы, попадающие последними, если остался бюджет " + "(тесты, конфиги, CI)", + ) # --- LLM Models --- st.subheader("Модели LLM") llm_cfg = config.get("llm", {}) -stage_labels = {"analysis": "Анализ", "post": "Генерация поста", "review": "Ревью"} +STAGE_HELP = { + "analysis": ( + "Анализ", + "Технический анализ каждого репозитория. Точность важнее креативности", + ), + "post": ( + "Генерация поста", + "Генерация текста поста. Нужен живой, читаемый текст", + ), + "review": ( + "Ревью", + "Проверка поста на качество. Строгая проверка, минимальная вариативность", + ), +} -for stage, label in stage_labels.items(): +for stage, (label, stage_help) in STAGE_HELP.items(): st.caption(label) mc = llm_cfg.get(stage, {}) defaults = MODEL_DEFAULTS[stage] col1, col2, col3 = st.columns([3, 1, 1]) with col1: - st.text_input("Модель", value=mc.get("model", defaults[0]), key=f"{stage}_m") + st.text_input( + "Модель", + value=mc.get("model", defaults[0]), + key=f"{stage}_m", + help=stage_help, + ) with col2: st.number_input( "Temperature", @@ -114,6 +192,7 @@ def _save_config(data: dict) -> None: max_value=2.0, step=0.1, key=f"{stage}_t", + help="0 = детерминированный, 2 = максимально креативный", ) with col3: st.number_input( @@ -123,26 +202,54 @@ def _save_config(data: dict) -> None: max_value=16000, step=100, key=f"{stage}_k", + help="Максимальная длина ответа модели", ) # --- Save --- st.divider() if st.button("Сохранить", type="primary", use_container_width=True): - config["llm_api_key"] = llm_api_key - config["llm_base_url"] = llm_base_url - config["git_token"] = git_token - config["period_days"] = period_days - config["timezone"] = timezone - config["max_review_iterations"] = max_review - config["log_cleanup_enabled"] = log_cleanup - config["log_retention_days"] = log_retention - config["post"] = {"site_name": site_name, "language": language} - config["llm"] = {} - for stage in stage_labels: - config["llm"][stage] = { - "model": st.session_state[f"{stage}_m"], - "temperature": st.session_state[f"{stage}_t"], - "max_tokens": st.session_state[f"{stage}_k"], + errors = [] + if not llm_api_key: + errors.append("LLM API Key — обязательное поле") + if not llm_base_url: + errors.append("LLM Base URL — обязательное поле") + if not site_name: + errors.append("Название сайта — обязательное поле") + if errors: + for e in errors: + st.error(e) + else: + config["llm_api_key"] = llm_api_key + config["llm_base_url"] = llm_base_url + config["git_token"] = git_token + config["period_days"] = period_days + config["timezone"] = timezone + config["max_review_iterations"] = max_review + config["log_cleanup_enabled"] = log_cleanup + config["log_retention_days"] = log_retention + config["noise_patterns"] = _lines_to_list(noise) + config["priority_patterns"] = _lines_to_list(priority) + config["secondary_patterns"] = _lines_to_list(secondary) + config["post"] = { + "site_name": site_name, + "site_description": site_desc, + "language": language, } - _save_config(config) - st.success("Настройки сохранены!") + config["llm"] = {} + for stage in STAGE_HELP: + config["llm"][stage] = { + "model": st.session_state[f"{stage}_m"], + "temperature": st.session_state[f"{stage}_t"], + "max_tokens": st.session_state[f"{stage}_k"], + } + _save_yaml(CONFIG_PATH, config) + + mkdocs = _load_yaml(MKDOCS_PATH) + mkdocs["site_name"] = site_name + if site_desc: + mkdocs["site_description"] = site_desc + elif "site_description" in mkdocs: + del mkdocs["site_description"] + _save_yaml(MKDOCS_PATH, mkdocs) + + st.success("Настройки сохранены!") diff --git a/app/config.py b/app/config.py index ca5b4bd..fb8d7e1 100644 --- a/app/config.py +++ b/app/config.py @@ -61,6 +61,7 @@ class LLMConfig(BaseModel): class PostConfig(BaseModel): site_name: str = "My Project" + site_description: str = "" language: str = "en" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..95a86f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + init: + build: + context: . + dockerfile: admin/Dockerfile + entrypoint: sh + command: + - -c + - | + mkdir -p /data/output /data/logs /data/posts /data/prompts + test -f /data/mkdocs.yml || cp /app/mkdocs-default.yml /data/mkdocs.yml + cp -n /app/prompts/*.txt /data/prompts/ 2>/dev/null || true + volumes: + - data:/data + + admin: + build: + context: . + dockerfile: admin/Dockerfile + ports: + - "8501:8501" + volumes: + - data:/data + environment: + - LLM_API_KEY=${LLM_API_KEY:-} + - GIT_TOKEN=${GIT_TOKEN:-} + depends_on: + init: + condition: service_completed_successfully + restart: unless-stopped + + mkdocs: + build: + context: mkdocs + ports: + - "8000:8000" + entrypoint: sh + command: + - -c + - | + ln -sf /data/mkdocs.yml /docs/mkdocs.yml + ln -sf /data/posts /docs/docs/blog/posts + exec mkdocs serve --dev-addr=0.0.0.0:8000 + volumes: + - data:/data:ro + depends_on: + init: + condition: service_completed_successfully + restart: unless-stopped + +volumes: + data: