From f925042a939c9bd16bbb626fb0831d11c4bdca16 Mon Sep 17 00:00:00 2001 From: Aissa Date: Sun, 16 Aug 2026 23:23:48 +0000 Subject: [PATCH] Stop War Room A2A loops and unlock full chat history with dates. Detect repeating topics and pause A2A, load older messages beyond the old ~120 cap, and show clear date/time stamps on every bubble. --- cockpit/app/routes/agents_api.py | 26 +++- cockpit/app/services/agent_chat.py | 166 ++++++++++++++++++++++++-- cockpit/static/css/agents-warroom.css | 34 ++++++ cockpit/static/js/agents-warroom.js | 91 +++++++++++++- cockpit/templates/agents.html | 15 ++- 5 files changed, 307 insertions(+), 25 deletions(-) diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py index 6c13d61..93b0f08 100644 --- a/cockpit/app/routes/agents_api.py +++ b/cockpit/app/routes/agents_api.py @@ -572,16 +572,36 @@ async def warroom_open_dm(agent_key: str) -> dict[str, Any]: @router.get("/warroom/rooms/{room_id}/messages") -async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]: +async def warroom_list_messages( + room_id: int, + limit: int = 500, + after_id: int | None = None, + before_id: int | None = None, +) -> dict[str, Any]: from app.services import agent_chat agent_chat.ensure_warroom() room = agent_chat.get_room(room_id) if not room: raise HTTPException(404, "Room niet gevonden") - items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id) + lim = min(max(1, limit), 5000) + items = agent_chat.list_messages( + room_id, limit=lim, after_id=after_id, before_id=before_id + ) deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else [] - return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids} + total = agent_chat.count_messages(room_id) + oldest = agent_chat.oldest_message_id(room_id) + first_id = int(items[0]["id"]) if items else None + has_more = bool(oldest is not None and first_id is not None and first_id > oldest) + return { + "ok": True, + "room": room, + "items": items, + "deleted_ids": deleted_ids, + "total": total, + "has_more": has_more, + "oldest_id": oldest, + } @router.post("/warroom/rooms/{room_id}/messages") diff --git a/cockpit/app/services/agent_chat.py b/cockpit/app/services/agent_chat.py index 17e2aef..8ef3b5e 100644 --- a/cockpit/app/services/agent_chat.py +++ b/cockpit/app/services/agent_chat.py @@ -197,13 +197,30 @@ def get_room(room_id: int) -> dict[str, Any] | None: return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,))) -def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None) -> list[dict[str, Any]]: +def list_messages( + room_id: int, + *, + limit: int = 100, + after_id: int | None = None, + before_id: int | None = None, +) -> list[dict[str, Any]]: + """List messages. Default = newest page. before_id = older page (scroll up).""" + lim = max(1, min(int(limit), 5000)) if after_id: rows = fetch_all( """SELECT * FROM warroom_messages WHERE room_id = %s AND id > %s AND deleted_at IS NULL ORDER BY created_at ASC, id ASC LIMIT %s""", - (room_id, after_id, limit), + (room_id, after_id, lim), + ) + elif before_id: + rows = fetch_all( + """SELECT * FROM ( + SELECT * FROM warroom_messages + WHERE room_id = %s AND id < %s AND deleted_at IS NULL + ORDER BY created_at DESC, id DESC LIMIT %s + ) t ORDER BY created_at ASC, id ASC""", + (room_id, before_id, lim), ) else: rows = fetch_all( @@ -212,11 +229,29 @@ def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None WHERE room_id = %s AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT %s ) t ORDER BY created_at ASC, id ASC""", - (room_id, limit), + (room_id, lim), ) return [_iso(r) for r in rows] # type: ignore +def count_messages(room_id: int) -> int: + row = fetch_one( + "SELECT COUNT(*) AS c FROM warroom_messages WHERE room_id = %s AND deleted_at IS NULL", + (room_id,), + ) + return int((row or {}).get("c") or 0) + + +def oldest_message_id(room_id: int) -> int | None: + row = fetch_one( + """SELECT id FROM warroom_messages + WHERE room_id = %s AND deleted_at IS NULL + ORDER BY id ASC LIMIT 1""", + (room_id,), + ) + return int(row["id"]) if row and row.get("id") is not None else None + + def delete_message(room_id: int, message_id: int) -> dict[str, Any] | None: """Soft-delete a warroom message. Returns the row or None if missing/already deleted.""" row = fetch_one( @@ -508,8 +543,10 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str: accuracy + "War Room-regels:\n" + "- Max 4 korte zinnen, Nederlands.\n" - + "- Geen @zelf-prefix / geen herhalingen.\n" - + "- Peer @mentions verplicht: eindig met @iemand + gerichte vraag of reactie.\n" + + "- Geen @zelf-prefix.\n" + + "- GEEN herhalingen: zeg niet opnieuw wat net al besloten/gezegd is.\n" + + "- Peer @mentions alleen als je écht iets nieuws toevoegt; anders geen @mention-ketting.\n" + + "- Bij gesloten besluiten (gestopt/klaar): max 1 bevestiging, daarna ander dossier of stop.\n" + "- Zeg nooit dat agents uit staan.\n" + "- Menselijk & echt: reageer concreet op de vorige spreker (feit, mop of vraag).\n" + "- Lichte food-moppen / Foodlinkk-humor mag (respectvol, geen geloof/politiek/grof).\n" @@ -1200,7 +1237,9 @@ async def post_user_message_stream( user_focus = ( f"Prioriteit: beantwoord EERST het bericht van {username or 'Aissa'}. " f"Spreek hem aan als Aissa. " - f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. Daarna optioneel 1 @mention naar een collega." + f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. " + f"Geen herhaling van eerdere berichten. Max 3 zinnen. " + f"@mention alleen als je iets nieuws toevoegt." ) for key in speakers: async for ev in _emit_speak(key, trig=f"{trigger}\n\n{user_focus}"): @@ -1220,6 +1259,74 @@ async def post_user_message_stream( yield {"event": "done", "ok": True, "a2a": True} + +def _norm_msg(text: str) -> str: + t = (text or "").lower() + t = re.sub(r"@\w+", " ", t) + t = re.sub(r"[^a-z0-9àáäâèéëêìíïîòóöôùúüû\s]+", " ", t) + t = re.sub(r"\s+", " ", t).strip() + return t + + +def _token_set(text: str) -> set[str]: + stop = { + "de", "het", "een", "en", "of", "op", "voor", "van", "naar", "met", "zijn", "niet", + "geen", "wel", "dat", "die", "dit", "dan", "ook", "als", "bij", "uit", "aan", "er", + "ik", "je", "we", "jij", "hij", "zij", "aissa", "herman", "even", "check", "checken", + "begrepen", "helder", "prima", "punt", "dus", "nog", "meer", "kun", "kan", "moet", + } + return {w for w in _norm_msg(text).split() if len(w) > 2 and w not in stop} + + +def _overlap_ratio(a: str, b: str) -> float: + sa, sb = _token_set(a), _token_set(b) + if not sa or not sb: + return 0.0 + return len(sa & sb) / max(1, min(len(sa), len(sb))) + + +def _is_conversation_looping(messages: list[dict], *, min_msgs: int = 4, threshold: float = 0.42) -> bool: + """True if recent agent messages keep restating the same decision/theme.""" + agents = [m for m in messages if m.get("sender_type") == "agent"][-8:] + if len(agents) < min_msgs: + return False + texts = [str(m.get("content") or "") for m in agents] + # pairwise overlap among last messages + hits = 0 + pairs = 0 + for i in range(len(texts)): + for j in range(i + 1, len(texts)): + pairs += 1 + if _overlap_ratio(texts[i], texts[j]) >= threshold: + hits += 1 + if pairs and (hits / pairs) >= 0.45: + return True + # keyword lock: same closed decision repeated + lock_words = ("website stopt", "website stop", "geen website", "dielines", "label-mock", "op de nas", "scheelt", "oprui") + recent_join = " ".join(_norm_msg(t) for t in texts[-5:]) + lock_hits = sum(1 for w in lock_words if w in recent_join) + return lock_hits >= 3 + + +def _anti_repeat_block(messages: list[dict], *, limit: int = 4) -> str: + agents = [m for m in messages if m.get("sender_type") == "agent"][-limit:] + if not agents: + return "" + lines = ["ANTI-HERHALING (hard):"] + lines.append("- Herhaal NIET wat collega's net al zeiden (besluit, mop, of check-vraag).") + lines.append("- Als het besluit al gevallen is: 1 korte bevestiging MAX, daarna NIEUW onderwerp of stilte.") + lines.append("- Verboden: opnieuw 'website stopt', 'dielines op NAS', 'even checken @product' als dat al 2x gezegd is.") + lines.append("- Lever 1 nieuw feit/actie uit JOUW rol, of zeg niks nieuws en @mention iemand met een écht nieuwe vraag.") + lines.append("Recent al gezegd (NIET napraten):") + for m in agents: + who = m.get("agent_key") or "?" + excerpt = (m.get("content") or "").strip().replace("\n", " ") + if len(excerpt) > 140: + excerpt = excerpt[:139] + "…" + lines.append(f"- @{who}: {excerpt}") + return "\n".join(lines) + + async def continue_warroom_stream( room_id: int, *, @@ -1267,7 +1374,40 @@ async def continue_warroom_stream( if not topic: topic = "Volg Aissa's input als leidraad; spreek hem aan als Aissa; wissel cockpit-dossier met lichte humor; escaleer alleen met @aissa als echt nodig." - produced = [m for m in recent if m.get("sender_type") == "agent"][-6:] + produced = [m for m in recent if m.get("sender_type") == "agent"][-8:] + anti = _anti_repeat_block(recent, limit=5) + looping = _is_conversation_looping(recent) + + # Bij herhalingslus: stop autonome A2A i.p.v. nog meer napraters + if looping: + yield { + "event": "continue_start", + "topic": "Herhaling gedetecteerd — A2A pauzeert dit onderwerp", + } + # Herman mag 1x afronden en doorpakken, daarna klaar + close_trig = ( + f"Topic van Aissa: {topic}\n" + f"{anti}\n" + "De War Room herhaalt zich. Jij bent @herman.\n" + "Schrijf MAX 3 zinnen: (1) besluit is al genomen — stop dit onderwerp, " + "(2) noem 1 concreet ander dossier uit cockpit (CRM/retail/pitch), " + "(3) GEEN @mention-ketting over website/packaging/NAS-opruimen.\n" + "Geen mop verplicht. Natuurlijk NL." + ) + async for kind_ev, payload in _agent_speak_stream( + room, "herman", trigger=close_trig, hermes_user=hermes_user, in_reply_to=None + ): + if kind_ev == "start": + yield {"event": "typing_start", **payload} + elif kind_ev == "token": + yield {"event": "typing", **payload} + elif kind_ev == "done": + yield {"event": "agent", "message": payload} + elif kind_ev == "error": + yield {"event": "system", "message": payload} + yield {"event": "done", "ok": True, "continued": True, "loop_break": True} + return + yield {"event": "continue_start", "topic": topic[:200]} for turn in range(max(1, min(int(max_turns), 5))): @@ -1295,16 +1435,18 @@ async def continue_warroom_stream( src = last trig = ( f"Autonoom War Room A2A (user kan meelezen). Topic: {topic}\n" - f"Jij bent @{target}. Reageer ECHT op @{src}.\n" - f"Kies passend: (a) 1 cockpit-feit + gerichte vraag, OF (b) korte respectvolle food-mop + @mention terug naar de inhoud.\n" - f"Breng JOUW expertise in (niet herhalen wat @{src} zei). Spreek de CEO aan als Aissa. Altijd @mention 1 collega; escalatie naar CEO-inbox via @aissa. Max 4 zinnen. Natuurlijk NL." + f"{anti}\n" + f"Jij bent @{target}. Reageer op @{src} met NIEUWE info uit jouw rol.\n" + f"Verboden: napraten van hetzelfde besluit. Max 3 zinnen. " + f"@mention alleen bij écht nieuwe vraag. Spreek Aissa aan als Aissa." ) else: target, src = nxt[0] trig = ( f"Autonoom War Room A2A. Topic: {topic}\n" - f"@{src} sprak je aan. Reageer op die inhoud (of mop) alsof je in een echt teamoverleg zit.\n" - f"Geef waarde vanuit JOUW rol (feit/actie/vraag). Trek er 1 collega bij die iets kan toevoegen via @mention — niet alleen Herman. Korte food-humor mag." + f"{anti}\n" + f"@{src} noemde je. Geef 1 NIEUW punt vanuit jouw rol — geen echo.\n" + f"Max 3 zinnen. Geen herhaling van website-stop/NAS-check als dat al gezegd is." ) async for kind_ev, payload in _agent_speak_stream( diff --git a/cockpit/static/css/agents-warroom.css b/cockpit/static/css/agents-warroom.css index f8a0126..9cf1802 100644 --- a/cockpit/static/css/agents-warroom.css +++ b/cockpit/static/css/agents-warroom.css @@ -662,3 +662,37 @@ background: rgba(14, 40, 64, 0.98); border-color: rgba(56, 189, 248, 0.7); } + + +.wr-history-bar { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.45rem 0.35rem 0.65rem; + position: sticky; + top: 0; + z-index: 5; + background: linear-gradient(180deg, rgba(8,12,18,0.96), rgba(8,12,18,0.72) 70%, transparent); +} +.wr-time { + font-variant-numeric: tabular-nums; + white-space: nowrap; + opacity: 0.85; +} +.wr-day-sep { + display: flex; + justify-content: center; + margin: 0.75rem 0 0.5rem; +} +.wr-day-sep span { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0.2rem 0.65rem; + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.2); + opacity: 0.9; +} diff --git a/cockpit/static/js/agents-warroom.js b/cockpit/static/js/agents-warroom.js index 4a0a4c2..04880cf 100644 --- a/cockpit/static/js/agents-warroom.js +++ b/cockpit/static/js/agents-warroom.js @@ -29,6 +29,10 @@ window.WarRoomHub = (function () { replyHighlightId: null, stickToBottom: true, showJumpLatest: false, + hasMoreHistory: false, + loadingHistory: false, + historyTotal: 0, + _loadingOlder: false, _typingScrollAt: 0, rosterCollapsed: true, a2aPausedUntil: 0, @@ -158,13 +162,15 @@ window.WarRoomHub = (function () { try { if (!(this.rooms || []).length) await this.loadRooms(); var meta = (this.rooms || []).find(function (r) { return Number(r.id) === id; }) || null; - var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=120').then(function (x) { + var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=800').then(function (x) { return x.json(); }); // messages API returns {items}, NOT {room} — keep room from rooms list this.room = meta || r.room || { id: id, kind: 'warroom', title: 'War Room' }; this.messages = this.normalizeList(r.items || []); this.lastId = this.maxId(this.messages); + this.historyTotal = r.total || this.messages.length; + this.hasMoreHistory = !!r.has_more; this.stickToBottom = true; this.showJumpLatest = false; this.$nextTick(function () { @@ -174,7 +180,7 @@ window.WarRoomHub = (function () { }.bind(this)); var last = (this.messages || [])[(this.messages || []).length - 1]; if (last && last.sender_type === 'agent' && this.room && this.room.kind === 'warroom') { - if (this.autoDebate) this.scheduleContinue(60000); + if (this.autoDebate) this.scheduleContinue(120000); } } catch (e) { this.status = e.message || 'Laden mislukt'; @@ -246,6 +252,58 @@ window.WarRoomHub = (function () { var near = this.isNearBottom(el, 100); this.stickToBottom = near; this.showJumpLatest = !near; + // bovenaan: oudere geschiedenis laden + if (el.scrollTop < 80) this.loadOlderMessages(); + }, + + minId(items) { + var n = null; + (items || []).forEach(function (m) { + var id = Number(m && m.id); + if (!isNaN(id) && (n === null || id < n)) n = id; + }); + return n; + }, + + async loadOlderMessages() { + if (!this.room || !this.room.id) return; + if (this.loadingHistory || this._loadingOlder || !this.hasMoreHistory) return; + if (!(this.messages || []).length) return; + this._loadingOlder = true; + this.loadingHistory = true; + var el = this.$refs && this.$refs.wrLog; + var prevHeight = el ? el.scrollHeight : 0; + var prevTop = el ? el.scrollTop : 0; + try { + var before = this.minId(this.messages); + var r = await fetch( + '/api/agents/warroom/rooms/' + this.room.id + + '/messages?limit=400&before_id=' + before, + { credentials: 'same-origin' } + ).then(function (x) { return x.json(); }); + var older = this.normalizeList(r.items || []); + if (older.length) { + var existing = {}; + (this.messages || []).forEach(function (m) { existing[String(m.id)] = true; }); + var merged = older.filter(function (m) { return !existing[String(m.id)]; }) + .concat(this.messages || []); + this.messages = merged; + this.historyTotal = r.total || this.historyTotal; + this.hasMoreHistory = !!r.has_more; + var self = this; + this.$nextTick(function () { + self.paintAvatars(); + if (el) { + // scrollpositie behouden na prepend + el.scrollTop = el.scrollHeight - prevHeight + prevTop; + } + }); + } else { + this.hasMoreHistory = false; + } + } catch (e) { /* quiet */ } + this.loadingHistory = false; + this._loadingOlder = false; }, scrollLog(force) { @@ -538,7 +596,7 @@ window.WarRoomHub = (function () { setTimeout(function () { self.replyHighlightId = null; }, 4000); }); } - if (this.autoDebate) this.scheduleContinue(60000); + if (this.autoDebate) this.scheduleContinue(120000); } catch (e) { // Keep optimistic message; restore draft only on hard fail this.status = e.message || 'Verzenden mislukt'; @@ -642,6 +700,10 @@ window.WarRoomHub = (function () { if (window.Cockpit) Cockpit.toast(this.status, 'error'); } else if (type === 'done') { this.typing = null; + if (ev.loop_break) { + this.pauseA2A(180000); + this.status = 'A2A pauze — onderwerp herhaalde zich'; + } } }, @@ -733,13 +795,15 @@ window.WarRoomHub = (function () { opts = opts || {}; if (!this.room || !this.room.id) return; try { - var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=200', { + var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=800', { credentials: 'same-origin', }).then(function (x) { return x.json(); }); var items = this.normalizeList(r.items || []); if (items.length) { this.messages = items; this.lastId = this.maxId(items); + this.historyTotal = r.total || items.length; + this.hasMoreHistory = !!r.has_more; } if (!opts.silent) { this.$nextTick(function () { @@ -793,11 +857,11 @@ window.WarRoomHub = (function () { this.busy = false; this.status = 'A2A live'; // keep conversation alive - this.scheduleContinue(55000); + this.scheduleContinue(90000); } catch (e) { this.busy = false; this.status = 'A2A hervatten…'; - this.scheduleContinue(45000); + this.scheduleContinue(75000); } this._continueBusy = false; }, @@ -871,6 +935,21 @@ window.WarRoomHub = (function () { return d.toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); }, + /** Compact stamp on each bubble: 16 aug · 00:53 */ + formatMsgStamp(m) { + var raw = (m && (m.created_at || m.createdAt || m.ts)) || ''; + if (!raw) return ''; + var d = new Date(raw); + if (isNaN(d.getTime())) return ''; + var today = new Date(); + var sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate(); + var time = d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }); + if (sameDay) return time; + var date = d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined }); + return date + ' · ' + time; + }, + + dayKey(m) { var raw = (m && (m.created_at || m.createdAt || m.ts)) || ''; if (!raw) return ''; diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index 73d31ce..2db7098 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -5,7 +5,7 @@ - + {% endblock %} {% block content %} @@ -313,7 +313,7 @@

+ x-text="(screenCleared ? '0' : visibleMessages().length) + ' geladen' + (historyTotal ? (' · ' + historyTotal + ' totaal') : '')">
@@ -352,6 +352,13 @@
+
+ + +

Scherm is geleegd. @@ -383,7 +390,7 @@

- +