diff --git a/README.md b/README.md index 04d3991..2260210 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,11 @@ All releases, with checksums: [conductloop-releases](https://github.com/EVMCOD/c - **Backups** of the local database, daily and before every upgrade, restorable from Setup. - **It learns from your 👍/👎**: proposed rules that you approve, all stored on your computer. -The screenshots use a fictional vault. Dark theme: ◐ in the corner. +| Ask the team | The CEO's answer | +|---|---| +| ![Team mode: the CEO hands the question to the Head of Product, CTO, CMO and CFO; each card shows its answer and time](docs/screenshots/09-team-cards.png) | ![The CEO's final answer: a numbered plan with sources](docs/screenshots/08-team-answer.png) | + +The screenshots use a fictional vault; the team ones ran on a real model (MiniMax-M3). Dark theme: ◐ in the corner. --- diff --git a/docs/screenshots/08-team-answer.png b/docs/screenshots/08-team-answer.png new file mode 100644 index 0000000..3ff8f25 Binary files /dev/null and b/docs/screenshots/08-team-answer.png differ diff --git a/docs/screenshots/09-team-cards.png b/docs/screenshots/09-team-cards.png new file mode 100644 index 0000000..e353204 Binary files /dev/null and b/docs/screenshots/09-team-cards.png differ diff --git a/docs/screenshots/10-brain-answer.png b/docs/screenshots/10-brain-answer.png new file mode 100644 index 0000000..9c00f23 Binary files /dev/null and b/docs/screenshots/10-brain-answer.png differ diff --git a/ops_dashboard/brain.py b/ops_dashboard/brain.py index fc75d60..5741f34 100644 --- a/ops_dashboard/brain.py +++ b/ops_dashboard/brain.py @@ -219,7 +219,7 @@ def _local_endpoint(base_url: str | None) -> bool: - A cross-source TODO/pendiente list. You are direct, concise, and opinionated. Match the user's language. When you -cite a fact, append the source like [obsidian:filename.md] or [repo:name]. +cite a fact from the context excerpts, append the excerpt's number, like [1]. Don't make up information. If you don't know, say so. Today's date: {today} diff --git a/ops_dashboard/server.py b/ops_dashboard/server.py index 0e5ba51..1f5182f 100644 --- a/ops_dashboard/server.py +++ b/ops_dashboard/server.py @@ -2622,7 +2622,9 @@ def _brain_ask_tools(self) -> None: today = time.strftime("%Y-%m-%d") prefs = db.memory_list() pref_str = "\n".join(f"- {p['key']}: {p['value']}" for p in prefs) or "(none yet)" - system = brain.SYSTEM_PROMPT_BASE.format(today=today, preferences=pref_str) + system = brain.SYSTEM_PROMPT_BASE.format(today=today, preferences=pref_str) + ( + "\n\nThere are no numbered excerpts here: cite facts from tool results by " + "source, like [obsidian:filename.md] or [repo:name].") messages = [{"role": "user", "content": q}] tool_log = [] def on_call(name, args, result): diff --git a/ops_dashboard/web/chat.js b/ops_dashboard/web/chat.js index 6c540bf..c483c15 100644 --- a/ops_dashboard/web/chat.js +++ b/ops_dashboard/web/chat.js @@ -58,8 +58,43 @@ return '' + text + ''; }); s = s.replace(/\[(\d{1,2})\]/g, '$1'); + // Some models cite by source ("[obsidian:Pricing.md]") instead of [N]: show the name as a chip. + s = s.replace(/\[(obsidian|repo|memory|web|voice|mcp):([^\]\n]{1,120})\]/g, function (_m, kind, name) { + var label = name.split('/').pop().replace(/\.md$/i, ''); + return '' + label + ''; + }); return s; } + var LIST = /^(\s*)([-*•]|\d+[.)])\s+(.*)$/; + // One list starting at lines[i]. Deeper-indented items nest inside the item + // above, and an ordered list keeps its first number, so "1. … - note … 2. …" + // doesn't restart at 1. + function list(lines, i, indent) { + var first = LIST.exec(lines[i]), ordered = /\d/.test(first[2]), items = []; + while (i < lines.length) { + if (!lines[i].trim() && i + 1 < lines.length) { + var next = LIST.exec(lines[i + 1]); + if (next && next[1].length >= indent) { i++; continue; } + break; + } + var m = LIST.exec(lines[i]); + if (!m || m[1].length < indent) break; + if (m[1].length > indent && items.length) { + var sub = list(lines, i, m[1].length); + items[items.length - 1] += sub.html; + i = sub.i; + continue; + } + if (/\d/.test(m[2]) !== ordered) break; + items.push('
  • ' + inline(m[3])); + i++; + } + var tag = ordered ? 'ol' : 'ul', n = ordered ? parseInt(first[2], 10) : 1; + return { + html: '<' + tag + (n > 1 ? ' start="' + n + '"' : '') + '>' + items.join('
  • ') + '', + i: i + }; + } function md(src) { var out = [], lines = String(src || '').replace(/\r/g, '').split('\n'), i = 0, para = []; function flush() { if (para.length) { out.push('

    ' + para.map(inline).join('
    ') + '

    '); para = []; } } @@ -76,14 +111,12 @@ } var h = /^(#{1,3})\s+(.*)$/.exec(line); if (h) { flush(); out.push('' + inline(h[2]) + ''); i++; continue; } - if (/^\s*([-*•])\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) { + var item = LIST.exec(line); + if (item) { flush(); - var ordered = /^\s*\d+[.)]\s+/.test(line), items = []; - while (i < lines.length && (ordered ? /^\s*\d+[.)]\s+/ : /^\s*[-*•]\s+/).test(lines[i])) { - items.push('
  • ' + inline(lines[i].replace(/^\s*([-*•]|\d+[.)])\s+/, '')) + '
  • '); - i++; - } - out.push((ordered ? '
      ' : '
    ' : '')); + var r = list(lines, i, item[1].length); + out.push(r.html); + i = r.i; continue; } if (/^>\s?/.test(line)) { flush(); out.push('
    ' + inline(line.replace(/^>\s?/, '')) + '
    '); i++; continue; } diff --git a/tests/test_chat_markdown_js.py b/tests/test_chat_markdown_js.py new file mode 100644 index 0000000..5a6d1a9 --- /dev/null +++ b/tests/test_chat_markdown_js.py @@ -0,0 +1,55 @@ +"""Unit test for the markdown renderer in web/chat.js (esc → inline → list → +md). Pulls those functions out of the IIFE and runs them under Node; skipped +when Node isn't installed.""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +CHAT = Path(__file__).resolve().parent.parent / "ops_dashboard" / "web" / "chat.js" +NODE = shutil.which("node") + +CASES = { + # A bullet between numbered items used to close the
      and restart at 1. + "split_ordered": "1. Ship it\n- note\n2. Tell people", + "nested": "1. Ship it\n - build\n - test\n2. Tell people", + "loose": "1. One\n\n2. Two", + "source_cite": "Price is 12 dollars [obsidian:Pricing experiment.md] and [repo:tidepool].", + "number_cite": "Fact [2].", + "escaped": "[obsidian:.md]", +} + + +def _render() -> dict: + src = CHAT.read_text() + start = src.index(" function esc(s)") + helpers = src[start:src.index(" function tint(", start)] + md_start = src.index(" function inline(s)") + md_end = src.index(" // Re-render at most once per frame") + script = helpers + src[md_start:md_end] + ( + "\nconst cases = JSON.parse(process.argv[1]); const out = {};" + "\nfor (const k in cases) out[k] = md(cases[k]);" + "\nprocess.stdout.write(JSON.stringify(out));") + run = subprocess.run([NODE, "-e", script, json.dumps(CASES)], capture_output=True, text=True, timeout=30) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +@pytest.mark.skipif(not NODE, reason="node not installed") +def test_chat_markdown_lists_and_citations(): + out = _render() + assert out["split_ordered"] == '
      1. Ship it
      1. Tell people
      ' + assert out["nested"] == "
      1. Ship it
        • build
        • test
      2. Tell people
      " + assert out["loose"] == "
      1. One
      2. Two
      " + assert 'Pricing experiment' in out["source_cite"] + assert 'tidepool' in out["source_cite"] + assert out["number_cite"] == '

      Fact 2.

      ' + # Escaped before formatting: the only tags are ours, the name stays text. + assert re.findall(r"<(\w+)", out["escaped"]) == ["p", "span"] + assert out["escaped"] == ('

      ' + "<img src=x onerror=alert(1)>

      ")