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.
Files changed (38) hide show
  1. graph_knowledge_doc_parser-0.1.0.dist-info/METADATA +326 -0
  2. graph_knowledge_doc_parser-0.1.0.dist-info/RECORD +38 -0
  3. graph_knowledge_doc_parser-0.1.0.dist-info/WHEEL +4 -0
  4. graph_knowledge_doc_parser-0.1.0.dist-info/entry_points.txt +3 -0
  5. kg_doc_parser/__init__.py +9 -0
  6. kg_doc_parser/cast_hinting.py +19 -0
  7. kg_doc_parser/document_ingester_logger.py +766 -0
  8. kg_doc_parser/models.py +277 -0
  9. kg_doc_parser/ocr.py +752 -0
  10. kg_doc_parser/pdf2png.py +286 -0
  11. kg_doc_parser/semantic_document_splitting_layerwise_edits.py +3302 -0
  12. kg_doc_parser/text_processing_utils.py +30 -0
  13. kg_doc_parser/utils/__init__.py +0 -0
  14. kg_doc_parser/utils/bounded_threadpool_executor.py +37 -0
  15. kg_doc_parser/utils/file_loaders.py +405 -0
  16. kg_doc_parser/utils/langchain.py +220 -0
  17. kg_doc_parser/utils/log.py +135 -0
  18. kg_doc_parser/utils/version_chaining.py +1278 -0
  19. kg_doc_parser/workflow_ingest/__init__.py +187 -0
  20. kg_doc_parser/workflow_ingest/_kogwistar.py +13 -0
  21. kg_doc_parser/workflow_ingest/adapters.py +212 -0
  22. kg_doc_parser/workflow_ingest/cache.py +63 -0
  23. kg_doc_parser/workflow_ingest/cli.py +324 -0
  24. kg_doc_parser/workflow_ingest/clients.py +444 -0
  25. kg_doc_parser/workflow_ingest/demo_harness.py +427 -0
  26. kg_doc_parser/workflow_ingest/design.py +208 -0
  27. kg_doc_parser/workflow_ingest/handlers.py +617 -0
  28. kg_doc_parser/workflow_ingest/models.py +575 -0
  29. kg_doc_parser/workflow_ingest/ocr_pipeline.py +1581 -0
  30. kg_doc_parser/workflow_ingest/page_index.py +473 -0
  31. kg_doc_parser/workflow_ingest/parser_core.py +862 -0
  32. kg_doc_parser/workflow_ingest/parsing.py +249 -0
  33. kg_doc_parser/workflow_ingest/probe.py +164 -0
  34. kg_doc_parser/workflow_ingest/providers.py +412 -0
  35. kg_doc_parser/workflow_ingest/runners.py +546 -0
  36. kg_doc_parser/workflow_ingest/semantics.py +231 -0
  37. kg_doc_parser/workflow_ingest/service.py +112 -0
  38. 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
+ )