mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 19:04:47 +00:00
1183 lines
62 KiB
Python
1183 lines
62 KiB
Python
#!/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",
|
||
]:
|
||
try:
|
||
con.execute(sql)
|
||
except Exception:
|
||
pass
|
||
con.commit()
|
||
con.close()
|
||
|
||
# ── Helpers ──────────────────────────────────────────
|
||
def get_project(token):
|
||
return db().execute("SELECT * FROM projects WHERE token=?", (token,)).fetchone()
|
||
|
||
def history(project_id):
|
||
rows = db().execute(
|
||
"SELECT role, content FROM messages WHERE project_id=? ORDER BY id", (project_id,)
|
||
).fetchall()
|
||
return [{"role": r["role"], "content": r["content"]} for r in rows]
|
||
|
||
def save_artifact(project_id, kind, data):
|
||
con = db()
|
||
con.execute(
|
||
"INSERT INTO artifacts (project_id, kind, data_json, created_at) VALUES (?,?,?,?)",
|
||
(project_id, kind, json.dumps(data, ensure_ascii=False), now())
|
||
)
|
||
con.commit()
|
||
|
||
def latest_artifact(project_id, kind):
|
||
row = db().execute(
|
||
"SELECT data_json FROM artifacts WHERE project_id=? AND kind=? ORDER BY id DESC LIMIT 1",
|
||
(project_id, kind)
|
||
).fetchone()
|
||
return json.loads(row["data_json"]) if row else None
|
||
|
||
def run_tool(project_id, tool, tool_name, instruction, extra_context=None, max_tokens=4096):
|
||
"""Универсальный вызов forced-tool Opus на основе истории интервью."""
|
||
msgs = history(project_id)
|
||
if not msgs:
|
||
return None, "no interview data"
|
||
ctx = instruction
|
||
if extra_context:
|
||
ctx = extra_context + "\n\n" + instruction
|
||
msgs = msgs + [{"role": "user", "content": ctx}]
|
||
try:
|
||
resp = client.messages.create(
|
||
model=MODEL, max_tokens=max_tokens,
|
||
system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
|
||
tools=[tool],
|
||
tool_choice={"type": "tool", "name": tool_name},
|
||
messages=msgs
|
||
)
|
||
block = next(b for b in resp.content if b.type == "tool_use")
|
||
return block.input, {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens}
|
||
except Exception as e:
|
||
return None, str(e)
|
||
|
||
# ── Routes ───────────────────────────────────────────
|
||
@app.route("/api/health")
|
||
def health():
|
||
return jsonify({"ok": True, "model": MODEL, "time": now()})
|
||
|
||
@app.route("/api/project/new", methods=["POST"])
|
||
def new_project():
|
||
data = request.get_json(force=True) or {}
|
||
token = secrets.token_urlsafe(8)
|
||
con = db()
|
||
cur = con.execute(
|
||
"INSERT INTO projects (token, client_name, niche, status, created_at) VALUES (?,?,?,?,?)",
|
||
(token, data.get("client_name", ""), data.get("niche", ""), "interview", now())
|
||
)
|
||
con.commit()
|
||
pid = cur.lastrowid
|
||
|
||
# Первое приветствие Елены — открывает интервью
|
||
greeting = _elena_first_message(data.get("client_name", ""), data.get("niche", ""))
|
||
con.execute(
|
||
"INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)",
|
||
(pid, "assistant", greeting, now())
|
||
)
|
||
con.commit()
|
||
return jsonify({"token": token, "greeting": greeting})
|
||
|
||
def _elena_first_message(name, niche):
|
||
nm = f", {name}" if name else ""
|
||
return (f"Здравствуйте{nm}! Я Елена, ваш консультант. "
|
||
f"Моя задача — разобрать ваш бизнес по полочкам и показать где можно ускориться и заработать больше.\n\n"
|
||
f"Расскажите своими словами: чем вы занимаетесь и что сейчас беспокоит больше всего? "
|
||
f"Можно текстом или голосом — как удобнее.")
|
||
|
||
@app.route("/api/project/profile", methods=["POST"])
|
||
def save_profile():
|
||
data = request.get_json(force=True) or {}
|
||
proj = get_project(data.get("token"))
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
name = (data.get("client_name") or "").strip()
|
||
niche = (data.get("niche") or "").strip()
|
||
desc = (data.get("description") or "").strip()
|
||
con = db()
|
||
con.execute("UPDATE projects SET client_name=?, niche=?, description=? WHERE id=?",
|
||
(name, niche, desc, proj["id"]))
|
||
con.commit()
|
||
|
||
# Профиль становится первым реальным сообщением клиента — Елена сразу в контексте.
|
||
# Удаляем старое дефолтное приветствие и формируем диалог заново.
|
||
con.execute("DELETE FROM messages WHERE project_id=?", (proj["id"],))
|
||
profile_msg = f"Меня зовут {name}." if name else ""
|
||
if niche: profile_msg += f" Сфера деятельности: {niche}."
|
||
if desc: profile_msg += f"\n\nО моей деятельности: {desc}"
|
||
profile_msg = profile_msg.strip() or "Хочу разобрать свой бизнес."
|
||
con.execute("INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)",
|
||
(proj["id"], "user", profile_msg, now()))
|
||
con.commit()
|
||
|
||
# Елена отвечает на профиль
|
||
try:
|
||
resp = client.messages.create(
|
||
model=MODEL, max_tokens=900,
|
||
system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
|
||
messages=[{"role": "user", "content": profile_msg}]
|
||
)
|
||
reply = resp.content[0].text
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
con.execute("INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)",
|
||
(proj["id"], "assistant", reply, now()))
|
||
con.commit()
|
||
return jsonify({"ok": True, "profile_msg": profile_msg, "reply": reply})
|
||
|
||
@app.route("/api/chat", methods=["POST"])
|
||
def chat():
|
||
data = request.get_json(force=True) or {}
|
||
token = data.get("token")
|
||
msg = (data.get("message") or "").strip()
|
||
if not token or not msg:
|
||
return jsonify({"error": "token and message required"}), 400
|
||
proj = get_project(token)
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
|
||
con = db()
|
||
con.execute(
|
||
"INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)",
|
||
(proj["id"], "user", msg, now())
|
||
)
|
||
con.commit()
|
||
|
||
msgs = history(proj["id"])
|
||
|
||
try:
|
||
resp = client.messages.create(
|
||
model=MODEL,
|
||
max_tokens=1024,
|
||
system=[{
|
||
"type": "text",
|
||
"text": SYSTEM_PROMPT,
|
||
"cache_control": {"type": "ephemeral"}
|
||
}],
|
||
messages=msgs
|
||
)
|
||
reply = resp.content[0].text
|
||
usage = {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens}
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
con.execute(
|
||
"INSERT INTO messages (project_id, role, content, created_at) VALUES (?,?,?,?)",
|
||
(proj["id"], "assistant", reply, now())
|
||
)
|
||
con.commit()
|
||
return jsonify({"reply": reply, "usage": usage})
|
||
|
||
# ── Tool schema: строгий IDEF0 (ICOM + декомпозиция) ──
|
||
ARROW = {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "Что за поток (название стрелки)"},
|
||
"source": {"type": "string", "description": "Откуда: внешний источник или node_id функции"},
|
||
"exists": {"type": "boolean", "description": "true если реально существует в бизнесе, false если ДОЛЖЕН быть но отсутствует"}
|
||
},
|
||
"required": ["name", "source", "exists"]
|
||
}
|
||
OUT_ARROW = {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"target": {"type": "string", "description": "Куда идёт: внешний потребитель или node_id функции; 'НИКУДА' если выход никто не использует"},
|
||
"exists": {"type": "boolean"}
|
||
},
|
||
"required": ["name", "target", "exists"]
|
||
}
|
||
MECH = {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "Исполнитель или инструмент"},
|
||
"type": {"type": "string", "enum": ["human", "equipment", "software", "none"]},
|
||
"load": {"type": "string", "description": "Нагрузка/объём потребления, если известно"}
|
||
},
|
||
"required": ["name", "type"]
|
||
}
|
||
|
||
def _activity_schema():
|
||
return {
|
||
"type": "object",
|
||
"properties": {
|
||
"node_id": {"type": "string", "description": "Идентификатор по IDEF0: A0, A1, A2, A1.1 ..."},
|
||
"parent": {"type": "string", "description": "node_id родителя или 'A0' для верхнего уровня"},
|
||
"function": {"type": "string", "description": "Функция ГЛАГОЛОМ + объект, напр. 'Раскроить ткань', 'Принять заказ'"},
|
||
"inputs": {"type": "array", "items": ARROW, "description": "I — что ПРЕОБРАЗУЕТСЯ (сырьё, данные на обработку)"},
|
||
"controls": {"type": "array", "items": ARROW, "description": "C — что УПРАВЛЯЕТ но не расходуется (нормы, регламенты, ТЗ, лекала). Если нет — пустой массив = функция без контроля"},
|
||
"outputs": {"type": "array", "items": OUT_ARROW, "description": "O — результат функции"},
|
||
"mechanisms":{"type": "array", "items": MECH, "description": "M — кто/чем выполняется (люди, оборудование, ПО)"},
|
||
"completeness": {"type": "integer", "description": "0-100"},
|
||
"issues": {"type": "array", "items": {"type": "string"}}
|
||
},
|
||
"required": ["node_id","parent","function","inputs","controls","outputs","mechanisms","completeness","issues"]
|
||
}
|
||
|
||
MODEL_TOOL = {
|
||
"name": "build_idef0_model",
|
||
"description": "Строит функциональную модель бизнеса клиента по стандарту IDEF0: контекстная диаграмма A-0, декомпозиция A0, анализ стрелок ICOM.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"client_summary": {"type": "string", "description": "1-2 предложения: кто клиент и чем занимается"},
|
||
"business_pattern": {"type": "string", "description": "Тип бизнеса и модель монетизации"},
|
||
"context": {
|
||
"type": "object",
|
||
"description": "Контекстная диаграмма A-0: весь бизнес как ОДНА функция",
|
||
"properties": {
|
||
"function": {"type": "string", "description": "Главная функция бизнеса глаголом, напр. 'Производить и продавать швейные изделия'"},
|
||
"inputs": {"type": "array", "items": ARROW},
|
||
"controls": {"type": "array", "items": ARROW},
|
||
"outputs": {"type": "array", "items": OUT_ARROW},
|
||
"mechanisms":{"type": "array", "items": MECH}
|
||
},
|
||
"required": ["function","inputs","controls","outputs","mechanisms"]
|
||
},
|
||
"activities": {
|
||
"type": "array",
|
||
"description": "Декомпозиция A0 на функции (3-8 для МСБ), при необходимости под-функции A1.1 и т.д.",
|
||
"items": _activity_schema()
|
||
},
|
||
"arrow_issues": {
|
||
"type": "array",
|
||
"description": "Анализ стрелок — разрывы модели",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"type": {"type": "string", "enum": ["missing_control","dangling_output","dangling_input","overloaded_mechanism","manual_bridge","broken_flow"]},
|
||
"title": {"type": "string"},
|
||
"description": {"type": "string"},
|
||
"node_id": {"type": "string", "description": "К какой функции относится"},
|
||
"severity": {"type": "string", "enum": ["critical","high","medium"]}
|
||
},
|
||
"required": ["type","title","description","node_id","severity"]
|
||
}
|
||
},
|
||
"missing_info": {"type": "array", "items": {"type": "string"}}
|
||
},
|
||
"required": ["client_summary","business_pattern","context","activities","arrow_issues","missing_info"]
|
||
}
|
||
}
|
||
|
||
BUILD_INSTRUCTION = """Построй функциональную модель бизнеса клиента по строгому стандарту IDEF0.
|
||
|
||
ПРАВИЛА ICOM (различай 4 типа стрелок):
|
||
- INPUT (I, слева): то что ПРЕОБРАЗУЕТСЯ функцией в выход (сырьё, заявка, данные на обработку).
|
||
- CONTROL (C, сверху): то что УПРАВЛЯЕТ функцией но НЕ расходуется (нормы, регламенты, ТЗ, лекала, прайс, ГОСТ). КРИТИЧНО: если у функции нет контроля — оставь controls пустым, это сигнал проблемы 'работают как привыкли'.
|
||
- OUTPUT (O, справа): результат функции. Если выход никто не использует — target='НИКУДА'.
|
||
- MECHANISM (M, снизу): кто/чем выполняется (человек, оборудование, ПО). type='none' если механизма по сути нет.
|
||
|
||
ШАГИ:
|
||
1. context (A-0): опиши весь бизнес как ОДНУ функцию глаголом, с её внешними ICOM.
|
||
2. activities (A0): декомпозируй на 3-8 главных функций. Каждая — ГЛАГОЛ+объект ('Принять заказ', 'Раскроить ткань'). Присвой node_id A1, A2... Свяжи стрелки: выход одной функции = вход или контроль другой (source/target = node_id).
|
||
3. Где функция сложная — декомпозируй глубже (A1.1, A1.2) с parent.
|
||
4. arrow_issues: проанализируй стрелки:
|
||
- missing_control: функция без управляющих норм
|
||
- dangling_output: выход никто не потребляет
|
||
- dangling_input: вход ниоткуда не приходит
|
||
- overloaded_mechanism: один механизм (человек) на много функций
|
||
- manual_bridge: человек вручную переносит данные между функциями
|
||
- broken_flow: выход одной функции не доходит до входа следующей
|
||
5. Помечай exists=false для стрелок которые ДОЛЖНЫ быть, но в бизнесе отсутствуют.
|
||
|
||
Функция = глагол. Не выдумывай то чего нет в интервью.
|
||
Вызови build_idef0_model."""
|
||
|
||
@app.route("/api/build-model", methods=["POST"])
|
||
def build_model():
|
||
data = request.get_json(force=True) or {}
|
||
token = data.get("token")
|
||
proj = get_project(token)
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
|
||
msgs = history(proj["id"])
|
||
if not msgs:
|
||
return jsonify({"error": "no interview data"}), 400
|
||
|
||
docs = documents_context(proj["id"])
|
||
build_text = (docs + "\n\n" + BUILD_INSTRUCTION) if docs else BUILD_INSTRUCTION
|
||
msgs = msgs + [{"role": "user", "content": build_text}]
|
||
|
||
try:
|
||
resp = client.messages.create(
|
||
model=MODEL,
|
||
max_tokens=4096,
|
||
system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
|
||
tools=[MODEL_TOOL],
|
||
tool_choice={"type": "tool", "name": "build_idef0_model"},
|
||
messages=msgs
|
||
)
|
||
block = next(b for b in resp.content if b.type == "tool_use")
|
||
model_data = block.input
|
||
usage = {"in": resp.usage.input_tokens, "out": resp.usage.output_tokens}
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
con = db()
|
||
con.execute(
|
||
"INSERT INTO models (project_id, blocks_json, created_at) VALUES (?,?,?)",
|
||
(proj["id"], json.dumps(model_data, ensure_ascii=False), now())
|
||
)
|
||
con.execute("UPDATE projects SET status='model_ready' WHERE id=?", (proj["id"],))
|
||
con.commit()
|
||
return jsonify({"model": model_data, "usage": usage})
|
||
|
||
# ══ СЕЛЕКТОР МЕТОДОЛОГИЙ ═════════════════════════════
|
||
SELECT_TOOL = {
|
||
"name": "recommend_methodologies",
|
||
"description": "Анализирует бизнес клиента и рекомендует набор методологий моделирования.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"business_type": {"type": "string", "description": "Распознанный тип: эксперт / услуги / производство / торговля / иное"},
|
||
"recommended": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"method": {"type": "string", "enum": ["canvas", "idef0", "vsm", "dfd", "erd"]},
|
||
"use": {"type": "boolean", "description": "Применять ли"},
|
||
"depth": {"type": "string", "enum": ["full", "light", "skip"]},
|
||
"reason": {"type": "string", "description": "Почему именно так для этого клиента"}
|
||
},
|
||
"required": ["method", "use", "depth", "reason"]
|
||
}
|
||
},
|
||
"rationale": {"type": "string", "description": "Общее обоснование набора"}
|
||
},
|
||
"required": ["business_type", "recommended", "rationale"]
|
||
}
|
||
}
|
||
SELECT_INSTRUCTION = """Проанализируй бизнес клиента и реши какие методологии моделирования применить.
|
||
Доступны: canvas (Business Model Canvas — стратегия), idef0 (функциональная модель), vsm (поток ценности, потери), dfd (потоки данных), erd (модель данных).
|
||
Правила: canvas нужен почти всем. idef0 — где есть процессы. vsm — где есть поток материала/товара (производство, торговля). Для простого эксперта без производства vsm пропусти.
|
||
Для каждой метод. укажи use, depth (full/light/skip) и reason под ЭТОГО клиента. Дай rationale.
|
||
Вызови recommend_methodologies."""
|
||
|
||
@app.route("/api/select-methodologies", methods=["POST"])
|
||
def select_methodologies():
|
||
data = request.get_json(force=True) or {}
|
||
proj = get_project(data.get("token"))
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
result, usage = run_tool(proj["id"], SELECT_TOOL, "recommend_methodologies", SELECT_INSTRUCTION, max_tokens=1500)
|
||
if result is None:
|
||
return jsonify({"error": usage}), 500
|
||
save_artifact(proj["id"], "selection", result)
|
||
return jsonify({"selection": result, "usage": usage})
|
||
|
||
# ══ CANVAS — Business Model Canvas (9 блоков) ════════
|
||
CANVAS_BLOCK = {
|
||
"type": "object",
|
||
"properties": {
|
||
"items": {"type": "array", "items": {"type": "string"}, "description": "Пункты блока"},
|
||
"completeness": {"type": "integer"},
|
||
"note": {"type": "string", "description": "Комментарий / выявленная проблема по блоку"}
|
||
},
|
||
"required": ["items", "completeness", "note"]
|
||
}
|
||
CANVAS_TOOL = {
|
||
"name": "build_canvas",
|
||
"description": "Строит Business Model Canvas клиента (9 блоков) из интервью.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"value_propositions": CANVAS_BLOCK,
|
||
"customer_segments": CANVAS_BLOCK,
|
||
"channels": CANVAS_BLOCK,
|
||
"customer_relationships": CANVAS_BLOCK,
|
||
"revenue_streams": CANVAS_BLOCK,
|
||
"key_resources": CANVAS_BLOCK,
|
||
"key_activities": CANVAS_BLOCK,
|
||
"key_partners": CANVAS_BLOCK,
|
||
"cost_structure": CANVAS_BLOCK,
|
||
"insight": {"type": "string", "description": "Главный стратегический вывод по бизнес-модели"}
|
||
},
|
||
"required": ["value_propositions","customer_segments","channels","customer_relationships","revenue_streams","key_resources","key_activities","key_partners","cost_structure","insight"]
|
||
}
|
||
}
|
||
CANVAS_INSTRUCTION = """Построй Business Model Canvas клиента из интервью. 9 блоков:
|
||
- value_propositions: ценность, которую получает клиент
|
||
- customer_segments: кто платит, сегменты
|
||
- channels: как доходит ценность до клиента
|
||
- customer_relationships: тип отношений с клиентами
|
||
- revenue_streams: откуда деньги, модель монетизации
|
||
- key_resources: ключевые ресурсы
|
||
- key_activities: ключевые действия
|
||
- key_partners: партнёры, поставщики
|
||
- cost_structure: основные издержки
|
||
Каждый блок: items (пункты), completeness (0-100), note (проблема/комментарий). Дай insight — главный стратегический вывод.
|
||
Не выдумывай. Где данных нет — низкий completeness. Вызови build_canvas."""
|
||
|
||
@app.route("/api/build-canvas", methods=["POST"])
|
||
def build_canvas():
|
||
data = request.get_json(force=True) or {}
|
||
proj = get_project(data.get("token"))
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
result, usage = run_tool(proj["id"], CANVAS_TOOL, "build_canvas", CANVAS_INSTRUCTION, extra_context=documents_context(proj["id"]), max_tokens=3000)
|
||
if result is None:
|
||
return jsonify({"error": usage}), 500
|
||
save_artifact(proj["id"], "canvas", result)
|
||
return jsonify({"canvas": result, "usage": usage})
|
||
|
||
# ══ ГЕНЕРАТОР ТЗ (части A-C из IDEF0) ════════════════
|
||
SPEC_TOOL = {
|
||
"name": "build_tech_spec",
|
||
"description": "Собирает техническое задание на ПО из модели бизнеса: контекст, модули, модель данных.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"overview": {"type": "string", "description": "A1-A2: что за система, для кого, какую проблему решает, границы"},
|
||
"roles": {
|
||
"type": "array",
|
||
"description": "A3: роли пользователей (из mechanisms IDEF0)",
|
||
"items": {"type": "object", "properties": {
|
||
"name": {"type": "string"}, "does": {"type": "string"}, "access": {"type": "string"}
|
||
}, "required": ["name","does","access"]}
|
||
},
|
||
"modules": {
|
||
"type": "array",
|
||
"description": "B: модули системы (из функций IDEF0)",
|
||
"items": {"type": "object", "properties": {
|
||
"name": {"type": "string", "description": "Название модуля"},
|
||
"source_node": {"type": "string", "description": "node_id функции IDEF0"},
|
||
"purpose": {"type": "string"},
|
||
"screens": {"type": "array", "items": {"type": "string"}, "description": "Экраны модуля"},
|
||
"inputs_data": {"type": "string", "description": "Какие данные вводятся"},
|
||
"outputs_data": {"type": "string", "description": "Что система создаёт/показывает"},
|
||
"rules": {"type": "array", "items": {"type": "string"}, "description": "Бизнес-правила (из controls)"},
|
||
"roles": {"type": "array", "items": {"type": "string"}, "description": "Кто пользуется"}
|
||
}, "required": ["name","source_node","purpose","screens","inputs_data","outputs_data","rules","roles"]}
|
||
},
|
||
"entities": {
|
||
"type": "array",
|
||
"description": "C: модель данных (таблицы)",
|
||
"items": {"type": "object", "properties": {
|
||
"name": {"type": "string", "description": "Сущность/таблица"},
|
||
"fields": {"type": "array", "items": {"type": "object", "properties": {
|
||
"field": {"type": "string"}, "type": {"type": "string"}, "note": {"type": "string"}
|
||
}, "required": ["field","type"]}},
|
||
"relations": {"type": "array", "items": {"type": "string"}, "description": "Связи с другими таблицами"},
|
||
"example": {"type": "string", "description": "Пример строки данных"}
|
||
}, "required": ["name","fields","relations","example"]}
|
||
},
|
||
"open_questions": {"type": "array", "items": {"type": "string"}, "description": "Что уточнить перед разработкой"}
|
||
},
|
||
"required": ["overview","roles","modules","entities","open_questions"]
|
||
}
|
||
}
|
||
|
||
@app.route("/api/build-spec", methods=["POST"])
|
||
def build_spec():
|
||
data = request.get_json(force=True) or {}
|
||
proj = get_project(data.get("token"))
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
# Подгружаем готовую IDEF0 модель как контекст
|
||
model_row = db().execute("SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (proj["id"],)).fetchone()
|
||
if not model_row:
|
||
return jsonify({"error": "сначала постройте IDEF0 модель"}), 400
|
||
idef0 = model_row["blocks_json"]
|
||
instruction = ("На основе интервью и построенной IDEF0-модели собери ТЗ на программу для бизнеса клиента.\n"
|
||
"МАППИНГ: функция IDEF0 → модуль; Input → вводимые данные; Output → что показывает; "
|
||
"Control → бизнес-правила; Mechanism → роли; хранилища → таблицы данных.\n"
|
||
"overview (A1-A2): что за система, для кого, проблема, границы.\n"
|
||
"roles (A3): из mechanisms.\n"
|
||
"modules (B): каждая функция = модуль с экранами, данными, правилами.\n"
|
||
"entities (C): модель данных — таблицы с полями, связями, примером строки.\n"
|
||
"Думай как проектировщик ПО. Вызови build_tech_spec.\n\n"
|
||
f"IDEF0-МОДЕЛЬ:\n{idef0}")
|
||
result, usage = run_tool(proj["id"], SPEC_TOOL, "build_tech_spec", instruction, max_tokens=8192)
|
||
if result is None:
|
||
return jsonify({"error": usage}), 500
|
||
save_artifact(proj["id"], "spec", result)
|
||
con = db(); con.execute("UPDATE projects SET status='spec_ready' WHERE id=?", (proj["id"],)); con.commit()
|
||
return jsonify({"spec": result, "usage": usage})
|
||
|
||
@app.route("/api/project/crm", methods=["POST"])
|
||
def update_crm():
|
||
data = request.get_json(force=True) or {}
|
||
proj = get_project(data.get("token"))
|
||
if not proj:
|
||
return jsonify({"error": "project not found"}), 404
|
||
crm = latest_artifact(proj["id"], "crm") or {
|
||
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
|
||
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid"
|
||
}
|
||
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule", "stage_payments"):
|
||
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})
|
||
|
||
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=? 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"),
|
||
"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()]
|
||
})
|
||
|
||
# ── 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)
|