graph-knowledge-doc-parser 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.
- graph_knowledge_doc_parser-0.1.0.dist-info/METADATA +326 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/RECORD +38 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/WHEEL +4 -0
- graph_knowledge_doc_parser-0.1.0.dist-info/entry_points.txt +3 -0
- kg_doc_parser/__init__.py +9 -0
- kg_doc_parser/cast_hinting.py +19 -0
- kg_doc_parser/document_ingester_logger.py +766 -0
- kg_doc_parser/models.py +277 -0
- kg_doc_parser/ocr.py +752 -0
- kg_doc_parser/pdf2png.py +286 -0
- kg_doc_parser/semantic_document_splitting_layerwise_edits.py +3302 -0
- kg_doc_parser/text_processing_utils.py +30 -0
- kg_doc_parser/utils/__init__.py +0 -0
- kg_doc_parser/utils/bounded_threadpool_executor.py +37 -0
- kg_doc_parser/utils/file_loaders.py +405 -0
- kg_doc_parser/utils/langchain.py +220 -0
- kg_doc_parser/utils/log.py +135 -0
- kg_doc_parser/utils/version_chaining.py +1278 -0
- kg_doc_parser/workflow_ingest/__init__.py +187 -0
- kg_doc_parser/workflow_ingest/_kogwistar.py +13 -0
- kg_doc_parser/workflow_ingest/adapters.py +212 -0
- kg_doc_parser/workflow_ingest/cache.py +63 -0
- kg_doc_parser/workflow_ingest/cli.py +324 -0
- kg_doc_parser/workflow_ingest/clients.py +444 -0
- kg_doc_parser/workflow_ingest/demo_harness.py +427 -0
- kg_doc_parser/workflow_ingest/design.py +208 -0
- kg_doc_parser/workflow_ingest/handlers.py +617 -0
- kg_doc_parser/workflow_ingest/models.py +575 -0
- kg_doc_parser/workflow_ingest/ocr_pipeline.py +1581 -0
- kg_doc_parser/workflow_ingest/page_index.py +473 -0
- kg_doc_parser/workflow_ingest/parser_core.py +862 -0
- kg_doc_parser/workflow_ingest/parsing.py +249 -0
- kg_doc_parser/workflow_ingest/probe.py +164 -0
- kg_doc_parser/workflow_ingest/providers.py +412 -0
- kg_doc_parser/workflow_ingest/runners.py +546 -0
- kg_doc_parser/workflow_ingest/semantics.py +231 -0
- kg_doc_parser/workflow_ingest/service.py +112 -0
- kg_doc_parser/workflow_ingest/smoke_assets.py +62 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""
|
|
2
|
+
document_ingest_logger.py
|
|
3
|
+
|
|
4
|
+
SQLite telemetry for LangChain ingestion runs:
|
|
5
|
+
- Before LLM call (on_llm_start)
|
|
6
|
+
- After LLM call with token usage + cost (on_llm_end)
|
|
7
|
+
- Errors (on_llm_error)
|
|
8
|
+
|
|
9
|
+
Why this design:
|
|
10
|
+
- No global/mutable callback state (no cb.set_document_id()).
|
|
11
|
+
Document identity is passed per-invoke via `RunnableConfig.metadata`.
|
|
12
|
+
- Concurrency-safe + fast: callback methods enqueue events; a single writer thread
|
|
13
|
+
batches inserts into SQLite (avoids "database is locked" under parallel ingestion).
|
|
14
|
+
- Queryable: events include run_id/parent_run_id so you can reconstruct chains.
|
|
15
|
+
|
|
16
|
+
--------------------------------------------------------------------------------
|
|
17
|
+
RECOMMENDED USAGE (per-document metadata; safe for concurrency)
|
|
18
|
+
|
|
19
|
+
from .workflow_ingest.providers import WorkflowProviderSettings, build_chat_model
|
|
20
|
+
from document_ingest_logger import DocumentIngestSQLiteCallback
|
|
21
|
+
|
|
22
|
+
cb = DocumentIngestSQLiteCallback(db_path="logs/document_ingest.sqlite")
|
|
23
|
+
|
|
24
|
+
llm = build_chat_model(
|
|
25
|
+
WorkflowProviderSettings.from_env().parser,
|
|
26
|
+
callbacks=[cb],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Per-document call
|
|
30
|
+
doc_id = "doc_123"
|
|
31
|
+
result = llm.invoke(
|
|
32
|
+
"Summarize this document ...",
|
|
33
|
+
config={
|
|
34
|
+
"metadata": {
|
|
35
|
+
"document_id": doc_id,
|
|
36
|
+
"source_filename": "foo.pdf", # optional; you can put anything here
|
|
37
|
+
"n_try": n_try,
|
|
38
|
+
"stage": "summary",
|
|
39
|
+
},
|
|
40
|
+
"tags": ["document_ingest"],
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Structured output still works; callbacks still fire.
|
|
45
|
+
# structured_llm = llm.with_structured_output(MySchema)
|
|
46
|
+
# structured_llm.invoke(..., config={"metadata": {"document_id": doc_id}})
|
|
47
|
+
|
|
48
|
+
--------------------------------------------------------------------------------
|
|
49
|
+
ALTERNATIVE USAGE (attach callback per-call)
|
|
50
|
+
|
|
51
|
+
result = llm.invoke(
|
|
52
|
+
"...",
|
|
53
|
+
config={"callbacks": [cb], "metadata": {"document_id": doc_id}},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
--------------------------------------------------------------------------------
|
|
57
|
+
SCHEMA
|
|
58
|
+
|
|
59
|
+
Table: ingest_events
|
|
60
|
+
|
|
61
|
+
Columns:
|
|
62
|
+
- id INTEGER PK
|
|
63
|
+
- ts_iso TEXT ISO timestamp (UTC by default)
|
|
64
|
+
- document_id TEXT from config.metadata["document_id"] if present
|
|
65
|
+
- run_id TEXT LangChain run_id
|
|
66
|
+
- parent_run_id TEXT LangChain parent_run_id
|
|
67
|
+
- event_name TEXT e.g. "llm_start" / "llm_end" / "llm_error"
|
|
68
|
+
- model_name TEXT best-effort from generation_info
|
|
69
|
+
- filename TEXT best-effort from metadata or exception frames
|
|
70
|
+
- line_number INTEGER best-effort from exception frames
|
|
71
|
+
- token_count INTEGER best-effort: total tokens if known, else 0
|
|
72
|
+
- cost_usd REAL computed from usage + pricing table if possible
|
|
73
|
+
- try REAL Number of current try
|
|
74
|
+
- metadata_json TEXT JSON blob with all extra details
|
|
75
|
+
|
|
76
|
+
--------------------------------------------------------------------------------
|
|
77
|
+
NOTES
|
|
78
|
+
- You can add indices or additional columns as needed.
|
|
79
|
+
- Cost calculation is model-table based; if unknown model name, cost falls back
|
|
80
|
+
to a conservative default (or 0.0 depending on your preference).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
from __future__ import annotations
|
|
84
|
+
|
|
85
|
+
import json
|
|
86
|
+
import os
|
|
87
|
+
import queue
|
|
88
|
+
import sqlite3
|
|
89
|
+
import threading
|
|
90
|
+
import time
|
|
91
|
+
import traceback
|
|
92
|
+
from dataclasses import dataclass
|
|
93
|
+
from datetime import datetime, timezone
|
|
94
|
+
from typing import Any, Dict, Optional, Tuple, Callable
|
|
95
|
+
from threading import Lock
|
|
96
|
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
|
97
|
+
from langchain_core.outputs.chat_generation import ChatGeneration
|
|
98
|
+
from langchain_core.outputs.llm_result import LLMResult
|
|
99
|
+
from langchain_core.messages import BaseMessage
|
|
100
|
+
from uuid import UUID
|
|
101
|
+
|
|
102
|
+
# ---------------------------
|
|
103
|
+
# Pricing / cost calculation
|
|
104
|
+
# ---------------------------
|
|
105
|
+
|
|
106
|
+
# per-1K token pricing in USD (example values; keep yours here)
|
|
107
|
+
_COST_TABLE: Dict[str, Dict[str, float]] = {
|
|
108
|
+
"gemini-3-flash-preview": {"input": 0.0005, "output": 0.003, "cache": 0.0},
|
|
109
|
+
"gemini-3.0-pro": {"input": 0.002000, "output": 0.0120, "cache": 0.00031},
|
|
110
|
+
"gemini-2.0-flash": {"input": 0.0001, "output": 0.0004, "cache": 0.0},
|
|
111
|
+
"gemini-1.5-pro": {"input": 0.001250, "output": 0.005, "cache": 0.0},
|
|
112
|
+
"gemini-2.5-flash-preview-04-17": {"input": 0.000150, "output": 0.0035, "cache": 0.0000375},
|
|
113
|
+
"gemini-2.5-flash": {"input": 0.000300, "output": 0.0025, "cache": 0.0000375},
|
|
114
|
+
"gemini-2.5-pro-preview-03-25": {"input": 0.001250, "output": 0.0100, "cache": 0.00031},
|
|
115
|
+
"gemini-2.5-pro": {"input": 0.001250, "output": 0.0100, "cache": 0.00031},
|
|
116
|
+
"gemini-2.5-flash-lite": {"input": 0.0001, "output": 0.0040, "cache": 0.00025},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Some providers prefix with "models/...." (you already handled this in your file)
|
|
120
|
+
for _k in list(_COST_TABLE.keys()):
|
|
121
|
+
if not _k.startswith("models/"):
|
|
122
|
+
_COST_TABLE["models/" + _k] = _COST_TABLE[_k]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def calculate_cost_usd(
|
|
126
|
+
*,
|
|
127
|
+
model_name: str,
|
|
128
|
+
input_tokens: int,
|
|
129
|
+
output_tokens: int,
|
|
130
|
+
cached_tokens: int,
|
|
131
|
+
) -> float:
|
|
132
|
+
"""
|
|
133
|
+
Compute USD cost for a single LLM call based on token usage.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
model_name:
|
|
138
|
+
Provider-reported model name (e.g. "models/gemini-2.5-flash").
|
|
139
|
+
input_tokens / output_tokens / cached_tokens:
|
|
140
|
+
From provider usage metadata.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
float
|
|
145
|
+
Total cost in USD for this call.
|
|
146
|
+
|
|
147
|
+
Notes
|
|
148
|
+
-----
|
|
149
|
+
- Cached tokens are charged at the table's "cache" rate, and are excluded from "input".
|
|
150
|
+
- If the model is unknown, we default to a conservative high-price fallback
|
|
151
|
+
(you can instead return 0.0 if you prefer).
|
|
152
|
+
"""
|
|
153
|
+
price = _COST_TABLE.get(
|
|
154
|
+
model_name,
|
|
155
|
+
# conservative fallback: approximate "expensive"
|
|
156
|
+
{"input": 1.250 / 1_000_000, "output": 5.0 / 1_000_000, "cache": 0.0},
|
|
157
|
+
)
|
|
158
|
+
billable_input = max(input_tokens - cached_tokens, 0)
|
|
159
|
+
input_cost = (billable_input / 1000.0) * price["input"]
|
|
160
|
+
cache_cost = (cached_tokens / 1000.0) * price["cache"]
|
|
161
|
+
output_cost = (output_tokens / 1000.0) * price["output"]
|
|
162
|
+
return float(input_cost + cache_cost + output_cost)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------
|
|
166
|
+
# SQLite writer (threaded)
|
|
167
|
+
# ---------------------------
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class _IngestEvent:
|
|
171
|
+
"""
|
|
172
|
+
Internal event representation queued from callback thread(s) to writer thread.
|
|
173
|
+
"""
|
|
174
|
+
ts_iso: str
|
|
175
|
+
document_id: Optional[str]
|
|
176
|
+
run_id: Optional[str]
|
|
177
|
+
parent_run_id: Optional[str]
|
|
178
|
+
event_name: str
|
|
179
|
+
model_name: Optional[str]
|
|
180
|
+
filename: Optional[str]
|
|
181
|
+
line_number: Optional[int]
|
|
182
|
+
token_count: int
|
|
183
|
+
cost_usd: float
|
|
184
|
+
n_try: float
|
|
185
|
+
metadata_json: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class SQLiteIngestEventWriter:
|
|
189
|
+
"""
|
|
190
|
+
Background writer that persists ingestion events to SQLite.
|
|
191
|
+
|
|
192
|
+
You generally do NOT use this class directly; it is owned by
|
|
193
|
+
`DocumentIngestSQLiteCallback`.
|
|
194
|
+
|
|
195
|
+
Design goals:
|
|
196
|
+
- Avoid SQLite lock contention by using a single writer thread.
|
|
197
|
+
- Keep callback fast: enqueue + return.
|
|
198
|
+
- Initialize DB schema once and set WAL mode for concurrency.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
db_path:
|
|
203
|
+
SQLite file path, e.g. "logs/document_ingest.sqlite".
|
|
204
|
+
Parent directories are created automatically.
|
|
205
|
+
flush_interval_sec:
|
|
206
|
+
How often the writer flushes queued events to disk.
|
|
207
|
+
max_batch_size:
|
|
208
|
+
Max number of events inserted per batch.
|
|
209
|
+
|
|
210
|
+
Shutdown
|
|
211
|
+
--------
|
|
212
|
+
Call `.close()` to stop the writer thread and flush remaining events.
|
|
213
|
+
"""
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
db_path: str,
|
|
217
|
+
*,
|
|
218
|
+
flush_interval_sec: float = 0.25,
|
|
219
|
+
max_batch_size: int = 200,
|
|
220
|
+
) -> None:
|
|
221
|
+
self.db_path = db_path
|
|
222
|
+
self.flush_interval_sec = float(flush_interval_sec)
|
|
223
|
+
self.max_batch_size = int(max_batch_size)
|
|
224
|
+
|
|
225
|
+
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
|
226
|
+
|
|
227
|
+
self._q: "queue.Queue[_IngestEvent]" = queue.Queue()
|
|
228
|
+
self._stop = threading.Event()
|
|
229
|
+
self._thread = threading.Thread(target=self._run, name="sqlite_ingest_writer", daemon=True)
|
|
230
|
+
|
|
231
|
+
self._init_db()
|
|
232
|
+
self._thread.start()
|
|
233
|
+
|
|
234
|
+
def _connect(self) -> sqlite3.Connection:
|
|
235
|
+
conn = sqlite3.connect(self.db_path, timeout=10)
|
|
236
|
+
# Pragmas for better concurrency and fewer "database is locked" errors.
|
|
237
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
238
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
239
|
+
conn.execute("PRAGMA busy_timeout=5000;")
|
|
240
|
+
return conn
|
|
241
|
+
|
|
242
|
+
def _init_db(self) -> None:
|
|
243
|
+
with self._connect() as conn:
|
|
244
|
+
conn.execute(
|
|
245
|
+
"""
|
|
246
|
+
CREATE TABLE IF NOT EXISTS ingest_events (
|
|
247
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
248
|
+
ts_iso TEXT NOT NULL,
|
|
249
|
+
document_id TEXT,
|
|
250
|
+
run_id TEXT,
|
|
251
|
+
parent_run_id TEXT,
|
|
252
|
+
event_name TEXT NOT NULL,
|
|
253
|
+
model_name TEXT,
|
|
254
|
+
filename TEXT,
|
|
255
|
+
line_number INTEGER,
|
|
256
|
+
token_count INTEGER NOT NULL,
|
|
257
|
+
cost_usd REAL NOT NULL,
|
|
258
|
+
n_try REAL NOT NULL,
|
|
259
|
+
metadata_json TEXT NOT NULL
|
|
260
|
+
);
|
|
261
|
+
"""
|
|
262
|
+
)
|
|
263
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_ingest_events_doc ON ingest_events(document_id);")
|
|
264
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_ingest_events_run ON ingest_events(run_id);")
|
|
265
|
+
conn.commit()
|
|
266
|
+
|
|
267
|
+
def enqueue(self, event: _IngestEvent) -> None:
|
|
268
|
+
"""Enqueue an event for persistence (non-blocking)."""
|
|
269
|
+
self._q.put(event)
|
|
270
|
+
|
|
271
|
+
def close(self) -> None:
|
|
272
|
+
"""
|
|
273
|
+
Stop the writer thread and flush remaining events.
|
|
274
|
+
|
|
275
|
+
Safe to call multiple times.
|
|
276
|
+
"""
|
|
277
|
+
if self._stop.is_set():
|
|
278
|
+
return
|
|
279
|
+
self._stop.set()
|
|
280
|
+
self._thread.join(timeout=5)
|
|
281
|
+
|
|
282
|
+
def _run(self) -> None:
|
|
283
|
+
pending: list[_IngestEvent] = []
|
|
284
|
+
last_flush = time.time()
|
|
285
|
+
|
|
286
|
+
while not self._stop.is_set():
|
|
287
|
+
timeout = max(self.flush_interval_sec - (time.time() - last_flush), 0.01)
|
|
288
|
+
try:
|
|
289
|
+
ev = self._q.get(timeout=timeout)
|
|
290
|
+
pending.append(ev)
|
|
291
|
+
except queue.Empty:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
should_flush = (
|
|
295
|
+
pending
|
|
296
|
+
and (len(pending) >= self.max_batch_size or (time.time() - last_flush) >= self.flush_interval_sec)
|
|
297
|
+
)
|
|
298
|
+
if should_flush:
|
|
299
|
+
self._flush(pending)
|
|
300
|
+
pending.clear()
|
|
301
|
+
last_flush = time.time()
|
|
302
|
+
|
|
303
|
+
# Final drain + flush on stop
|
|
304
|
+
try:
|
|
305
|
+
while True:
|
|
306
|
+
pending.append(self._q.get_nowait())
|
|
307
|
+
except queue.Empty:
|
|
308
|
+
pass
|
|
309
|
+
if pending:
|
|
310
|
+
self._flush(pending)
|
|
311
|
+
|
|
312
|
+
# Optional checkpoint
|
|
313
|
+
try:
|
|
314
|
+
with self._connect() as conn:
|
|
315
|
+
conn.execute("PRAGMA wal_checkpoint;")
|
|
316
|
+
conn.commit()
|
|
317
|
+
except Exception:
|
|
318
|
+
# Swallow writer shutdown errors; don't crash application exit.
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
def _flush(self, batch: list[_IngestEvent]) -> None:
|
|
322
|
+
rows = [
|
|
323
|
+
(
|
|
324
|
+
e.ts_iso,
|
|
325
|
+
e.document_id,
|
|
326
|
+
e.run_id,
|
|
327
|
+
e.parent_run_id,
|
|
328
|
+
e.event_name,
|
|
329
|
+
e.model_name,
|
|
330
|
+
e.filename,
|
|
331
|
+
e.line_number,
|
|
332
|
+
e.token_count,
|
|
333
|
+
e.cost_usd,
|
|
334
|
+
e.n_try,
|
|
335
|
+
e.metadata_json,
|
|
336
|
+
)
|
|
337
|
+
for e in batch
|
|
338
|
+
]
|
|
339
|
+
with self._connect() as conn:
|
|
340
|
+
conn.executemany(
|
|
341
|
+
"""
|
|
342
|
+
INSERT INTO ingest_events (
|
|
343
|
+
ts_iso, document_id, run_id, parent_run_id, event_name,
|
|
344
|
+
model_name, filename, line_number, token_count, cost_usd, n_try, metadata_json
|
|
345
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
346
|
+
""",
|
|
347
|
+
rows,
|
|
348
|
+
)
|
|
349
|
+
conn.commit()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------
|
|
353
|
+
# Helper: error frame info
|
|
354
|
+
# ---------------------------
|
|
355
|
+
|
|
356
|
+
def _best_effort_error_location(exc: BaseException) -> Tuple[Optional[str], Optional[int]]:
|
|
357
|
+
"""
|
|
358
|
+
Extract (filename, line_number) from the deepest traceback frame.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
(filename, line_number)
|
|
363
|
+
Both may be None if unavailable.
|
|
364
|
+
"""
|
|
365
|
+
tb = exc.__traceback__
|
|
366
|
+
if tb is None:
|
|
367
|
+
return None, None
|
|
368
|
+
last = None
|
|
369
|
+
while tb is not None:
|
|
370
|
+
last = tb
|
|
371
|
+
tb = tb.tb_next
|
|
372
|
+
if last is None:
|
|
373
|
+
return None, None
|
|
374
|
+
frame = last.tb_frame
|
|
375
|
+
return frame.f_code.co_filename, int(last.tb_lineno)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _utc_now_iso() -> str:
|
|
379
|
+
"""UTC ISO timestamp with timezone, suitable for sqlite TEXT."""
|
|
380
|
+
return datetime.now(timezone.utc).isoformat()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------
|
|
384
|
+
# The LangChain callback
|
|
385
|
+
# ---------------------------
|
|
386
|
+
def _invoked_model_name(kwargs: Dict[str, Any]) -> Optional[str]:
|
|
387
|
+
inv_params = kwargs.get("invocation_params") or {}
|
|
388
|
+
m = inv_params.get("model")
|
|
389
|
+
return str(m) if m else None
|
|
390
|
+
class DocumentIngestSQLiteCallback(BaseCallbackHandler):
|
|
391
|
+
"""
|
|
392
|
+
LangChain callback that records ingestion telemetry into SQLite.
|
|
393
|
+
|
|
394
|
+
What gets logged
|
|
395
|
+
---------------
|
|
396
|
+
- on_llm_start: "llm_start" event with document_id + prompt stats (no token count).
|
|
397
|
+
- on_llm_end: "llm_end" event with token usage + computed cost if available.
|
|
398
|
+
- on_llm_error: "llm_error" event with traceback info + best-effort filename/line.
|
|
399
|
+
|
|
400
|
+
Passing document_id
|
|
401
|
+
-------------------
|
|
402
|
+
Do NOT call set_document_id() or mutate callback state.
|
|
403
|
+
Instead pass it via `RunnableConfig.metadata`:
|
|
404
|
+
|
|
405
|
+
llm.invoke(
|
|
406
|
+
"...",
|
|
407
|
+
config={"metadata": {"document_id": "doc_123", "source_filename": "a.pdf"}}
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
This is safe even under parallel ingestion because each run carries its own metadata.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
db_path:
|
|
415
|
+
SQLite database file path (e.g. "logs/document_ingest.sqlite").
|
|
416
|
+
"""
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
db_path: str,
|
|
420
|
+
*,
|
|
421
|
+
log_prompts: bool = False,
|
|
422
|
+
log_chat_messages: bool = False,
|
|
423
|
+
log_responses: bool = False,
|
|
424
|
+
log_errors: bool = True,
|
|
425
|
+
include_traceback: bool = True,
|
|
426
|
+
max_text_chars: int = 40_000,
|
|
427
|
+
redact: Callable[[str], str] | None = None,
|
|
428
|
+
) -> None:
|
|
429
|
+
super().__init__()
|
|
430
|
+
self._writer = SQLiteIngestEventWriter(db_path=db_path)
|
|
431
|
+
|
|
432
|
+
self.log_prompts = log_prompts
|
|
433
|
+
self.log_chat_messages = log_chat_messages
|
|
434
|
+
self.log_responses = log_responses
|
|
435
|
+
self.log_errors = log_errors
|
|
436
|
+
self.include_traceback = include_traceback
|
|
437
|
+
self.max_text_chars = int(max_text_chars)
|
|
438
|
+
self.redact = redact or (lambda s: s)
|
|
439
|
+
self._run_meta: dict[str, dict[str, Any]] = {}
|
|
440
|
+
self._run_meta_lock = Lock()
|
|
441
|
+
def _remember(self, run_id: UUID, metadata: dict[str, Any] | None, tags: list[str] | None) -> None:
|
|
442
|
+
md = dict(metadata or {})
|
|
443
|
+
md["_tags"] = list(tags or [])
|
|
444
|
+
with self._run_meta_lock:
|
|
445
|
+
self._run_meta[str(run_id)] = md
|
|
446
|
+
|
|
447
|
+
def _recall(self, run_id: UUID, metadata: dict[str, Any] | None, tags: list[str] | None) -> dict[str, Any]:
|
|
448
|
+
# prefer real-time metadata if present; else fallback to remembered
|
|
449
|
+
if metadata:
|
|
450
|
+
return dict(metadata)
|
|
451
|
+
with self._run_meta_lock:
|
|
452
|
+
return dict(self._run_meta.get(str(run_id), {}))
|
|
453
|
+
|
|
454
|
+
def _forget(self, run_id: UUID) -> None:
|
|
455
|
+
with self._run_meta_lock:
|
|
456
|
+
self._run_meta.pop(str(run_id), None)
|
|
457
|
+
def _clip(self, s: str) -> str:
|
|
458
|
+
s = self.redact(s)
|
|
459
|
+
if len(s) <= self.max_text_chars:
|
|
460
|
+
return s
|
|
461
|
+
return s[: self.max_text_chars//2] + f"\n...[clipped {len(s) - self.max_text_chars} chars]" + s[-(self.max_text_chars+1)//2:]
|
|
462
|
+
|
|
463
|
+
def close(self) -> None:
|
|
464
|
+
"""Flush and stop the background writer thread."""
|
|
465
|
+
self._writer.close()
|
|
466
|
+
|
|
467
|
+
# ---- LangChain events ----
|
|
468
|
+
|
|
469
|
+
def on_chat_model_start(
|
|
470
|
+
self,
|
|
471
|
+
serialized: Dict[str, Any],
|
|
472
|
+
messages: list[list[BaseMessage]],
|
|
473
|
+
*,
|
|
474
|
+
run_id: "UUID",
|
|
475
|
+
parent_run_id: Optional["UUID"] = None,
|
|
476
|
+
tags: Optional[list[str]] = None,
|
|
477
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
478
|
+
**kwargs: Any,
|
|
479
|
+
) -> None:
|
|
480
|
+
self._remember(run_id, metadata, tags)
|
|
481
|
+
md = metadata or {}
|
|
482
|
+
document_id = md.get("document_id")
|
|
483
|
+
filename = md.get("source_filename")
|
|
484
|
+
n_try = float(md.get("n_try", 0))
|
|
485
|
+
|
|
486
|
+
payload: Dict[str, Any] = {
|
|
487
|
+
"serialized": serialized,
|
|
488
|
+
"tags": tags or [],
|
|
489
|
+
"metadata": md,
|
|
490
|
+
"message_batches": len(messages),
|
|
491
|
+
"message_counts": [len(batch) for batch in messages],
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if self.log_chat_messages:
|
|
495
|
+
# Convert BaseMessage to a JSON-friendly dict
|
|
496
|
+
payload["chat_messages"] = [
|
|
497
|
+
[
|
|
498
|
+
{
|
|
499
|
+
"type": m.type,
|
|
500
|
+
"content": self._clip(str(m.content)),
|
|
501
|
+
"additional_kwargs": getattr(m, "additional_kwargs", None),
|
|
502
|
+
"response_metadata": getattr(m, "response_metadata", None),
|
|
503
|
+
"name": getattr(m, "name", None),
|
|
504
|
+
"id": getattr(m, "id", None),
|
|
505
|
+
}
|
|
506
|
+
for m in batch
|
|
507
|
+
]
|
|
508
|
+
for batch in messages
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
self._writer.enqueue(
|
|
512
|
+
_IngestEvent(
|
|
513
|
+
ts_iso=_utc_now_iso(),
|
|
514
|
+
document_id=str(document_id) if document_id is not None else None,
|
|
515
|
+
run_id=str(run_id),
|
|
516
|
+
parent_run_id=str(parent_run_id) if parent_run_id is not None else None,
|
|
517
|
+
event_name="chat_model_start",
|
|
518
|
+
model_name=_invoked_model_name(kwargs) or None,
|
|
519
|
+
filename=str(filename) if filename is not None else None,
|
|
520
|
+
line_number=md.get("line_number"),
|
|
521
|
+
token_count=0,
|
|
522
|
+
cost_usd=0.0,
|
|
523
|
+
n_try=n_try,
|
|
524
|
+
metadata_json=json.dumps(payload, ensure_ascii=False),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
def on_llm_start(
|
|
528
|
+
self,
|
|
529
|
+
serialized: Dict[str, Any],
|
|
530
|
+
prompts: list[str],
|
|
531
|
+
*,
|
|
532
|
+
run_id: str,
|
|
533
|
+
parent_run_id: Optional[str] = None,
|
|
534
|
+
tags: Optional[list[str]] = None,
|
|
535
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
536
|
+
**kwargs: Any,
|
|
537
|
+
) -> None:
|
|
538
|
+
|
|
539
|
+
self._remember(run_id, metadata, tags)
|
|
540
|
+
model_name = None
|
|
541
|
+
"""
|
|
542
|
+
Called before an LLM request is sent.
|
|
543
|
+
|
|
544
|
+
We intentionally do NOT try to token-count here (provider-specific).
|
|
545
|
+
Instead we log prompt sizes and a few identifiers.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
md = metadata or {}
|
|
549
|
+
document_id = md.get("document_id")
|
|
550
|
+
filename = md.get("source_filename")
|
|
551
|
+
if model_name is None:
|
|
552
|
+
if inv_params := kwargs.get('invocation_params'):
|
|
553
|
+
if inv_params.get("model"):
|
|
554
|
+
model_name=inv_params.get("model")
|
|
555
|
+
payload = {
|
|
556
|
+
"serialized": serialized,
|
|
557
|
+
"tags": tags or [],
|
|
558
|
+
"metadata": md,
|
|
559
|
+
"prompt_count": len(prompts),
|
|
560
|
+
"prompt_chars_total": sum(len(p) for p in prompts),
|
|
561
|
+
}
|
|
562
|
+
if self.log_prompts:
|
|
563
|
+
payload["prompts"] = [self._clip(p) for p in prompts]
|
|
564
|
+
if model_name is None:
|
|
565
|
+
mdn: str | None= md.get("model_name")
|
|
566
|
+
if mdn is not None:
|
|
567
|
+
model_name = mdn
|
|
568
|
+
self._writer.enqueue(
|
|
569
|
+
_IngestEvent(
|
|
570
|
+
ts_iso=_utc_now_iso(),
|
|
571
|
+
document_id=str(document_id) if document_id is not None else None,
|
|
572
|
+
run_id=str(run_id),
|
|
573
|
+
parent_run_id=str(parent_run_id) if parent_run_id is not None else None,
|
|
574
|
+
event_name="llm_start",
|
|
575
|
+
model_name=model_name,
|
|
576
|
+
filename=str(filename) if filename is not None else None,
|
|
577
|
+
line_number=md.get("line_number"),
|
|
578
|
+
token_count=0,
|
|
579
|
+
cost_usd=0.0,
|
|
580
|
+
n_try = payload.get('n_try', 0),
|
|
581
|
+
metadata_json=json.dumps(payload, ensure_ascii=False),
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def on_llm_end(
|
|
586
|
+
self,
|
|
587
|
+
response: LLMResult,
|
|
588
|
+
*,
|
|
589
|
+
run_id: str,
|
|
590
|
+
parent_run_id: Optional[str] = None,
|
|
591
|
+
tags: Optional[list[str]] = None,
|
|
592
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
593
|
+
**kwargs: Any,
|
|
594
|
+
) -> None:
|
|
595
|
+
"""
|
|
596
|
+
Called after the LLM returns.
|
|
597
|
+
|
|
598
|
+
Extracts provider usage if present (Gemini via usage_metadata),
|
|
599
|
+
computes cost, and logs token counts + model name.
|
|
600
|
+
"""
|
|
601
|
+
md = metadata or {}
|
|
602
|
+
mdud = self._recall(run_id, metadata, tags) or {}
|
|
603
|
+
md.update(mdud)
|
|
604
|
+
document_id = md.get("document_id")
|
|
605
|
+
filename = md.get("source_filename")
|
|
606
|
+
|
|
607
|
+
# Defaults if we can't find usage
|
|
608
|
+
input_tokens = output_tokens = cached_tokens = reasoning_tokens = 0
|
|
609
|
+
model_name: Optional[str] = None
|
|
610
|
+
|
|
611
|
+
# LangChain response.generations: List[List[Generation]]
|
|
612
|
+
for gen_list in response.generations:
|
|
613
|
+
for gen in gen_list:
|
|
614
|
+
if isinstance(gen, ChatGeneration) and hasattr(gen, "message"):
|
|
615
|
+
msg = gen.message
|
|
616
|
+
# Gemini usage_metadata is commonly stored on the message
|
|
617
|
+
usage = getattr(msg, "usage_metadata", None)
|
|
618
|
+
if usage:
|
|
619
|
+
input_tokens = int(usage.get("input_tokens", 0) or 0)
|
|
620
|
+
output_tokens = int(usage.get("output_tokens", 0) or 0)
|
|
621
|
+
try:
|
|
622
|
+
cached_tokens = int(usage["input_token_details"]["cache_read"] or 0)
|
|
623
|
+
except Exception:
|
|
624
|
+
cached_tokens = 0
|
|
625
|
+
try:
|
|
626
|
+
reasoning_tokens = int(usage["output_token_details"]["reasoning"] or 0)
|
|
627
|
+
except Exception:
|
|
628
|
+
reasoning_tokens = 0
|
|
629
|
+
|
|
630
|
+
# Model name is often in generation_info
|
|
631
|
+
gi = getattr(gen, "generation_info", None) or {}
|
|
632
|
+
model_name = gi.get("model_name") or model_name
|
|
633
|
+
if model_name is None:
|
|
634
|
+
if inv_params := kwargs.get('invocation_params'):
|
|
635
|
+
if inv_params.get("model"):
|
|
636
|
+
model_name=inv_params.get("model")
|
|
637
|
+
token_count = int(input_tokens + output_tokens)
|
|
638
|
+
cost_usd = 0.0
|
|
639
|
+
if model_name and (input_tokens or output_tokens or cached_tokens):
|
|
640
|
+
cost_usd = calculate_cost_usd(
|
|
641
|
+
model_name=str(model_name),
|
|
642
|
+
input_tokens=input_tokens,
|
|
643
|
+
output_tokens=output_tokens,
|
|
644
|
+
cached_tokens=cached_tokens,
|
|
645
|
+
)
|
|
646
|
+
payload = {
|
|
647
|
+
"tags": tags or [],
|
|
648
|
+
"metadata": md,
|
|
649
|
+
"input_tokens": input_tokens,
|
|
650
|
+
"output_tokens": output_tokens,
|
|
651
|
+
"cached_tokens": cached_tokens,
|
|
652
|
+
"reasoning_tokens": reasoning_tokens,
|
|
653
|
+
"response_llm_output": getattr(response, "llm_output", None),
|
|
654
|
+
}
|
|
655
|
+
if self.log_responses:
|
|
656
|
+
outs: list[dict[str, Any]] = []
|
|
657
|
+
for gen_list in response.generations:
|
|
658
|
+
for gen in gen_list:
|
|
659
|
+
item: dict[str, Any] = {"type": type(gen).__name__}
|
|
660
|
+
|
|
661
|
+
# Text-y
|
|
662
|
+
if hasattr(gen, "text") and gen.text:
|
|
663
|
+
item["text"] = self._clip(str(gen.text))
|
|
664
|
+
|
|
665
|
+
# Chat-y
|
|
666
|
+
if isinstance(gen, ChatGeneration) and hasattr(gen, "message"):
|
|
667
|
+
msg = gen.message
|
|
668
|
+
item["message"] = {
|
|
669
|
+
"type": getattr(msg, "type", None),
|
|
670
|
+
"content": self._clip(str(getattr(msg, "content", ""))),
|
|
671
|
+
"additional_kwargs": getattr(msg, "additional_kwargs", None),
|
|
672
|
+
"response_metadata": getattr(msg, "response_metadata", None),
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
item["generation_info"] = getattr(gen, "generation_info", None)
|
|
676
|
+
outs.append(item)
|
|
677
|
+
|
|
678
|
+
payload["outputs"] = outs
|
|
679
|
+
|
|
680
|
+
if model_name is None:
|
|
681
|
+
mdn: str | None= md.get("model_name")
|
|
682
|
+
if mdn is not None:
|
|
683
|
+
model_name = mdn
|
|
684
|
+
self._writer.enqueue(
|
|
685
|
+
_IngestEvent(
|
|
686
|
+
ts_iso=_utc_now_iso(),
|
|
687
|
+
document_id=str(document_id) if document_id is not None else None,
|
|
688
|
+
run_id=str(run_id),
|
|
689
|
+
parent_run_id=str(parent_run_id) if parent_run_id is not None else None,
|
|
690
|
+
event_name="llm_end",
|
|
691
|
+
model_name=str(model_name) if model_name is not None else None,
|
|
692
|
+
filename=str(filename) if filename is not None else None,
|
|
693
|
+
line_number=md.get("line_number"),
|
|
694
|
+
token_count=token_count,
|
|
695
|
+
cost_usd=float(cost_usd),
|
|
696
|
+
n_try=float(payload.get('n_try', 0)),
|
|
697
|
+
metadata_json=json.dumps(payload, ensure_ascii=False),
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def on_llm_error(
|
|
702
|
+
self,
|
|
703
|
+
error: BaseException,
|
|
704
|
+
*,
|
|
705
|
+
run_id: str,
|
|
706
|
+
parent_run_id: Optional[str] = None,
|
|
707
|
+
tags: Optional[list[str]] = None,
|
|
708
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
709
|
+
**kwargs: Any,
|
|
710
|
+
) -> None:
|
|
711
|
+
"""
|
|
712
|
+
Called when the LLM call errors.
|
|
713
|
+
|
|
714
|
+
Logs:
|
|
715
|
+
- exception type/message
|
|
716
|
+
- traceback string
|
|
717
|
+
- best-effort filename/line_number from traceback
|
|
718
|
+
"""
|
|
719
|
+
model_name: str|None = None
|
|
720
|
+
md = metadata or {}
|
|
721
|
+
mdud = self._recall(run_id, metadata, tags) or {}
|
|
722
|
+
md.update(mdud)
|
|
723
|
+
document_id = md.get("document_id")
|
|
724
|
+
# prefer explicit metadata filename; else fallback to traceback location
|
|
725
|
+
meta_filename = md.get("source_filename")
|
|
726
|
+
|
|
727
|
+
tb_str = "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
728
|
+
tb_file, tb_line = _best_effort_error_location(error)
|
|
729
|
+
|
|
730
|
+
payload = {
|
|
731
|
+
"tags": tags or [],
|
|
732
|
+
"metadata": md,
|
|
733
|
+
"error_type": type(error).__name__,
|
|
734
|
+
"error_message": str(error),
|
|
735
|
+
"traceback": tb_str,
|
|
736
|
+
}
|
|
737
|
+
if self.log_errors:
|
|
738
|
+
payload["error_message"] = self._clip(str(error))
|
|
739
|
+
if self.include_traceback:
|
|
740
|
+
payload["traceback"] = self._clip(tb_str)
|
|
741
|
+
else:
|
|
742
|
+
payload["error_type"] = type(error).__name__
|
|
743
|
+
if model_name is None:
|
|
744
|
+
if inv_params := kwargs.get('invocation_params'):
|
|
745
|
+
if inv_params.get("model"):
|
|
746
|
+
model_name=inv_params.get("model")
|
|
747
|
+
if model_name is None:
|
|
748
|
+
mdn: str | None= md.get("model_name")
|
|
749
|
+
if mdn is not None:
|
|
750
|
+
model_name = mdn
|
|
751
|
+
self._writer.enqueue(
|
|
752
|
+
_IngestEvent(
|
|
753
|
+
ts_iso=_utc_now_iso(),
|
|
754
|
+
document_id=str(document_id) if document_id is not None else None,
|
|
755
|
+
run_id=str(run_id),
|
|
756
|
+
parent_run_id=str(parent_run_id) if parent_run_id is not None else None,
|
|
757
|
+
event_name="llm_error",
|
|
758
|
+
model_name=model_name,
|
|
759
|
+
filename=str(meta_filename or tb_file) if (meta_filename or tb_file) is not None else None,
|
|
760
|
+
line_number=int(tb_line) if tb_line is not None else None,
|
|
761
|
+
token_count=0,
|
|
762
|
+
cost_usd=0.0,
|
|
763
|
+
n_try=float(payload.get('n_try', 0)),
|
|
764
|
+
metadata_json=json.dumps(payload, ensure_ascii=False),
|
|
765
|
+
)
|
|
766
|
+
)
|