mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 14:24:47 +00:00
feat(cabinet): «Спросить Елену» на этапах 3-5 + память + фиксация отклонений
- backend: канал messages (interview/qa), /api/ask с полным контекстом (интервью + документы + артефакт этапа + Q&A), tool record_deviation - хранилище отклонений (артефакт deviations: эталон Елены + выбор клиента + причина) - кабинет: свёрнутый док «Спросить Елену» на этапах 3-5, контекст этапа
This commit is contained in:
parent
30160a0999
commit
cf5e4f050a
@ -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 ─────────────────────────────────────
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user