wasrusgen1-crm/backend/elena_app.py
wasrusgen 149f02da37 feat: организационный слой — оргструктура + должностные инструкции (между IDEF0 и ТЗ)
- backend: генераторы build-orgchart (из IDEF0 mechanisms) и build-jobs
  (должностные с учётом отклонений клиента), артефакты orgchart/jobs в state
- кабинет: вкладка «🏢 Организация» на этапе 4 — оргструктура (роли, штат,
  подчинённость, узкие места) + должностные (ответственность, KPI, полномочия)
2026-06-01 23:48:34 +03:00

1529 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
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()
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/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})
# ── «Спросить Елену» на этапах 35: 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 и в обязанностях, с оговоркой про риск (например, совмещение склада и пошива — риск «плавающего» учёта остатков).
Вызови 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=3500)
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/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": "", "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})
# ── СБП (Сбер) — динамический 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/<doc>")
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/<token>")
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():
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():
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_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_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/<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=? 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"),
"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"]
text = (msg.get("text") or "").strip()
cmd = text.split()[0].split("@")[0].lower() if text.startswith("/") else ""
# /start [token] — открыть кабинет или прислать ссылку
if cmd == "/start":
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"Привет! Ваш проект: <b>{proj['client_name'] or 'без названия'}</b>\n"
f"Кабинет: {url}",
reply_markup={"inline_keyboard": [[
{"text": "Открыть кабинет", "web_app": {"url": url}}
]]}
)
return jsonify({"ok": True})
tg_send(chat_id,
"Добро пожаловать в <b>@wasrusgen1 | КОНСАЛТИНГ</b>\n\n"
"Для доступа к кабинету вам нужна персональная ссылка от консультанта.\n\n"
"Напишите нам: <a href=\"https://t.me/wasrusgen1\">@wasrusgen1</a>",
)
# /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"Проект: <b>{row['client_name']}</b>\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)