diff --git a/backend/elena_app.py b/backend/elena_app.py index aff6573..c6b5328 100644 --- a/backend/elena_app.py +++ b/backend/elena_app.py @@ -135,6 +135,7 @@ def init_db(): # Миграции — добавляем колонки если нет (идемпотентно) 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) @@ -148,11 +149,59 @@ 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=? ORDER BY id", (project_id,) + "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( @@ -306,6 +355,112 @@ def chat(): con.commit() return jsonify({"reply": reply, "usage": usage}) +# ── «Спросить Елену» на этапах 3–5: Q&A с полной памятью + фиксация отклонений ── +ASK_GUIDE = """РЕЖИМ ВОПРОСОВ ПО ЭТАПУ. +Клиент задаёт вопросы по уже построенному артефакту (стратегия / функциональная модель / ТЗ). Правила: +1. Отвечай как Елена — простым языком, по делу, без воды. У тебя есть полный контекст: интервью, документы, текущий артефакт. +2. Если клиент НЕ согласен и настаивает на своём («мне так удобно», «у нас так нельзя») — не переубеждай силой. Вызови инструмент record_deviation: что рекомендуешь ты (методологически верно), что выбрал клиент и ПОЧЕМУ (причина клиента — самое важное, часто это реальное ограничение, не учтённое в интервью). +3. Сам артефакт в этом режиме ты НЕ перестраиваешь — только объясняешь и фиксируешь отклонения. Перестройку сделает консультант. +4. После фиксации отклонения — коротко подтверди клиенту, что его пожелание записано и будет учтено при внедрении, но честно оставь свою рекомендацию в силе.""" + +DEVIATION_TOOL = { + "name": "record_deviation", + "description": "Зафиксировать отклонение: клиент настоял на варианте, отличном от методологически верного. Вызывай ТОЛЬКО когда клиент явно отказывается от рекомендации и выбирает своё.", + "input_schema": { + "type": "object", + "properties": { + "stage": {"type": "string", "description": "Этап/артефакт: canvas, idef0, spec, documents"}, + "node": {"type": "string", "description": "Конкретный узел/блок/функция, к которому относится отклонение"}, + "elena_rec": {"type": "string", "description": "Что рекомендует Елена — методологически верно"}, + "client_choice": {"type": "string", "description": "Что выбрал клиент"}, + "reason": {"type": "string", "description": "Причина клиента — почему ему так нужно/удобно (самое важное)"} + }, + "required": ["node", "elena_rec", "client_choice", "reason"] + } +} + +def run_ask(messages, system, pid): + """Агентный цикл: Елена отвечает и при необходимости фиксирует отклонение через tool.""" + recorded = False + for _ in range(3): + resp = client.messages.create( + model=MODEL, max_tokens=1100, + system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}], + tools=[DEVIATION_TOOL], + messages=messages + ) + tool_uses = [b for b in resp.content if b.type == "tool_use"] + text = "".join(b.text for b in resp.content if b.type == "text") + if tool_uses: + messages.append({"role": "assistant", "content": resp.content}) + results = [] + for tu in tool_uses: + if tu.name == "record_deviation": + item = dict(tu.input) + item.setdefault("stage", "") + add_deviation(pid, item) + recorded = True + results.append({"type": "tool_result", "tool_use_id": tu.id, "content": "Отклонение зафиксировано."}) + messages.append({"role": "user", "content": results}) + continue + return (text or "Записала ваши пожелания."), recorded + return "Записала ваши пожелания — учтём при внедрении.", recorded + +@app.route("/api/ask", methods=["POST"]) +def ask(): + data = request.get_json(force=True) or {} + token = data.get("token") + msg = (data.get("message") or "").strip() + stage = (data.get("stage") or "").strip() + if not token or not msg: + return jsonify({"error": "token and message required"}), 400 + proj = get_project(token) + if not proj: + return jsonify({"error": "project not found"}), 404 + pid = proj["id"] + + con = db() + con.execute( + "INSERT INTO messages (project_id, role, content, created_at, channel) VALUES (?,?,?,?,?)", + (pid, "user", msg, now(), "qa") + ) + con.commit() + + # Полный контекст проекта — одним блоком, чтобы не ломать чередование ролей + ctx_parts = [] + iv = interview_as_text(pid) + if iv: + ctx_parts.append(iv) + docs = documents_context(pid) + if docs: + ctx_parts.append(docs) + art = stage_artifact_context(pid, stage) + if art: + ctx_parts.append(art) + context_block = "\n\n".join(ctx_parts) + + qa = [{"role": m["role"], "content": m["content"]} for m in db().execute( + "SELECT role, content FROM messages WHERE project_id=? AND channel='qa' ORDER BY id", (pid,) + ).fetchall()] + + messages = [ + {"role": "user", "content": (context_block or "Контекст проекта пока пуст.") + + "\n\n[Ниже — вопросы клиента по текущему этапу. Отвечай как Елена.]"}, + {"role": "assistant", "content": "Держу весь контекст проекта. Готова ответить на ваши вопросы по этапу."}, + ] + qa + + try: + reply, recorded = run_ask(messages, SYSTEM_PROMPT + "\n\n" + ASK_GUIDE, pid) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + con.execute( + "INSERT INTO messages (project_id, role, content, created_at, channel) VALUES (?,?,?,?,?)", + (pid, "assistant", reply, now(), "qa") + ) + con.commit() + return jsonify({"reply": reply, "deviation_recorded": recorded}) + # ── Tool schema: строгий IDEF0 (ICOM + декомпозиция) ── ARROW = { "type": "object", @@ -1164,7 +1319,7 @@ def get_project_state(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"],) + "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"],) @@ -1200,7 +1355,9 @@ def get_project_state(token): "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()] + "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 ───────────────────────────────────── diff --git a/docs/cabinet.html b/docs/cabinet.html index f8ed437..2b42eca 100644 --- a/docs/cabinet.html +++ b/docs/cabinet.html @@ -66,6 +66,28 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ .scroll{flex:1;overflow-y:auto} /* Chat */ .chat{padding:24px 26px;display:flex;flex-direction:column;gap:14px} +/* Спросить Елену — док на этапах 3-5 */ +.askdock{display:none;border-top:1px solid var(--border);background:var(--white);flex:0 0 auto} +.askdock.show{display:block} +.askdock-head{display:flex;align-items:center;gap:8px;padding:11px 18px;cursor:pointer;font-size:13px;font-weight:700;color:var(--primary);user-select:none} +.askdock-head .ad-sub{font-weight:500;color:#9ca3af;font-size:12px} +.askdock-head .ad-chev{margin-left:auto;transition:transform .2s} +.askdock.open .ad-chev{transform:rotate(180deg)} +.askdock-body{display:none;border-top:1px solid var(--border)} +.askdock.open .askdock-body{display:block} +.askdock-thread{max-height:240px;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:10px} +.askdock-thread:empty{display:none} +.askdock-inbar{display:flex;gap:8px;padding:10px 14px;border-top:1px solid var(--border)} +.askdock-inbar textarea{flex:1;border:1px solid var(--border);border-radius:10px;padding:9px 12px;font:inherit;font-size:13px;resize:none;max-height:90px;outline:none} +.askdock-inbar textarea:focus{border-color:var(--primary)} +.am{display:flex;gap:8px;font-size:13px;line-height:1.45} +.am .am-av{width:24px;height:24px;border-radius:50%;flex:0 0 24px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff} +.am.u{flex-direction:row-reverse} +.am.u .am-av{background:#64748b} +.am.e .am-av{background:var(--primary)} +.am .am-bb{background:#f1f5f9;border-radius:10px;padding:7px 11px;max-width:86%} +.am.u .am-bb{background:#e2e8f0} +.am-dev{font-size:11px;color:#92400E;background:#FEF3C7;border-radius:6px;padding:4px 8px;margin-top:5px;font-weight:600} .msg{display:flex;gap:10px;max-width:80%} .msg.user{align-self:flex-end;flex-direction:row-reverse} .av{width:30px;height:30px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:#fff} @@ -324,6 +346,18 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
+ +
+
💬 Спросить Елену об этом этапе
+
+
+
+ + +
+
+
+ @@ -358,6 +392,13 @@ function go(n){ if(n===3)renderDocs(); if(n===4)renderAnalysis(); if(n===5)renderSpecPane(); + // Док «Спросить Елену» — только на этапах 3-5 + const dock=document.getElementById("askDock"); + if(dock){ + if(n>=3&&n<=5){dock.classList.add("show");document.getElementById("adSub").textContent=STAGE_LBL[n]||"об этапе"; + if(!dock.dataset.rendered){renderAskThread();dock.dataset.rendered="1";}} + else dock.classList.remove("show"); + } } async function saveProfile(){ @@ -394,6 +435,37 @@ function addMsg(role,text){const m=document.createElement("div");m.className="ms function showTyping(){const t=document.createElement("div");t.className="msg";t.id="typing";t.innerHTML=`
Е
`;chat.appendChild(t);chat.scrollTop=chat.scrollHeight} function hideTyping(){const t=document.getElementById("typing");if(t)t.remove()} +/* ── Спросить Елену (этапы 3-5) ── */ +const STAGE_LBL={3:"о документах",4:"о стратегии и модели",5:"о ТЗ и плане"}; +function toggleAsk(){document.getElementById("askDock").classList.toggle("open")} +function addAsk(role,text,dev){ + const t=document.getElementById("askThread");if(!t)return; + const m=document.createElement("div");m.className="am "+(role==="user"?"u":"e"); + m.innerHTML=`
${role==="user"?"Я":"Е"}
${fmt(text)}${dev?'
⚠ Ваше пожелание зафиксировано — учтём при внедрении
':''}
`; + t.appendChild(m);t.scrollTop=t.scrollHeight; +} +function renderAskThread(){ + const t=document.getElementById("askThread");if(!t)return; + t.innerHTML="";(state&&state.qa||[]).forEach(m=>addAsk(m.role==="user"?"user":"elena",m.content)); +} +async function askElena(){ + const inp=document.getElementById("askInp");const text=inp.value.trim();if(!text)return; + inp.value="";inp.style.height="auto"; + document.getElementById("askDock").classList.add("open"); + addAsk("user",text); + const btn=document.getElementById("askSend");btn.disabled=true; + const tp=document.createElement("div");tp.className="am e";tp.id="askTyping";tp.innerHTML='
Е
'; + document.getElementById("askThread").appendChild(tp); + try{ + const r=await fetch(`${API}/api/ask`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token,message:text,stage:String(cur)})}); + const d=await r.json(); + const x=document.getElementById("askTyping");if(x)x.remove(); + addAsk("elena",d.reply||("Ошибка: "+(d.error||"?")),d.deviation_recorded); + state.qa=state.qa||[];state.qa.push({role:"user",content:text},{role:"assistant",content:d.reply||""}); + }catch(e){const x=document.getElementById("askTyping");if(x)x.remove();addAsk("elena","Ошибка связи: "+e.message)} + btn.disabled=false; +} + async function init(){ // Telegram Mini App: развернуть на весь экран + токен из start_param если нет в URL const tg=window.Telegram&&window.Telegram.WebApp;