#!/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, hmac, hashlib from urllib.parse import parse_qsl 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) def _yookassa_creds(): """shopId и secret из .env. Если нет — None (демо-режим).""" try: env = open(os.path.join(BASE, ".env")).read() sid = re.search(r'YOOKASSA_SHOP_ID=(\S+)', env) sec = re.search(r'YOOKASSA_SECRET=(\S+)', env) if sid and sec: return sid.group(1), sec.group(1) except Exception: pass return None, None client = anthropic.Anthropic(api_key=_key()) SYSTEM_PROMPT = open(PROMPT_PATH, encoding="utf-8").read() # ── Операторская авторизация (Telegram initData + пароль на десктопе) ── def _env(key, default=None): try: env = open(os.path.join(BASE, ".env")).read() m = re.search(rf'^{re.escape(key)}=(.*)$', env, re.M) if m: return m.group(1).strip() except Exception: pass return default TG_BOT_TOKEN = "8767209545:AAEVgfL-bAhg6j0fHUyKWUze4SLTfJbLklM" OPERATOR_PASSWORD = _env("OPERATOR_PASSWORD", "") ADMIN_TG_IDS = set(x for x in (_env("ADMIN_TG_IDS", "") or "").replace(" ", "").split(",") if x) def _op_token(): """Серверный операторский токен (зависит от пароля — смена пароля инвалидирует сессии).""" secret = (OPERATOR_PASSWORD or "elena-op-fallback").encode() return hmac.new(secret, b"operator-session-v1", hashlib.sha256).hexdigest() def verify_tg_initdata(init_data): """Проверка подписи Telegram WebApp initData. Возвращает dict пользователя или None.""" try: data = dict(parse_qsl(init_data, keep_blank_values=True)) recv = data.pop("hash", None) if not recv: return None check = "\n".join(f"{k}={data[k]}" for k in sorted(data)) secret = hmac.new(b"WebAppData", TG_BOT_TOKEN.encode(), hashlib.sha256).digest() calc = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(calc, recv): return None return json.loads(data.get("user", "{}")) except Exception: return None def is_operator(): """True если запрос несёт валидный операторский токен.""" return bool(OPERATOR_PASSWORD) and request.headers.get("X-Operator-Token") == _op_token() 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, tg_chat_id 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) ); CREATE TABLE IF NOT EXISTS acceptances ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, doc TEXT NOT NULL, -- 'offer' | 'pep' | 'pdn' doc_version TEXT NOT NULL, doc_hash TEXT NOT NULL, -- SHA-256 текста документа (что подписано) identifier TEXT, -- телефон/email подписанта code TEXT, -- код подтверждения ip TEXT, user_agent TEXT, payment_id TEXT, -- акцепт оплатой accepted_at TEXT NOT NULL -- append-only, не редактируется ); """) # Миграции — добавляем колонки если нет (идемпотентно) for sql in [ "ALTER TABLE projects ADD COLUMN tg_chat_id TEXT", "ALTER TABLE messages ADD COLUMN channel TEXT DEFAULT 'interview'", ]: try: con.execute(sql) except Exception: pass con.commit() con.close() # Миграции должны идти и под gunicorn (он импортирует модуль, __main__ не выполняется) init_db() # ── Helpers ────────────────────────────────────────── def get_project(token): return db().execute("SELECT * FROM projects WHERE token=?", (token,)).fetchone() def history(project_id): # Только канал интервью — он питает билд-инструменты. Q&A по этапам сюда не попадает. rows = db().execute( "SELECT role, content FROM messages WHERE project_id=? AND (channel='interview' OR channel IS NULL) ORDER BY id", (project_id,) ).fetchall() return [{"role": r["role"], "content": r["content"]} for r in rows] def interview_as_text(project_id): """Интервью в виде текста — для подмешивания в контекст Q&A без нарушения чередования ролей.""" rows = db().execute( "SELECT role, content FROM messages WHERE project_id=? AND (channel='interview' OR channel IS NULL) ORDER BY id", (project_id,) ).fetchall() if not rows: return None out = ["ИНТЕРВЬЮ С КЛИЕНТОМ (этап 1, диагностика):"] for r in rows: who = "Клиент" if r["role"] == "user" else "Елена" out.append(f"{who}: {r['content']}") return "\n".join(out) def get_deviations(project_id): d = latest_artifact(project_id, "deviations") return d.get("items", []) if d else [] def add_deviation(project_id, item): items = get_deviations(project_id) item["at"] = now() items.append(item) save_artifact(project_id, "deviations", {"items": items}) def stage_artifact_context(project_id, stage): """Текущий артефакт этапа + уже зафиксированные отклонения — как контекст для Елены.""" s = (stage or "").lower() parts = [] if s in ("4", "canvas", "idef0", "analysis", "model", "анализ"): cv = latest_artifact(project_id, "canvas") if cv: parts.append("ТЕКУЩАЯ СТРАТЕГИЯ (Business Model Canvas):\n" + json.dumps(cv, ensure_ascii=False)[:4000]) mrow = db().execute( "SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (project_id,) ).fetchone() if mrow: parts.append("ТЕКУЩАЯ ФУНКЦИОНАЛЬНАЯ МОДЕЛЬ (IDEF0):\n" + mrow["blocks_json"][:4000]) elif s in ("5", "spec", "план", "тз"): sp = latest_artifact(project_id, "spec") if sp: parts.append("ТЕКУЩЕЕ ТЗ:\n" + json.dumps(sp, ensure_ascii=False)[:5000]) dev = get_deviations(project_id) if dev: parts.append("УЖЕ ЗАФИКСИРОВАННЫЕ ОТКЛОНЕНИЯ КЛИЕНТА (не дублируй их):\n" + json.dumps(dev, ensure_ascii=False)[:2000]) return "\n\n".join(parts) if parts else None 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/ai-status") def ai_status(): """Лёгкая проверка доступности AI-движка (пинг в 1 токен). Не бросает — всегда 200.""" try: client.messages.create(model=MODEL, max_tokens=1, messages=[{"role": "user", "content": "ping"}]) return jsonify({"ok": True}) except Exception as e: msg = str(e) low = ("credit balance" in msg.lower()) or ("too low" in msg.lower()) or ("billing" in msg.lower()) return jsonify({"ok": False, "reason": "low_balance" if low else "error", "detail": msg[:200]}) @app.route("/api/operator/auth", methods=["POST"]) def operator_auth(): """Вход оператора: либо Telegram initData (tg-id в ADMIN_TG_IDS), либо пароль (десктоп).""" data = request.get_json(force=True) or {} init_data = data.get("init_data") password = data.get("password") name = "Оператор" if init_data: user = verify_tg_initdata(init_data) if not user: return jsonify({"ok": False, "error": "bad_initdata"}), 401 if str(user.get("id")) not in ADMIN_TG_IDS: return jsonify({"ok": False, "error": "not_admin"}), 403 name = user.get("first_name") or "Оператор" elif password is not None: if not OPERATOR_PASSWORD or not hmac.compare_digest(str(password), OPERATOR_PASSWORD): return jsonify({"ok": False, "error": "bad_password"}), 401 else: return jsonify({"ok": False, "error": "no_creds"}), 400 return jsonify({"ok": True, "op_token": _op_token(), "name": name}) @app.route("/api/operator/check") def operator_check(): return jsonify({"ok": is_operator()}) @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}) # ── «Спросить Елену» на этапах 3–5: Q&A с полной памятью + фиксация отклонений ── ASK_GUIDE = """РЕЖИМ ВОПРОСОВ ПО ЭТАПУ. Клиент задаёт вопросы по уже построенному артефакту (стратегия / функциональная модель / ТЗ). Правила: 1. Отвечай как Елена — простым языком, по делу, без воды. У тебя есть полный контекст: интервью, документы, текущий артефакт. 2. Если клиент НЕ согласен и настаивает на своём («мне так удобно», «у нас так нельзя») — не переубеждай силой. Вызови инструмент record_deviation: что рекомендуешь ты (методологически верно), что выбрал клиент и ПОЧЕМУ (причина клиента — самое важное, часто это реальное ограничение, не учтённое в интервью). 3. Сам артефакт в этом режиме ты НЕ перестраиваешь — только объясняешь и фиксируешь отклонения. Перестройку сделает консультант. 4. После фиксации отклонения — коротко подтверди клиенту, что его пожелание записано и будет учтено при внедрении, но честно оставь свою рекомендацию в силе.""" DEVIATION_TOOL = { "name": "record_deviation", "description": "Зафиксировать отклонение: клиент настоял на варианте, отличном от методологически верного. Вызывай ТОЛЬКО когда клиент явно отказывается от рекомендации и выбирает своё.", "input_schema": { "type": "object", "properties": { "stage": {"type": "string", "description": "Этап/артефакт: canvas, idef0, spec, documents"}, "node": {"type": "string", "description": "Конкретный узел/блок/функция, к которому относится отклонение"}, "elena_rec": {"type": "string", "description": "Что рекомендует Елена — методологически верно"}, "client_choice": {"type": "string", "description": "Что выбрал клиент"}, "reason": {"type": "string", "description": "Причина клиента — почему ему так нужно/удобно (самое важное)"} }, "required": ["node", "elena_rec", "client_choice", "reason"] } } def run_ask(messages, system, pid): """Агентный цикл: Елена отвечает и при необходимости фиксирует отклонение через tool.""" recorded = False for _ in range(3): resp = client.messages.create( model=MODEL, max_tokens=1100, system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}], tools=[DEVIATION_TOOL], messages=messages ) tool_uses = [b for b in resp.content if b.type == "tool_use"] text = "".join(b.text for b in resp.content if b.type == "text") if tool_uses: messages.append({"role": "assistant", "content": resp.content}) results = [] for tu in tool_uses: if tu.name == "record_deviation": item = dict(tu.input) item.setdefault("stage", "") add_deviation(pid, item) recorded = True results.append({"type": "tool_result", "tool_use_id": tu.id, "content": "Отклонение зафиксировано."}) messages.append({"role": "user", "content": results}) continue return (text or "Записала ваши пожелания."), recorded return "Записала ваши пожелания — учтём при внедрении.", recorded @app.route("/api/ask", methods=["POST"]) def ask(): data = request.get_json(force=True) or {} token = data.get("token") msg = (data.get("message") or "").strip() stage = (data.get("stage") 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 pid = proj["id"] con = db() con.execute( "INSERT INTO messages (project_id, role, content, created_at, channel) VALUES (?,?,?,?,?)", (pid, "user", msg, now(), "qa") ) con.commit() # Полный контекст проекта — одним блоком, чтобы не ломать чередование ролей ctx_parts = [] iv = interview_as_text(pid) if iv: ctx_parts.append(iv) docs = documents_context(pid) if docs: ctx_parts.append(docs) art = stage_artifact_context(pid, stage) if art: ctx_parts.append(art) context_block = "\n\n".join(ctx_parts) qa = [{"role": m["role"], "content": m["content"]} for m in db().execute( "SELECT role, content FROM messages WHERE project_id=? AND channel='qa' ORDER BY id", (pid,) ).fetchall()] messages = [ {"role": "user", "content": (context_block or "Контекст проекта пока пуст.") + "\n\n[Ниже — вопросы клиента по текущему этапу. Отвечай как Елена.]"}, {"role": "assistant", "content": "Держу весь контекст проекта. Готова ответить на ваши вопросы по этапу."}, ] + qa try: reply, recorded = run_ask(messages, SYSTEM_PROMPT + "\n\n" + ASK_GUIDE, pid) except Exception as e: return jsonify({"error": str(e)}), 500 con.execute( "INSERT INTO messages (project_id, role, content, created_at, channel) VALUES (?,?,?,?,?)", (pid, "assistant", reply, now(), "qa") ) con.commit() return jsonify({"reply": reply, "deviation_recorded": recorded}) # ── Организационный слой: оргструктура + должностные инструкции (между IDEF0 и ТЗ) ── ORGCHART_TOOL = { "name": "build_orgchart", "description": "Строит целевую оргструктуру (TO-BE) из функциональной модели и интервью.", "input_schema": { "type": "object", "properties": { "units": {"type": "array", "items": {"type": "object", "properties": { "role": {"type": "string", "description": "Должность/роль"}, "reports_to": {"type": "string", "description": "Кому подчиняется (роль) или '—' для верхнего уровня"}, "headcount": {"type": "integer", "description": "Сколько человек на этой роли"}, "owns_functions": {"type": "array", "items": {"type": "string"}, "description": "Функции IDEF0 (node_id или название), за которые отвечает"}, "note": {"type": "string", "description": "Комментарий: совмещения, особенности, конфликт интересов"} }, "required": ["role", "reports_to", "headcount"]}}, "insight": {"type": "string", "description": "Ключевой вывод: узкие места, перегруз, конфликты интересов в структуре"} }, "required": ["units", "insight"] } } ORG_INSTRUCTION = """Построй целевую оргструктуру (TO-BE) бизнеса клиента. Опирайся на функциональную модель (mechanisms — кто выполняет функции) и интервью. Учитывай реальный масштаб — не раздувай штат. Где есть совмещения ролей или конфликт интересов (один человек и исполняет, и контролирует) — отметь в note соответствующей роли и в insight. Если зафиксированы отклонения клиента — учитывай их (например, совмещение склада и пошива оставлено по требованию клиента). Вызови build_orgchart.""" @app.route("/api/build-orgchart", methods=["POST"]) def build_orgchart_route(): data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "not found"}), 404 extra = stage_artifact_context(proj["id"], "idef0") # canvas + idef0 + отклонения result, usage = run_tool(proj["id"], ORGCHART_TOOL, "build_orgchart", ORG_INSTRUCTION, extra_context=extra, max_tokens=2500) if result is None: return jsonify({"error": usage}), 500 save_artifact(proj["id"], "orgchart", result) return jsonify({"orgchart": result, "usage": usage}) JOBS_TOOL = { "name": "build_job_descriptions", "description": "Должностные инструкции по ролям из оргструктуры и функций, с учётом отклонений клиента.", "input_schema": { "type": "object", "properties": { "roles": {"type": "array", "items": {"type": "object", "properties": { "role": {"type": "string"}, "purpose": {"type": "string", "description": "Цель должности одним предложением"}, "responsibilities": {"type": "array", "items": {"type": "string"}, "description": "Зоны ответственности"}, "kpis": {"type": "array", "items": {"type": "string"}, "description": "Показатели эффективности (измеримые)"}, "reports_to": {"type": "string", "description": "Кому подчиняется"}, "authority": {"type": "array", "items": {"type": "string"}, "description": "Права и полномочия"}, "deviation_note": {"type": "string", "description": "Если роль затронута отклонением клиента — как именно (совмещение и риск)"} }, "required": ["role", "purpose", "responsibilities", "kpis"]}} }, "required": ["roles"] } } JOBS_INSTRUCTION = """Составь должностные инструкции по ролям из целевой оргструктуры и функциональной модели. Для каждой роли: цель должности, зоны ответственности, измеримые KPI, кому подчиняется, права/полномочия. ВАЖНО: если роль затронута отклонением клиента (совмещение функций и т.п.) — отрази это честно в deviation_note и в обязанностях, с оговоркой про риск (например, совмещение склада и пошива — риск «плавающего» учёта остатков). Будь лаконичен: 4-6 зон ответственности, 2-4 KPI, 1-3 пункта полномочий на роль. Вызови build_job_descriptions.""" @app.route("/api/build-jobs", methods=["POST"]) def build_jobs_route(): data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "not found"}), 404 pid = proj["id"] parts = [] org = latest_artifact(pid, "orgchart") if org: parts.append("ЦЕЛЕВАЯ ОРГСТРУКТУРА:\n" + json.dumps(org, ensure_ascii=False)[:3000]) art = stage_artifact_context(pid, "idef0") # idef0 + canvas + отклонения if art: parts.append(art) extra = "\n\n".join(parts) if parts else None result, usage = run_tool(pid, JOBS_TOOL, "build_job_descriptions", JOBS_INSTRUCTION, extra_context=extra, max_tokens=8000) if result is None: return jsonify({"error": usage}), 500 save_artifact(pid, "jobs", result) return jsonify({"jobs": result, "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/build-spec-client", methods=["POST"]) def build_spec_client(): """ТЗ под вариант клиента: полная пересборка с учётом зафиксированных отклонений и орг. слоя.""" data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 pid = proj["id"] model_row = db().execute("SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (pid,)).fetchone() if not model_row: return jsonify({"error": "сначала постройте IDEF0 модель"}), 400 devs = get_deviations(pid) if not devs: return jsonify({"error": "нет зафиксированных отклонений — вариант клиента совпадает с эталоном"}), 400 idef0 = model_row["blocks_json"] extra = [] org = latest_artifact(pid, "orgchart") if org: extra.append("ЦЕЛЕВАЯ ОРГСТРУКТУРА:\n" + json.dumps(org, ensure_ascii=False)[:3000]) jobs = latest_artifact(pid, "jobs") if jobs: extra.append("ДОЛЖНОСТНЫЕ ИНСТРУКЦИИ:\n" + json.dumps(jobs, ensure_ascii=False)[:3000]) extra.append("ЗАФИКСИРОВАННЫЕ ОТКЛОНЕНИЯ КЛИЕНТА (реализуем ИХ вариант, не эталон):\n" + json.dumps(devs, ensure_ascii=False)[:3000]) instruction = ("Собери ТЗ на программу под РЕАЛЬНЫЙ вариант клиента (с его отклонениями), а не под методологический эталон.\n" "Базис — интервью и IDEF0, НО там, где клиент настоял на своём (см. отклонения) — проектируй под выбор клиента.\n" "Например, если клиент оставил совмещение склада и пошива — модули и роли должны это отражать (один человек, общий доступ).\n" "ОБЯЗАТЕЛЬНО: по КАЖДОМУ отклонению добавь пункт в open_questions с риском, о котором предупреждала Елена " "(формат: «Риск [узел]: [что может пойти не так из-за выбора клиента] — рекомендация Елены была [...]»). " "Это защита: при внедрении видно, где клиент пошёл против методологии.\n" "МАППИНГ: функция → модуль; Input → данные; Output → показ; Control → правила; Mechanism → роли; хранилища → таблицы.\n" "Думай как проектировщик ПО. Вызови build_tech_spec.\n\n" f"IDEF0-МОДЕЛЬ:\n{idef0}\n\n" + "\n\n".join(extra)) result, usage = run_tool(pid, SPEC_TOOL, "build_tech_spec", instruction, max_tokens=8192) if result is None: return jsonify({"error": usage}), 500 save_artifact(pid, "spec_client", result) return jsonify({"spec_client": result, "usage": usage}) @app.route("/api/project/crm", methods=["POST"]) def update_crm(): if not is_operator(): return jsonify({"error": "unauthorized"}), 401 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": "", "payments": [], "billing_type": "paid" } for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule", "stage_payments", "stage_prices", "complexity", "stage_due"): if k in data: crm[k] = data[k] # paid_amount = сумма платежей (если есть реестр) if "payments" in crm and isinstance(crm["payments"], list): crm["paid_amount"] = sum(p.get("amount", 0) for p in crm["payments"]) save_artifact(proj["id"], "crm", crm) return jsonify({"ok": True, "crm": crm}) # ── Sber API «Платежи» (PAY): счета для юрлиц через OAuth2 + mTLS (.p12) ── # КАРКАС: активируется когда в .env появятся SBER_PAY_ENABLED=1 + пароль .p12 + client_secret. SBER_PAY_ENABLED = _env("SBER_PAY_ENABLED", "0") == "1" SBER_CLIENT_ID = _env("SBER_CLIENT_ID", "") SBER_CLIENT_SECRET = _env("SBER_CLIENT_SECRET", "") SBER_P12_PATH = _env("SBER_P12_PATH", os.path.join(BASE, "certs", "sber.p12")) SBER_P12_PASSWORD = _env("SBER_P12_PASSWORD", "") SBER_API_BASE = _env("SBER_API_BASE", "https://api.sberbank.ru") # уточнить по докам СберБизнес SBER_SCOPE = _env("SBER_SCOPE", "") _SBER_TOKEN_CACHE = {"access": None, "exp": 0} def _sber_session(): """requests-сессия с mTLS из .p12. (session, paths) или (None, reason).""" if not SBER_PAY_ENABLED: return None, "SBER_PAY_ENABLED!=1" try: import requests, tempfile from cryptography.hazmat.primitives.serialization import ( pkcs12, Encoding, PrivateFormat, NoEncryption) raw = open(SBER_P12_PATH, "rb").read() pwd = SBER_P12_PASSWORD.encode() if SBER_P12_PASSWORD else None key, cert, _ = pkcs12.load_key_and_certificates(raw, pwd) cf = tempfile.NamedTemporaryFile(delete=False, suffix=".pem") cf.write(cert.public_bytes(Encoding.PEM)); cf.close() kf = tempfile.NamedTemporaryFile(delete=False, suffix=".pem") kf.write(key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())); kf.close() s = requests.Session(); s.cert = (cf.name, kf.name) return s, (cf.name, kf.name) except Exception as e: return None, f"mTLS error: {e}" def _sber_token(): """OAuth2 access token с кэшем/рефрешем. None если не настроено.""" if not SBER_PAY_ENABLED: return None if _SBER_TOKEN_CACHE["access"] and _SBER_TOKEN_CACHE["exp"] > time.time() + 30: return _SBER_TOKEN_CACHE["access"] sess, _info = _sber_session() if not sess: return None try: r = sess.post(SBER_API_BASE.rstrip("/") + "/ic/sso/api/v2/oauth", data={"grant_type": "client_credentials", "scope": SBER_SCOPE}, auth=(SBER_CLIENT_ID, SBER_CLIENT_SECRET), timeout=20) j = r.json() tok = j.get("access_token") if tok: _SBER_TOKEN_CACHE["access"] = tok _SBER_TOKEN_CACHE["exp"] = time.time() + int(j.get("expires_in", 3600)) return tok except Exception: return None @app.route("/api/payment/sber-status") def sber_status(): """Диагностика готовности Sber PAY.""" if not SBER_PAY_ENABLED: return jsonify({"enabled": False, "reason": "каркас готов — ждёт SBER_PAY_ENABLED=1 + пароль .p12 + client_secret в .env"}) sess, info = _sber_session() if not sess: return jsonify({"enabled": True, "mtls": False, "reason": info}) return jsonify({"enabled": True, "mtls": True, "client_id": bool(SBER_CLIENT_ID), "secret": bool(SBER_CLIENT_SECRET), "token": bool(_sber_token())}) @app.route("/api/payment/sber-invoice", methods=["POST"]) def sber_invoice(): """Выставление счёта юрлицу (Sber PAY). endpoint/поля уточнить по докам Сбера.""" data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 amount = float(data.get("amount") or 0) if amount <= 0: return jsonify({"error": "bad amount"}), 400 if not SBER_PAY_ENABLED: return jsonify({"demo": True, "reason": "Sber PAY не активирован (нет пароля .p12 / client_secret). Каркас готов."}) tok = _sber_token() if not tok: return jsonify({"error": "sber auth failed"}), 502 sess, _info = _sber_session() order = "INV-" + secrets.token_hex(5) crm = latest_artifact(proj["id"], "crm") or {} crm.setdefault("sber_invoices", {})[order] = {"amount": amount, "status": "issued", "at": now()} save_artifact(proj["id"], "crm", crm) try: payload = { "amount": int(round(amount * 100)), "currency": "RUB", "orderNumber": order, "description": (data.get("description") or "Оплата консалтинга")[:255], "callbackUrl": "https://wasrusgen1.ru/consulting/api/payment/sber-webhook", } r = sess.post(SBER_API_BASE.rstrip("/") + "/v1/invoices", # путь уточнить по докам json=payload, headers={"Authorization": "Bearer " + tok}, timeout=25) return jsonify({"ok": True, "order": order, "status": r.status_code, "sber": r.json()}) except Exception as e: return jsonify({"error": str(e), "order": order}), 502 # ── СБП (Сбер) — динамический QR на оплату этапа ───────────────── SBER_SBP_ENABLED = os.getenv("SBER_SBP_ENABLED", "0") == "1" SBER_SBP_URL = os.getenv("SBER_SBP_URL", "") # базовый URL API СБП Сбера SBER_SBP_TOKEN = os.getenv("SBER_SBP_TOKEN", "") # API-токен / secret key SBER_SBP_MERCHANT = os.getenv("SBER_SBP_MERCHANT", "") # merchantId / TID SBER_SBP_MEMBER = os.getenv("SBER_SBP_MEMBER", "") # memberId (банк-участник СБП) SBP_WEBHOOK_URL = "https://wasrusgen1.ru/consulting/api/payment/sber-webhook" @app.route("/api/payment/sber-qr", methods=["POST"]) def sber_qr(): data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 amount = float(data.get("amount") or 0) if amount <= 0: return jsonify({"error": "bad amount"}), 400 stage = data.get("stage_key") or "" desc = data.get("description") or "Оплата консалтинга" order_id = "ORD-" + secrets.token_hex(6) crm = latest_artifact(proj["id"], "crm") or {} crm.setdefault("sber_qr", {})[order_id] = {"stage": stage, "amount": amount, "status": "pending"} save_artifact(proj["id"], "crm", crm) if not SBER_SBP_ENABLED: # демо-режим: реквизитов СберБизнес ещё нет — отдаём заглушку, UX работает return jsonify({ "demo": True, "order_id": order_id, "amount": amount, "qr_payload": "https://qr.nspk.ru/DEMO" + order_id.replace("ORD-", ""), "message": "Демо-режим: СБП не настроен. Добавьте SBER_SBP_* в .env." }) # боевой режим — регистрация динамического QR в Сбере try: import urllib.request payload = { "merchantId": SBER_SBP_MERCHANT, "memberId": SBER_SBP_MEMBER, "amount": int(round(amount * 100)), "currency": "643", "orderId": order_id, "description": desc[:140], "callbackUrl": SBP_WEBHOOK_URL, } # NB: путь endpoint уточнить по документации СберБизнес (СБП C2B dynamic QR) req = urllib.request.Request( SBER_SBP_URL.rstrip("/") + "/sbp/c2b/qr/dynamic", data=json.dumps(payload).encode("utf-8"), headers={"Authorization": "Bearer " + SBER_SBP_TOKEN, "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=20) as r: res = json.loads(r.read().decode("utf-8")) return jsonify({"order_id": order_id, "amount": amount, "qr_payload": res.get("payload") or res.get("qrUrl"), "qr_image": res.get("qrImage") or res.get("image"), "raw": res}) except Exception as e: return jsonify({"error": "sber_error", "detail": str(e)}), 502 @app.route("/api/payment/sber-webhook", methods=["POST"]) def sber_webhook(): data = request.get_json(force=True) or {} order_id = data.get("orderId") or data.get("order") or data.get("orderNumber") status = str(data.get("status") or data.get("paymentStatus") or "").lower() paid_ok = status in ("paid", "success", "confirmed", "approved", "deposited") if not order_id: return jsonify({"error": "no order"}), 400 today = datetime.now(timezone.utc).strftime("%Y-%m-%d") for row in db().execute("SELECT id FROM projects").fetchall(): crm = latest_artifact(row["id"], "crm") if crm and order_id in (crm.get("sber_qr") or {}): rec = crm["sber_qr"][order_id] if paid_ok and rec.get("status") != "paid": rec["status"] = "paid" amount = rec.get("amount", 0); stage = rec.get("stage") or None crm.setdefault("payments", []).append({"date": today, "amount": amount, "note": "СБП (Сбер)", "stage": stage, "method": "sbp"}) if stage: crm.setdefault("stage_payments", {})[stage] = {"amount": amount, "date": today, "method": "sbp"} crm["paid_amount"] = sum(p.get("amount", 0) for p in crm.get("payments", [])) save_artifact(row["id"], "crm", crm) return jsonify({"ok": True}) return jsonify({"error": "order not found"}), 404 # ── Ручное подтверждение оплаты по QR (демо / нал-у-стойки) ───── @app.route("/api/payment/sber-confirm", methods=["POST"]) def sber_confirm(): data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 order_id = data.get("order_id") crm = latest_artifact(proj["id"], "crm") or {} rec = (crm.get("sber_qr") or {}).get(order_id) if not rec: return jsonify({"error": "order not found"}), 404 if rec.get("status") != "paid": today = datetime.now(timezone.utc).strftime("%Y-%m-%d") rec["status"] = "paid" amount = rec.get("amount", 0); stage = rec.get("stage") or None crm.setdefault("payments", []).append({"date": today, "amount": amount, "note": "СБП (Сбер)", "stage": stage, "method": "sbp"}) if stage: crm.setdefault("stage_payments", {})[stage] = {"amount": amount, "date": today, "method": "sbp"} crm["paid_amount"] = sum(p.get("amount", 0) for p in crm.get("payments", [])) save_artifact(proj["id"], "crm", crm) return jsonify({"ok": True, "crm": crm}) import hashlib # Версии и тексты юридических документов (хеш фиксируется при акцепте) LEGAL_DOCS = {"offer": "1.0", "pep": "1.0", "pdn": "1.0"} def _doc_hash(doc): """SHA-256 текста документа — для доказательства что подписано.""" path = os.path.join(BASE, "legal", {"offer": "dogovor_oferta.md", "pep": "soglashenie_pep.md", "pdn": "politika_pdn.md"}.get(doc, "")) try: return hashlib.sha256(open(path, "rb").read()).hexdigest() except Exception: return "" @app.route("/api/legal/") def legal_text(doc): """Отдаёт текст юридического документа для ознакомления.""" fname = {"offer": "dogovor_oferta.md", "pep": "soglashenie_pep.md", "pdn": "politika_pdn.md"}.get(doc) if not fname: return jsonify({"error": "unknown doc"}), 404 try: text = open(os.path.join(BASE, "legal", fname), encoding="utf-8").read() return jsonify({"doc": doc, "version": LEGAL_DOCS.get(doc, "1.0"), "text": text}) except Exception as e: return jsonify({"error": str(e)}), 404 @app.route("/api/sign/request", methods=["POST"]) def sign_request(): """Генерирует код подтверждения (ПЭП). В проде — отправка по SMS/email.""" data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 identifier = (data.get("identifier") or "").strip() if not identifier: return jsonify({"error": "укажите телефон или email"}), 400 code = "".join(secrets.choice("0123456789") for _ in range(4)) # храним код в crm artifact (TTL ~10 мин через timestamp) crm = latest_artifact(proj["id"], "crm") or {} crm["_sign_code"] = code crm["_sign_identifier"] = identifier crm["_sign_at"] = now() save_artifact(proj["id"], "crm", crm) # ДЕМО: возвращаем код (в проде — SMS/email, код не возвращается) return jsonify({"ok": True, "demo_code": code, "note": "ДЕМО: в проде код придёт по SMS/email"}) @app.route("/api/sign/confirm", methods=["POST"]) def sign_confirm(): """Проверяет код и фиксирует подписание (акцепт) в журнал.""" data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 code = (data.get("code") or "").strip() crm = latest_artifact(proj["id"], "crm") or {} if not crm.get("_sign_code") or code != crm["_sign_code"]: return jsonify({"error": "неverный код"}), 400 identifier = crm.get("_sign_identifier", "") docs = data.get("docs", ["offer", "pep"]) ip = request.headers.get("X-Real-IP") or request.headers.get("X-Forwarded-For", request.remote_addr or "") ua = request.headers.get("User-Agent", "")[:300] con = db() recorded = [] for doc in docs: if doc not in LEGAL_DOCS: continue h = _doc_hash(doc) con.execute( "INSERT INTO acceptances (project_id, doc, doc_version, doc_hash, identifier, code, ip, user_agent, payment_id, accepted_at) VALUES (?,?,?,?,?,?,?,?,?,?)", (proj["id"], doc, LEGAL_DOCS[doc], h, identifier, code, ip, ua, "", now()) ) recorded.append(doc) # очищаем временный код crm.pop("_sign_code", None); crm.pop("_sign_at", None) save_artifact(proj["id"], "crm", crm) con.commit() return jsonify({"ok": True, "signed": recorded, "identifier": identifier, "at": now()}) @app.route("/api/accept", methods=["POST"]) def accept_documents(): """Фиксация акцепта (ПЭП) в append-only журнал с хешем документов.""" data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 docs = data.get("docs", ["offer", "pep"]) # какие документы акцептованы identifier = data.get("identifier", "") # телефон/email code = data.get("code", "") payment_id = data.get("payment_id", "") ip = request.headers.get("X-Real-IP") or request.headers.get("X-Forwarded-For", request.remote_addr or "") ua = request.headers.get("User-Agent", "")[:300] con = db() recorded = [] for doc in docs: if doc not in LEGAL_DOCS: continue h = _doc_hash(doc) con.execute( "INSERT INTO acceptances (project_id, doc, doc_version, doc_hash, identifier, code, ip, user_agent, payment_id, accepted_at) VALUES (?,?,?,?,?,?,?,?,?,?)", (proj["id"], doc, LEGAL_DOCS[doc], h, identifier, code, ip, ua, payment_id, now()) ) recorded.append({"doc": doc, "version": LEGAL_DOCS[doc], "hash": h[:16] + "..."}) con.commit() return jsonify({"ok": True, "accepted": recorded, "at": now()}) @app.route("/api/acceptances/") def get_acceptances(token): """Выписка из журнала акцептов (доказательная база).""" proj = get_project(token) if not proj: return jsonify({"error": "not found"}), 404 rows = db().execute( "SELECT doc, doc_version, doc_hash, identifier, code, ip, payment_id, accepted_at FROM acceptances WHERE project_id=? ORDER BY id", (proj["id"],) ).fetchall() return jsonify({"acceptances": [dict(r) for r in rows]}) @app.route("/api/payment/create", methods=["POST"]) def payment_create(): """Создаёт платёж. method: card | sbp | cash.""" import urllib.request, urllib.parse data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 amount = float(data.get("amount", 0)) method = data.get("method", "card") # card | sbp | cash desc = data.get("description", f"Оплата консалтинга — {proj['client_name'] or 'клиент'}") if amount <= 0: return jsonify({"error": "сумма должна быть больше 0"}), 400 # Наличные — не онлайн: фиксируем намерение, Руслан подтвердит вручную if method == "cash": crm = latest_artifact(proj["id"], "crm") or {} pending = crm.get("pending_cash", []) pending.append({"amount": amount, "desc": desc, "at": now()}) crm["pending_cash"] = pending save_artifact(proj["id"], "crm", crm) return jsonify({"method": "cash", "instructions": "Оплата наличными при встрече. Консультант подтвердит получение.", "amount": amount}) sid, sec = _yookassa_creds() return_url = data.get("return_url", "https://wasrusgen1.ru/consulting/cabinet.html") # Демо-режим если ключей ЮKassa нет if not sid: return jsonify({ "method": method, "demo": True, "confirmation_url": return_url + "?demo_paid=" + str(int(amount)), "note": "ДЕМО: ключи ЮKassa не настроены. Реальная оплата заработает после добавления YOOKASSA_SHOP_ID/SECRET." }) # Реальный вызов ЮKassa API import base64 as b64m, json as jsonm payload = { "amount": {"value": f"{amount:.2f}", "currency": "RUB"}, "capture": True, "description": desc, "metadata": {"token": proj["token"]}, "confirmation": {"type": "redirect", "return_url": return_url} } if method == "sbp": payload["payment_method_data"] = {"type": "sbp"} payload["confirmation"] = {"type": "qr"} try: body = jsonm.dumps(payload).encode() req = urllib.request.Request("https://api.yookassa.ru/v3/payments", data=body, method="POST") auth = b64m.b64encode(f"{sid}:{sec}".encode()).decode() req.add_header("Authorization", "Basic " + auth) req.add_header("Content-Type", "application/json") req.add_header("Idempotence-Key", secrets.token_hex(16)) resp = urllib.request.urlopen(req, timeout=20) result = jsonm.loads(resp.read()) conf = result.get("confirmation", {}) return jsonify({ "method": method, "payment_id": result.get("id"), "confirmation_url": conf.get("confirmation_url"), "qr": conf.get("confirmation_data") # для СБП — QR-данные }) except Exception as e: return jsonify({"error": "ЮKassa: " + str(e)}), 500 @app.route("/api/payment/webhook", methods=["POST"]) def payment_webhook(): """ЮKassa шлёт уведомление о статусе. При succeeded — платёж в реестр.""" import json as jsonm data = request.get_json(force=True) or {} event = data.get("event") obj = data.get("object", {}) if event == "payment.succeeded": token = obj.get("metadata", {}).get("token") proj = get_project(token) if token else None if proj: amount = float(obj.get("amount", {}).get("value", 0)) method = obj.get("payment_method", {}).get("type", "") crm = latest_artifact(proj["id"], "crm") or {"payments": []} crm.setdefault("payments", []).append({ "date": now()[:10], "amount": amount, "note": f"ЮKassa ({method})", "auto": True }) crm["paid_amount"] = sum(p.get("amount", 0) for p in crm["payments"]) save_artifact(proj["id"], "crm", crm) return jsonify({"ok": True}) # ЮKassa требует 200 @app.route("/api/project/delete", methods=["POST"]) def delete_project(): if not is_operator(): return jsonify({"error": "unauthorized"}), 401 data = request.get_json(force=True) or {} proj = get_project(data.get("token")) if not proj: return jsonify({"error": "project not found"}), 404 pid = proj["id"] con = db() con.execute("DELETE FROM messages WHERE project_id=?", (pid,)) con.execute("DELETE FROM models WHERE project_id=?", (pid,)) con.execute("DELETE FROM artifacts WHERE project_id=?", (pid,)) con.execute("DELETE FROM projects WHERE id=?", (pid,)) con.commit() # удалить загруженные документы import shutil pdir = os.path.join(UPLOAD_DIR, proj["token"]) if os.path.isdir(pdir): shutil.rmtree(pdir, ignore_errors=True) return jsonify({"ok": True}) @app.route("/api/project/tasks", methods=["POST"]) def update_tasks(): if not is_operator(): return jsonify({"error": "unauthorized"}), 401 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(): if not is_operator(): return jsonify({"error": "unauthorized"}), 401 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(): if not is_operator(): return jsonify({"error": "unauthorized"}), 401 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_selection": latest_artifact(pid, "selection") is not None, "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_org": latest_artifact(pid, "orgchart") 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}) # ══ ЦЕНООБРАЗОВАНИЕ под клиента ══════════════════════ PRICING_TOOL = { "name": "build_pricing", "description": "Оценивает масштаб работы по клиенту, анализирует рынок консалтинга и предлагает таблицу цен с аргументами.", "input_schema": { "type": "object", "properties": { "scale": { "type": "object", "description": "Оценка масштаба работы", "properties": { "size": {"type": "string", "enum": ["micro", "small", "medium", "large"], "description": "micro=соло, small=2-10, medium=10-50, large=50+"}, "complexity": {"type": "string", "enum": ["low", "medium", "high"]}, "scope": {"type": "string", "description": "Объём работ: сколько ролей интервьюировать, процессов, документов"}, "effort_estimate": {"type": "string", "description": "Оценка трудозатрат (часы/недели) и токенов AI"} }, "required": ["size", "complexity", "scope", "effort_estimate"] }, "market": {"type": "string", "description": "Анализ рынка: вилка цен на аналогичный консалтинг в РФ, с кем сравниваем"}, "packages": { "type": "array", "description": "2-3 пакета услуг", "items": {"type": "object", "properties": { "name": {"type": "string"}, "scope": {"type": "array", "items": {"type": "string"}, "description": "Что входит"}, "price": {"type": "integer", "description": "Цена в рублях"}, "duration": {"type": "string", "description": "Срок"}, "argument": {"type": "string", "description": "Аргумент почему такая цена"} }, "required": ["name", "scope", "price", "duration", "argument"]} }, "recommended": {"type": "string", "description": "Какой пакет рекомендуется этому клиенту и почему"}, "rationale": {"type": "string", "description": "Главный аргумент по цене для продажи (ROI, экономия клиента)"} }, "required": ["scale", "market", "packages", "recommended", "rationale"] } } @app.route("/api/build-pricing", methods=["POST"]) def build_pricing(): 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() extra = ("ПОСТРОЕННАЯ МОДЕЛЬ БИЗНЕСА (для оценки масштаба):\n" + model_row["blocks_json"]) if model_row else None instr = ("На основе первичного интервью (и модели бизнеса, если есть) сформируй ЦЕНОВОЕ ПРЕДЛОЖЕНИЕ для клиента.\n" "1. Оцени МАСШТАБ работы: размер бизнеса, сложность, сколько ролей интервьюировать, процессов, документов, оценка трудозатрат.\n" "2. Проанализируй РЫНОК: вилка цен на аналогичный консалтинг (разбор бизнеса + ТЗ на ПО) в РФ для такого масштаба.\n" "3. Предложи 2-3 ПАКЕТА (напр. Экспресс / Стандарт / Премиум или по этапам) с ценами в рублях, составом, сроком и аргументом цены.\n" "4. Рекомендуй пакет под этого клиента.\n" "5. Главный аргумент по цене — через ROI/экономию клиента (сколько он теряет сейчас vs стоимость).\n" "Цены реалистичные для МСБ РФ. Вызови build_pricing.") result, usage = run_tool(proj["id"], PRICING_TOOL, "build_pricing", instr, extra_context=extra, max_tokens=2500) if result is None: return jsonify({"error": usage}), 500 save_artifact(proj["id"], "pricing", result) return jsonify({"pricing": 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=? AND (channel='interview' OR channel IS NULL) 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() crm = latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":"","billing_type":"paid"} payments = crm.get("payments", []) if isinstance(crm.get("payments"), list) else [] paid_total = sum(p.get("amount", 0) for p in payments) if payments else (crm.get("paid_amount", 0) or 0) deal_amount = crm.get("deal_amount", 0) or 0 debt = max(0, deal_amount - paid_total) billing = crm.get("billing_type", "paid") # Разблокировка выгрузки документов/ТЗ: бесплатный клиент ИЛИ долг закрыт (платный с deal>0) unlocked = (billing == "free") or (deal_amount > 0 and debt <= 0) crm["paid_amount"] = paid_total crm["debt"] = debt 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"), "orgchart": latest_artifact(proj["id"], "orgchart"), "jobs": latest_artifact(proj["id"], "jobs"), "spec": latest_artifact(proj["id"], "spec"), "spec_client": latest_artifact(proj["id"], "spec_client"), "approvals": latest_artifact(proj["id"], "approvals") or {}, "crm": crm, "unlocked": unlocked, "debt": debt, "paid_total": paid_total, "deal_amount": deal_amount, "billing_type": billing, "tasks": (latest_artifact(proj["id"], "tasks") or {}).get("items", []), "pricing": latest_artifact(proj["id"], "pricing"), "signed": db().execute("SELECT 1 FROM acceptances WHERE project_id=? AND doc='offer' LIMIT 1", (proj["id"],)).fetchone() is not None, "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()], "qa": [{"role": m["role"], "content": m["content"]} for m in db().execute("SELECT role, content FROM messages WHERE project_id=? AND channel='qa' ORDER BY id", (proj["id"],)).fetchall()], "deviations": get_deviations(proj["id"]) }) # ── Telegram Bot ───────────────────────────────────── TG_TOKEN = "8767209545:AAEVgfL-bAhg6j0fHUyKWUze4SLTfJbLklM" TG_API = f"https://api.telegram.org/bot{TG_TOKEN}" CABINET_URL = "https://wasrusgen1.ru/consulting/cabinet.html" def tg_send(chat_id, text, reply_markup=None): import urllib.request as ur payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} if reply_markup: payload["reply_markup"] = json.dumps(reply_markup) body = json.dumps(payload).encode() req = ur.Request(f"{TG_API}/sendMessage", data=body, headers={"Content-Type": "application/json"}) try: ur.urlopen(req, timeout=8) except Exception as e: app.logger.error(f"tg_send error: {e}") @app.route("/api/tg/webhook", methods=["POST"]) def tg_webhook(): data = request.get_json(silent=True) or {} msg = data.get("message") or data.get("callback_query", {}).get("message") if not msg: return jsonify({"ok": True}) chat_id = msg["chat"]["id"] uid = str((msg.get("from") or {}).get("id", chat_id)) text = (msg.get("text") or "").strip() cmd = text.split()[0].split("@")[0].lower() if text.startswith("/") else "" CRM_URL = "https://wasrusgen1.ru/consulting/crm.html" # /myid — узнать свой Telegram ID (для регистрации оператора и алертов) if cmd == "/myid": tg_send(chat_id, f"Ваш Telegram ID: {uid}\nПередайте его консультанту для доступа к операторской CRM.") return jsonify({"ok": True}) # /start [token] — оператору CRM, клиенту кабинет if cmd == "/start": if uid in ADMIN_TG_IDS: tg_send(chat_id, "👋 Операторская CRM @wasrusgen1 | КОНСАЛТИНГ", reply_markup={"inline_keyboard": [[{"text": "🖥 Открыть CRM", "web_app": {"url": CRM_URL}}]]}) return jsonify({"ok": True}) parts = text.split() token = parts[1] if len(parts) > 1 else None if token: proj = get_project(token) if proj: url = f"{CABINET_URL}?t={token}" tg_send(chat_id, f"Привет! Ваш проект: {proj['client_name'] or 'без названия'}\n" f"Кабинет: {url}", reply_markup={"inline_keyboard": [[ {"text": "Открыть кабинет", "web_app": {"url": url}} ]]} ) return jsonify({"ok": True}) tg_send(chat_id, "Добро пожаловать в @wasrusgen1 | КОНСАЛТИНГ\n\n" "Для доступа к кабинету вам нужна персональная ссылка от консультанта.\n\n" "Напишите нам: @wasrusgen1", ) # /status — статус проекта по tg_id (если привязан) elif cmd == "/status": row = db().execute( "SELECT token, client_name, status FROM projects WHERE tg_chat_id=? ORDER BY id DESC LIMIT 1", (str(chat_id),) ).fetchone() if row: tg_send(chat_id, f"Проект: {row['client_name']}\n" f"Статус: {row['status']}\n" f"Кабинет: {CABINET_URL}?t={row['token']}" ) else: tg_send(chat_id, "Привязанный проект не найден. Откройте кабинет по ссылке от консультанта.") elif cmd == "/help": tg_send(chat_id, "/start — открыть кабинет\n" "/status — статус проекта\n\n" "По вопросам: @wasrusgen1" ) return jsonify({"ok": True}) if __name__ == "__main__": init_db() app.run(host="0.0.0.0", port=5002)