remote-coder 0.4.1__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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
from app.admin.advanced_settings import FileAdvancedSettingsStore
|
|
10
|
+
from app.models import UiLanguage
|
|
11
|
+
from app.telegram.i18n import instruction_frame_labels
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_AMBIGUOUS_FOLLOWUP = re.compile(
|
|
15
|
+
r"^\s*(작업\s*시작해줘|진행해줘|그거\s*해줘|시작해줘)\s*$",
|
|
16
|
+
re.UNICODE | re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_REPLY_SNIPPET_MAX = 800
|
|
20
|
+
# 순환 reply 체인 방지 상한.
|
|
21
|
+
_REPLY_CHAIN_MAX_DEPTH = 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_ambiguous_followup(text: str) -> bool:
|
|
25
|
+
return bool(_AMBIGUOUS_FOLLOWUP.match(text.strip()))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _truncate_snippet(text: str, limit: int = _REPLY_SNIPPET_MAX) -> str:
|
|
29
|
+
snippet = text.strip().replace("\r\n", "\n").replace("\r", "\n")
|
|
30
|
+
if len(snippet) > limit:
|
|
31
|
+
return snippet[:limit].rstrip() + "...(truncated)"
|
|
32
|
+
return snippet
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ConversationEntry:
|
|
37
|
+
id: int
|
|
38
|
+
project: str
|
|
39
|
+
chat_id: int
|
|
40
|
+
role: str
|
|
41
|
+
text: str
|
|
42
|
+
job_id: str | None
|
|
43
|
+
message_id: int | None = None
|
|
44
|
+
reply_to_message_id: int | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ConversationRoleCount:
|
|
49
|
+
role: str
|
|
50
|
+
count: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ConversationDbChatStats:
|
|
55
|
+
db_path: Path
|
|
56
|
+
db_exists: bool
|
|
57
|
+
db_size_bytes: int
|
|
58
|
+
total_rows: int
|
|
59
|
+
rows_by_role: dict[str, int]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ConversationReport:
|
|
64
|
+
project: str
|
|
65
|
+
chat_id: int
|
|
66
|
+
total_entries: int
|
|
67
|
+
role_counts: list[ConversationRoleCount]
|
|
68
|
+
latest_user_text: str | None
|
|
69
|
+
latest_job_id: str | None
|
|
70
|
+
latest_job_result: str | None
|
|
71
|
+
recent_entries: list[ConversationEntry]
|
|
72
|
+
|
|
73
|
+
def count_for(self, role: str) -> int:
|
|
74
|
+
for item in self.role_counts:
|
|
75
|
+
if item.role == role:
|
|
76
|
+
return item.count
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ensure_entry_columns(conn: sqlite3.Connection) -> None:
|
|
81
|
+
cur = conn.execute("PRAGMA table_info(conversation_entries)")
|
|
82
|
+
names = {str(row[1]) for row in cur.fetchall()}
|
|
83
|
+
if "message_id" not in names:
|
|
84
|
+
conn.execute("ALTER TABLE conversation_entries ADD COLUMN message_id INTEGER")
|
|
85
|
+
if "reply_to_message_id" not in names:
|
|
86
|
+
conn.execute("ALTER TABLE conversation_entries ADD COLUMN reply_to_message_id INTEGER")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SQLiteConversationStore:
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
db_path: Path,
|
|
93
|
+
advanced_settings_store: FileAdvancedSettingsStore | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._db_path = db_path.resolve()
|
|
96
|
+
self._lock = Lock()
|
|
97
|
+
self._advanced_settings_store = advanced_settings_store
|
|
98
|
+
self.ensure_schema()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def db_path(self) -> Path:
|
|
102
|
+
return self._db_path
|
|
103
|
+
|
|
104
|
+
def ensure_schema(self) -> None:
|
|
105
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with self._lock:
|
|
107
|
+
conn = sqlite3.connect(self._db_path)
|
|
108
|
+
try:
|
|
109
|
+
conn.execute(
|
|
110
|
+
"""
|
|
111
|
+
CREATE TABLE IF NOT EXISTS conversation_entries (
|
|
112
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
113
|
+
project TEXT NOT NULL,
|
|
114
|
+
chat_id INTEGER NOT NULL,
|
|
115
|
+
role TEXT NOT NULL,
|
|
116
|
+
text TEXT NOT NULL,
|
|
117
|
+
job_id TEXT,
|
|
118
|
+
message_id INTEGER,
|
|
119
|
+
reply_to_message_id INTEGER,
|
|
120
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
121
|
+
)
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
_ensure_entry_columns(conn)
|
|
125
|
+
conn.execute(
|
|
126
|
+
"""
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_project_chat_id
|
|
128
|
+
ON conversation_entries (project, chat_id, id)
|
|
129
|
+
"""
|
|
130
|
+
)
|
|
131
|
+
conn.execute(
|
|
132
|
+
"""
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_user_message_id
|
|
134
|
+
ON conversation_entries (project, chat_id, message_id)
|
|
135
|
+
WHERE role = 'user' AND message_id IS NOT NULL
|
|
136
|
+
"""
|
|
137
|
+
)
|
|
138
|
+
conn.execute(
|
|
139
|
+
"""
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_message_id
|
|
141
|
+
ON conversation_entries (project, chat_id, message_id)
|
|
142
|
+
WHERE message_id IS NOT NULL
|
|
143
|
+
"""
|
|
144
|
+
)
|
|
145
|
+
conn.execute(
|
|
146
|
+
"""
|
|
147
|
+
CREATE TABLE IF NOT EXISTS message_branch_links (
|
|
148
|
+
project TEXT NOT NULL,
|
|
149
|
+
chat_id INTEGER NOT NULL,
|
|
150
|
+
message_id INTEGER NOT NULL,
|
|
151
|
+
branch TEXT NOT NULL,
|
|
152
|
+
job_id TEXT,
|
|
153
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
154
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
155
|
+
PRIMARY KEY (project, chat_id, message_id)
|
|
156
|
+
)
|
|
157
|
+
"""
|
|
158
|
+
)
|
|
159
|
+
conn.commit()
|
|
160
|
+
finally:
|
|
161
|
+
conn.close()
|
|
162
|
+
|
|
163
|
+
def delete_chat_memory(self, *, project: str, chat_id: int) -> tuple[int, int]:
|
|
164
|
+
with self._lock:
|
|
165
|
+
conn = sqlite3.connect(self._db_path)
|
|
166
|
+
try:
|
|
167
|
+
links_cur = conn.execute(
|
|
168
|
+
"DELETE FROM message_branch_links WHERE project = ? AND chat_id = ?",
|
|
169
|
+
(project, chat_id),
|
|
170
|
+
)
|
|
171
|
+
links_removed = links_cur.rowcount
|
|
172
|
+
entries_cur = conn.execute(
|
|
173
|
+
"DELETE FROM conversation_entries WHERE project = ? AND chat_id = ?",
|
|
174
|
+
(project, chat_id),
|
|
175
|
+
)
|
|
176
|
+
entries_removed = entries_cur.rowcount
|
|
177
|
+
conn.commit()
|
|
178
|
+
finally:
|
|
179
|
+
conn.close()
|
|
180
|
+
return int(entries_removed), int(links_removed)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _delete_oldest_entries(conn: sqlite3.Connection, limit: int) -> None:
|
|
184
|
+
if limit <= 0:
|
|
185
|
+
return
|
|
186
|
+
conn.execute(
|
|
187
|
+
"""
|
|
188
|
+
DELETE FROM conversation_entries
|
|
189
|
+
WHERE id IN (
|
|
190
|
+
SELECT id FROM conversation_entries ORDER BY id ASC LIMIT ?
|
|
191
|
+
)
|
|
192
|
+
""",
|
|
193
|
+
(limit,),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _cleanup_orphan_branch_links(conn: sqlite3.Connection) -> None:
|
|
198
|
+
conn.execute(
|
|
199
|
+
"""
|
|
200
|
+
DELETE FROM message_branch_links
|
|
201
|
+
WHERE message_id IS NOT NULL
|
|
202
|
+
AND message_id NOT IN (
|
|
203
|
+
SELECT message_id FROM conversation_entries
|
|
204
|
+
WHERE message_id IS NOT NULL
|
|
205
|
+
)
|
|
206
|
+
"""
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _apply_memory_limits(self, conn: sqlite3.Connection) -> None:
|
|
210
|
+
if self._advanced_settings_store is None:
|
|
211
|
+
return
|
|
212
|
+
cfg = self._advanced_settings_store.get()
|
|
213
|
+
if not cfg.conversation_memory_limit_enabled:
|
|
214
|
+
return
|
|
215
|
+
max_rows = cfg.conversation_memory_max_rows
|
|
216
|
+
max_bytes = cfg.conversation_memory_max_bytes
|
|
217
|
+
|
|
218
|
+
for _ in range(500):
|
|
219
|
+
total = int(conn.execute("SELECT COUNT(*) FROM conversation_entries").fetchone()[0])
|
|
220
|
+
if max_rows is None or total <= max_rows:
|
|
221
|
+
break
|
|
222
|
+
to_delete = total - max_rows
|
|
223
|
+
self._delete_oldest_entries(conn, to_delete)
|
|
224
|
+
self._cleanup_orphan_branch_links(conn)
|
|
225
|
+
conn.commit()
|
|
226
|
+
|
|
227
|
+
for _ in range(500):
|
|
228
|
+
if max_bytes is None:
|
|
229
|
+
break
|
|
230
|
+
conn.commit()
|
|
231
|
+
size = self._db_path.stat().st_size if self._db_path.exists() else 0
|
|
232
|
+
if size <= max_bytes:
|
|
233
|
+
break
|
|
234
|
+
total = int(conn.execute("SELECT COUNT(*) FROM conversation_entries").fetchone()[0])
|
|
235
|
+
if total == 0:
|
|
236
|
+
break
|
|
237
|
+
batch = min(100, max(1, total // 5))
|
|
238
|
+
self._delete_oldest_entries(conn, batch)
|
|
239
|
+
self._cleanup_orphan_branch_links(conn)
|
|
240
|
+
conn.commit()
|
|
241
|
+
conn.execute("VACUUM")
|
|
242
|
+
|
|
243
|
+
def append(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
project: str,
|
|
247
|
+
chat_id: int,
|
|
248
|
+
role: str,
|
|
249
|
+
text: str,
|
|
250
|
+
job_id: str | None = None,
|
|
251
|
+
message_id: int | None = None,
|
|
252
|
+
reply_to_message_id: int | None = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
with self._lock:
|
|
255
|
+
conn = sqlite3.connect(self._db_path)
|
|
256
|
+
try:
|
|
257
|
+
conn.execute(
|
|
258
|
+
"""
|
|
259
|
+
INSERT INTO conversation_entries (
|
|
260
|
+
project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
261
|
+
)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
263
|
+
""",
|
|
264
|
+
(project, chat_id, role, text, job_id, message_id, reply_to_message_id),
|
|
265
|
+
)
|
|
266
|
+
conn.commit()
|
|
267
|
+
self._apply_memory_limits(conn)
|
|
268
|
+
finally:
|
|
269
|
+
conn.close()
|
|
270
|
+
|
|
271
|
+
def list_recent(self, project: str, chat_id: int, limit: int) -> list[ConversationEntry]:
|
|
272
|
+
if limit <= 0:
|
|
273
|
+
return []
|
|
274
|
+
with self._lock:
|
|
275
|
+
conn = sqlite3.connect(self._db_path)
|
|
276
|
+
try:
|
|
277
|
+
cur = conn.execute(
|
|
278
|
+
"""
|
|
279
|
+
SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
280
|
+
FROM conversation_entries
|
|
281
|
+
WHERE project = ? AND chat_id = ?
|
|
282
|
+
ORDER BY id DESC
|
|
283
|
+
LIMIT ?
|
|
284
|
+
""",
|
|
285
|
+
(project, chat_id, limit),
|
|
286
|
+
)
|
|
287
|
+
rows = cur.fetchall()
|
|
288
|
+
finally:
|
|
289
|
+
conn.close()
|
|
290
|
+
rows.reverse()
|
|
291
|
+
return [_row_to_entry(r) for r in rows]
|
|
292
|
+
|
|
293
|
+
def get_user_entry_by_message_id(
|
|
294
|
+
self, project: str, chat_id: int, message_id: int
|
|
295
|
+
) -> ConversationEntry | None:
|
|
296
|
+
entry = self.get_entry_by_message_id(project, chat_id, message_id)
|
|
297
|
+
return entry if entry is not None and entry.role == "user" else None
|
|
298
|
+
|
|
299
|
+
def get_entry_by_message_id(
|
|
300
|
+
self, project: str, chat_id: int, message_id: int
|
|
301
|
+
) -> ConversationEntry | None:
|
|
302
|
+
with self._lock:
|
|
303
|
+
conn = sqlite3.connect(self._db_path)
|
|
304
|
+
try:
|
|
305
|
+
row = conn.execute(
|
|
306
|
+
"""
|
|
307
|
+
SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
308
|
+
FROM conversation_entries
|
|
309
|
+
WHERE project = ? AND chat_id = ? AND message_id = ?
|
|
310
|
+
ORDER BY id DESC
|
|
311
|
+
LIMIT 1
|
|
312
|
+
""",
|
|
313
|
+
(project, chat_id, message_id),
|
|
314
|
+
).fetchone()
|
|
315
|
+
finally:
|
|
316
|
+
conn.close()
|
|
317
|
+
return _row_to_entry(row) if row is not None else None
|
|
318
|
+
|
|
319
|
+
def get_job_id_for_message_id(self, project: str, chat_id: int, message_id: int) -> str | None:
|
|
320
|
+
entry = self.get_entry_by_message_id(project, chat_id, message_id)
|
|
321
|
+
if entry is not None and entry.job_id:
|
|
322
|
+
return entry.job_id
|
|
323
|
+
with self._lock:
|
|
324
|
+
conn = sqlite3.connect(self._db_path)
|
|
325
|
+
try:
|
|
326
|
+
link = conn.execute(
|
|
327
|
+
"""
|
|
328
|
+
SELECT job_id
|
|
329
|
+
FROM message_branch_links
|
|
330
|
+
WHERE project = ? AND chat_id = ? AND message_id = ?
|
|
331
|
+
""",
|
|
332
|
+
(project, chat_id, message_id),
|
|
333
|
+
).fetchone()
|
|
334
|
+
finally:
|
|
335
|
+
conn.close()
|
|
336
|
+
return str(link[0]) if link is not None and link[0] is not None else None
|
|
337
|
+
|
|
338
|
+
def get_latest_job_result_text_for_user_message(
|
|
339
|
+
self, project: str, chat_id: int, message_id: int
|
|
340
|
+
) -> str | None:
|
|
341
|
+
with self._lock:
|
|
342
|
+
conn = sqlite3.connect(self._db_path)
|
|
343
|
+
try:
|
|
344
|
+
link = conn.execute(
|
|
345
|
+
"""
|
|
346
|
+
SELECT job_id
|
|
347
|
+
FROM message_branch_links
|
|
348
|
+
WHERE project = ? AND chat_id = ? AND message_id = ?
|
|
349
|
+
""",
|
|
350
|
+
(project, chat_id, message_id),
|
|
351
|
+
).fetchone()
|
|
352
|
+
job_id = str(link[0]) if link is not None and link[0] is not None else None
|
|
353
|
+
if job_id is None:
|
|
354
|
+
user_row = conn.execute(
|
|
355
|
+
"""
|
|
356
|
+
SELECT job_id
|
|
357
|
+
FROM conversation_entries
|
|
358
|
+
WHERE project = ? AND chat_id = ? AND role = 'user' AND message_id = ?
|
|
359
|
+
ORDER BY id DESC
|
|
360
|
+
LIMIT 1
|
|
361
|
+
""",
|
|
362
|
+
(project, chat_id, message_id),
|
|
363
|
+
).fetchone()
|
|
364
|
+
job_id = str(user_row[0]) if user_row is not None and user_row[0] is not None else None
|
|
365
|
+
if job_id is None:
|
|
366
|
+
return None
|
|
367
|
+
row = conn.execute(
|
|
368
|
+
"""
|
|
369
|
+
SELECT text
|
|
370
|
+
FROM conversation_entries
|
|
371
|
+
WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
|
|
372
|
+
ORDER BY id DESC
|
|
373
|
+
LIMIT 1
|
|
374
|
+
""",
|
|
375
|
+
(project, chat_id, job_id),
|
|
376
|
+
).fetchone()
|
|
377
|
+
finally:
|
|
378
|
+
conn.close()
|
|
379
|
+
return str(row[0]) if row is not None else None
|
|
380
|
+
|
|
381
|
+
def bind_user_message_job(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
project: str,
|
|
385
|
+
chat_id: int,
|
|
386
|
+
message_id: int,
|
|
387
|
+
job_id: str,
|
|
388
|
+
) -> None:
|
|
389
|
+
with self._lock:
|
|
390
|
+
conn = sqlite3.connect(self._db_path)
|
|
391
|
+
try:
|
|
392
|
+
conn.execute(
|
|
393
|
+
"""
|
|
394
|
+
UPDATE conversation_entries
|
|
395
|
+
SET job_id = ?
|
|
396
|
+
WHERE id = (
|
|
397
|
+
SELECT id
|
|
398
|
+
FROM conversation_entries
|
|
399
|
+
WHERE project = ? AND chat_id = ? AND role = 'user' AND message_id = ?
|
|
400
|
+
ORDER BY id DESC
|
|
401
|
+
LIMIT 1
|
|
402
|
+
)
|
|
403
|
+
""",
|
|
404
|
+
(job_id, project, chat_id, message_id),
|
|
405
|
+
)
|
|
406
|
+
conn.commit()
|
|
407
|
+
finally:
|
|
408
|
+
conn.close()
|
|
409
|
+
|
|
410
|
+
def format_job_context(
|
|
411
|
+
self, project: str, chat_id: int, job_id: str, language: UiLanguage = UiLanguage.ENGLISH
|
|
412
|
+
) -> str:
|
|
413
|
+
with self._lock:
|
|
414
|
+
conn = sqlite3.connect(self._db_path)
|
|
415
|
+
try:
|
|
416
|
+
user_row = conn.execute(
|
|
417
|
+
"""
|
|
418
|
+
SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
419
|
+
FROM conversation_entries
|
|
420
|
+
WHERE project = ? AND chat_id = ? AND role = 'user' AND job_id = ?
|
|
421
|
+
ORDER BY id ASC
|
|
422
|
+
LIMIT 1
|
|
423
|
+
""",
|
|
424
|
+
(project, chat_id, job_id),
|
|
425
|
+
).fetchone()
|
|
426
|
+
result_row = conn.execute(
|
|
427
|
+
"""
|
|
428
|
+
SELECT text
|
|
429
|
+
FROM conversation_entries
|
|
430
|
+
WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
|
|
431
|
+
ORDER BY id DESC
|
|
432
|
+
LIMIT 1
|
|
433
|
+
""",
|
|
434
|
+
(project, chat_id, job_id),
|
|
435
|
+
).fetchone()
|
|
436
|
+
history_rows = conn.execute(
|
|
437
|
+
"""
|
|
438
|
+
SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
439
|
+
FROM conversation_entries
|
|
440
|
+
WHERE project = ? AND chat_id = ? AND job_id = ?
|
|
441
|
+
ORDER BY id ASC
|
|
442
|
+
LIMIT 20
|
|
443
|
+
""",
|
|
444
|
+
(project, chat_id, job_id),
|
|
445
|
+
).fetchall()
|
|
446
|
+
finally:
|
|
447
|
+
conn.close()
|
|
448
|
+
|
|
449
|
+
if user_row is None and result_row is None and not history_rows:
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
labels = instruction_frame_labels(language)
|
|
453
|
+
lines = [labels.reply_job_open, f"job_id={job_id}:"]
|
|
454
|
+
if user_row is not None:
|
|
455
|
+
user_entry = _row_to_entry(user_row)
|
|
456
|
+
if user_entry.message_id is not None:
|
|
457
|
+
lines.append(f" original_message_id: {user_entry.message_id}")
|
|
458
|
+
lines.append(f" original_user: {_truncate_snippet(user_entry.text)}")
|
|
459
|
+
else:
|
|
460
|
+
lines.append(f" original_user: {labels.none_absent}")
|
|
461
|
+
if result_row is not None:
|
|
462
|
+
lines.append(f" job_result: {_truncate_snippet(str(result_row[0]))}")
|
|
463
|
+
else:
|
|
464
|
+
lines.append(f" job_result: {labels.none_absent}")
|
|
465
|
+
if history_rows:
|
|
466
|
+
lines.append(" job_history:")
|
|
467
|
+
for row in history_rows:
|
|
468
|
+
entry = _row_to_entry(row)
|
|
469
|
+
message_part = f" message_id={entry.message_id}" if entry.message_id is not None else ""
|
|
470
|
+
lines.append(f" - {entry.role}{message_part}: {_truncate_snippet(entry.text)}")
|
|
471
|
+
lines.append(labels.reply_job_close)
|
|
472
|
+
return "\n".join(lines)
|
|
473
|
+
|
|
474
|
+
def format_reply_context(
|
|
475
|
+
self, project: str, chat_id: int, reply_to_message_id: int, language: UiLanguage = UiLanguage.ENGLISH
|
|
476
|
+
) -> str:
|
|
477
|
+
reply_entry = self.get_entry_by_message_id(project, chat_id, reply_to_message_id)
|
|
478
|
+
if reply_entry is not None and reply_entry.role != "user" and reply_entry.job_id:
|
|
479
|
+
return self.format_job_context(project, chat_id, reply_entry.job_id, language)
|
|
480
|
+
return self.format_reply_chain_context(project, chat_id, reply_to_message_id, language)
|
|
481
|
+
|
|
482
|
+
def collect_reply_chain_message_ids(
|
|
483
|
+
self, project: str, chat_id: int, reply_to_message_id: int
|
|
484
|
+
) -> set[int]:
|
|
485
|
+
ids: set[int] = set()
|
|
486
|
+
cur: int | None = reply_to_message_id
|
|
487
|
+
depth = 0
|
|
488
|
+
seen: set[int] = set()
|
|
489
|
+
while cur is not None and depth < _REPLY_CHAIN_MAX_DEPTH:
|
|
490
|
+
if cur in seen:
|
|
491
|
+
break
|
|
492
|
+
seen.add(cur)
|
|
493
|
+
entry = self.get_user_entry_by_message_id(project, chat_id, cur)
|
|
494
|
+
if entry is None or entry.message_id is None:
|
|
495
|
+
break
|
|
496
|
+
ids.add(entry.message_id)
|
|
497
|
+
cur = entry.reply_to_message_id
|
|
498
|
+
depth += 1
|
|
499
|
+
return ids
|
|
500
|
+
|
|
501
|
+
def get_reply_chain_user_entries_newest_first(
|
|
502
|
+
self, project: str, chat_id: int, reply_to_message_id: int
|
|
503
|
+
) -> list[ConversationEntry]:
|
|
504
|
+
chain: list[ConversationEntry] = []
|
|
505
|
+
cur: int | None = reply_to_message_id
|
|
506
|
+
depth = 0
|
|
507
|
+
seen: set[int] = set()
|
|
508
|
+
while cur is not None and depth < _REPLY_CHAIN_MAX_DEPTH:
|
|
509
|
+
if cur in seen:
|
|
510
|
+
break
|
|
511
|
+
seen.add(cur)
|
|
512
|
+
entry = self.get_user_entry_by_message_id(project, chat_id, cur)
|
|
513
|
+
if entry is None:
|
|
514
|
+
break
|
|
515
|
+
chain.append(entry)
|
|
516
|
+
cur = entry.reply_to_message_id
|
|
517
|
+
depth += 1
|
|
518
|
+
return chain
|
|
519
|
+
|
|
520
|
+
def format_reply_chain_context(
|
|
521
|
+
self, project: str, chat_id: int, reply_to_message_id: int, language: UiLanguage = UiLanguage.ENGLISH
|
|
522
|
+
) -> str:
|
|
523
|
+
newest_first = self.get_reply_chain_user_entries_newest_first(project, chat_id, reply_to_message_id)
|
|
524
|
+
if not newest_first:
|
|
525
|
+
return ""
|
|
526
|
+
labels = instruction_frame_labels(language)
|
|
527
|
+
ordered = list(reversed(newest_first))
|
|
528
|
+
lines: list[str] = [labels.reply_chain_open]
|
|
529
|
+
for e in ordered:
|
|
530
|
+
mid = e.message_id
|
|
531
|
+
lines.append(f"message_id={mid}:")
|
|
532
|
+
lines.append(f" user: {_truncate_snippet(e.text)}")
|
|
533
|
+
job_text = self.get_latest_job_result_text_for_user_message(project, chat_id, mid) if mid else None
|
|
534
|
+
if job_text:
|
|
535
|
+
lines.append(f" job_result: {_truncate_snippet(job_text)}")
|
|
536
|
+
else:
|
|
537
|
+
lines.append(f" job_result: {labels.none_absent}")
|
|
538
|
+
lines.append(labels.reply_chain_close)
|
|
539
|
+
return "\n".join(lines)
|
|
540
|
+
|
|
541
|
+
def bind_message_branch(
|
|
542
|
+
self,
|
|
543
|
+
*,
|
|
544
|
+
project: str,
|
|
545
|
+
chat_id: int,
|
|
546
|
+
message_id: int,
|
|
547
|
+
branch: str,
|
|
548
|
+
job_id: str | None = None,
|
|
549
|
+
) -> None:
|
|
550
|
+
with self._lock:
|
|
551
|
+
conn = sqlite3.connect(self._db_path)
|
|
552
|
+
try:
|
|
553
|
+
conn.execute(
|
|
554
|
+
"""
|
|
555
|
+
INSERT INTO message_branch_links (project, chat_id, message_id, branch, job_id)
|
|
556
|
+
VALUES (?, ?, ?, ?, ?)
|
|
557
|
+
ON CONFLICT(project, chat_id, message_id)
|
|
558
|
+
DO UPDATE SET
|
|
559
|
+
branch = excluded.branch,
|
|
560
|
+
job_id = excluded.job_id,
|
|
561
|
+
updated_at = datetime('now')
|
|
562
|
+
""",
|
|
563
|
+
(project, chat_id, message_id, branch, job_id),
|
|
564
|
+
)
|
|
565
|
+
conn.commit()
|
|
566
|
+
finally:
|
|
567
|
+
conn.close()
|
|
568
|
+
|
|
569
|
+
def get_bound_branch(self, project: str, chat_id: int, message_id: int) -> str | None:
|
|
570
|
+
with self._lock:
|
|
571
|
+
conn = sqlite3.connect(self._db_path)
|
|
572
|
+
try:
|
|
573
|
+
row = conn.execute(
|
|
574
|
+
"""
|
|
575
|
+
SELECT branch
|
|
576
|
+
FROM message_branch_links
|
|
577
|
+
WHERE project = ? AND chat_id = ? AND message_id = ?
|
|
578
|
+
""",
|
|
579
|
+
(project, chat_id, message_id),
|
|
580
|
+
).fetchone()
|
|
581
|
+
finally:
|
|
582
|
+
conn.close()
|
|
583
|
+
return str(row[0]) if row is not None and row[0] is not None else None
|
|
584
|
+
|
|
585
|
+
def get_entries_for_branch(
|
|
586
|
+
self, project: str, chat_id: int, branch: str
|
|
587
|
+
) -> list[tuple[str, str | None]]:
|
|
588
|
+
# 반환: (user_text, job_result_text or None) 시간순 목록.
|
|
589
|
+
with self._lock:
|
|
590
|
+
conn = sqlite3.connect(self._db_path)
|
|
591
|
+
try:
|
|
592
|
+
links = conn.execute(
|
|
593
|
+
"""
|
|
594
|
+
SELECT message_id, job_id
|
|
595
|
+
FROM message_branch_links
|
|
596
|
+
WHERE project = ? AND chat_id = ? AND branch = ?
|
|
597
|
+
ORDER BY created_at ASC
|
|
598
|
+
""",
|
|
599
|
+
(project, chat_id, branch),
|
|
600
|
+
).fetchall()
|
|
601
|
+
finally:
|
|
602
|
+
conn.close()
|
|
603
|
+
|
|
604
|
+
result: list[tuple[str, str | None]] = []
|
|
605
|
+
for message_id, job_id in links:
|
|
606
|
+
user_entry = self.get_user_entry_by_message_id(project, chat_id, message_id)
|
|
607
|
+
if user_entry is None:
|
|
608
|
+
continue
|
|
609
|
+
job_result: str | None = None
|
|
610
|
+
if job_id:
|
|
611
|
+
with self._lock:
|
|
612
|
+
conn = sqlite3.connect(self._db_path)
|
|
613
|
+
try:
|
|
614
|
+
row = conn.execute(
|
|
615
|
+
"""
|
|
616
|
+
SELECT text FROM conversation_entries
|
|
617
|
+
WHERE project = ? AND chat_id = ? AND role = 'job_result' AND job_id = ?
|
|
618
|
+
ORDER BY id DESC LIMIT 1
|
|
619
|
+
""",
|
|
620
|
+
(project, chat_id, str(job_id)),
|
|
621
|
+
).fetchone()
|
|
622
|
+
finally:
|
|
623
|
+
conn.close()
|
|
624
|
+
job_result = str(row[0]) if row is not None else None
|
|
625
|
+
else:
|
|
626
|
+
job_result = self.get_latest_job_result_text_for_user_message(
|
|
627
|
+
project, chat_id, message_id
|
|
628
|
+
)
|
|
629
|
+
result.append((user_entry.text, job_result))
|
|
630
|
+
return result
|
|
631
|
+
|
|
632
|
+
def generate_report(
|
|
633
|
+
self,
|
|
634
|
+
project: str,
|
|
635
|
+
chat_id: int,
|
|
636
|
+
recent_limit: int = 5,
|
|
637
|
+
) -> ConversationReport | None:
|
|
638
|
+
safe_limit = max(0, recent_limit)
|
|
639
|
+
with self._lock:
|
|
640
|
+
conn = sqlite3.connect(self._db_path)
|
|
641
|
+
try:
|
|
642
|
+
total_entries = int(
|
|
643
|
+
conn.execute(
|
|
644
|
+
"""
|
|
645
|
+
SELECT COUNT(*)
|
|
646
|
+
FROM conversation_entries
|
|
647
|
+
WHERE project = ? AND chat_id = ?
|
|
648
|
+
""",
|
|
649
|
+
(project, chat_id),
|
|
650
|
+
).fetchone()[0]
|
|
651
|
+
)
|
|
652
|
+
if total_entries == 0:
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
role_rows = conn.execute(
|
|
656
|
+
"""
|
|
657
|
+
SELECT role, COUNT(*)
|
|
658
|
+
FROM conversation_entries
|
|
659
|
+
WHERE project = ? AND chat_id = ?
|
|
660
|
+
GROUP BY role
|
|
661
|
+
ORDER BY role
|
|
662
|
+
""",
|
|
663
|
+
(project, chat_id),
|
|
664
|
+
).fetchall()
|
|
665
|
+
latest_user_row = conn.execute(
|
|
666
|
+
"""
|
|
667
|
+
SELECT text
|
|
668
|
+
FROM conversation_entries
|
|
669
|
+
WHERE project = ? AND chat_id = ? AND role = 'user'
|
|
670
|
+
ORDER BY id DESC
|
|
671
|
+
LIMIT 1
|
|
672
|
+
""",
|
|
673
|
+
(project, chat_id),
|
|
674
|
+
).fetchone()
|
|
675
|
+
latest_job_row = conn.execute(
|
|
676
|
+
"""
|
|
677
|
+
SELECT job_id, text
|
|
678
|
+
FROM conversation_entries
|
|
679
|
+
WHERE project = ? AND chat_id = ? AND role = 'job_result'
|
|
680
|
+
ORDER BY id DESC
|
|
681
|
+
LIMIT 1
|
|
682
|
+
""",
|
|
683
|
+
(project, chat_id),
|
|
684
|
+
).fetchone()
|
|
685
|
+
recent_rows: list[tuple[object, ...]] = []
|
|
686
|
+
if safe_limit > 0:
|
|
687
|
+
recent_rows = conn.execute(
|
|
688
|
+
"""
|
|
689
|
+
SELECT id, project, chat_id, role, text, job_id, message_id, reply_to_message_id
|
|
690
|
+
FROM conversation_entries
|
|
691
|
+
WHERE project = ? AND chat_id = ?
|
|
692
|
+
ORDER BY id DESC
|
|
693
|
+
LIMIT ?
|
|
694
|
+
""",
|
|
695
|
+
(project, chat_id, safe_limit),
|
|
696
|
+
).fetchall()
|
|
697
|
+
finally:
|
|
698
|
+
conn.close()
|
|
699
|
+
|
|
700
|
+
recent_rows.reverse()
|
|
701
|
+
return ConversationReport(
|
|
702
|
+
project=project,
|
|
703
|
+
chat_id=chat_id,
|
|
704
|
+
total_entries=total_entries,
|
|
705
|
+
role_counts=[
|
|
706
|
+
ConversationRoleCount(role=str(role), count=int(count)) for role, count in role_rows
|
|
707
|
+
],
|
|
708
|
+
latest_user_text=str(latest_user_row[0]) if latest_user_row is not None else None,
|
|
709
|
+
latest_job_id=str(latest_job_row[0]) if latest_job_row and latest_job_row[0] else None,
|
|
710
|
+
latest_job_result=str(latest_job_row[1]) if latest_job_row is not None else None,
|
|
711
|
+
recent_entries=[_row_to_entry(r) for r in recent_rows],
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def get_chat_stats(self, project: str, chat_id: int) -> ConversationDbChatStats:
|
|
715
|
+
db_exists = self._db_path.exists()
|
|
716
|
+
size_bytes = self._db_path.stat().st_size if db_exists else 0
|
|
717
|
+
rows_by_role: dict[str, int] = {}
|
|
718
|
+
total_rows = 0
|
|
719
|
+
with self._lock:
|
|
720
|
+
conn = sqlite3.connect(self._db_path)
|
|
721
|
+
try:
|
|
722
|
+
total_rows = int(
|
|
723
|
+
conn.execute(
|
|
724
|
+
"""
|
|
725
|
+
SELECT COUNT(*)
|
|
726
|
+
FROM conversation_entries
|
|
727
|
+
WHERE project = ? AND chat_id = ?
|
|
728
|
+
""",
|
|
729
|
+
(project, chat_id),
|
|
730
|
+
).fetchone()[0]
|
|
731
|
+
)
|
|
732
|
+
for role, cnt in conn.execute(
|
|
733
|
+
"""
|
|
734
|
+
SELECT role, COUNT(*)
|
|
735
|
+
FROM conversation_entries
|
|
736
|
+
WHERE project = ? AND chat_id = ?
|
|
737
|
+
GROUP BY role
|
|
738
|
+
ORDER BY role
|
|
739
|
+
""",
|
|
740
|
+
(project, chat_id),
|
|
741
|
+
).fetchall():
|
|
742
|
+
rows_by_role[str(role)] = int(cnt)
|
|
743
|
+
finally:
|
|
744
|
+
conn.close()
|
|
745
|
+
return ConversationDbChatStats(
|
|
746
|
+
db_path=self._db_path,
|
|
747
|
+
db_exists=db_exists,
|
|
748
|
+
db_size_bytes=size_bytes,
|
|
749
|
+
total_rows=total_rows,
|
|
750
|
+
rows_by_role=rows_by_role,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _row_to_entry(r: tuple[object, ...]) -> ConversationEntry:
|
|
755
|
+
return ConversationEntry(
|
|
756
|
+
id=int(r[0]),
|
|
757
|
+
project=str(r[1]),
|
|
758
|
+
chat_id=int(r[2]),
|
|
759
|
+
role=str(r[3]),
|
|
760
|
+
text=str(r[4]),
|
|
761
|
+
job_id=str(r[5]) if r[5] is not None else None,
|
|
762
|
+
message_id=int(r[6]) if len(r) > 6 and r[6] is not None else None,
|
|
763
|
+
reply_to_message_id=int(r[7]) if len(r) > 7 and r[7] is not None else None,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
class ConversationContextBuilder:
|
|
768
|
+
@staticmethod
|
|
769
|
+
def build(entries: list[ConversationEntry], current_user_line: str, language: UiLanguage = UiLanguage.ENGLISH) -> str:
|
|
770
|
+
labels = instruction_frame_labels(language)
|
|
771
|
+
lines: list[str] = [
|
|
772
|
+
labels.prev_context_open,
|
|
773
|
+
]
|
|
774
|
+
for e in entries:
|
|
775
|
+
label = e.role
|
|
776
|
+
if e.job_id:
|
|
777
|
+
label = f"{e.role} (job_id={e.job_id})"
|
|
778
|
+
snippet = _truncate_snippet(e.text, 800)
|
|
779
|
+
lines.append(f"{label}: {snippet}")
|
|
780
|
+
lines.extend(
|
|
781
|
+
[
|
|
782
|
+
labels.prev_context_close,
|
|
783
|
+
"",
|
|
784
|
+
labels.current_request_open,
|
|
785
|
+
current_user_line.strip(),
|
|
786
|
+
labels.current_request_close,
|
|
787
|
+
]
|
|
788
|
+
)
|
|
789
|
+
return "\n".join(lines)
|