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 +0 -0
- dd_chat/app.py +15 -0
- dd_chat/chat_db.py +250 -0
- dd_chat/chat_engine.py +229 -0
- dd_chat/chat_export.py +168 -0
- dd_chat/chat_models.py +187 -0
- dd_chat/chat_render.py +73 -0
- dd_chat/chat_text.py +22 -0
- dd_chat/chat_turn.graph.yaml +154 -0
- dd_chat/cli.py +17 -0
- dd_chat/compare_answers.graph.yaml +66 -0
- dd_chat/link_check.py +170 -0
- dd_chat/views/about.py +123 -0
- dd_chat/views/config_llm.py +62 -0
- dd_chat/views/multi_chat.py +653 -0
- dd_chat/views/review.py +201 -0
- dd_chat-0.1.0.dist-info/METADATA +128 -0
- dd_chat-0.1.0.dist-info/RECORD +22 -0
- dd_chat-0.1.0.dist-info/WHEEL +5 -0
- dd_chat-0.1.0.dist-info/entry_points.txt +2 -0
- dd_chat-0.1.0.dist-info/licenses/LICENSE +201 -0
- dd_chat-0.1.0.dist-info/top_level.txt +1 -0
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
|