feat(cabinet): «Спросить Елену» на этапах 3-5 + память + фиксация отклонений

- backend: канал messages (interview/qa), /api/ask с полным контекстом
  (интервью + документы + артефакт этапа + Q&A), tool record_deviation
- хранилище отклонений (артефакт deviations: эталон Елены + выбор клиента + причина)
- кабинет: свёрнутый док «Спросить Елену» на этапах 3-5, контекст этапа
This commit is contained in:
wasrusgen 2026-06-01 23:36:28 +03:00
parent 30160a0999
commit cf5e4f050a
2 changed files with 232 additions and 3 deletions

View File

@ -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})
# ── «Спросить Елену» на этапах 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})
# ── 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 ─────────────────────────────────────

View File

@ -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
<div class="scroll"><div class="pad" id="specPad"></div></div>
</div>
<!-- Спросить Елену — док на этапах 3-5 -->
<div class="askdock" id="askDock">
<div class="askdock-head" onclick="toggleAsk()">💬 Спросить Елену <span class="ad-sub" id="adSub">об этом этапе</span><svg class="ad-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></div>
<div class="askdock-body">
<div class="askdock-thread" id="askThread"></div>
<div class="askdock-inbar">
<textarea id="askInp" rows="1" placeholder="Спросите Елену об этом этапе…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();askElena()}"></textarea>
<button class="icon-btn send" id="askSend" onclick="askElena()"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg></button>
</div>
</div>
</div>
</main>
</div>
@ -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=`<div class="av e">Е</div><div class="typing"><span></span><span></span><span></span></div>`;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=`<div class="am-av">${role==="user"?"Я":"Е"}</div><div class="am-bb">${fmt(text)}${dev?'<div class="am-dev">⚠ Ваше пожелание зафиксировано — учтём при внедрении</div>':''}</div>`;
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='<div class="am-av">Е</div><div class="am-bb"></div>';
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;