contexttrace 1.2.0__tar.gz → 1.3.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.
Files changed (129) hide show
  1. {contexttrace-1.2.0 → contexttrace-1.3.0}/PKG-INFO +59 -2
  2. {contexttrace-1.2.0 → contexttrace-1.3.0}/README.md +57 -0
  3. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/__init__.py +13 -2
  4. contexttrace-1.3.0/contexttrace/_version.py +1 -0
  5. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/cli.py +37 -3
  6. contexttrace-1.3.0/contexttrace/evidence_integrity.py +257 -0
  7. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/__init__.py +10 -2
  8. contexttrace-1.3.0/contexttrace/integrations/_lineage.py +34 -0
  9. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/langchain.py +46 -0
  10. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/llamaindex.py +50 -0
  11. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/repair.py +77 -6
  12. contexttrace-1.3.0/contexttrace/verify/atomic_coverage.py +312 -0
  13. contexttrace-1.3.0/contexttrace/verify/local_quality.py +884 -0
  14. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/trace_inspect.py +3 -0
  15. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace.egg-info/SOURCES.txt +4 -0
  16. {contexttrace-1.2.0 → contexttrace-1.3.0}/pyproject.toml +2 -2
  17. contexttrace-1.2.0/contexttrace/_version.py +0 -1
  18. {contexttrace-1.2.0 → contexttrace-1.3.0}/LICENSE +0 -0
  19. {contexttrace-1.2.0 → contexttrace-1.3.0}/MANIFEST.in +0 -0
  20. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/capture.py +0 -0
  21. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/capture_endpoint.py +0 -0
  22. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/client.py +0 -0
  23. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/config.py +0 -0
  24. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/contracts.py +0 -0
  25. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/demo.py +0 -0
  26. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/demo_data.py +0 -0
  27. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/diagnose.py +0 -0
  28. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/diagnose_report.py +0 -0
  29. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/endpoint_eval.py +0 -0
  30. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/errors.py +0 -0
  31. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/evaluator.py +0 -0
  32. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/fastapi.py +0 -0
  33. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/langgraph.py +0 -0
  34. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/integrations/opentelemetry.py +0 -0
  35. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/local.py +0 -0
  36. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/privacy.py +0 -0
  37. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/py.typed +0 -0
  38. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/regression.py +0 -0
  39. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/reliability.py +0 -0
  40. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/report.py +0 -0
  41. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/__init__.py +0 -0
  42. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/claim-verification-hybrid-v2.schema.json +0 -0
  43. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/claim-verification-v1.schema.json +0 -0
  44. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/claim-verification-v2.schema.json +0 -0
  45. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/diagnosis-v1.schema.json +0 -0
  46. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/regression-case-v1.schema.json +0 -0
  47. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/repair-plan-v1.schema.json +0 -0
  48. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/schemas/trace-v1.schema.json +0 -0
  49. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/storage/__init__.py +0 -0
  50. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/storage/sqlite_store.py +0 -0
  51. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/thresholds.py +0 -0
  52. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/transport.py +0 -0
  53. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/__init__.py +0 -0
  54. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/abstention.py +0 -0
  55. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/audit.py +0 -0
  56. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/audit_benchmark.py +0 -0
  57. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/audit_benchmark_cases.json +0 -0
  58. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/audit_report.py +0 -0
  59. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/benchmark.py +0 -0
  60. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/calibration.py +0 -0
  61. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/citations.py +0 -0
  62. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/claims.py +0 -0
  63. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/compare.py +0 -0
  64. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/compare_report.py +0 -0
  65. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/demos.py +0 -0
  66. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/evidence.py +0 -0
  67. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/external_benchmark_cases.json +0 -0
  68. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/facts.py +0 -0
  69. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/__init__.py +0 -0
  70. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/constants.py +0 -0
  71. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/development_manifest.json +0 -0
  72. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/relations.py +0 -0
  73. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/runner.py +0 -0
  74. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/hybrid_v2/schema.py +0 -0
  75. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/judges.py +0 -0
  76. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/local_ml.py +0 -0
  77. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/local_nli.py +0 -0
  78. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/nli_calibration.py +0 -0
  79. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/public_holdout_cases.json +0 -0
  80. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/qa.py +0 -0
  81. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/qa_report.py +0 -0
  82. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/real_benchmark_cases.json +0 -0
  83. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/report.py +0 -0
  84. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/root_cause.py +0 -0
  85. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/rulepacks/generic_v1.yaml +0 -0
  86. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/rulepacks/legacy_ragtruth_calibrated.yaml +0 -0
  87. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/rulepacks/policy.yaml +0 -0
  88. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/rulepacks/temporal.yaml +0 -0
  89. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/runner.py +0 -0
  90. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/schema.py +0 -0
  91. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core/__init__.py +0 -0
  92. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/__init__.py +0 -0
  93. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/cascade.py +0 -0
  94. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/citations.py +0 -0
  95. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/claims.py +0 -0
  96. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/constants.py +0 -0
  97. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/diagnosis.py +0 -0
  98. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/limits.py +0 -0
  99. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/nli.py +0 -0
  100. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/profile.py +0 -0
  101. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/rulepacks/generic_v2.json +0 -0
  102. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/rulepacks/source_condition_v2.json +0 -0
  103. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/rulepacks.py +0 -0
  104. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/runner.py +0 -0
  105. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/schema.py +0 -0
  106. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2/source.py +0 -0
  107. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/__init__.py +0 -0
  108. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/artifacts/__init__.py +0 -0
  109. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/artifacts/support-risk-v1.json +0 -0
  110. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/attribution.py +0 -0
  111. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/bounded.py +0 -0
  112. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/checker.py +0 -0
  113. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/claims.py +0 -0
  114. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/grouping.py +0 -0
  115. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/profile.py +0 -0
  116. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/risk_model.py +0 -0
  117. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/runner.py +0 -0
  118. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_core_v2_1/source.py +0 -0
  119. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/semantic_normalization.py +0 -0
  120. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/source_trust.py +0 -0
  121. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/spans.py +0 -0
  122. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/statuses.py +0 -0
  123. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/suite.py +0 -0
  124. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/suite_report.py +0 -0
  125. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/triage.py +0 -0
  126. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/verify/verdicts.py +0 -0
  127. {contexttrace-1.2.0 → contexttrace-1.3.0}/contexttrace/viewer.py +0 -0
  128. {contexttrace-1.2.0 → contexttrace-1.3.0}/setup.cfg +0 -0
  129. {contexttrace-1.2.0 → contexttrace-1.3.0}/setup.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contexttrace
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Local-first evidence-chain debugger for RAG and AI agent claim grounding, citation checks, root-cause diagnosis, and regression tests.
5
- Author: ContextTrace contributors
5
+ Author: Samarth Vinayaka
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/samarth1412/Context-Trace
8
8
  Project-URL: Documentation, https://github.com/samarth1412/Context-Trace/tree/main/docs
@@ -149,6 +149,63 @@ ContextTrace classifies each claim as `supported`, `partially_supported`, `unsup
149
149
 
150
150
  Important: `supported` means grounded by the selected evidence span. It does not mean independently true, current, or authoritative.
151
151
 
152
+ ### Audit evidence transformations
153
+
154
+ Instrument a selector or chunker with captured source lineage, then check
155
+ whether it dropped a linked answer, declared qualifier, condition, or value:
156
+
157
+ ```python
158
+ from contexttrace import build_evidence_lineage, capture_rag_trace
159
+
160
+ source = "Question: When? Answer: After approval."
161
+ lineage = build_evidence_lineage(
162
+ source_unit_id="refund_qa",
163
+ source_text=source,
164
+ linked_parts=[
165
+ {"id": "question", "role": "question", "text": "Question: When?"},
166
+ {"id": "answer", "role": "answer", "text": "Answer: After approval."},
167
+ ],
168
+ )
169
+ trace = capture_rag_trace(
170
+ query="When?",
171
+ answer="After approval.",
172
+ contexts=[{"id": "selected", "text": "Question: When?", "metadata": lineage}],
173
+ )
174
+ ```
175
+
176
+ At a framework selection boundary, clone and bind the selected object directly:
177
+
178
+ ```python
179
+ from contexttrace import (
180
+ bind_langchain_evidence_lineage,
181
+ bind_llamaindex_evidence_lineage,
182
+ )
183
+
184
+ selected_document = bind_langchain_evidence_lineage(
185
+ selected_document,
186
+ source_document=parent_document,
187
+ material_spans=declared_material_spans,
188
+ )
189
+ selected_node = bind_llamaindex_evidence_lineage(
190
+ selected_node,
191
+ source_node=parent_node,
192
+ material_spans=declared_material_spans,
193
+ )
194
+ ```
195
+
196
+ Both helpers preserve the original object and existing callback paths carry the
197
+ lineage metadata into ContextTrace.
198
+
199
+ ```bash
200
+ contexttrace inspect trace.json --fail-on evidence_integrity
201
+ contexttrace repair trace.json --out repair.md
202
+ ```
203
+
204
+ The audit runs locally with no model or network calls. Missing lineage stays
205
+ unknown, and the stable claim verifier remains unchanged. See the
206
+ [evidence-integrity contract](../../docs/evidence-integrity-v1.3.md) and
207
+ [offline examples](../../examples/evidence_integrity/README.md).
208
+
152
209
  ## Diagnose An Agent Trace
153
210
 
154
211
  `diagnose` also accepts agent step traces and localizes tool/final-answer
@@ -74,6 +74,63 @@ ContextTrace classifies each claim as `supported`, `partially_supported`, `unsup
74
74
 
75
75
  Important: `supported` means grounded by the selected evidence span. It does not mean independently true, current, or authoritative.
76
76
 
77
+ ### Audit evidence transformations
78
+
79
+ Instrument a selector or chunker with captured source lineage, then check
80
+ whether it dropped a linked answer, declared qualifier, condition, or value:
81
+
82
+ ```python
83
+ from contexttrace import build_evidence_lineage, capture_rag_trace
84
+
85
+ source = "Question: When? Answer: After approval."
86
+ lineage = build_evidence_lineage(
87
+ source_unit_id="refund_qa",
88
+ source_text=source,
89
+ linked_parts=[
90
+ {"id": "question", "role": "question", "text": "Question: When?"},
91
+ {"id": "answer", "role": "answer", "text": "Answer: After approval."},
92
+ ],
93
+ )
94
+ trace = capture_rag_trace(
95
+ query="When?",
96
+ answer="After approval.",
97
+ contexts=[{"id": "selected", "text": "Question: When?", "metadata": lineage}],
98
+ )
99
+ ```
100
+
101
+ At a framework selection boundary, clone and bind the selected object directly:
102
+
103
+ ```python
104
+ from contexttrace import (
105
+ bind_langchain_evidence_lineage,
106
+ bind_llamaindex_evidence_lineage,
107
+ )
108
+
109
+ selected_document = bind_langchain_evidence_lineage(
110
+ selected_document,
111
+ source_document=parent_document,
112
+ material_spans=declared_material_spans,
113
+ )
114
+ selected_node = bind_llamaindex_evidence_lineage(
115
+ selected_node,
116
+ source_node=parent_node,
117
+ material_spans=declared_material_spans,
118
+ )
119
+ ```
120
+
121
+ Both helpers preserve the original object and existing callback paths carry the
122
+ lineage metadata into ContextTrace.
123
+
124
+ ```bash
125
+ contexttrace inspect trace.json --fail-on evidence_integrity
126
+ contexttrace repair trace.json --out repair.md
127
+ ```
128
+
129
+ The audit runs locally with no model or network calls. Missing lineage stays
130
+ unknown, and the stable claim verifier remains unchanged. See the
131
+ [evidence-integrity contract](../../docs/evidence-integrity-v1.3.md) and
132
+ [offline examples](../../examples/evidence_integrity/README.md).
133
+
77
134
  ## Diagnose An Agent Trace
78
135
 
79
136
  `diagnose` also accepts agent step traces and localizes tool/final-answer
@@ -12,10 +12,17 @@ from contexttrace.errors import (
12
12
  ContextTraceHTTPError,
13
13
  ContextTraceLocalError,
14
14
  )
15
+ from contexttrace.evidence_integrity import audit_evidence_integrity, build_evidence_lineage
15
16
  from contexttrace.integrations.fastapi import ContextTraceFastAPIMiddleware
16
- from contexttrace.integrations.langchain import ContextTraceCallbackHandler
17
+ from contexttrace.integrations.langchain import (
18
+ ContextTraceCallbackHandler,
19
+ bind_langchain_evidence_lineage,
20
+ )
17
21
  from contexttrace.integrations.langgraph import ContextTraceLangGraphTracer
18
- from contexttrace.integrations.llamaindex import ContextTraceLlamaIndexCallbackHandler
22
+ from contexttrace.integrations.llamaindex import (
23
+ ContextTraceLlamaIndexCallbackHandler,
24
+ bind_llamaindex_evidence_lineage,
25
+ )
19
26
  from contexttrace.integrations.opentelemetry import OpenTelemetryExporter, export_contexttrace_trace
20
27
  from contexttrace.privacy import PrivacyPolicy, TextCipher
21
28
  from contexttrace.reliability import ReliabilityScore, ReliabilityScorer
@@ -47,7 +54,11 @@ __all__ = [
47
54
  "capture_response_trace",
48
55
  "build_repair_plan",
49
56
  "build_regression_case",
57
+ "build_evidence_lineage",
58
+ "bind_langchain_evidence_lineage",
59
+ "bind_llamaindex_evidence_lineage",
50
60
  "diagnose_payload",
61
+ "audit_evidence_integrity",
51
62
  "diagnose_trace_file",
52
63
  "write_diagnosis_regression_test",
53
64
  "export_contexttrace_trace",
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -405,7 +405,13 @@ def verify_v2_command(
405
405
  @cli.command("inspect")
406
406
  @click.argument("trace_json")
407
407
  @click.option("--json", "json_output", is_flag=True, help="Print trace inspection as JSON.")
408
- def inspect_command(trace_json: str, json_output: bool) -> int:
408
+ @click.option(
409
+ "--fail-on",
410
+ multiple=True,
411
+ type=click.Choice(["warning", "evidence_integrity", "unknown_integrity"]),
412
+ help="Exit nonzero on trace warnings, observed evidence-integrity issues, or uncaptured lineage.",
413
+ )
414
+ def inspect_command(trace_json: str, json_output: bool, fail_on: tuple[str, ...]) -> int:
409
415
  """Inspect portable RAG trace shape before verification."""
410
416
 
411
417
  try:
@@ -416,7 +422,7 @@ def inspect_command(trace_json: str, json_output: bool) -> int:
416
422
  result = inspect_trace(trace, trace_path=trace_json)
417
423
  if json_output:
418
424
  click.echo(json.dumps(result, indent=2))
419
- return 0
425
+ return _inspect_exit_code(result, fail_on)
420
426
 
421
427
  click.echo("Trace: %s" % trace_json)
422
428
  click.echo("Query: %s" % result["query"])
@@ -440,6 +446,21 @@ def inspect_command(trace_json: str, json_output: bool) -> int:
440
446
  if result["metadata_keys"]:
441
447
  click.echo("Metadata keys: %s" % ", ".join(result["metadata_keys"]))
442
448
 
449
+ integrity = result["evidence_integrity"]
450
+ integrity_summary = integrity["summary"]
451
+ click.echo(
452
+ "Evidence integrity: %s (%s issues, %s assessed, %s unknown)"
453
+ % (
454
+ integrity["status"],
455
+ integrity_summary["issue_count"],
456
+ integrity_summary["assessed_contexts"],
457
+ integrity_summary["unknown_contexts"],
458
+ )
459
+ )
460
+ for issue in integrity["issues"]:
461
+ detail = issue.get("item_role") or issue.get("observed")
462
+ click.echo("- %s [%s]: %s" % (issue["type"], issue["selected_context_id"], detail))
463
+
443
464
  warnings = result["warnings"]
444
465
  if warnings:
445
466
  click.echo("Warnings:")
@@ -449,7 +470,20 @@ def inspect_command(trace_json: str, json_output: bool) -> int:
449
470
  click.echo("Suggested next commands:")
450
471
  for command in result["suggested_next_commands"]:
451
472
  click.echo("- %s" % command)
452
- return 0
473
+ return _inspect_exit_code(result, fail_on)
474
+
475
+
476
+ def _inspect_exit_code(result: dict[str, object], fail_on: tuple[str, ...]) -> int:
477
+ integrity = result.get("evidence_integrity") or {}
478
+ summary = (integrity.get("summary") or {}) if isinstance(integrity, dict) else {}
479
+ issue_count = int(summary.get("issue_count") or 0) if isinstance(summary, dict) else 0
480
+ unknown_count = int(summary.get("unknown_contexts") or 0) if isinstance(summary, dict) else 0
481
+ should_fail = (
482
+ "warning" in fail_on and bool(result.get("warnings"))
483
+ or "evidence_integrity" in fail_on and issue_count > 0
484
+ or "unknown_integrity" in fail_on and unknown_count > 0
485
+ )
486
+ return 1 if should_fail else 0
453
487
 
454
488
 
455
489
  @cli.command("diagnose")
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from typing import Any, Iterable
6
+
7
+ from contexttrace.verify.schema import RAGTrace, TraceContext
8
+
9
+
10
+ EVIDENCE_METADATA_KEY = "contexttrace_evidence"
11
+
12
+ LINKED_PART_DROPPED = "linked_part_dropped"
13
+ MATERIAL_SPAN_DROPPED = "material_span_dropped"
14
+ SELECTED_TEXT_NOT_IN_SOURCE = "selected_text_not_in_source"
15
+ INVALID_LINEAGE = "invalid_lineage"
16
+
17
+
18
+ def build_evidence_lineage(
19
+ *,
20
+ source_unit_id: str,
21
+ source_text: str,
22
+ linked_parts: Iterable[dict[str, Any]] | None = None,
23
+ material_spans: Iterable[dict[str, Any]] | None = None,
24
+ transformation: str | None = None,
25
+ ) -> dict[str, Any]:
26
+ """Build namespaced metadata for deterministic source-to-selection checks."""
27
+
28
+ unit_id = _required_text(source_unit_id, "source_unit_id")
29
+ text = _required_text(source_text, "source_text")
30
+ lineage: dict[str, Any] = {
31
+ "schema_version": "1.0",
32
+ "source_unit_id": unit_id,
33
+ "source_text": text,
34
+ }
35
+ if linked_parts is not None:
36
+ lineage["linked_parts"] = _build_parts(linked_parts, text, "linked_parts")
37
+ if material_spans is not None:
38
+ lineage["material_spans"] = _build_parts(material_spans, text, "material_spans")
39
+ if transformation is not None:
40
+ lineage["transformation"] = _required_text(transformation, "transformation")
41
+ return {EVIDENCE_METADATA_KEY: lineage}
42
+
43
+
44
+ def audit_evidence_integrity(trace: RAGTrace) -> dict[str, Any]:
45
+ """Report observed evidence loss from explicitly captured lineage metadata."""
46
+
47
+ assessments = [_assess_context(context) for context in trace.contexts]
48
+ captured = [item for item in assessments if item["capture_status"] == "captured"]
49
+ unknown = [item for item in assessments if item["capture_status"] != "captured"]
50
+ issues = [issue for item in captured for issue in item["issues"]]
51
+ if not captured:
52
+ status = "not_captured"
53
+ elif issues:
54
+ status = "issues_found"
55
+ elif unknown:
56
+ status = "partial"
57
+ else:
58
+ status = "complete"
59
+ counts: dict[str, int] = {}
60
+ for issue in issues:
61
+ issue_type = str(issue["type"])
62
+ counts[issue_type] = counts.get(issue_type, 0) + 1
63
+ return {
64
+ "schema_version": "1.0",
65
+ "status": status,
66
+ "summary": {
67
+ "total_contexts": len(trace.contexts),
68
+ "assessed_contexts": len(captured),
69
+ "unknown_contexts": len(unknown),
70
+ "issue_count": len(issues),
71
+ "issue_types": counts,
72
+ },
73
+ "issues": issues,
74
+ "contexts": assessments,
75
+ "network_calls": 0,
76
+ "model_calls": 0,
77
+ }
78
+
79
+
80
+ def _assess_context(context: TraceContext) -> dict[str, Any]:
81
+ raw = context.metadata.get(EVIDENCE_METADATA_KEY)
82
+ if raw is None:
83
+ return {
84
+ "selected_context_id": context.id,
85
+ "capture_status": "not_captured",
86
+ "reason": "No contexttrace_evidence lineage metadata was captured.",
87
+ "issues": [],
88
+ }
89
+ if not isinstance(raw, dict):
90
+ return _invalid_context(context.id, "contexttrace_evidence must be an object.")
91
+ source_unit_id = _clean(raw.get("source_unit_id"))
92
+ source_text = _clean(raw.get("source_text"))
93
+ if not source_unit_id or not source_text:
94
+ return _invalid_context(
95
+ context.id,
96
+ "Captured lineage must include non-empty source_unit_id and source_text.",
97
+ )
98
+
99
+ selected = _normalize(context.text)
100
+ source = _normalize(source_text)
101
+ linked_parts, invalid_parts = _parts(raw.get("linked_parts"), source_text, "linked_parts")
102
+ material_spans, invalid_spans = _parts(
103
+ raw.get("material_spans"), source_text, "material_spans"
104
+ )
105
+ if invalid_parts or invalid_spans:
106
+ return _invalid_context(context.id, "; ".join(invalid_parts + invalid_spans))
107
+
108
+ issues: list[dict[str, Any]] = []
109
+ if selected not in source:
110
+ issues.append(
111
+ _issue(
112
+ issue_type=SELECTED_TEXT_NOT_IN_SOURCE,
113
+ severity="high",
114
+ context_id=context.id,
115
+ source_unit_id=source_unit_id,
116
+ observed="Selected text is not a normalized verbatim span of the captured source unit.",
117
+ )
118
+ )
119
+ else:
120
+ present_parts = [
121
+ part
122
+ for part in linked_parts
123
+ if part["required"] and _normalize(part["text"]) in selected
124
+ ]
125
+ for part in linked_parts:
126
+ if not part["required"] or _normalize(part["text"]) in selected:
127
+ continue
128
+ if present_parts:
129
+ issues.append(
130
+ _issue(
131
+ issue_type=LINKED_PART_DROPPED,
132
+ severity="high",
133
+ context_id=context.id,
134
+ source_unit_id=source_unit_id,
135
+ observed="A required linked part is present in the source unit but absent from the selected text.",
136
+ item=part,
137
+ )
138
+ )
139
+ for span in material_spans:
140
+ if span["required"] and _normalize(span["text"]) not in selected:
141
+ issues.append(
142
+ _issue(
143
+ issue_type=MATERIAL_SPAN_DROPPED,
144
+ severity="high",
145
+ context_id=context.id,
146
+ source_unit_id=source_unit_id,
147
+ observed="A declared material span is present in the source unit but absent from the selected text.",
148
+ item=span,
149
+ )
150
+ )
151
+
152
+ return {
153
+ "selected_context_id": context.id,
154
+ "source_unit_id": source_unit_id,
155
+ "transformation": _clean(raw.get("transformation")) or None,
156
+ "capture_status": "captured",
157
+ "issues": issues,
158
+ }
159
+
160
+
161
+ def _parts(value: Any, source_text: str, field: str) -> tuple[list[dict[str, Any]], list[str]]:
162
+ if value is None:
163
+ return [], []
164
+ if not isinstance(value, list):
165
+ return [], ["%s must be a list." % field]
166
+ parts: list[dict[str, Any]] = []
167
+ invalid: list[str] = []
168
+ for index, item in enumerate(value):
169
+ try:
170
+ part = _part(item, index, field)
171
+ except ValueError as exc:
172
+ invalid.append(str(exc))
173
+ continue
174
+ if _normalize(part["text"]) not in _normalize(source_text):
175
+ invalid.append("%s[%s].text is not present in source_text." % (field, index))
176
+ parts.append(part)
177
+ return parts, invalid
178
+
179
+
180
+ def _build_parts(
181
+ values: Iterable[dict[str, Any]], source_text: str, field: str
182
+ ) -> list[dict[str, Any]]:
183
+ parts = [_part(value, index, field) for index, value in enumerate(values)]
184
+ for index, part in enumerate(parts):
185
+ if _normalize(part["text"]) not in _normalize(source_text):
186
+ raise ValueError("%s[%s].text must be present in source_text." % (field, index))
187
+ return parts
188
+
189
+
190
+ def _part(value: Any, index: int, field: str) -> dict[str, Any]:
191
+ if not isinstance(value, dict):
192
+ raise ValueError("%s[%s] must be an object." % (field, index))
193
+ required = value.get("required", True)
194
+ if not isinstance(required, bool):
195
+ raise ValueError("%s[%s].required must be a boolean." % (field, index))
196
+ return {
197
+ "id": _required_text(value.get("id"), "%s[%s].id" % (field, index)),
198
+ "role": _required_text(value.get("role"), "%s[%s].role" % (field, index)),
199
+ "text": _required_text(value.get("text"), "%s[%s].text" % (field, index)),
200
+ "required": required,
201
+ }
202
+
203
+
204
+ def _issue(
205
+ *,
206
+ issue_type: str,
207
+ severity: str,
208
+ context_id: str,
209
+ source_unit_id: str,
210
+ observed: str,
211
+ item: dict[str, Any] | None = None,
212
+ ) -> dict[str, Any]:
213
+ result: dict[str, Any] = {
214
+ "type": issue_type,
215
+ "status": "observed",
216
+ "severity": severity,
217
+ "selected_context_id": context_id,
218
+ "source_unit_id": source_unit_id,
219
+ "observed": observed,
220
+ }
221
+ if item is not None:
222
+ result.update(
223
+ {
224
+ "item_id": item["id"],
225
+ "item_role": item["role"],
226
+ "missing_text": item["text"],
227
+ }
228
+ )
229
+ return result
230
+
231
+
232
+ def _invalid_context(context_id: str, reason: str) -> dict[str, Any]:
233
+ return {
234
+ "selected_context_id": context_id,
235
+ "capture_status": INVALID_LINEAGE,
236
+ "reason": reason,
237
+ "issues": [],
238
+ }
239
+
240
+
241
+ def _required_text(value: Any, field: str) -> str:
242
+ if not isinstance(value, str):
243
+ raise ValueError("%s must be a non-empty string." % field)
244
+ text = value.strip()
245
+ if not text:
246
+ raise ValueError("%s must be a non-empty string." % field)
247
+ return text
248
+
249
+
250
+ def _clean(value: Any) -> str:
251
+ return "" if value is None else str(value).strip()
252
+
253
+
254
+ def _normalize(value: str) -> str:
255
+ text = unicodedata.normalize("NFKC", value)
256
+ text = text.translate(str.maketrans("“”‘’", "\"\"''"))
257
+ return re.sub(r"\s+", " ", text).strip().casefold()
@@ -1,7 +1,13 @@
1
1
  from contexttrace.integrations.fastapi import ContextTraceFastAPIMiddleware
2
- from contexttrace.integrations.langchain import ContextTraceCallbackHandler
2
+ from contexttrace.integrations.langchain import (
3
+ ContextTraceCallbackHandler,
4
+ bind_langchain_evidence_lineage,
5
+ )
3
6
  from contexttrace.integrations.langgraph import ContextTraceLangGraphTracer
4
- from contexttrace.integrations.llamaindex import ContextTraceLlamaIndexCallbackHandler
7
+ from contexttrace.integrations.llamaindex import (
8
+ ContextTraceLlamaIndexCallbackHandler,
9
+ bind_llamaindex_evidence_lineage,
10
+ )
5
11
  from contexttrace.integrations.opentelemetry import OpenTelemetryExporter, export_contexttrace_trace
6
12
 
7
13
  __all__ = [
@@ -9,6 +15,8 @@ __all__ = [
9
15
  "ContextTraceFastAPIMiddleware",
10
16
  "ContextTraceLangGraphTracer",
11
17
  "ContextTraceLlamaIndexCallbackHandler",
18
+ "bind_langchain_evidence_lineage",
19
+ "bind_llamaindex_evidence_lineage",
12
20
  "OpenTelemetryExporter",
13
21
  "export_contexttrace_trace",
14
22
  ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import copy
4
+ from typing import Any
5
+
6
+
7
+ def clone_with_updates(value: Any, **updates: Any) -> Any:
8
+ """Clone a framework value without mutating the application-owned object."""
9
+
10
+ if isinstance(value, dict):
11
+ cloned = dict(value)
12
+ cloned.update(updates)
13
+ return cloned
14
+
15
+ model_copy = getattr(value, "model_copy", None)
16
+ if callable(model_copy):
17
+ return model_copy(update=updates)
18
+
19
+ legacy_copy = getattr(value, "copy", None)
20
+ if callable(legacy_copy):
21
+ try:
22
+ return legacy_copy(update=updates)
23
+ except TypeError:
24
+ pass
25
+
26
+ try:
27
+ cloned = copy(value)
28
+ for key, item in updates.items():
29
+ setattr(cloned, key, item)
30
+ except (AttributeError, TypeError) as exc:
31
+ raise TypeError(
32
+ "Could not clone %s with evidence-lineage metadata." % type(value).__name__
33
+ ) from exc
34
+ return cloned
@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
7
7
  from typing import Any, Callable, Dict, Iterable, Optional
8
8
 
9
9
  from contexttrace.client import ContextTrace
10
+ from contexttrace.evidence_integrity import build_evidence_lineage
11
+ from contexttrace.integrations._lineage import clone_with_updates
10
12
 
11
13
  try:
12
14
  from langchain_core.callbacks import BaseCallbackHandler
@@ -436,6 +438,50 @@ def langchain_document_to_chunk(document: Any, index: int = 0) -> dict[str, Any]
436
438
  }
437
439
 
438
440
 
441
+ def bind_langchain_evidence_lineage(
442
+ selected_document: Any,
443
+ *,
444
+ source_document: Any,
445
+ source_unit_id: str | None = None,
446
+ linked_parts: Iterable[dict[str, Any]] | None = None,
447
+ material_spans: Iterable[dict[str, Any]] | None = None,
448
+ transformation: str = "langchain_document_selection",
449
+ ) -> Any:
450
+ """Clone a selected LangChain document with its captured source lineage."""
451
+
452
+ source_chunk = langchain_document_to_chunk(source_document)
453
+ selected_chunk = langchain_document_to_chunk(selected_document)
454
+ resolved_source_id = source_unit_id or _langchain_document_id(source_document)
455
+ if not resolved_source_id:
456
+ raise ValueError(
457
+ "source_unit_id is required when the source document has no chunk_id, id, or doc_id."
458
+ )
459
+ lineage = build_evidence_lineage(
460
+ source_unit_id=str(resolved_source_id),
461
+ source_text=str(source_chunk["content"]),
462
+ linked_parts=linked_parts,
463
+ material_spans=material_spans,
464
+ transformation=transformation,
465
+ )
466
+ metadata = dict(selected_chunk["metadata"])
467
+ metadata.update(lineage)
468
+ return clone_with_updates(selected_document, metadata=metadata)
469
+
470
+
471
+ def _langchain_document_id(document: Any) -> Any:
472
+ metadata = getattr(document, "metadata", None) or {}
473
+ if isinstance(document, dict):
474
+ metadata = document.get("metadata") or metadata
475
+ if not isinstance(metadata, dict):
476
+ metadata = {}
477
+ return (
478
+ metadata.get("chunk_id")
479
+ or metadata.get("id")
480
+ or metadata.get("doc_id")
481
+ or getattr(document, "id", None)
482
+ )
483
+
484
+
439
485
  def _extract_query(inputs: Any) -> Optional[str]:
440
486
  if isinstance(inputs, str):
441
487
  return inputs
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
6
6
  from typing import Any, Callable, Dict, Iterable, Optional
7
7
 
8
8
  from contexttrace.client import ContextTrace
9
+ from contexttrace.evidence_integrity import build_evidence_lineage
10
+ from contexttrace.integrations._lineage import clone_with_updates
9
11
 
10
12
  try:
11
13
  from llama_index.core.callbacks.base_handler import BaseCallbackHandler
@@ -365,6 +367,54 @@ def llamaindex_node_to_chunk(node_or_node_with_score: Any, index: int = 0) -> di
365
367
  }
366
368
 
367
369
 
370
+ def bind_llamaindex_evidence_lineage(
371
+ selected_node: Any,
372
+ *,
373
+ source_node: Any,
374
+ source_unit_id: str | None = None,
375
+ linked_parts: Iterable[dict[str, Any]] | None = None,
376
+ material_spans: Iterable[dict[str, Any]] | None = None,
377
+ transformation: str = "llamaindex_node_postprocessor",
378
+ ) -> Any:
379
+ """Clone a selected LlamaIndex node with its captured source lineage."""
380
+
381
+ source_chunk = llamaindex_node_to_chunk(source_node)
382
+ selected_chunk = llamaindex_node_to_chunk(selected_node)
383
+ resolved_source_id = source_unit_id or _llamaindex_node_id(source_node)
384
+ if not resolved_source_id:
385
+ raise ValueError(
386
+ "source_unit_id is required when the source node has no chunk_id, id, doc_id, or node_id."
387
+ )
388
+ lineage = build_evidence_lineage(
389
+ source_unit_id=str(resolved_source_id),
390
+ source_text=str(source_chunk["content"]),
391
+ linked_parts=linked_parts,
392
+ material_spans=material_spans,
393
+ transformation=transformation,
394
+ )
395
+ metadata = dict(selected_chunk["metadata"])
396
+ metadata.update(lineage)
397
+
398
+ selected_value = getattr(selected_node, "node", selected_node)
399
+ cloned_node = clone_with_updates(selected_value, metadata=metadata)
400
+ if selected_value is selected_node:
401
+ return cloned_node
402
+ return clone_with_updates(selected_node, node=cloned_node)
403
+
404
+
405
+ def _llamaindex_node_id(node_or_node_with_score: Any) -> Any:
406
+ node = getattr(node_or_node_with_score, "node", node_or_node_with_score)
407
+ metadata = _node_metadata(node)
408
+ return (
409
+ metadata.get("chunk_id")
410
+ or metadata.get("id")
411
+ or metadata.get("doc_id")
412
+ or getattr(node, "node_id", None)
413
+ or getattr(node, "id_", None)
414
+ or getattr(node, "id", None)
415
+ )
416
+
417
+
368
418
  def _node_metadata(node: Any) -> dict[str, Any]:
369
419
  metadata = getattr(node, "metadata", None)
370
420
  if metadata is None: