rag-failcase-kit 0.1.0__tar.gz
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.
- rag_failcase_kit-0.1.0/.gitignore +28 -0
- rag_failcase_kit-0.1.0/LICENSE +21 -0
- rag_failcase_kit-0.1.0/PKG-INFO +221 -0
- rag_failcase_kit-0.1.0/README.md +192 -0
- rag_failcase_kit-0.1.0/examples/basic_usage.py +20 -0
- rag_failcase_kit-0.1.0/examples/fastapi_example.py +33 -0
- rag_failcase_kit-0.1.0/examples/langgraph_example.py +38 -0
- rag_failcase_kit-0.1.0/pyproject.toml +61 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/__init__.py +8 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/cli.py +74 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/logger.py +28 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/py.typed +1 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/report.py +59 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/schema.py +120 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/trace.py +112 -0
- rag_failcase_kit-0.1.0/src/rag_failcase_kit/writer.py +22 -0
- rag_failcase_kit-0.1.0/tests/test_cli.py +26 -0
- rag_failcase_kit-0.1.0/tests/test_logger.py +11 -0
- rag_failcase_kit-0.1.0/tests/test_trace.py +77 -0
- rag_failcase_kit-0.1.0/tests/test_writer.py +14 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python caches
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
|
|
6
|
+
# Test and tooling caches
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Build artifacts
|
|
19
|
+
build/
|
|
20
|
+
dist/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
|
|
23
|
+
# Local logs generated by examples
|
|
24
|
+
logs/
|
|
25
|
+
|
|
26
|
+
# Environment files
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rag-failcase-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rag-failcase-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight JSONL tracing for debugging failed RAG answers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/slayer011019/rag-failcase-kit
|
|
6
|
+
Project-URL: Repository, https://github.com/slayer011019/rag-failcase-kit
|
|
7
|
+
Project-URL: Issues, https://github.com/slayer011019/rag-failcase-kit/issues
|
|
8
|
+
Author: rag-failcase-kit contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: debugging,jsonl,logging,rag,retrieval
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# rag-failcase-kit
|
|
31
|
+
|
|
32
|
+
`rag-failcase-kit` is a lightweight Python package for recording one RAG pipeline run as one JSONL trace.
|
|
33
|
+
|
|
34
|
+
When a RAG application returns an empty, inaccurate, or weakly relevant answer, it is often hard to tell where the failure happened. The issue may be retrieval quality, prompt construction, fallback logic, answer generation, or a runtime error. In small projects this often becomes a pile of `print` statements, which is difficult to search, compare, or replay later.
|
|
35
|
+
|
|
36
|
+
This package keeps the first version intentionally small: it records query, retrieved documents, scores, fallback usage, answer preview, latency, status, and errors into a JSONL file.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
From PyPI:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install rag-failcase-kit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
From a local checkout:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For development and release checks:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 30-Second Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from rag_failcase_kit import RagLogger
|
|
62
|
+
|
|
63
|
+
logger = RagLogger(project="demo-rag", log_path="logs/rag_traces.jsonl")
|
|
64
|
+
|
|
65
|
+
with logger.trace("What is RAG?") as trace:
|
|
66
|
+
trace.log_retrieval(
|
|
67
|
+
[
|
|
68
|
+
{
|
|
69
|
+
"content": "Retrieval-augmented generation combines retrieval with text generation.",
|
|
70
|
+
"source": "docs/rag.md",
|
|
71
|
+
"score": 0.91,
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
top_k=3,
|
|
75
|
+
min_score=0.7,
|
|
76
|
+
)
|
|
77
|
+
trace.log_answer("RAG retrieves relevant context and uses it to generate an answer.")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The log directory is created automatically. Each trace is appended as one JSON object per line.
|
|
81
|
+
|
|
82
|
+
## CLI Usage
|
|
83
|
+
|
|
84
|
+
The package installs a `rag-failcase` command:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
rag-failcase summary logs/rag_traces.jsonl
|
|
88
|
+
rag-failcase failures logs/rag_traces.jsonl
|
|
89
|
+
rag-failcase inspect logs/rag_traces.jsonl --trace-id <trace_id>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
All CLI commands print JSON output.
|
|
93
|
+
|
|
94
|
+
Example summary:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"total_traces": 12,
|
|
99
|
+
"successes": 9,
|
|
100
|
+
"failures": 3,
|
|
101
|
+
"fallback_used": 4,
|
|
102
|
+
"avg_latency_ms": 128.42
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Log Schema
|
|
107
|
+
|
|
108
|
+
Example JSONL record:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"trace_id": "9fcd7b91-6b12-46fb-8ff1-5f6f1ef49b15",
|
|
113
|
+
"project": "demo-rag",
|
|
114
|
+
"query": "What is RAG?",
|
|
115
|
+
"retrieval": {
|
|
116
|
+
"documents": [
|
|
117
|
+
{
|
|
118
|
+
"content_preview": "Retrieval-augmented generation combines retrieval with text generation.",
|
|
119
|
+
"metadata": {
|
|
120
|
+
"chunk_id": "rag-001"
|
|
121
|
+
},
|
|
122
|
+
"source": "docs/rag.md",
|
|
123
|
+
"score": 0.91
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"top_k": 3,
|
|
127
|
+
"min_score": 0.7
|
|
128
|
+
},
|
|
129
|
+
"retrieved_count": 1,
|
|
130
|
+
"fallback": {
|
|
131
|
+
"used": false,
|
|
132
|
+
"strategy": null,
|
|
133
|
+
"reason": null
|
|
134
|
+
},
|
|
135
|
+
"answer": "RAG retrieves relevant context and uses it to generate an answer.",
|
|
136
|
+
"answer_preview": "RAG retrieves relevant context and uses it to generate an answer.",
|
|
137
|
+
"latency_ms": 3.241,
|
|
138
|
+
"status": "success",
|
|
139
|
+
"error": null,
|
|
140
|
+
"created_at": "2026-07-09T00:00:00+00:00"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Document content is stored as a bounded preview to avoid unexpectedly large logs.
|
|
145
|
+
|
|
146
|
+
## Real RAG Integration
|
|
147
|
+
|
|
148
|
+
Wrap the part of your RAG pipeline that handles one user query:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from rag_failcase_kit import RagLogger
|
|
152
|
+
|
|
153
|
+
logger = RagLogger(project="support-bot", log_path="logs/support_rag.jsonl")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def answer_question(query, retriever, llm):
|
|
157
|
+
with logger.trace(query) as trace:
|
|
158
|
+
documents = retriever.search(query)
|
|
159
|
+
trace.log_retrieval(documents, top_k=5, min_score=0.75)
|
|
160
|
+
|
|
161
|
+
if not documents:
|
|
162
|
+
trace.log_fallback("no_documents", reason="Retriever returned no documents.")
|
|
163
|
+
answer = "I do not have enough context to answer."
|
|
164
|
+
trace.log_answer(answer)
|
|
165
|
+
return answer
|
|
166
|
+
|
|
167
|
+
answer = llm.generate(query=query, documents=documents)
|
|
168
|
+
trace.log_answer(answer)
|
|
169
|
+
return answer
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`documents` can be dictionaries or document-like objects. For objects, the logger safely checks common fields such as `page_content`, `metadata`, `source`, and `score`.
|
|
173
|
+
|
|
174
|
+
## Optional Integrations
|
|
175
|
+
|
|
176
|
+
The package does not depend on FastAPI, LangChain, or LangGraph.
|
|
177
|
+
|
|
178
|
+
FastAPI example:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip install fastapi uvicorn
|
|
182
|
+
uvicorn examples.fastapi_example:app --reload
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
LangGraph-style example:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python examples/langgraph_example.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
See:
|
|
192
|
+
|
|
193
|
+
- [examples/basic_usage.py](examples/basic_usage.py)
|
|
194
|
+
- [examples/fastapi_example.py](examples/fastapi_example.py)
|
|
195
|
+
- [examples/langgraph_example.py](examples/langgraph_example.py)
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
pip install -e ".[dev]"
|
|
201
|
+
pytest
|
|
202
|
+
python -m build
|
|
203
|
+
twine check dist/*
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
To test the console script in a clean environment:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
python -m venv .venv-test
|
|
210
|
+
. .venv-test/bin/activate
|
|
211
|
+
pip install dist/rag_failcase_kit-0.1.0-py3-none-any.whl
|
|
212
|
+
rag-failcase --help
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Roadmap
|
|
216
|
+
|
|
217
|
+
- Add prompt logging with explicit redaction controls.
|
|
218
|
+
- Add richer failure categorization helpers.
|
|
219
|
+
- Add Markdown or HTML reports.
|
|
220
|
+
- Add log rotation and sampling controls.
|
|
221
|
+
- Add optional adapters for popular RAG frameworks without making them required dependencies.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# rag-failcase-kit
|
|
2
|
+
|
|
3
|
+
`rag-failcase-kit` is a lightweight Python package for recording one RAG pipeline run as one JSONL trace.
|
|
4
|
+
|
|
5
|
+
When a RAG application returns an empty, inaccurate, or weakly relevant answer, it is often hard to tell where the failure happened. The issue may be retrieval quality, prompt construction, fallback logic, answer generation, or a runtime error. In small projects this often becomes a pile of `print` statements, which is difficult to search, compare, or replay later.
|
|
6
|
+
|
|
7
|
+
This package keeps the first version intentionally small: it records query, retrieved documents, scores, fallback usage, answer preview, latency, status, and errors into a JSONL file.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
From PyPI:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install rag-failcase-kit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
From a local checkout:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For development and release checks:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 30-Second Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from rag_failcase_kit import RagLogger
|
|
33
|
+
|
|
34
|
+
logger = RagLogger(project="demo-rag", log_path="logs/rag_traces.jsonl")
|
|
35
|
+
|
|
36
|
+
with logger.trace("What is RAG?") as trace:
|
|
37
|
+
trace.log_retrieval(
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
"content": "Retrieval-augmented generation combines retrieval with text generation.",
|
|
41
|
+
"source": "docs/rag.md",
|
|
42
|
+
"score": 0.91,
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
top_k=3,
|
|
46
|
+
min_score=0.7,
|
|
47
|
+
)
|
|
48
|
+
trace.log_answer("RAG retrieves relevant context and uses it to generate an answer.")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The log directory is created automatically. Each trace is appended as one JSON object per line.
|
|
52
|
+
|
|
53
|
+
## CLI Usage
|
|
54
|
+
|
|
55
|
+
The package installs a `rag-failcase` command:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
rag-failcase summary logs/rag_traces.jsonl
|
|
59
|
+
rag-failcase failures logs/rag_traces.jsonl
|
|
60
|
+
rag-failcase inspect logs/rag_traces.jsonl --trace-id <trace_id>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
All CLI commands print JSON output.
|
|
64
|
+
|
|
65
|
+
Example summary:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"total_traces": 12,
|
|
70
|
+
"successes": 9,
|
|
71
|
+
"failures": 3,
|
|
72
|
+
"fallback_used": 4,
|
|
73
|
+
"avg_latency_ms": 128.42
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Log Schema
|
|
78
|
+
|
|
79
|
+
Example JSONL record:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"trace_id": "9fcd7b91-6b12-46fb-8ff1-5f6f1ef49b15",
|
|
84
|
+
"project": "demo-rag",
|
|
85
|
+
"query": "What is RAG?",
|
|
86
|
+
"retrieval": {
|
|
87
|
+
"documents": [
|
|
88
|
+
{
|
|
89
|
+
"content_preview": "Retrieval-augmented generation combines retrieval with text generation.",
|
|
90
|
+
"metadata": {
|
|
91
|
+
"chunk_id": "rag-001"
|
|
92
|
+
},
|
|
93
|
+
"source": "docs/rag.md",
|
|
94
|
+
"score": 0.91
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"top_k": 3,
|
|
98
|
+
"min_score": 0.7
|
|
99
|
+
},
|
|
100
|
+
"retrieved_count": 1,
|
|
101
|
+
"fallback": {
|
|
102
|
+
"used": false,
|
|
103
|
+
"strategy": null,
|
|
104
|
+
"reason": null
|
|
105
|
+
},
|
|
106
|
+
"answer": "RAG retrieves relevant context and uses it to generate an answer.",
|
|
107
|
+
"answer_preview": "RAG retrieves relevant context and uses it to generate an answer.",
|
|
108
|
+
"latency_ms": 3.241,
|
|
109
|
+
"status": "success",
|
|
110
|
+
"error": null,
|
|
111
|
+
"created_at": "2026-07-09T00:00:00+00:00"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Document content is stored as a bounded preview to avoid unexpectedly large logs.
|
|
116
|
+
|
|
117
|
+
## Real RAG Integration
|
|
118
|
+
|
|
119
|
+
Wrap the part of your RAG pipeline that handles one user query:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from rag_failcase_kit import RagLogger
|
|
123
|
+
|
|
124
|
+
logger = RagLogger(project="support-bot", log_path="logs/support_rag.jsonl")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def answer_question(query, retriever, llm):
|
|
128
|
+
with logger.trace(query) as trace:
|
|
129
|
+
documents = retriever.search(query)
|
|
130
|
+
trace.log_retrieval(documents, top_k=5, min_score=0.75)
|
|
131
|
+
|
|
132
|
+
if not documents:
|
|
133
|
+
trace.log_fallback("no_documents", reason="Retriever returned no documents.")
|
|
134
|
+
answer = "I do not have enough context to answer."
|
|
135
|
+
trace.log_answer(answer)
|
|
136
|
+
return answer
|
|
137
|
+
|
|
138
|
+
answer = llm.generate(query=query, documents=documents)
|
|
139
|
+
trace.log_answer(answer)
|
|
140
|
+
return answer
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`documents` can be dictionaries or document-like objects. For objects, the logger safely checks common fields such as `page_content`, `metadata`, `source`, and `score`.
|
|
144
|
+
|
|
145
|
+
## Optional Integrations
|
|
146
|
+
|
|
147
|
+
The package does not depend on FastAPI, LangChain, or LangGraph.
|
|
148
|
+
|
|
149
|
+
FastAPI example:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install fastapi uvicorn
|
|
153
|
+
uvicorn examples.fastapi_example:app --reload
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
LangGraph-style example:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python examples/langgraph_example.py
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
See:
|
|
163
|
+
|
|
164
|
+
- [examples/basic_usage.py](examples/basic_usage.py)
|
|
165
|
+
- [examples/fastapi_example.py](examples/fastapi_example.py)
|
|
166
|
+
- [examples/langgraph_example.py](examples/langgraph_example.py)
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
pip install -e ".[dev]"
|
|
172
|
+
pytest
|
|
173
|
+
python -m build
|
|
174
|
+
twine check dist/*
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
To test the console script in a clean environment:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python -m venv .venv-test
|
|
181
|
+
. .venv-test/bin/activate
|
|
182
|
+
pip install dist/rag_failcase_kit-0.1.0-py3-none-any.whl
|
|
183
|
+
rag-failcase --help
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Roadmap
|
|
187
|
+
|
|
188
|
+
- Add prompt logging with explicit redaction controls.
|
|
189
|
+
- Add richer failure categorization helpers.
|
|
190
|
+
- Add Markdown or HTML reports.
|
|
191
|
+
- Add log rotation and sampling controls.
|
|
192
|
+
- Add optional adapters for popular RAG frameworks without making them required dependencies.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Basic usage example for rag-failcase-kit."""
|
|
2
|
+
|
|
3
|
+
from rag_failcase_kit import RagLogger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
logger = RagLogger(project="demo-rag", log_path="logs/rag_traces.jsonl")
|
|
7
|
+
|
|
8
|
+
with logger.trace("What is RAG?") as trace:
|
|
9
|
+
trace.log_retrieval(
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
"content": "Retrieval-augmented generation combines retrieval with text generation.",
|
|
13
|
+
"source": "docs/rag.md",
|
|
14
|
+
"score": 0.91,
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
top_k=3,
|
|
18
|
+
min_score=0.7,
|
|
19
|
+
)
|
|
20
|
+
trace.log_answer("RAG retrieves relevant context and uses it to generate an answer.")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""FastAPI integration example.
|
|
2
|
+
|
|
3
|
+
Install FastAPI separately to run this file:
|
|
4
|
+
|
|
5
|
+
pip install fastapi uvicorn
|
|
6
|
+
uvicorn examples.fastapi_example:app --reload
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from rag_failcase_kit import RagLogger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
app = FastAPI()
|
|
15
|
+
logger = RagLogger(project="fastapi-rag", log_path="logs/fastapi_rag.jsonl")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.get("/ask")
|
|
19
|
+
def ask(query: str) -> dict:
|
|
20
|
+
"""Record one trace around a minimal RAG-like request."""
|
|
21
|
+
|
|
22
|
+
with logger.trace(query) as trace:
|
|
23
|
+
documents = [{"content": "FastAPI is a Python web framework.", "score": 0.88}]
|
|
24
|
+
trace.log_retrieval(documents, top_k=1)
|
|
25
|
+
|
|
26
|
+
if not documents:
|
|
27
|
+
trace.log_fallback("no_context", reason="Retriever returned no documents.")
|
|
28
|
+
answer = "I do not have enough context to answer."
|
|
29
|
+
else:
|
|
30
|
+
answer = "FastAPI is commonly used to build Python APIs."
|
|
31
|
+
|
|
32
|
+
trace.log_answer(answer)
|
|
33
|
+
return {"answer": answer, "trace_id": trace.trace_id}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""LangGraph-style integration example without importing LangGraph.
|
|
2
|
+
|
|
3
|
+
The package does not depend on LangGraph. This example shows where tracing can
|
|
4
|
+
be placed inside graph node functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, List, TypedDict
|
|
8
|
+
|
|
9
|
+
from rag_failcase_kit import RagLogger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class State(TypedDict, total=False):
|
|
13
|
+
query: str
|
|
14
|
+
documents: List[Dict[str, object]]
|
|
15
|
+
answer: str
|
|
16
|
+
trace_id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = RagLogger(project="langgraph-rag", log_path="logs/langgraph_rag.jsonl")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rag_node(state: State) -> State:
|
|
23
|
+
"""Example node that records retrieval and answer generation."""
|
|
24
|
+
|
|
25
|
+
query = state["query"]
|
|
26
|
+
with logger.trace(query) as trace:
|
|
27
|
+
documents = [{"content": "Graph nodes can share state.", "score": 0.83}]
|
|
28
|
+
trace.log_retrieval(documents, top_k=2)
|
|
29
|
+
|
|
30
|
+
answer = "A graph node can log retrieval and answer events inside one trace."
|
|
31
|
+
trace.log_answer(answer)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
**state,
|
|
35
|
+
"documents": documents,
|
|
36
|
+
"answer": answer,
|
|
37
|
+
"trace_id": trace.trace_id,
|
|
38
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rag-failcase-kit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight JSONL tracing for debugging failed RAG answers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "rag-failcase-kit contributors" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["rag", "logging", "debugging", "jsonl", "retrieval"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Debuggers",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/slayer011019/rag-failcase-kit"
|
|
33
|
+
Repository = "https://github.com/slayer011019/rag-failcase-kit"
|
|
34
|
+
Issues = "https://github.com/slayer011019/rag-failcase-kit/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"build>=1.2",
|
|
39
|
+
"pytest>=7.0",
|
|
40
|
+
"twine>=5.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
rag-failcase = "rag_failcase_kit.cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
pythonpath = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/rag_failcase_kit"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = [
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"pyproject.toml",
|
|
58
|
+
"src",
|
|
59
|
+
"examples",
|
|
60
|
+
"tests",
|
|
61
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Command line interface for rag-failcase-kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
from rag_failcase_kit.report import failure_records, find_record, load_records, summarize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Build the CLI argument parser."""
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="rag-failcase",
|
|
18
|
+
description="Inspect rag-failcase-kit JSONL trace logs.",
|
|
19
|
+
)
|
|
20
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
summary_parser = subparsers.add_parser("summary", help="Show aggregate log statistics.")
|
|
23
|
+
summary_parser.add_argument("log_path")
|
|
24
|
+
|
|
25
|
+
failures_parser = subparsers.add_parser("failures", help="List failed traces.")
|
|
26
|
+
failures_parser.add_argument("log_path")
|
|
27
|
+
|
|
28
|
+
inspect_parser = subparsers.add_parser("inspect", help="Show one trace by ID.")
|
|
29
|
+
inspect_parser.add_argument("log_path")
|
|
30
|
+
inspect_parser.add_argument("--trace-id", required=True)
|
|
31
|
+
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
36
|
+
"""Run the command line interface."""
|
|
37
|
+
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
records = load_records(args.log_path)
|
|
43
|
+
except FileNotFoundError as exc:
|
|
44
|
+
print(str(exc), file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
if args.command == "summary":
|
|
48
|
+
_print_json(summarize(records))
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
if args.command == "failures":
|
|
52
|
+
_print_json(failure_records(records))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if args.command == "inspect":
|
|
56
|
+
record = find_record(records, args.trace_id)
|
|
57
|
+
if record is None:
|
|
58
|
+
print(f"Trace not found: {args.trace_id}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
_print_json(record)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
parser.print_help()
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_json(value: Union[Dict[str, Any], List[Dict[str, Any]]]) -> None:
|
|
68
|
+
"""Print JSON output for humans and scripts."""
|
|
69
|
+
|
|
70
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Public logger facade for creating RAG traces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
from rag_failcase_kit.trace import RagTrace
|
|
9
|
+
from rag_failcase_kit.writer import JsonlWriter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RagLogger:
|
|
13
|
+
"""Create structured JSONL traces for RAG runs.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
project: Human-readable project or application name.
|
|
17
|
+
log_path: JSONL file path where traces will be appended.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, project: str, log_path: Union[str, Path] = "logs/rag_traces.jsonl") -> None:
|
|
21
|
+
self.project = project
|
|
22
|
+
self.log_path = Path(log_path)
|
|
23
|
+
self.writer = JsonlWriter(self.log_path)
|
|
24
|
+
|
|
25
|
+
def trace(self, query: str) -> RagTrace:
|
|
26
|
+
"""Create a context manager for one RAG query execution."""
|
|
27
|
+
|
|
28
|
+
return RagTrace(project=self.project, query=query, writer=self.writer)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read and summarize JSONL trace logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_records(log_path: Union[str, Path]) -> List[Dict[str, Any]]:
|
|
11
|
+
"""Load trace records from a JSONL file."""
|
|
12
|
+
|
|
13
|
+
path = Path(log_path)
|
|
14
|
+
if not path.exists():
|
|
15
|
+
raise FileNotFoundError(f"Log file not found: {path}")
|
|
16
|
+
|
|
17
|
+
records: List[Dict[str, Any]] = []
|
|
18
|
+
with path.open("r", encoding="utf-8") as file:
|
|
19
|
+
for line in file:
|
|
20
|
+
if line.strip():
|
|
21
|
+
records.append(json.loads(line))
|
|
22
|
+
return records
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def summarize(records: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
|
|
26
|
+
"""Return aggregate statistics for trace records."""
|
|
27
|
+
|
|
28
|
+
items = list(records)
|
|
29
|
+
total = len(items)
|
|
30
|
+
failures = [record for record in items if record.get("status") != "success"]
|
|
31
|
+
fallback_used = [record for record in items if record.get("fallback", {}).get("used")]
|
|
32
|
+
latencies = [
|
|
33
|
+
record["latency_ms"]
|
|
34
|
+
for record in items
|
|
35
|
+
if isinstance(record.get("latency_ms"), (int, float))
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"total_traces": total,
|
|
40
|
+
"successes": total - len(failures),
|
|
41
|
+
"failures": len(failures),
|
|
42
|
+
"fallback_used": len(fallback_used),
|
|
43
|
+
"avg_latency_ms": round(sum(latencies) / len(latencies), 3) if latencies else None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def failure_records(records: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
48
|
+
"""Return records that indicate a failed or errored trace."""
|
|
49
|
+
|
|
50
|
+
return [record for record in records if record.get("status") != "success"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_record(records: Iterable[Dict[str, Any]], trace_id: str) -> Optional[Dict[str, Any]]:
|
|
54
|
+
"""Find a trace by ID."""
|
|
55
|
+
|
|
56
|
+
for record in records:
|
|
57
|
+
if record.get("trace_id") == trace_id:
|
|
58
|
+
return record
|
|
59
|
+
return None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Schema helpers for trace records.
|
|
2
|
+
|
|
3
|
+
The project intentionally uses plain dictionaries instead of a validation
|
|
4
|
+
dependency. This keeps the MVP lightweight and easy to inspect.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TraceRecord = Dict[str, Any]
|
|
14
|
+
DocumentRecord = Dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utc_now_iso() -> str:
|
|
18
|
+
"""Return the current UTC time as an ISO-8601 string."""
|
|
19
|
+
|
|
20
|
+
return datetime.now(timezone.utc).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_trace_record(
|
|
24
|
+
*,
|
|
25
|
+
trace_id: str,
|
|
26
|
+
project: str,
|
|
27
|
+
query: str,
|
|
28
|
+
created_at: Optional[str] = None,
|
|
29
|
+
) -> TraceRecord:
|
|
30
|
+
"""Create the default JSON-serializable record for one RAG trace."""
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"trace_id": trace_id,
|
|
34
|
+
"project": project,
|
|
35
|
+
"query": query,
|
|
36
|
+
"retrieval": {
|
|
37
|
+
"documents": [],
|
|
38
|
+
"top_k": None,
|
|
39
|
+
"min_score": None,
|
|
40
|
+
},
|
|
41
|
+
"retrieved_count": 0,
|
|
42
|
+
"fallback": {
|
|
43
|
+
"used": False,
|
|
44
|
+
"strategy": None,
|
|
45
|
+
"reason": None,
|
|
46
|
+
},
|
|
47
|
+
"answer": None,
|
|
48
|
+
"answer_preview": None,
|
|
49
|
+
"latency_ms": None,
|
|
50
|
+
"status": "running",
|
|
51
|
+
"error": None,
|
|
52
|
+
"created_at": created_at or utc_now_iso(),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def preview_text(value: Any, *, limit: int = 500) -> Optional[str]:
|
|
57
|
+
"""Convert a value to a bounded text preview."""
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
text = str(value)
|
|
63
|
+
if len(text) <= limit:
|
|
64
|
+
return text
|
|
65
|
+
return text[:limit] + "..."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_documents(documents: List[Any], *, content_limit: int = 500) -> List[DocumentRecord]:
|
|
69
|
+
"""Normalize dictionaries or document-like objects into safe log records.
|
|
70
|
+
|
|
71
|
+
Supported object attributes are intentionally broad enough for common RAG
|
|
72
|
+
libraries without importing them: page_content, metadata, source, and score.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
return [extract_document(document, content_limit=content_limit) for document in documents]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_document(document: Any, *, content_limit: int = 500) -> DocumentRecord:
|
|
79
|
+
"""Normalize one document-like value into a JSON-serializable dictionary."""
|
|
80
|
+
|
|
81
|
+
if isinstance(document, dict):
|
|
82
|
+
content = (
|
|
83
|
+
document.get("content")
|
|
84
|
+
or document.get("page_content")
|
|
85
|
+
or document.get("text")
|
|
86
|
+
or document.get("body")
|
|
87
|
+
)
|
|
88
|
+
metadata = document.get("metadata") or {}
|
|
89
|
+
source = document.get("source")
|
|
90
|
+
if source is None and isinstance(metadata, dict):
|
|
91
|
+
source = metadata.get("source")
|
|
92
|
+
score = document.get("score")
|
|
93
|
+
if score is None:
|
|
94
|
+
score = document.get("similarity_score")
|
|
95
|
+
else:
|
|
96
|
+
content = getattr(document, "page_content", None) or getattr(document, "content", None)
|
|
97
|
+
metadata = getattr(document, "metadata", {}) or {}
|
|
98
|
+
source = getattr(document, "source", None)
|
|
99
|
+
if source is None and isinstance(metadata, dict):
|
|
100
|
+
source = metadata.get("source")
|
|
101
|
+
score = getattr(document, "score", None)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"content_preview": preview_text(content, limit=content_limit),
|
|
105
|
+
"metadata": make_json_safe(metadata),
|
|
106
|
+
"source": source,
|
|
107
|
+
"score": score,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def make_json_safe(value: Any) -> Any:
|
|
112
|
+
"""Convert common Python values into JSON-safe structures."""
|
|
113
|
+
|
|
114
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
115
|
+
return value
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
return {str(key): make_json_safe(item) for key, item in value.items()}
|
|
118
|
+
if isinstance(value, (list, tuple, set)):
|
|
119
|
+
return [make_json_safe(item) for item in value]
|
|
120
|
+
return str(value)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Trace object used to record one RAG execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import traceback
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import Any, List, Optional
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from rag_failcase_kit.schema import extract_documents, new_trace_record, preview_text
|
|
11
|
+
from rag_failcase_kit.writer import JsonlWriter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RagTrace:
|
|
15
|
+
"""Collect structured events for one RAG pipeline run."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, project: str, query: str, writer: JsonlWriter) -> None:
|
|
18
|
+
self.project = project
|
|
19
|
+
self.query = query
|
|
20
|
+
self.writer = writer
|
|
21
|
+
self.trace_id = str(uuid4())
|
|
22
|
+
self.record = new_trace_record(
|
|
23
|
+
trace_id=self.trace_id,
|
|
24
|
+
project=self.project,
|
|
25
|
+
query=self.query,
|
|
26
|
+
)
|
|
27
|
+
self._started_at = perf_counter()
|
|
28
|
+
self._finished = False
|
|
29
|
+
|
|
30
|
+
def __enter__(self) -> "RagTrace":
|
|
31
|
+
"""Enter the trace context manager."""
|
|
32
|
+
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
36
|
+
"""Finish the trace when leaving a context manager.
|
|
37
|
+
|
|
38
|
+
Exceptions are logged and then allowed to propagate.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if exc is not None:
|
|
42
|
+
self.log_error(exc)
|
|
43
|
+
self.finish(status="error")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if not self._finished:
|
|
47
|
+
self.finish()
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def log_retrieval(
|
|
51
|
+
self,
|
|
52
|
+
documents: List[Any],
|
|
53
|
+
top_k: Optional[int] = None,
|
|
54
|
+
min_score: Optional[float] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Log retrieved documents and retrieval parameters."""
|
|
57
|
+
|
|
58
|
+
normalized_documents = extract_documents(documents)
|
|
59
|
+
self.record["retrieval"] = {
|
|
60
|
+
"documents": normalized_documents,
|
|
61
|
+
"top_k": top_k,
|
|
62
|
+
"min_score": min_score,
|
|
63
|
+
}
|
|
64
|
+
self.record["retrieved_count"] = len(normalized_documents)
|
|
65
|
+
|
|
66
|
+
def log_fallback(self, strategy: str, reason: Optional[str] = None) -> None:
|
|
67
|
+
"""Log that fallback behavior was used."""
|
|
68
|
+
|
|
69
|
+
self.record["fallback"] = {
|
|
70
|
+
"used": True,
|
|
71
|
+
"strategy": strategy,
|
|
72
|
+
"reason": reason,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def log_answer(self, answer: Any) -> None:
|
|
76
|
+
"""Log the generated answer and a bounded preview."""
|
|
77
|
+
|
|
78
|
+
self.record["answer"] = answer
|
|
79
|
+
self.record["answer_preview"] = preview_text(answer)
|
|
80
|
+
|
|
81
|
+
def log_error(self, error: Any) -> None:
|
|
82
|
+
"""Log an error raised or observed during the RAG run."""
|
|
83
|
+
|
|
84
|
+
if isinstance(error, BaseException):
|
|
85
|
+
formatted_traceback = None
|
|
86
|
+
if error.__traceback__ is not None:
|
|
87
|
+
formatted_traceback = "".join(
|
|
88
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
89
|
+
)
|
|
90
|
+
self.record["error"] = {
|
|
91
|
+
"type": error.__class__.__name__,
|
|
92
|
+
"message": str(error),
|
|
93
|
+
"traceback": formatted_traceback,
|
|
94
|
+
}
|
|
95
|
+
else:
|
|
96
|
+
self.record["error"] = {
|
|
97
|
+
"type": type(error).__name__,
|
|
98
|
+
"message": str(error),
|
|
99
|
+
"traceback": None,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def finish(self, status: str = "success") -> None:
|
|
103
|
+
"""Finalize and write the trace exactly once."""
|
|
104
|
+
|
|
105
|
+
if self._finished:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
elapsed_ms = (perf_counter() - self._started_at) * 1000
|
|
109
|
+
self.record["latency_ms"] = round(elapsed_ms, 3)
|
|
110
|
+
self.record["status"] = status
|
|
111
|
+
self.writer.write(self.record)
|
|
112
|
+
self._finished = True
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""JSONL writer for trace records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JsonlWriter:
|
|
11
|
+
"""Append JSON records to a JSONL file."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, log_path: Union[str, Path]) -> None:
|
|
14
|
+
self.log_path = Path(log_path)
|
|
15
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
def write(self, record: Dict[str, Any]) -> None:
|
|
18
|
+
"""Append one JSON-serializable record as a single line."""
|
|
19
|
+
|
|
20
|
+
with self.log_path.open("a", encoding="utf-8") as file:
|
|
21
|
+
json.dump(record, file, ensure_ascii=False, default=str)
|
|
22
|
+
file.write("\n")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from rag_failcase_kit.cli import main
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_cli_summary_outputs_counts(tmp_path, capsys):
|
|
7
|
+
log_path = tmp_path / "traces.jsonl"
|
|
8
|
+
records = [
|
|
9
|
+
{"trace_id": "1", "status": "success", "fallback": {"used": False}, "latency_ms": 10},
|
|
10
|
+
{"trace_id": "2", "status": "error", "fallback": {"used": True}, "latency_ms": 30},
|
|
11
|
+
]
|
|
12
|
+
log_path.write_text(
|
|
13
|
+
"\n".join(json.dumps(record) for record in records) + "\n",
|
|
14
|
+
encoding="utf-8",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
exit_code = main(["summary", str(log_path)])
|
|
18
|
+
|
|
19
|
+
captured = capsys.readouterr()
|
|
20
|
+
summary = json.loads(captured.out)
|
|
21
|
+
assert exit_code == 0
|
|
22
|
+
assert summary["total_traces"] == 2
|
|
23
|
+
assert summary["successes"] == 1
|
|
24
|
+
assert summary["failures"] == 1
|
|
25
|
+
assert summary["fallback_used"] == 1
|
|
26
|
+
assert summary["avg_latency_ms"] == 20
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from rag_failcase_kit import RagLogger
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_logger_initialization_creates_log_directory(tmp_path):
|
|
5
|
+
log_path = tmp_path / "nested" / "traces.jsonl"
|
|
6
|
+
|
|
7
|
+
logger = RagLogger(project="test-project", log_path=log_path)
|
|
8
|
+
|
|
9
|
+
assert logger.project == "test-project"
|
|
10
|
+
assert logger.log_path == log_path
|
|
11
|
+
assert log_path.parent.exists()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from rag_failcase_kit import RagLogger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_first_record(log_path):
|
|
7
|
+
return json.loads(log_path.read_text(encoding="utf-8").splitlines()[0])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_trace_saves_jsonl_record(tmp_path):
|
|
11
|
+
log_path = tmp_path / "traces.jsonl"
|
|
12
|
+
logger = RagLogger(project="demo", log_path=log_path)
|
|
13
|
+
|
|
14
|
+
with logger.trace("hello") as trace:
|
|
15
|
+
trace.log_answer("world")
|
|
16
|
+
|
|
17
|
+
record = read_first_record(log_path)
|
|
18
|
+
assert record["project"] == "demo"
|
|
19
|
+
assert record["query"] == "hello"
|
|
20
|
+
assert record["answer_preview"] == "world"
|
|
21
|
+
assert record["status"] == "success"
|
|
22
|
+
assert record["trace_id"]
|
|
23
|
+
assert isinstance(record["latency_ms"], float)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_retrieval_logging_handles_dicts_and_objects(tmp_path):
|
|
27
|
+
class Document:
|
|
28
|
+
page_content = "object document content"
|
|
29
|
+
metadata = {"source": "object.md"}
|
|
30
|
+
score = 0.81
|
|
31
|
+
|
|
32
|
+
log_path = tmp_path / "traces.jsonl"
|
|
33
|
+
logger = RagLogger(project="demo", log_path=log_path)
|
|
34
|
+
|
|
35
|
+
with logger.trace("query") as trace:
|
|
36
|
+
trace.log_retrieval(
|
|
37
|
+
[
|
|
38
|
+
{"content": "dict document content", "source": "dict.md", "score": 0.95},
|
|
39
|
+
Document(),
|
|
40
|
+
],
|
|
41
|
+
top_k=2,
|
|
42
|
+
min_score=0.8,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
record = read_first_record(log_path)
|
|
46
|
+
assert record["retrieved_count"] == 2
|
|
47
|
+
assert record["retrieval"]["top_k"] == 2
|
|
48
|
+
assert record["retrieval"]["min_score"] == 0.8
|
|
49
|
+
assert record["retrieval"]["documents"][0]["content_preview"] == "dict document content"
|
|
50
|
+
assert record["retrieval"]["documents"][1]["source"] == "object.md"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_fallback_logging(tmp_path):
|
|
54
|
+
log_path = tmp_path / "traces.jsonl"
|
|
55
|
+
logger = RagLogger(project="demo", log_path=log_path)
|
|
56
|
+
|
|
57
|
+
with logger.trace("query") as trace:
|
|
58
|
+
trace.log_fallback("no_results", reason="Retriever returned nothing.")
|
|
59
|
+
|
|
60
|
+
record = read_first_record(log_path)
|
|
61
|
+
assert record["fallback"]["used"] is True
|
|
62
|
+
assert record["fallback"]["strategy"] == "no_results"
|
|
63
|
+
assert record["fallback"]["reason"] == "Retriever returned nothing."
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_error_logging_with_explicit_finish(tmp_path):
|
|
67
|
+
log_path = tmp_path / "traces.jsonl"
|
|
68
|
+
logger = RagLogger(project="demo", log_path=log_path)
|
|
69
|
+
|
|
70
|
+
with logger.trace("query") as trace:
|
|
71
|
+
trace.log_error(ValueError("bad answer"))
|
|
72
|
+
trace.finish(status="error")
|
|
73
|
+
|
|
74
|
+
record = read_first_record(log_path)
|
|
75
|
+
assert record["status"] == "error"
|
|
76
|
+
assert record["error"]["type"] == "ValueError"
|
|
77
|
+
assert record["error"]["message"] == "bad answer"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from rag_failcase_kit.writer import JsonlWriter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_writer_appends_single_json_line(tmp_path):
|
|
7
|
+
log_path = tmp_path / "logs" / "trace.jsonl"
|
|
8
|
+
writer = JsonlWriter(log_path)
|
|
9
|
+
|
|
10
|
+
writer.write({"trace_id": "abc", "status": "success"})
|
|
11
|
+
|
|
12
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
13
|
+
assert len(lines) == 1
|
|
14
|
+
assert json.loads(lines[0]) == {"trace_id": "abc", "status": "success"}
|