mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:44:45 +00:00
infra: backend in repo + deploy.sh for autodeploy
This commit is contained in:
parent
6477ef3ed4
commit
239cc3ffba
773
backend/elena_app.py
Normal file
773
backend/elena_app.py
Normal file
@ -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/<token>")
|
||||||
|
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)
|
||||||
20
deploy.sh
Normal file
20
deploy.sh
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user