dd-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dd_chat/__init__.py ADDED
File without changes
dd_chat/app.py ADDED
@@ -0,0 +1,15 @@
1
+ """Multi-Chat: ask one question, let several LLMs answer side by side. A showcase of GraphLang.
2
+
3
+ streamlit run src/app.py (from the repo root, so .streamlit/config.toml applies)
4
+ """
5
+ import streamlit as st
6
+
7
+ st.set_page_config(page_title="Multi-Chat ยท GraphLang", page_icon="๐Ÿ’ฌ", layout="wide")
8
+
9
+ pages = [
10
+ st.Page("views/multi_chat.py", title="Chats", icon="๐Ÿ’ฌ", default=True),
11
+ st.Page("views/review.py", title="Review", icon="๐Ÿ”"),
12
+ st.Page("views/config_llm.py", title="Settings", icon="โš™๏ธ"),
13
+ st.Page("views/about.py", title="About", icon="โ„น๏ธ"),
14
+ ]
15
+ st.navigation(pages).run()
dd_chat/chat_db.py ADDED
@@ -0,0 +1,250 @@
1
+ """SQLite persistence for the app: chat sessions, and settings (Chats-page widgets, Settings-page model choices, presets).
2
+
3
+ The file is `multi_chat.db` under the GraphLang home (`~/.graflang`, or $GRAFLANG_HOME). Tables:
4
+ settings(key, value) one JSON value per key
5
+ sessions(id, title, created, updated, disabled, rev) disabled = 1: soft-deleted, hidden from the lists but kept;
6
+ rev: bumped by every save, so two browser tabs cannot overwrite each other's chat (see save_session)
7
+ messages(session_id, bot, pos, role, content, ...) one row per message; MSG_FIELDS lists the columns
8
+ judgements(session_id, q, judge, content, seconds) a model's comparison of the answers to question q
9
+ `bot` is the chatbot number minus one; `pos` the message's place in that chatbot's history.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sqlite3
16
+ from contextlib import contextmanager
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any, Iterator
20
+
21
+ DB = Path(os.environ.get("GRAFLANG_HOME") or Path.home() / ".graflang") / "multi_chat.db"
22
+ COLUMNS = { # message field -> column type; every field but role and content may be missing from a message
23
+ "role": "TEXT NOT NULL", "content": "TEXT NOT NULL", "shown": "TEXT", "q": "INTEGER", "model": "TEXT", "seconds": "REAL",
24
+ "feedback": "INTEGER DEFAULT 0", "est_tokens": "INTEGER", "ctx_tokens": "INTEGER", "language": "TEXT", "links": "TEXT",
25
+ "input_tokens": "INTEGER", "output_tokens": "INTEGER", "cost_usd": "REAL", "best": "INTEGER DEFAULT 0", "shared_from": "INTEGER",
26
+ "temperature": "REAL", "instructions": "TEXT",
27
+ }
28
+ MSG_FIELDS = tuple(COLUMNS)
29
+ JSON_FIELDS = ("links",)
30
+
31
+ SCHEMA = f"""
32
+ CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
33
+ CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, created TEXT NOT NULL, updated TEXT NOT NULL,
34
+ disabled INTEGER NOT NULL DEFAULT 0, rev INTEGER NOT NULL DEFAULT 0);
35
+ CREATE TABLE IF NOT EXISTS messages (
36
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, bot INTEGER NOT NULL, pos INTEGER NOT NULL,
37
+ {", ".join(f"{name} {kind}" for name, kind in COLUMNS.items())}, PRIMARY KEY (session_id, bot, pos));
38
+ CREATE TABLE IF NOT EXISTS judgements (
39
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, q INTEGER NOT NULL, judge TEXT, content TEXT NOT NULL, seconds REAL,
40
+ PRIMARY KEY (session_id, q));
41
+ """
42
+ _ready: set[str] = set() # databases whose schema this process has already checked
43
+
44
+
45
+ def _migrate(con: sqlite3.Connection) -> None:
46
+ con.executescript(SCHEMA)
47
+ have = {r["name"] for r in con.execute("PRAGMA table_info(sessions)")}
48
+ for column, kind in (("disabled", "INTEGER NOT NULL DEFAULT 0"), ("rev", "INTEGER NOT NULL DEFAULT 0")): # a database from before these
49
+ if column not in have:
50
+ con.execute(f"ALTER TABLE sessions ADD COLUMN {column} {kind}")
51
+ have = {r["name"] for r in con.execute("PRAGMA table_info(messages)")}
52
+ for column, kind in COLUMNS.items():
53
+ if column not in have:
54
+ con.execute(f"ALTER TABLE messages ADD COLUMN {column} {kind.replace(' NOT NULL', '')}")
55
+
56
+
57
+ @contextmanager
58
+ def connect() -> Iterator[sqlite3.Connection]:
59
+ """A connection in one transaction (committed on success, rolled back on an error), closed afterwards."""
60
+ DB.parent.mkdir(parents=True, exist_ok=True)
61
+ con = sqlite3.connect(DB, timeout=10)
62
+ try:
63
+ con.row_factory = sqlite3.Row
64
+ con.execute("PRAGMA foreign_keys = ON")
65
+ if str(DB) not in _ready:
66
+ with con:
67
+ _migrate(con)
68
+ _ready.add(str(DB))
69
+ with con:
70
+ yield con
71
+ finally:
72
+ con.close()
73
+
74
+
75
+ def _now() -> str:
76
+ return datetime.now().isoformat(timespec="seconds")
77
+
78
+
79
+ # ---- settings
80
+
81
+ def get_setting(key: str, default: Any = None) -> Any:
82
+ with connect() as con:
83
+ row = con.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
84
+ try:
85
+ return json.loads(row["value"]) if row else default
86
+ except ValueError:
87
+ return default
88
+
89
+
90
+ def set_setting(key: str, value) -> None:
91
+ with connect() as con:
92
+ con.execute("INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, json.dumps(value)))
93
+
94
+
95
+ def delete_setting(key: str) -> None:
96
+ with connect() as con:
97
+ con.execute("DELETE FROM settings WHERE key = ?", (key,))
98
+
99
+
100
+ # ---- chat sessions
101
+
102
+ def _title(histories: dict[int, list[dict]]) -> str:
103
+ first = next((m.get("shown") or m["content"] for _, h in sorted(histories.items()) for m in h if m["role"] == "user"), "chat")
104
+ return " ".join(first.split())[:80]
105
+
106
+
107
+ def _row(m: dict) -> tuple:
108
+ return tuple(json.dumps(m[f]) if f in JSON_FIELDS and m.get(f) else m.get(f) for f in MSG_FIELDS)
109
+
110
+
111
+ def _write(con: sqlite3.Connection, session_id: int, histories: dict[int, list[dict]], judgements: dict[int, dict] | None) -> None:
112
+ con.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
113
+ con.executemany(
114
+ f"INSERT INTO messages(session_id, bot, pos, {', '.join(MSG_FIELDS)}) VALUES(?, ?, ?, {', '.join('?' * len(MSG_FIELDS))})",
115
+ [(session_id, bot, pos, *_row(m)) for bot, history in histories.items() for pos, m in enumerate(history)])
116
+ if judgements is not None:
117
+ con.execute("DELETE FROM judgements WHERE session_id = ?", (session_id,))
118
+ con.executemany("INSERT INTO judgements(session_id, q, judge, content, seconds) VALUES(?, ?, ?, ?, ?)",
119
+ [(session_id, int(q), j.get("judge"), j["content"], j.get("seconds")) for q, j in judgements.items()])
120
+
121
+
122
+ def save_session(session_id: int | None, histories: dict[int, list[dict]], rev: int = 0,
123
+ judgements: dict[int, dict] | None = None) -> tuple[int | None, int, bool]:
124
+ """Write every chatbot's history (and the judgements) as one session; returns (session id, its new rev, forked).
125
+
126
+ `rev` is the revision this browser session last loaded or wrote. The write goes into `session_id` only when nobody else has written
127
+ to it since and it is not disabled; otherwise the chat is saved as a new session (forked = True), so no tab overwrites another's
128
+ messages and a chat disabled on the Review page does not keep growing out of sight. Nothing to save: (session_id, rev, False)."""
129
+ if not any(histories.values()):
130
+ return session_id, rev, False
131
+ title = _title(histories)
132
+ with connect() as con:
133
+ if session_id is not None:
134
+ moved = con.execute("UPDATE sessions SET title = ?, updated = ?, rev = rev + 1 WHERE id = ? AND rev = ? AND disabled = 0",
135
+ (title, _now(), session_id, rev)).rowcount
136
+ if moved:
137
+ _write(con, session_id, histories, judgements)
138
+ return session_id, rev + 1, False
139
+ forked = session_id is not None
140
+ new_id = int(con.execute("INSERT INTO sessions(title, created, updated, rev) VALUES(?, ?, ?, 1)", (title, _now(), _now())).lastrowid or 0)
141
+ _write(con, new_id, histories, judgements or {})
142
+ return new_id, 1, forked
143
+
144
+
145
+ def _message(r: sqlite3.Row) -> dict:
146
+ m = {f: r[f] for f in MSG_FIELDS if r[f] is not None}
147
+ for f in JSON_FIELDS:
148
+ if f in m:
149
+ try:
150
+ m[f] = json.loads(m[f])
151
+ except ValueError:
152
+ del m[f]
153
+ m["feedback"] = r["feedback"] or 0
154
+ if not m.get("best"):
155
+ m.pop("best", None)
156
+ return m
157
+
158
+
159
+ def load_session(session_id: int) -> dict[int, list[dict]]:
160
+ """{bot index: history} for a session (messages as the Chats page keeps them)."""
161
+ out: dict[int, list[dict]] = {}
162
+ with connect() as con:
163
+ for r in con.execute("SELECT * FROM messages WHERE session_id = ? ORDER BY bot, pos", (session_id,)):
164
+ out.setdefault(r["bot"], []).append(_message(r))
165
+ return out
166
+
167
+
168
+ def load_judgements(session_id: int) -> dict[int, dict]:
169
+ with connect() as con:
170
+ return {r["q"]: {"judge": r["judge"], "content": r["content"], "seconds": r["seconds"]}
171
+ for r in con.execute("SELECT * FROM judgements WHERE session_id = ? ORDER BY q", (session_id,))}
172
+
173
+
174
+ def session_rev(session_id: int) -> int:
175
+ with connect() as con:
176
+ row = con.execute("SELECT rev FROM sessions WHERE id = ?", (session_id,)).fetchone()
177
+ return row["rev"] if row else 0
178
+
179
+
180
+ def latest_session_id() -> int | None:
181
+ with connect() as con:
182
+ row = con.execute("SELECT id FROM sessions WHERE disabled = 0 ORDER BY updated DESC, id DESC LIMIT 1").fetchone()
183
+ return row["id"] if row else None
184
+
185
+
186
+ def _like(text: str) -> str:
187
+ return "%" + text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
188
+
189
+
190
+ def list_sessions(include_disabled: bool = False, search: str = "") -> list[dict]:
191
+ """Newest first: id, title, created, updated, disabled, questions, chatbots, models. Disabled sessions only when asked for;
192
+ with `search`, only the sessions whose title or any message contains it (any case)."""
193
+ where, args = "(? OR s.disabled = 0)", [int(include_disabled)]
194
+ if search.strip():
195
+ where += (" AND (s.title LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id"
196
+ " AND (m.content LIKE ? ESCAPE '\\' OR m.shown LIKE ? ESCAPE '\\')))")
197
+ args += [_like(search.strip())] * 3
198
+ with connect() as con:
199
+ rows = con.execute(f"""
200
+ SELECT s.id, s.title, s.created, s.updated, s.disabled,
201
+ (SELECT COUNT(DISTINCT q) FROM messages m WHERE m.session_id = s.id AND m.role = 'user') AS questions,
202
+ (SELECT COUNT(DISTINCT bot) FROM messages m WHERE m.session_id = s.id) AS chatbots,
203
+ (SELECT GROUP_CONCAT(model, ', ') FROM (SELECT DISTINCT model FROM messages m WHERE m.session_id = s.id AND model IS NOT NULL)) AS models,
204
+ (SELECT ROUND(SUM(cost_usd), 4) FROM messages m WHERE m.session_id = s.id) AS cost_usd
205
+ FROM sessions s WHERE {where} ORDER BY s.updated DESC, s.id DESC""", args).fetchall()
206
+ return [dict(r) for r in rows]
207
+
208
+
209
+ def set_session_disabled(session_id: int, disabled: bool) -> None:
210
+ """Soft delete: a disabled session is hidden from the Review table (unless asked for) and is not restored by the Chats page; its data stays."""
211
+ with connect() as con:
212
+ con.execute("UPDATE sessions SET disabled = ? WHERE id = ?", (int(disabled), session_id))
213
+
214
+
215
+ def delete_session(session_id: int) -> None:
216
+ """Hard delete: the session, its messages and judgements are gone."""
217
+ with connect() as con:
218
+ con.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
219
+
220
+
221
+ def delete_disabled() -> int:
222
+ """Hard-delete every disabled session; returns how many."""
223
+ with connect() as con:
224
+ return con.execute("DELETE FROM sessions WHERE disabled = 1").rowcount
225
+
226
+
227
+ def leaderboard() -> list[dict]:
228
+ """Per model, over the enabled sessions: answers, ๐Ÿ‘, ๐Ÿ‘Ž, wins (๐Ÿ† picks), contests (questions it answered that got a ๐Ÿ† pick),
229
+ average seconds, output tokens and cost per answer, and the total cost. An answer copied from a twin chatbot is not counted."""
230
+ with connect() as con:
231
+ rows = con.execute("""
232
+ WITH answers AS (
233
+ SELECT m.session_id, u.q, m.model, COALESCE(m.feedback, 0) AS feedback, COALESCE(m.best, 0) AS best, m.seconds, m.output_tokens, m.cost_usd
234
+ FROM messages m JOIN sessions s ON s.id = m.session_id
235
+ JOIN messages u ON u.session_id = m.session_id AND u.bot = m.bot AND u.pos = m.pos - 1 -- the question it answers
236
+ WHERE s.disabled = 0 AND m.role = 'assistant' AND m.model IS NOT NULL AND m.shared_from IS NULL),
237
+ picked AS (SELECT DISTINCT session_id, q FROM answers WHERE best = 1)
238
+ SELECT a.model AS model, COUNT(*) AS answers,
239
+ SUM(a.feedback = 1) AS up, SUM(a.feedback = -1) AS down, SUM(a.best = 1) AS wins,
240
+ SUM(p.q IS NOT NULL) AS contests,
241
+ ROUND(AVG(a.seconds), 1) AS avg_seconds, ROUND(AVG(a.output_tokens)) AS avg_output_tokens,
242
+ ROUND(AVG(a.cost_usd), 5) AS avg_cost_usd, ROUND(SUM(a.cost_usd), 4) AS total_cost_usd
243
+ FROM answers a LEFT JOIN picked p ON p.session_id = a.session_id AND p.q = a.q
244
+ GROUP BY a.model ORDER BY wins DESC, up - down DESC, answers DESC""").fetchall()
245
+ out = []
246
+ for r in rows:
247
+ d = dict(r)
248
+ d["win_rate"] = round(d["wins"] / d["contests"], 2) if d["contests"] else None
249
+ out.append(d)
250
+ return out
dd_chat/chat_engine.py ADDED
@@ -0,0 +1,229 @@
1
+ """Asking the models: one chatbot's turn, a turn with pictures, and a comparison of answers. No Streamlit here, so it runs in the
2
+ chatbots' threads and in tests (with the `mock` adapter).
3
+
4
+ A text turn is one run of `chat_turn.graph.yaml`; a comparison is one run of `compare_answers.graph.yaml`. Every run is metered
5
+ (`MeteredLLM`, capped at one model call), and its tokens, cost and model time come back with the reply.
6
+
7
+ Kernels: the graph's SOLVE nodes run in a Python (Jupyter) kernel. GraphLang starts a kernel for each run and stops it at the end, which
8
+ costs most of a second per chatbot per question. Here a few kernel pools are kept and lent to runs one at a time (`_kernels`); the
9
+ runtime's own pool is swapped for a lent one before the run. This leans on `GraphLangRuntime.kernels`, an attribute GraphLang does not
10
+ document: when it is missing the run simply starts its own kernel as before. The kernels' sockets go in the repo's `tmp/`
11
+ (`GRAFLANG_KERNEL_DIR`; set it yourself to choose another folder).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import atexit
16
+ import base64
17
+ import json
18
+ import os
19
+ import queue
20
+ import time
21
+ from contextlib import contextmanager
22
+ from dataclasses import asdict, dataclass
23
+ from functools import lru_cache
24
+ from pathlib import Path
25
+ from typing import Any, Iterator
26
+
27
+ import yaml
28
+ from graphlang.grafr.adapters import make_llm
29
+ from graphlang.grafr.kernel import KernelPool
30
+ from graphlang.grafr.metering import MeteredLLM
31
+ from graphlang.grafr.runtime import GraphLangRuntime, load_graph
32
+
33
+ ROOT = Path(__file__).resolve().parent
34
+ # The kernels' Unix sockets go in the repo's tmp/ (git-ignored) when run from a checkout, else in ~/.graflang/tmp (an installed package must not write into site-packages); never the folder Streamlit started in.
35
+ TMP = ROOT.parent / "tmp" if (ROOT.parent / "tests").is_dir() else Path(os.environ.get("GRAFLANG_HOME", Path.home() / ".graflang")) / "tmp"
36
+ os.environ.setdefault("GRAFLANG_KERNEL_DIR", str(TMP)) # read by GraphLang when a kernel starts; an older GraphLang ignores it
37
+ TMP.mkdir(parents=True, exist_ok=True)
38
+ TMP.chmod(0o700) # the sockets accept code: only this user may reach them
39
+ GRAPH = ROOT / "chat_turn.graph.yaml"
40
+ COMPARE_GRAPH = ROOT / "compare_answers.graph.yaml"
41
+ SAME_LANGUAGE = "the same language as the most recent user message"
42
+ IMAGE_FORMATS = {"ollama": "openai", "openrouter": "openai", "openai": "openai", "qwen": "openai", "anthropic": "anthropic"} # adapter -> image format
43
+ CHARS_PER_TOKEN = 4
44
+
45
+
46
+ @dataclass
47
+ class Turn:
48
+ """What one chatbot is asked with: `adapter:model`, recent turns it sees, reply language, its own instructions and temperature."""
49
+ llm: str
50
+ last_n: int = 5
51
+ language: str = "" # "" = the language of the question
52
+ instructions: str = ""
53
+ temperature: float | None = None
54
+ prices: tuple[float, float] | None = None # $ per million prompt / completion tokens, to estimate a cost the adapter does not report
55
+
56
+
57
+ @dataclass
58
+ class Answer:
59
+ reply: str
60
+ seconds: float # wall time of the turn, kernel and all
61
+ model_ms: int = 0 # time inside the model call
62
+ input_tokens: int = 0
63
+ output_tokens: int = 0
64
+ cost_usd: float | None = None # None: not known (the adapter reports none and the price is not known)
65
+
66
+ def fields(self) -> dict:
67
+ return asdict(self)
68
+
69
+
70
+ @lru_cache(maxsize=None)
71
+ def graph_input_default(name: str, graph: Path = GRAPH) -> str:
72
+ """The default of a graph input, so the image turns use the same text as the graph (e.g. `rules`)."""
73
+ doc = yaml.safe_load(graph.read_text(encoding="utf-8"))
74
+ return next(str(i.get("default", "")) for i in doc["workflow"]["inputs"] if i["name"] == name)
75
+
76
+
77
+ def reply_language(language: str) -> str:
78
+ """The phrase that completes "Write the reply in ...": the chatbot's language, or the language of the question when it has none."""
79
+ return language or SAME_LANGUAGE
80
+
81
+
82
+ # ---- kernels lent to runs
83
+
84
+ class _KeptPool(KernelPool):
85
+ """A kernel pool that outlives the run: the runtime's `close()` at the end of a run does nothing, `shutdown()` really stops it."""
86
+
87
+ def close(self) -> None:
88
+ pass
89
+
90
+ def shutdown(self) -> None:
91
+ KernelPool.close(self)
92
+
93
+
94
+ _idle: "queue.SimpleQueue[_KeptPool]" = queue.SimpleQueue()
95
+
96
+
97
+ @contextmanager
98
+ def _kernels() -> Iterator[_KeptPool]:
99
+ """An idle kept pool, or a new one; given back afterwards. A pool whose run failed is stopped, not reused (its kernel may be broken)."""
100
+ try:
101
+ pool = _idle.get_nowait()
102
+ except queue.Empty:
103
+ pool = _KeptPool("python3", str(ROOT))
104
+ atexit.register(pool.shutdown)
105
+ try:
106
+ yield pool
107
+ except BaseException:
108
+ pool.shutdown()
109
+ raise
110
+ _idle.put(pool)
111
+
112
+
113
+ def _run(graph: Path, llm: Any, inputs: dict) -> dict:
114
+ runtime = GraphLangRuntime(load_graph(graph), llm=llm, verbose=False)
115
+ if not isinstance(getattr(runtime, "kernels", None), KernelPool): # a GraphLang without the attribute: let it start its own kernel
116
+ return runtime.execute(inputs)
117
+ with _kernels() as pool:
118
+ runtime.kernels = pool
119
+ return runtime.execute(inputs)
120
+
121
+
122
+ # ---- the model, with a chatbot's temperature
123
+
124
+ class _Tuned:
125
+ """The run's LLM with this chatbot's temperature (GraphLang takes a temperature only from the node's config)."""
126
+
127
+ def __init__(self, inner: Any, temperature: float | None):
128
+ self.inner, self.temperature = inner, temperature
129
+
130
+ def __getattr__(self, attr):
131
+ if attr == "inner":
132
+ raise AttributeError(attr)
133
+ return getattr(self.inner, attr)
134
+
135
+ def generate(self, prompt: str, model: str | None = None, **opts: Any) -> str:
136
+ if self.temperature is not None:
137
+ opts["temperature"] = self.temperature
138
+ return self.inner.generate(prompt, model=model, **opts)
139
+
140
+
141
+ def _metered(turn: Turn) -> MeteredLLM:
142
+ adapter, _, model = turn.llm.partition(":")
143
+ return MeteredLLM(_Tuned(make_llm(adapter, model or None), turn.temperature), max_calls=1)
144
+
145
+
146
+ def _cost(reported: float | None, input_tokens: int, output_tokens: int, prices: tuple[float, float] | None) -> float | None:
147
+ if reported:
148
+ return reported
149
+ if prices:
150
+ return round((input_tokens * prices[0] + output_tokens * prices[1]) / 1_000_000, 6)
151
+ return None
152
+
153
+
154
+ def _answer(reply: str, started: float, meter: MeteredLLM, prices: tuple[float, float] | None) -> Answer:
155
+ snap = meter.snapshot()
156
+ reported = None if meter.cost_unreported else snap["cost_usd"]
157
+ if meter.name == "claude_cli": # the CLI reports cost 0 (subscription) and estimated tokens: the cost is not known
158
+ reported = None
159
+ return Answer(reply, time.monotonic() - started, snap["ms"], snap["input_tokens"], snap["output_tokens"],
160
+ _cost(reported, snap["input_tokens"], snap["output_tokens"], prices))
161
+
162
+
163
+ # ---- a chatbot's turn
164
+
165
+ def ask(question: str, history: list[dict], turn: Turn) -> Answer:
166
+ """One run of the graph for one chatbot. The graph sees only role and content."""
167
+ started = time.monotonic()
168
+ meter = _metered(turn)
169
+ plain = [{"role": m["role"], "content": m["content"]} for m in history]
170
+ out = _run(GRAPH, meter, {"history": json.dumps(plain), "question": question, "last_n": turn.last_n,
171
+ "language": reply_language(turn.language), "instructions": turn.instructions,
172
+ "rules": graph_input_default("rules")})
173
+ return _answer(out["reply"], started, meter, turn.prices)
174
+
175
+
176
+ def system_prompt(turn: Turn) -> str:
177
+ """The system prompt of an image turn: the graph's prompt, said directly."""
178
+ return " ".join(filter(None, ["You are a helpful assistant engaged in a multi-turn conversation.", turn.instructions,
179
+ f"Reply helpfully, in {reply_language(turn.language)}.", graph_input_default("rules")]))
180
+
181
+
182
+ def image_messages(question: str, history: list[dict], last_n: int, images: list[tuple[str, bytes]], fmt: str) -> list[dict]:
183
+ """The recent history, then the question with its images, in the adapter's message format."""
184
+ recent = [{"role": m["role"], "content": m["content"]} for m in history[-2 * last_n:]]
185
+ encoded = [(mime, base64.b64encode(data).decode("ascii")) for mime, data in images]
186
+ if fmt == "anthropic":
187
+ parts = [{"type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}} for mime, b64 in encoded]
188
+ content = parts + [{"type": "text", "text": question}]
189
+ else:
190
+ content = [{"type": "text", "text": question}] + [{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}} for mime, b64 in encoded]
191
+ return recent + [{"role": "user", "content": content}]
192
+
193
+
194
+ def ask_with_images(question: str, history: list[dict], turn: Turn, images: list[tuple[str, bytes]]) -> Answer:
195
+ """A turn with pictures, straight to the adapter (the graph passes text only)."""
196
+ adapter, _, model = turn.llm.partition(":")
197
+ fmt = IMAGE_FORMATS.get(adapter)
198
+ if not fmt:
199
+ raise RuntimeError("this chatbot cannot read images; use an Ollama vision model, or an OpenRouter, OpenAI, Qwen or Anthropic API model")
200
+ import dd_llm
201
+ started = time.monotonic()
202
+ options = {"temperature": turn.temperature} if turn.temperature is not None else {}
203
+ result = dd_llm.get_adapter(adapter).call(model=model, messages=image_messages(question, history, turn.last_n, images, fmt),
204
+ system=system_prompt(turn), **options)
205
+ if not result.success:
206
+ errors = result.error_history or []
207
+ raise RuntimeError(errors[-1]["error"] if errors else f"{adapter} call failed")
208
+ tokens_in, tokens_out = int(result.input_tokens or 0), int(result.output_tokens or 0)
209
+ return Answer(result.content, time.monotonic() - started, int(result.latency_ms or 0), tokens_in, tokens_out,
210
+ _cost(result.cost_usd, tokens_in, tokens_out, turn.prices))
211
+
212
+
213
+ def estimate_tokens(question: str, history: list[dict], turn: Turn) -> int:
214
+ """A rough count of the prompt a turn sends (four characters a token): the recent history, the question, and the graph's own text."""
215
+ recent = history[-2 * turn.last_n:]
216
+ chars = sum(len(m["content"]) + 30 for m in recent) + len(question) + len(turn.instructions) + len(graph_input_default("rules")) + 300
217
+ return chars // CHARS_PER_TOKEN
218
+
219
+
220
+ # ---- comparing answers
221
+
222
+ def compare(question: str, answers: list[tuple[str, str]], turn: Turn) -> Answer:
223
+ """One run of the comparison graph: `answers` are (label, text)."""
224
+ started = time.monotonic()
225
+ meter = _metered(turn)
226
+ text = "\n\n".join(f"### {label}\n{reply}" for label, reply in answers)
227
+ out = _run(COMPARE_GRAPH, meter, {"question": question, "answers": text,
228
+ "language": turn.language or "the same language as the question"})
229
+ return _answer(out["comparison"], started, meter, turn.prices)
dd_chat/chat_export.py ADDED
@@ -0,0 +1,168 @@
1
+ """Export and import of a chat: YAML (complete, can be read back) and Markdown (to read or share).
2
+
3
+ The YAML is organised by question. Each question has its text (`question`, as the page shows it), the full prompt when attached files made
4
+ it longer (`prompt`), the answers (chatbot number, `<provider>:<model-id>`, language, seconds, tokens, cost, feedback 1 / -1 / 0, `best`,
5
+ temperature, instructions, links, `shared_from` when a twin chatbot's reply was copied, and the response) and the comparison, if one was
6
+ made. Older exports (just chatbot, llm, language, seconds, feedback, response) still import.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+ import yaml
13
+
14
+ from chat_models import ADAPTER_PROVIDER, PROVIDERS
15
+
16
+ ANSWER_FIELDS = ("seconds", "input_tokens", "output_tokens", "cost_usd", "temperature", "instructions", "links", "shared_from",
17
+ "est_tokens", "ctx_tokens")
18
+
19
+
20
+ class _Dumper(yaml.SafeDumper):
21
+ pass
22
+
23
+
24
+ def _text(dumper: yaml.SafeDumper, value: str):
25
+ """Multi-line text as a literal block (`|`), so a response reads as it was written; PyYAML falls back to quoting when it must."""
26
+ return dumper.represent_scalar("tag:yaml.org,2002:str", value, style="|" if "\n" in value else None)
27
+
28
+
29
+ _Dumper.add_representer(str, _text)
30
+
31
+
32
+ def _clean(text: str) -> str:
33
+ """Line ends unified and control characters (which YAML cannot hold) removed."""
34
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
35
+ return "".join(c for c in text if c in "\n\t" or (ord(c) >= 32 and ord(c) != 127))
36
+
37
+
38
+ def by_question(histories: dict[int, list[dict]]) -> dict[int, dict]:
39
+ """{question number: {question, prompt, answers: [(chatbot index, assistant message)]}} across the chatbots."""
40
+ out: dict[int, dict] = {}
41
+ for i, history in sorted(histories.items()):
42
+ asked, pending = 0, None
43
+ for m in history:
44
+ if m["role"] == "user":
45
+ asked += 1
46
+ pending = m
47
+ elif pending is not None:
48
+ q = pending.get("q", asked)
49
+ entry = out.setdefault(q, {"question": pending.get("shown") or pending["content"], "prompt": pending["content"], "answers": []})
50
+ entry["answers"].append((i, m))
51
+ pending = None
52
+ return dict(sorted(out.items()))
53
+
54
+
55
+ def provider_llm(model: str | None) -> str:
56
+ adapter, _, name = (model or ":").partition(":")
57
+ return f"{ADAPTER_PROVIDER.get(adapter, adapter)}:{name}"
58
+
59
+
60
+ def export_yaml(histories: dict[int, list[dict]], judgements: dict[int, dict] | None = None, session: dict | None = None) -> str:
61
+ questions = []
62
+ for q, entry in by_question(histories).items():
63
+ item: dict = {"number": q, "question": _clean(entry["question"])}
64
+ if entry["prompt"] != entry["question"]:
65
+ item["prompt"] = _clean(entry["prompt"])
66
+ answers = []
67
+ for i, m in entry["answers"]:
68
+ a: dict = {"chatbot": i + 1, "llm": provider_llm(m.get("model")), "language": m.get("language") or "",
69
+ "feedback": m.get("feedback", 0), "best": bool(m.get("best"))}
70
+ for f in ANSWER_FIELDS:
71
+ if m.get(f) not in (None, "", {}):
72
+ a[f] = round(m[f], 2) if f == "seconds" else m[f]
73
+ a["response"] = _clean(m["content"])
74
+ answers.append(a)
75
+ item["answers"] = answers
76
+ if judgements and q in judgements:
77
+ j = judgements[q]
78
+ item["comparison"] = {"judge": provider_llm(j.get("judge")), "seconds": j.get("seconds"), "content": _clean(j["content"])}
79
+ questions.append(item)
80
+ doc: dict = {"exported": datetime.now().isoformat(timespec="seconds"), "format": 2}
81
+ if session:
82
+ doc["session"] = session
83
+ doc["questions"] = questions
84
+ return yaml.dump(doc, Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=10_000)
85
+
86
+
87
+ def export_markdown(histories: dict[int, list[dict]], judgements: dict[int, dict] | None = None, title: str = "Multi-Chat") -> str:
88
+ lines = [f"# {title}", "", f"Exported {datetime.now():%Y-%m-%d %H:%M}", ""]
89
+ for q, entry in by_question(histories).items():
90
+ lines += [f"## [{q}] {' '.join(entry['question'].split())[:120]}", ""]
91
+ if len(entry["question"]) > 120 or "\n" in entry["question"]:
92
+ lines += ["> " + line for line in entry["question"].split("\n")] + [""]
93
+ for i, m in entry["answers"]:
94
+ facts = [provider_llm(m.get("model"))]
95
+ if m.get("seconds") is not None:
96
+ facts.append(f"{m['seconds']:.1f}s")
97
+ if m.get("cost_usd") is not None:
98
+ facts.append(f"${m['cost_usd']:.4f}")
99
+ facts += [s for s, on in (("๐Ÿ† best", m.get("best")), ("๐Ÿ‘", m.get("feedback") == 1), ("๐Ÿ‘Ž", m.get("feedback") == -1)) if on]
100
+ lines += [f"### Chatbot {i + 1} ยท " + " ยท ".join(facts), "", m["content"].strip(), ""]
101
+ if judgements and q in judgements:
102
+ lines += [f"### Comparison ยท {provider_llm(judgements[q].get('judge'))}", "", judgements[q]["content"].strip(), ""]
103
+ return "\n".join(lines)
104
+
105
+
106
+ def _int(value, default: int) -> int:
107
+ try:
108
+ return int(value)
109
+ except (TypeError, ValueError):
110
+ return default
111
+
112
+
113
+ def _float(value) -> float | None:
114
+ try:
115
+ return float(value) if value is not None else None
116
+ except (TypeError, ValueError):
117
+ return None
118
+
119
+
120
+ def _model(llm) -> str:
121
+ """`provider:model` in the file -> `adapter:model`, as the page keeps it."""
122
+ provider, _, name = str(llm or "").partition(":")
123
+ return f"{PROVIDERS.get(provider, provider)}:{name}" if name else str(llm or "")
124
+
125
+
126
+ def parse_export(data: bytes) -> tuple[dict[int, list[dict]], dict[int, dict]]:
127
+ """The chat in an export file: ({chatbot index: history} like the Chats page keeps it, {question number: judgement}).
128
+ Raises ValueError if it is not an export."""
129
+ try:
130
+ doc = yaml.safe_load(data)
131
+ except yaml.YAMLError as e:
132
+ raise ValueError(f"not valid YAML: {e}") from e
133
+ if not isinstance(doc, dict) or not isinstance(doc.get("questions"), list):
134
+ raise ValueError("this does not look like a Multi-Chat export (no `questions` list)")
135
+ histories: dict[int, list[dict]] = {}
136
+ judgements: dict[int, dict] = {}
137
+ for n, q in enumerate(doc["questions"], 1):
138
+ if not isinstance(q, dict) or "question" not in q:
139
+ continue
140
+ number = _int(q.get("number"), n)
141
+ question = str(q["question"])
142
+ prompt = str(q.get("prompt") or question)
143
+ for a in q.get("answers") or []:
144
+ if not isinstance(a, dict):
145
+ continue
146
+ bot = max(0, _int(a.get("chatbot"), 1) - 1)
147
+ user = {"role": "user", "content": prompt, "q": number}
148
+ if prompt != question:
149
+ user["shown"] = question
150
+ reply = {"role": "assistant", "content": str(a.get("response") or ""), "model": _model(a.get("llm")),
151
+ "language": str(a["language"]) if a.get("language") else None, "feedback": _int(a.get("feedback"), 0) if a.get("feedback") in (1, -1, 0, "1", "-1", "0") else 0}
152
+ if a.get("best"):
153
+ reply["best"] = 1
154
+ for f in ("seconds", "cost_usd", "temperature"):
155
+ if _float(a.get(f)) is not None:
156
+ reply[f] = _float(a.get(f))
157
+ for f in ("input_tokens", "output_tokens", "est_tokens", "ctx_tokens", "shared_from"):
158
+ if a.get(f) is not None and _int(a.get(f), -1) >= 0:
159
+ reply[f] = _int(a.get(f), 0)
160
+ if isinstance(a.get("instructions"), str) and a["instructions"]:
161
+ reply["instructions"] = a["instructions"]
162
+ if isinstance(a.get("links"), dict):
163
+ reply["links"] = {str(k): str(v) for k, v in a["links"].items()}
164
+ histories.setdefault(bot, []).extend([user, reply])
165
+ c = q.get("comparison")
166
+ if isinstance(c, dict) and c.get("content"):
167
+ judgements[number] = {"judge": _model(c.get("judge")), "content": str(c["content"]), "seconds": _float(c.get("seconds"))}
168
+ return histories, judgements