From 239cc3ffbaaedf70889c236d81907c95c37e450d Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sat, 30 May 2026 14:18:14 +0300 Subject: [PATCH] infra: backend in repo + deploy.sh for autodeploy --- backend/elena_app.py | 773 +++++++++++++++++++++++++++++++++++++++++++ deploy.sh | 20 ++ 2 files changed, 793 insertions(+) create mode 100644 backend/elena_app.py create mode 100644 deploy.sh diff --git a/backend/elena_app.py b/backend/elena_app.py new file mode 100644 index 0000000..bc665f5 --- /dev/null +++ b/backend/elena_app.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ELENA CONSULTING — Backend API +@wasrusgen1 | КОНСАЛТИНГ +Реальный чат-интервью + построение бизнес-модели через Opus 4.8. +Крутится на Finnish VPS (RU IP блокируется Anthropic). +""" +import os, re, json, sqlite3, secrets, time, base64 +from datetime import datetime, timezone +from flask import Flask, request, jsonify, g +from flask_cors import CORS +import anthropic + +# ── Config ─────────────────────────────────────────── +BASE = "/opt/elena-consulting" +DB_PATH = os.path.join(BASE, "elena.db") +PROMPT_PATH = os.path.join(BASE, "elena_system_prompt.md") +UPLOAD_DIR = os.path.join(BASE, "uploads") +MODEL = "claude-opus-4-8" + +def extract_text(path, fname): + ext = fname.lower().rsplit(".", 1)[-1] if "." in fname else "" + try: + if ext == "pdf": + import pdfplumber + with pdfplumber.open(path) as pdf: + return "\n".join(p.extract_text() or "" for p in pdf.pages) + elif ext == "docx": + import docx + return "\n".join(p.text for p in docx.Document(path).paragraphs) + elif ext in ("xlsx", "xlsm"): + import openpyxl + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + out = [] + for ws in wb.worksheets: + out.append(f"# Лист: {ws.title}") + for row in ws.iter_rows(values_only=True): + cells = [str(c) for c in row if c is not None] + if cells: out.append(" | ".join(cells)) + return "\n".join(out) + elif ext in ("txt", "csv", "md"): + return open(path, encoding="utf-8", errors="replace").read() + except Exception as e: + return f"[не удалось извлечь текст из {fname}: {e}]" + return f"[{fname}: формат .{ext} не поддержан для извлечения текста]" + +def _key(): + env = open("/opt/zashita-api/.env").read() + return re.search(r'ANTHROPIC_API_KEY=(\S+)', env).group(1) + +client = anthropic.Anthropic(api_key=_key()) +SYSTEM_PROMPT = open(PROMPT_PATH, encoding="utf-8").read() + +app = Flask(__name__) +CORS(app) + +def now(): + return datetime.now(timezone.utc).isoformat() + +# ── DB ─────────────────────────────────────────────── +def db(): + if "db" not in g: + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + return g.db + +@app.teardown_appcontext +def close_db(exc): + d = g.pop("db", None) + if d: d.close() + +def init_db(): + con = sqlite3.connect(DB_PATH) + con.executescript(""" + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + client_name TEXT, + niche TEXT, + description TEXT, + status TEXT DEFAULT 'interview', + created_at TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + role TEXT NOT NULL, -- 'user' (клиент) | 'assistant' (Елена) + content TEXT NOT NULL, + created_at TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id) + ); + CREATE TABLE IF NOT EXISTS models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + blocks_json TEXT NOT NULL, + created_at TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id) + ); + CREATE TABLE IF NOT EXISTS artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + kind TEXT NOT NULL, -- 'selection' | 'canvas' | 'vsm' | 'spec' + data_json TEXT NOT NULL, + created_at TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id) + ); + """) + con.commit() + con.close() + +# ── Helpers ────────────────────────────────────────── +def get_project(token): + return db().execute("SELECT * FROM projects WHERE token=?", (token,)).fetchone() + +def history(project_id): + rows = db().execute( + "SELECT role, content FROM messages WHERE project_id=? ORDER BY id", (project_id,) + ).fetchall() + return [{"role": r["role"], "content": r["content"]} for r in rows] + +def save_artifact(project_id, kind, data): + con = db() + con.execute( + "INSERT INTO artifacts (project_id, kind, data_json, created_at) VALUES (?,?,?,?)", + (project_id, kind, json.dumps(data, ensure_ascii=False), now()) + ) + con.commit() + +def latest_artifact(project_id, kind): + row = db().execute( + "SELECT data_json FROM artifacts WHERE project_id=? AND kind=? ORDER BY id DESC LIMIT 1", + (project_id, kind) + ).fetchone() + return json.loads(row["data_json"]) if row else None + +def run_tool(project_id, tool, tool_name, instruction, extra_context=None, max_tokens=4096): + """Универсальный вызов forced-tool Opus на основе истории интервью.""" + msgs = history(project_id) + if not msgs: + return None, "no interview data" + ctx = instruction + if extra_context: + ctx = extra_context + "\n\n" + instruction + msgs = msgs + [{"role": "user", "content": ctx}] + try: + resp = client.messages.create( + model=MODEL, max_tokens=max_tokens, + system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}], + tools=[tool], + tool_choice={"type": "tool", "name": tool_name}, + messages=msgs + ) + block = next(b for b in resp.content if b.type == "tool_use") + return block.input, {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens} + except Exception as e: + return None, str(e) + +# ── Routes ─────────────────────────────────────────── +@app.route("/api/health") +def health(): + return jsonify({"ok": True, "model": MODEL, "time": now()}) + +@app.route("/api/project/new", methods=["POST"]) +def new_project(): + data = request.get_json(force=True) or {} + token = secrets.token_urlsafe(8) + con = db() + cur = con.execute( + "INSERT INTO projects (token, client_name, niche, status, created_at) VALUES (?,?,?,?,?)", + (token, data.get("client_name", ""), data.get("niche", ""), "interview", now()) + ) + con.commit() + pid = cur.lastrowid + + # Первое приветствие Елены — открывает интервью + greeting = _elena_first_message(data.get("client_name", ""), data.get("niche", "")) + con.execute( + "INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)", + (pid, "assistant", greeting, now()) + ) + con.commit() + return jsonify({"token": token, "greeting": greeting}) + +def _elena_first_message(name, niche): + nm = f", {name}" if name else "" + return (f"Здравствуйте{nm}! Я Елена, ваш консультант. " + f"Моя задача — разобрать ваш бизнес по полочкам и показать где можно ускориться и заработать больше.\n\n" + f"Расскажите своими словами: чем вы занимаетесь и что сейчас беспокоит больше всего? " + f"Можно текстом или голосом — как удобнее.") + +@app.route("/api/project/profile", methods=["POST"]) +def save_profile(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + name = (data.get("client_name") or "").strip() + niche = (data.get("niche") or "").strip() + desc = (data.get("description") or "").strip() + con = db() + con.execute("UPDATE projects SET client_name=?, niche=?, description=? WHERE id=?", + (name, niche, desc, proj["id"])) + con.commit() + + # Профиль становится первым реальным сообщением клиента — Елена сразу в контексте. + # Удаляем старое дефолтное приветствие и формируем диалог заново. + con.execute("DELETE FROM messages WHERE project_id=?", (proj["id"],)) + profile_msg = f"Меня зовут {name}." if name else "" + if niche: profile_msg += f" Сфера деятельности: {niche}." + if desc: profile_msg += f"\n\nО моей деятельности: {desc}" + profile_msg = profile_msg.strip() or "Хочу разобрать свой бизнес." + con.execute("INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)", + (proj["id"], "user", profile_msg, now())) + con.commit() + + # Елена отвечает на профиль + try: + resp = client.messages.create( + model=MODEL, max_tokens=900, + system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}], + messages=[{"role": "user", "content": profile_msg}] + ) + reply = resp.content[0].text + except Exception as e: + return jsonify({"error": str(e)}), 500 + con.execute("INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)", + (proj["id"], "assistant", reply, now())) + con.commit() + return jsonify({"ok": True, "profile_msg": profile_msg, "reply": reply}) + +@app.route("/api/chat", methods=["POST"]) +def chat(): + data = request.get_json(force=True) or {} + token = data.get("token") + msg = (data.get("message") or "").strip() + if not token or not msg: + return jsonify({"error": "token and message required"}), 400 + proj = get_project(token) + if not proj: + return jsonify({"error": "project not found"}), 404 + + con = db() + con.execute( + "INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)", + (proj["id"], "user", msg, now()) + ) + con.commit() + + msgs = history(proj["id"]) + + try: + resp = client.messages.create( + model=MODEL, + max_tokens=1024, + system=[{ + "type": "text", + "text": SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"} + }], + messages=msgs + ) + reply = resp.content[0].text + usage = {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens} + except Exception as e: + return jsonify({"error": str(e)}), 500 + + con.execute( + "INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)", + (proj["id"], "assistant", reply, now()) + ) + con.commit() + return jsonify({"reply": reply, "usage": usage}) + +# ── Tool schema: строгий IDEF0 (ICOM + декомпозиция) ── +ARROW = { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Что за поток (название стрелки)"}, + "source": {"type": "string", "description": "Откуда: внешний источник или node_id функции"}, + "exists": {"type": "boolean", "description": "true если реально существует в бизнесе, false если ДОЛЖЕН быть но отсутствует"} + }, + "required": ["name", "source", "exists"] +} +OUT_ARROW = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "target": {"type": "string", "description": "Куда идёт: внешний потребитель или node_id функции; 'НИКУДА' если выход никто не использует"}, + "exists": {"type": "boolean"} + }, + "required": ["name", "target", "exists"] +} +MECH = { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Исполнитель или инструмент"}, + "type": {"type": "string", "enum": ["human", "equipment", "software", "none"]}, + "load": {"type": "string", "description": "Нагрузка/объём потребления, если известно"} + }, + "required": ["name", "type"] +} + +def _activity_schema(): + return { + "type": "object", + "properties": { + "node_id": {"type": "string", "description": "Идентификатор по IDEF0: A0, A1, A2, A1.1 ..."}, + "parent": {"type": "string", "description": "node_id родителя или 'A0' для верхнего уровня"}, + "function": {"type": "string", "description": "Функция ГЛАГОЛОМ + объект, напр. 'Раскроить ткань', 'Принять заказ'"}, + "inputs": {"type": "array", "items": ARROW, "description": "I — что ПРЕОБРАЗУЕТСЯ (сырьё, данные на обработку)"}, + "controls": {"type": "array", "items": ARROW, "description": "C — что УПРАВЛЯЕТ но не расходуется (нормы, регламенты, ТЗ, лекала). Если нет — пустой массив = функция без контроля"}, + "outputs": {"type": "array", "items": OUT_ARROW, "description": "O — результат функции"}, + "mechanisms":{"type": "array", "items": MECH, "description": "M — кто/чем выполняется (люди, оборудование, ПО)"}, + "completeness": {"type": "integer", "description": "0-100"}, + "issues": {"type": "array", "items": {"type": "string"}} + }, + "required": ["node_id","parent","function","inputs","controls","outputs","mechanisms","completeness","issues"] + } + +MODEL_TOOL = { + "name": "build_idef0_model", + "description": "Строит функциональную модель бизнеса клиента по стандарту IDEF0: контекстная диаграмма A-0, декомпозиция A0, анализ стрелок ICOM.", + "input_schema": { + "type": "object", + "properties": { + "client_summary": {"type": "string", "description": "1-2 предложения: кто клиент и чем занимается"}, + "business_pattern": {"type": "string", "description": "Тип бизнеса и модель монетизации"}, + "context": { + "type": "object", + "description": "Контекстная диаграмма A-0: весь бизнес как ОДНА функция", + "properties": { + "function": {"type": "string", "description": "Главная функция бизнеса глаголом, напр. 'Производить и продавать швейные изделия'"}, + "inputs": {"type": "array", "items": ARROW}, + "controls": {"type": "array", "items": ARROW}, + "outputs": {"type": "array", "items": OUT_ARROW}, + "mechanisms":{"type": "array", "items": MECH} + }, + "required": ["function","inputs","controls","outputs","mechanisms"] + }, + "activities": { + "type": "array", + "description": "Декомпозиция A0 на функции (3-8 для МСБ), при необходимости под-функции A1.1 и т.д.", + "items": _activity_schema() + }, + "arrow_issues": { + "type": "array", + "description": "Анализ стрелок — разрывы модели", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["missing_control","dangling_output","dangling_input","overloaded_mechanism","manual_bridge","broken_flow"]}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "node_id": {"type": "string", "description": "К какой функции относится"}, + "severity": {"type": "string", "enum": ["critical","high","medium"]} + }, + "required": ["type","title","description","node_id","severity"] + } + }, + "missing_info": {"type": "array", "items": {"type": "string"}} + }, + "required": ["client_summary","business_pattern","context","activities","arrow_issues","missing_info"] + } +} + +BUILD_INSTRUCTION = """Построй функциональную модель бизнеса клиента по строгому стандарту IDEF0. + +ПРАВИЛА ICOM (различай 4 типа стрелок): +- INPUT (I, слева): то что ПРЕОБРАЗУЕТСЯ функцией в выход (сырьё, заявка, данные на обработку). +- CONTROL (C, сверху): то что УПРАВЛЯЕТ функцией но НЕ расходуется (нормы, регламенты, ТЗ, лекала, прайс, ГОСТ). КРИТИЧНО: если у функции нет контроля — оставь controls пустым, это сигнал проблемы 'работают как привыкли'. +- OUTPUT (O, справа): результат функции. Если выход никто не использует — target='НИКУДА'. +- MECHANISM (M, снизу): кто/чем выполняется (человек, оборудование, ПО). type='none' если механизма по сути нет. + +ШАГИ: +1. context (A-0): опиши весь бизнес как ОДНУ функцию глаголом, с её внешними ICOM. +2. activities (A0): декомпозируй на 3-8 главных функций. Каждая — ГЛАГОЛ+объект ('Принять заказ', 'Раскроить ткань'). Присвой node_id A1, A2... Свяжи стрелки: выход одной функции = вход или контроль другой (source/target = node_id). +3. Где функция сложная — декомпозируй глубже (A1.1, A1.2) с parent. +4. arrow_issues: проанализируй стрелки: + - missing_control: функция без управляющих норм + - dangling_output: выход никто не потребляет + - dangling_input: вход ниоткуда не приходит + - overloaded_mechanism: один механизм (человек) на много функций + - manual_bridge: человек вручную переносит данные между функциями + - broken_flow: выход одной функции не доходит до входа следующей +5. Помечай exists=false для стрелок которые ДОЛЖНЫ быть, но в бизнесе отсутствуют. + +Функция = глагол. Не выдумывай то чего нет в интервью. +Вызови build_idef0_model.""" + +@app.route("/api/build-model", methods=["POST"]) +def build_model(): + data = request.get_json(force=True) or {} + token = data.get("token") + proj = get_project(token) + if not proj: + return jsonify({"error": "project not found"}), 404 + + msgs = history(proj["id"]) + if not msgs: + return jsonify({"error": "no interview data"}), 400 + + docs = documents_context(proj["id"]) + build_text = (docs + "\n\n" + BUILD_INSTRUCTION) if docs else BUILD_INSTRUCTION + msgs = msgs + [{"role": "user", "content": build_text}] + + try: + resp = client.messages.create( + model=MODEL, + max_tokens=4096, + system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}], + tools=[MODEL_TOOL], + tool_choice={"type": "tool", "name": "build_idef0_model"}, + messages=msgs + ) + block = next(b for b in resp.content if b.type == "tool_use") + model_data = block.input + usage = {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens} + except Exception as e: + return jsonify({"error": str(e)}), 500 + + con = db() + con.execute( + "INSERT INTO models (project_id, blocks_json, created_at) VALUES (?,?,?)", + (proj["id"], json.dumps(model_data, ensure_ascii=False), now()) + ) + con.execute("UPDATE projects SET status='model_ready' WHERE id=?", (proj["id"],)) + con.commit() + return jsonify({"model": model_data, "usage": usage}) + +# ══ СЕЛЕКТОР МЕТОДОЛОГИЙ ═════════════════════════════ +SELECT_TOOL = { + "name": "recommend_methodologies", + "description": "Анализирует бизнес клиента и рекомендует набор методологий моделирования.", + "input_schema": { + "type": "object", + "properties": { + "business_type": {"type": "string", "description": "Распознанный тип: эксперт / услуги / производство / торговля / иное"}, + "recommended": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["canvas", "idef0", "vsm", "dfd", "erd"]}, + "use": {"type": "boolean", "description": "Применять ли"}, + "depth": {"type": "string", "enum": ["full", "light", "skip"]}, + "reason": {"type": "string", "description": "Почему именно так для этого клиента"} + }, + "required": ["method", "use", "depth", "reason"] + } + }, + "rationale": {"type": "string", "description": "Общее обоснование набора"} + }, + "required": ["business_type", "recommended", "rationale"] + } +} +SELECT_INSTRUCTION = """Проанализируй бизнес клиента и реши какие методологии моделирования применить. +Доступны: canvas (Business Model Canvas — стратегия), idef0 (функциональная модель), vsm (поток ценности, потери), dfd (потоки данных), erd (модель данных). +Правила: canvas нужен почти всем. idef0 — где есть процессы. vsm — где есть поток материала/товара (производство, торговля). Для простого эксперта без производства vsm пропусти. +Для каждой метод. укажи use, depth (full/light/skip) и reason под ЭТОГО клиента. Дай rationale. +Вызови recommend_methodologies.""" + +@app.route("/api/select-methodologies", methods=["POST"]) +def select_methodologies(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + result, usage = run_tool(proj["id"], SELECT_TOOL, "recommend_methodologies", SELECT_INSTRUCTION, max_tokens=1500) + if result is None: + return jsonify({"error": usage}), 500 + save_artifact(proj["id"], "selection", result) + return jsonify({"selection": result, "usage": usage}) + +# ══ CANVAS — Business Model Canvas (9 блоков) ════════ +CANVAS_BLOCK = { + "type": "object", + "properties": { + "items": {"type": "array", "items": {"type": "string"}, "description": "Пункты блока"}, + "completeness": {"type": "integer"}, + "note": {"type": "string", "description": "Комментарий / выявленная проблема по блоку"} + }, + "required": ["items", "completeness", "note"] +} +CANVAS_TOOL = { + "name": "build_canvas", + "description": "Строит Business Model Canvas клиента (9 блоков) из интервью.", + "input_schema": { + "type": "object", + "properties": { + "value_propositions": CANVAS_BLOCK, + "customer_segments": CANVAS_BLOCK, + "channels": CANVAS_BLOCK, + "customer_relationships": CANVAS_BLOCK, + "revenue_streams": CANVAS_BLOCK, + "key_resources": CANVAS_BLOCK, + "key_activities": CANVAS_BLOCK, + "key_partners": CANVAS_BLOCK, + "cost_structure": CANVAS_BLOCK, + "insight": {"type": "string", "description": "Главный стратегический вывод по бизнес-модели"} + }, + "required": ["value_propositions","customer_segments","channels","customer_relationships","revenue_streams","key_resources","key_activities","key_partners","cost_structure","insight"] + } +} +CANVAS_INSTRUCTION = """Построй Business Model Canvas клиента из интервью. 9 блоков: +- value_propositions: ценность, которую получает клиент +- customer_segments: кто платит, сегменты +- channels: как доходит ценность до клиента +- customer_relationships: тип отношений с клиентами +- revenue_streams: откуда деньги, модель монетизации +- key_resources: ключевые ресурсы +- key_activities: ключевые действия +- key_partners: партнёры, поставщики +- cost_structure: основные издержки +Каждый блок: items (пункты), completeness (0-100), note (проблема/комментарий). Дай insight — главный стратегический вывод. +Не выдумывай. Где данных нет — низкий completeness. Вызови build_canvas.""" + +@app.route("/api/build-canvas", methods=["POST"]) +def build_canvas(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + result, usage = run_tool(proj["id"], CANVAS_TOOL, "build_canvas", CANVAS_INSTRUCTION, extra_context=documents_context(proj["id"]), max_tokens=3000) + if result is None: + return jsonify({"error": usage}), 500 + save_artifact(proj["id"], "canvas", result) + return jsonify({"canvas": result, "usage": usage}) + +# ══ ГЕНЕРАТОР ТЗ (части A-C из IDEF0) ════════════════ +SPEC_TOOL = { + "name": "build_tech_spec", + "description": "Собирает техническое задание на ПО из модели бизнеса: контекст, модули, модель данных.", + "input_schema": { + "type": "object", + "properties": { + "overview": {"type": "string", "description": "A1-A2: что за система, для кого, какую проблему решает, границы"}, + "roles": { + "type": "array", + "description": "A3: роли пользователей (из mechanisms IDEF0)", + "items": {"type": "object", "properties": { + "name": {"type": "string"}, "does": {"type": "string"}, "access": {"type": "string"} + }, "required": ["name","does","access"]} + }, + "modules": { + "type": "array", + "description": "B: модули системы (из функций IDEF0)", + "items": {"type": "object", "properties": { + "name": {"type": "string", "description": "Название модуля"}, + "source_node": {"type": "string", "description": "node_id функции IDEF0"}, + "purpose": {"type": "string"}, + "screens": {"type": "array", "items": {"type": "string"}, "description": "Экраны модуля"}, + "inputs_data": {"type": "string", "description": "Какие данные вводятся"}, + "outputs_data": {"type": "string", "description": "Что система создаёт/показывает"}, + "rules": {"type": "array", "items": {"type": "string"}, "description": "Бизнес-правила (из controls)"}, + "roles": {"type": "array", "items": {"type": "string"}, "description": "Кто пользуется"} + }, "required": ["name","source_node","purpose","screens","inputs_data","outputs_data","rules","roles"]} + }, + "entities": { + "type": "array", + "description": "C: модель данных (таблицы)", + "items": {"type": "object", "properties": { + "name": {"type": "string", "description": "Сущность/таблица"}, + "fields": {"type": "array", "items": {"type": "object", "properties": { + "field": {"type": "string"}, "type": {"type": "string"}, "note": {"type": "string"} + }, "required": ["field","type"]}}, + "relations": {"type": "array", "items": {"type": "string"}, "description": "Связи с другими таблицами"}, + "example": {"type": "string", "description": "Пример строки данных"} + }, "required": ["name","fields","relations","example"]} + }, + "open_questions": {"type": "array", "items": {"type": "string"}, "description": "Что уточнить перед разработкой"} + }, + "required": ["overview","roles","modules","entities","open_questions"] + } +} + +@app.route("/api/build-spec", methods=["POST"]) +def build_spec(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + # Подгружаем готовую IDEF0 модель как контекст + model_row = db().execute("SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (proj["id"],)).fetchone() + if not model_row: + return jsonify({"error": "сначала постройте IDEF0 модель"}), 400 + idef0 = model_row["blocks_json"] + instruction = ("На основе интервью и построенной IDEF0-модели собери ТЗ на программу для бизнеса клиента.\n" + "МАППИНГ: функция IDEF0 → модуль; Input → вводимые данные; Output → что показывает; " + "Control → бизнес-правила; Mechanism → роли; хранилища → таблицы данных.\n" + "overview (A1-A2): что за система, для кого, проблема, границы.\n" + "roles (A3): из mechanisms.\n" + "modules (B): каждая функция = модуль с экранами, данными, правилами.\n" + "entities (C): модель данных — таблицы с полями, связями, примером строки.\n" + "Думай как проектировщик ПО. Вызови build_tech_spec.\n\n" + f"IDEF0-МОДЕЛЬ:\n{idef0}") + result, usage = run_tool(proj["id"], SPEC_TOOL, "build_tech_spec", instruction, max_tokens=8192) + if result is None: + return jsonify({"error": usage}), 500 + save_artifact(proj["id"], "spec", result) + con = db(); con.execute("UPDATE projects SET status='spec_ready' WHERE id=?", (proj["id"],)); con.commit() + return jsonify({"spec": result, "usage": usage}) + +@app.route("/api/project/crm", methods=["POST"]) +def update_crm(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + crm = latest_artifact(proj["id"], "crm") or { + "pipeline": "lead", "deal_amount": 0, "paid_amount": 0, + "contact": "", "source": "", "note": "" + } + for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note"): + if k in data: crm[k] = data[k] + save_artifact(proj["id"], "crm", crm) + return jsonify({"ok": True, "crm": crm}) + +@app.route("/api/project/tasks", methods=["POST"]) +def update_tasks(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + tasks = data.get("tasks", []) # [{text, due, done}] + save_artifact(proj["id"], "tasks", {"items": tasks}) + return jsonify({"ok": True, "tasks": tasks}) + +@app.route("/api/project/approve", methods=["POST"]) +def approve_stage(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + stage = data.get("stage") + approved = bool(data.get("approved", True)) + con = db() + # approvals хранятся как один артефакт kind='approvals' — словарь stage->bool + row = db().execute("SELECT id, data_json FROM artifacts WHERE project_id=? AND kind='approvals' ORDER BY id DESC LIMIT 1", (proj["id"],)).fetchone() + appr = json.loads(row["data_json"]) if row else {} + if approved: appr[stage] = now() + else: appr.pop(stage, None) + if row: + con.execute("UPDATE artifacts SET data_json=? WHERE id=?", (json.dumps(appr, ensure_ascii=False), row["id"])) + else: + con.execute("INSERT INTO artifacts (project_id, kind, data_json, created_at) VALUES (?,?,?,?)", + (proj["id"], "approvals", json.dumps(appr, ensure_ascii=False), now())) + con.commit() + return jsonify({"ok": True, "approvals": appr}) + +@app.route("/api/upload", methods=["POST"]) +def upload_doc(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + fname = (data.get("filename") or "file").replace("/", "_").replace("\\", "_") + b64 = data.get("content") or "" + try: + raw = base64.b64decode(b64.split(",")[-1]) + except Exception as e: + return jsonify({"error": "bad base64: " + str(e)}), 400 + if len(raw) > 15 * 1024 * 1024: + return jsonify({"error": "файл больше 15 МБ"}), 400 + pdir = os.path.join(UPLOAD_DIR, proj["token"]) + os.makedirs(pdir, exist_ok=True) + path = os.path.join(pdir, fname) + with open(path, "wb") as f: + f.write(raw) + text = extract_text(path, fname) + save_artifact(proj["id"], "document", {"filename": fname, "text": text[:10000], "size": len(raw)}) + return jsonify({"ok": True, "filename": fname, "chars": len(text), "size": len(raw)}) + +def documents_context(project_id): + rows = db().execute("SELECT data_json FROM artifacts WHERE project_id=? AND kind='document' ORDER BY id", (project_id,)).fetchall() + if not rows: + return None + parts = ["ЗАГРУЖЕННЫЕ ДОКУМЕНТЫ КЛИЕНТА:"] + for r in rows: + d = json.loads(r["data_json"]) + parts.append(f"\n=== {d['filename']} ===\n{d.get('text','')[:6000]}") + return "\n".join(parts) + +@app.route("/api/projects") +def list_projects(): + rows = db().execute( + "SELECT p.token, p.client_name, p.niche, p.status, p.created_at, " + "(SELECT COUNT(*) FROM messages m WHERE m.project_id=p.id) as msg_count " + "FROM projects p ORDER BY p.id DESC" + ).fetchall() + out = [] + for r in rows: + pid = db().execute("SELECT id FROM projects WHERE token=?", (r["token"],)).fetchone()["id"] + out.append({ + "token": r["token"], "client_name": r["client_name"] or "Без имени", + "niche": r["niche"] or "", "status": r["status"], + "created_at": r["created_at"], "msg_count": r["msg_count"], + "has_canvas": latest_artifact(pid, "canvas") is not None, + "has_idef0": db().execute("SELECT 1 FROM models WHERE project_id=? LIMIT 1", (pid,)).fetchone() is not None, + "has_spec": latest_artifact(pid, "spec") is not None, + "approvals": latest_artifact(pid, "approvals") or {}, + "crm": latest_artifact(pid, "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":""}, + "tasks": (latest_artifact(pid, "tasks") or {}).get("items", []) + }) + return jsonify({"projects": out}) + +SCREEN_TOOL = { + "name": "design_screen", + "description": "Проектирует один экран программы: layout, поля, действия, пример данных.", + "input_schema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Название экрана"}, + "type": {"type": "string", "enum": ["list", "card", "form", "dashboard", "kanban"], "description": "Тип экрана"}, + "columns": {"type": "array", "items": {"type": "string"}, "description": "Для list/kanban: колонки таблицы или стадии"}, + "fields": {"type": "array", "items": {"type": "object", "properties": { + "label": {"type": "string"}, "kind": {"type": "string", "enum": ["text","number","select","date","money","status","textarea"]} + }, "required": ["label","kind"]}, "description": "Для form/card: поля"}, + "rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}, "description": "Для list: 2-3 примера строк (значения по колонкам)"}, + "actions": {"type": "array", "items": {"type": "string"}, "description": "Кнопки/действия на экране"}, + "kpis": {"type": "array", "items": {"type": "object", "properties": {"value": {"type": "string"}, "label": {"type": "string"}}, "required": ["value","label"]}, "description": "Для dashboard: показатели"} + }, + "required": ["title", "type", "actions"] + } +} + +@app.route("/api/design-screen", methods=["POST"]) +def design_screen(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + module = data.get("module", "") + instr = (f"Спроектируй ОДИН экран программы для модуля «{module}» бизнеса клиента.\n" + "Используй реальные данные клиента из интервью для примеров (имена, суммы, статусы).\n" + "Выбери подходящий type (list/card/form/dashboard/kanban). Заполни поля/колонки/примеры строк/KPI.\n" + "actions — конкретные кнопки. Вызови design_screen.") + result, usage = run_tool(proj["id"], SCREEN_TOOL, "design_screen", instr, max_tokens=1500) + if result is None: + return jsonify({"error": usage}), 500 + return jsonify({"screen": result, "usage": usage}) + +@app.route("/api/project/") +def get_project_state(token): + proj = get_project(token) + if not proj: + return jsonify({"error": "not found"}), 404 + msgs = db().execute( + "SELECT role, content, created_at FROM messages WHERE project_id=? ORDER BY id", (proj["id"],) + ).fetchall() + model_row = db().execute( + "SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (proj["id"],) + ).fetchone() + return jsonify({ + "token": token, + "client_name": proj["client_name"], + "niche": proj["niche"], + "description": proj["description"] if "description" in proj.keys() else "", + "status": proj["status"], + "messages": [{"role": m["role"], "content": m["content"], "at": m["created_at"]} for m in msgs], + "model": json.loads(model_row["blocks_json"]) if model_row else None, + "selection": latest_artifact(proj["id"], "selection"), + "canvas": latest_artifact(proj["id"], "canvas"), + "spec": latest_artifact(proj["id"], "spec"), + "approvals": latest_artifact(proj["id"], "approvals") or {}, + "crm": latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":""}, + "tasks": (latest_artifact(proj["id"], "tasks") or {}).get("items", []), + "documents": [json.loads(r["data_json"]) and {"filename": json.loads(r["data_json"])["filename"], "size": json.loads(r["data_json"]).get("size",0)} for r in db().execute("SELECT data_json FROM artifacts WHERE project_id=? AND kind='document' ORDER BY id", (proj["id"],)).fetchall()] + }) + +if __name__ == "__main__": + init_db() + app.run(host="0.0.0.0", port=5002) diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..5e93b34 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Автодеплой Elena Consulting на Finnish VPS +# Запускается по cron каждые 2 мин: git pull → деплой при изменениях +set -e +REPO=/opt/elena-deploy +APP=/opt/elena-consulting +cd $REPO +BEFORE=$(git rev-parse HEAD) +git pull --quiet origin main +AFTER=$(git rev-parse HEAD) +if [ "$BEFORE" != "$AFTER" ]; then + echo "[$(date)] Изменения $BEFORE → $AFTER, деплой..." + cp $REPO/docs/cabinet.html $REPO/docs/crm.html $REPO/docs/elena_live.html $APP/static/ 2>/dev/null || true + if [ -f $REPO/backend/elena_app.py ]; then + cp $REPO/backend/elena_app.py $APP/elena_app.py + systemctl restart elena-consulting + echo "[$(date)] Backend перезапущен" + fi + echo "[$(date)] Деплой завершён" +fi