@seanhogg/builderforce-memory 2026.7.14 → 2026.9.27
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.
- package/README.md +10 -0
- package/dist/bridges/AnthropicBridge.d.ts +9 -1
- package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
- package/dist/bridges/AnthropicBridge.js +58 -2
- package/dist/bridges/AnthropicBridge.js.map +1 -1
- package/dist/bridges/CachingBridge.d.ts +9 -1
- package/dist/bridges/CachingBridge.d.ts.map +1 -1
- package/dist/bridges/CachingBridge.js +24 -1
- package/dist/bridges/CachingBridge.js.map +1 -1
- package/dist/bridges/OpenAIBridge.d.ts +4 -1
- package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
- package/dist/bridges/OpenAIBridge.js +40 -2
- package/dist/bridges/OpenAIBridge.js.map +1 -1
- package/dist/bridges/SemanticCachingBridge.d.ts +8 -1
- package/dist/bridges/SemanticCachingBridge.d.ts.map +1 -1
- package/dist/bridges/SemanticCachingBridge.js +26 -1
- package/dist/bridges/SemanticCachingBridge.js.map +1 -1
- package/dist/bridges/TransformerBridge.d.ts +13 -0
- package/dist/bridges/TransformerBridge.d.ts.map +1 -1
- package/dist/bridges/VertexAIBridge.d.ts +102 -0
- package/dist/bridges/VertexAIBridge.d.ts.map +1 -0
- package/dist/bridges/VertexAIBridge.js +224 -0
- package/dist/bridges/VertexAIBridge.js.map +1 -0
- package/dist/bridges/index.d.ts +3 -1
- package/dist/bridges/index.d.ts.map +1 -1
- package/dist/bridges/index.js +1 -0
- package/dist/bridges/index.js.map +1 -1
- package/dist/eval/EvalHarness.d.ts +37 -0
- package/dist/eval/EvalHarness.d.ts.map +1 -0
- package/dist/eval/EvalHarness.js +178 -0
- package/dist/eval/EvalHarness.js.map +1 -0
- package/dist/eval/graders.d.ts +76 -0
- package/dist/eval/graders.d.ts.map +1 -0
- package/dist/eval/graders.js +168 -0
- package/dist/eval/graders.js.map +1 -0
- package/dist/eval/index.d.ts +26 -0
- package/dist/eval/index.d.ts.map +1 -0
- package/dist/eval/index.js +26 -0
- package/dist/eval/index.js.map +1 -0
- package/dist/eval/types.d.ts +97 -0
- package/dist/eval/types.d.ts.map +1 -0
- package/dist/eval/types.js +5 -0
- package/dist/eval/types.js.map +1 -0
- package/dist/evermind/contract.d.ts +40 -0
- package/dist/evermind/contract.d.ts.map +1 -0
- package/dist/evermind/contract.js +13 -0
- package/dist/evermind/contract.js.map +1 -0
- package/dist/evermind/index.d.ts +15 -0
- package/dist/evermind/index.d.ts.map +1 -0
- package/dist/evermind/index.js +4 -0
- package/dist/evermind/index.js.map +1 -0
- package/dist/evermind/memoryBlock.d.ts +7 -0
- package/dist/evermind/memoryBlock.d.ts.map +1 -0
- package/dist/evermind/memoryBlock.js +37 -0
- package/dist/evermind/memoryBlock.js.map +1 -0
- package/dist/evermind/recall.d.ts +55 -0
- package/dist/evermind/recall.d.ts.map +1 -0
- package/dist/evermind/recall.js +105 -0
- package/dist/evermind/recall.js.map +1 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/ingest/IngestionPipeline.d.ts +95 -0
- package/dist/ingest/IngestionPipeline.d.ts.map +1 -0
- package/dist/ingest/IngestionPipeline.js +295 -0
- package/dist/ingest/IngestionPipeline.js.map +1 -0
- package/dist/ingest/index.d.ts +10 -0
- package/dist/ingest/index.d.ts.map +1 -0
- package/dist/ingest/index.js +8 -0
- package/dist/ingest/index.js.map +1 -0
- package/dist/ingest/parsers.d.ts +53 -0
- package/dist/ingest/parsers.d.ts.map +1 -0
- package/dist/ingest/parsers.js +306 -0
- package/dist/ingest/parsers.js.map +1 -0
- package/dist/ingest/types.d.ts +114 -0
- package/dist/ingest/types.d.ts.map +1 -0
- package/dist/ingest/types.js +19 -0
- package/dist/ingest/types.js.map +1 -0
- package/dist/memory/MemoryStore.d.ts +33 -0
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +29 -3
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/orchestration/AgentGraph.d.ts +104 -0
- package/dist/orchestration/AgentGraph.d.ts.map +1 -0
- package/dist/orchestration/AgentGraph.js +364 -0
- package/dist/orchestration/AgentGraph.js.map +1 -0
- package/dist/orchestration/checkpoint.d.ts +58 -0
- package/dist/orchestration/checkpoint.d.ts.map +1 -0
- package/dist/orchestration/checkpoint.js +92 -0
- package/dist/orchestration/checkpoint.js.map +1 -0
- package/dist/orchestration/index.d.ts +14 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +10 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/patterns.d.ts +159 -0
- package/dist/orchestration/patterns.d.ts.map +1 -0
- package/dist/orchestration/patterns.js +371 -0
- package/dist/orchestration/patterns.js.map +1 -0
- package/dist/orchestration/types.d.ts +146 -0
- package/dist/orchestration/types.d.ts.map +1 -0
- package/dist/orchestration/types.js +20 -0
- package/dist/orchestration/types.js.map +1 -0
- package/dist/rag/EnterpriseRetriever.d.ts +114 -0
- package/dist/rag/EnterpriseRetriever.d.ts.map +1 -0
- package/dist/rag/EnterpriseRetriever.js +240 -0
- package/dist/rag/EnterpriseRetriever.js.map +1 -0
- package/dist/rag/index.d.ts +6 -0
- package/dist/rag/index.d.ts.map +1 -0
- package/dist/rag/index.js +5 -0
- package/dist/rag/index.js.map +1 -0
- package/dist/retrieval/bm25.d.ts +48 -1
- package/dist/retrieval/bm25.d.ts.map +1 -1
- package/dist/retrieval/bm25.js +59 -13
- package/dist/retrieval/bm25.js.map +1 -1
- package/dist/retrieval/index.d.ts +2 -2
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +1 -1
- package/dist/retrieval/index.js.map +1 -1
- package/dist/telemetry/InstrumentedBridge.d.ts +62 -0
- package/dist/telemetry/InstrumentedBridge.d.ts.map +1 -0
- package/dist/telemetry/InstrumentedBridge.js +120 -0
- package/dist/telemetry/InstrumentedBridge.js.map +1 -0
- package/dist/telemetry/MetricsRegistry.d.ts +88 -0
- package/dist/telemetry/MetricsRegistry.d.ts.map +1 -0
- package/dist/telemetry/MetricsRegistry.js +167 -0
- package/dist/telemetry/MetricsRegistry.js.map +1 -0
- package/dist/telemetry/Tracer.d.ts +137 -0
- package/dist/telemetry/Tracer.d.ts.map +1 -0
- package/dist/telemetry/Tracer.js +244 -0
- package/dist/telemetry/Tracer.js.map +1 -0
- package/dist/telemetry/exporters.d.ts +68 -0
- package/dist/telemetry/exporters.d.ts.map +1 -0
- package/dist/telemetry/exporters.js +145 -0
- package/dist/telemetry/exporters.js.map +1 -0
- package/dist/telemetry/index.d.ts +19 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +12 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/pricing.d.ts +74 -0
- package/dist/telemetry/pricing.d.ts.map +1 -0
- package/dist/telemetry/pricing.js +98 -0
- package/dist/telemetry/pricing.js.map +1 -0
- package/dist/telemetry/traceparent.d.ts +50 -0
- package/dist/telemetry/traceparent.d.ts.map +1 -0
- package/dist/telemetry/traceparent.js +77 -0
- package/dist/telemetry/traceparent.js.map +1 -0
- package/dist/telemetry/types.d.ts +90 -0
- package/dist/telemetry/types.d.ts.map +1 -0
- package/dist/telemetry/types.js +17 -0
- package/dist/telemetry/types.js.map +1 -0
- package/dist/vectorstore/MemoryVectorStore.d.ts +72 -0
- package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -0
- package/dist/vectorstore/MemoryVectorStore.js +205 -0
- package/dist/vectorstore/MemoryVectorStore.js.map +1 -0
- package/dist/vectorstore/RestVectorStore.d.ts +79 -0
- package/dist/vectorstore/RestVectorStore.d.ts.map +1 -0
- package/dist/vectorstore/RestVectorStore.js +170 -0
- package/dist/vectorstore/RestVectorStore.js.map +1 -0
- package/dist/vectorstore/dialects.d.ts +101 -0
- package/dist/vectorstore/dialects.d.ts.map +1 -0
- package/dist/vectorstore/dialects.js +618 -0
- package/dist/vectorstore/dialects.js.map +1 -0
- package/dist/vectorstore/filter.d.ts +42 -0
- package/dist/vectorstore/filter.d.ts.map +1 -0
- package/dist/vectorstore/filter.js +132 -0
- package/dist/vectorstore/filter.js.map +1 -0
- package/dist/vectorstore/index.d.ts +15 -0
- package/dist/vectorstore/index.d.ts.map +1 -0
- package/dist/vectorstore/index.js +11 -0
- package/dist/vectorstore/index.js.map +1 -0
- package/dist/vectorstore/types.d.ts +151 -0
- package/dist/vectorstore/types.d.ts.map +1 -0
- package/dist/vectorstore/types.js +30 -0
- package/dist/vectorstore/types.js.map +1 -0
- package/package.json +39 -5
- package/src/bridges/AnthropicBridge.ts +68 -3
- package/src/bridges/CachingBridge.ts +28 -2
- package/src/bridges/OpenAIBridge.ts +48 -3
- package/src/bridges/SemanticCachingBridge.ts +29 -2
- package/src/bridges/TransformerBridge.ts +16 -0
- package/src/bridges/VertexAIBridge.ts +297 -0
- package/src/bridges/index.ts +3 -1
- package/src/eval/EvalHarness.ts +214 -0
- package/src/eval/graders.ts +218 -0
- package/src/eval/index.ts +26 -0
- package/src/eval/types.ts +107 -0
- package/src/evermind/contract.ts +39 -0
- package/src/evermind/index.ts +21 -0
- package/src/evermind/memoryBlock.ts +38 -0
- package/src/evermind/recall.ts +122 -0
- package/src/index.ts +232 -0
- package/src/ingest/IngestionPipeline.ts +378 -0
- package/src/ingest/index.ts +46 -0
- package/src/ingest/parsers.ts +318 -0
- package/src/ingest/types.ts +116 -0
- package/src/memory/MemoryStore.ts +55 -3
- package/src/memory/index.ts +1 -1
- package/src/orchestration/AgentGraph.ts +462 -0
- package/src/orchestration/checkpoint.ts +129 -0
- package/src/orchestration/index.ts +63 -0
- package/src/orchestration/patterns.ts +532 -0
- package/src/orchestration/types.ts +128 -0
- package/src/rag/EnterpriseRetriever.ts +348 -0
- package/src/rag/index.ts +18 -0
- package/src/retrieval/bm25.ts +71 -13
- package/src/retrieval/index.ts +2 -2
- package/src/telemetry/InstrumentedBridge.ts +152 -0
- package/src/telemetry/MetricsRegistry.ts +263 -0
- package/src/telemetry/Tracer.ts +310 -0
- package/src/telemetry/exporters.ts +187 -0
- package/src/telemetry/index.ts +56 -0
- package/src/telemetry/pricing.ts +129 -0
- package/src/telemetry/traceparent.ts +89 -0
- package/src/telemetry/types.ts +105 -0
- package/src/vectorstore/MemoryVectorStore.ts +250 -0
- package/src/vectorstore/RestVectorStore.ts +236 -0
- package/src/vectorstore/dialects.ts +702 -0
- package/src/vectorstore/filter.ts +148 -0
- package/src/vectorstore/index.ts +53 -0
- package/src/vectorstore/types.ts +149 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rag/EnterpriseRetriever.ts — hybrid, tenant-scoped, cited retrieval.
|
|
3
|
+
*
|
|
4
|
+
* The gap between a RAG demo and an enterprise RAG system is not the embedding
|
|
5
|
+
* model. It is these five properties, all of which live here:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Scoped.** Every read carries an {@link AccessScope} that is compiled into
|
|
8
|
+
* the database query. There is no code path that reads unscoped, so a
|
|
9
|
+
* cross-tenant answer is not something a caller can cause by forgetting.
|
|
10
|
+
* 2. **Hybrid.** Dense retrieval alone misses exact tokens — error codes, SKUs,
|
|
11
|
+
* contract clause numbers, function names. Lexical search alone misses
|
|
12
|
+
* paraphrase. RRF over both, then MMR for diversity, then an optional
|
|
13
|
+
* reranker.
|
|
14
|
+
* 3. **Honest about degradation.** When the store has no lexical index the
|
|
15
|
+
* retrieval is dense-only and SAYS so on the span and in the result, instead
|
|
16
|
+
* of quietly returning worse answers.
|
|
17
|
+
* 4. **Cited.** Every passage carries its source id, title and URI, and the
|
|
18
|
+
* prompt builder numbers them, so an answer can be checked. An uncitable
|
|
19
|
+
* answer is an unauditable one, and enterprises audit.
|
|
20
|
+
* 5. **Traced and costed.** Retrieval and generation are spans under one trace,
|
|
21
|
+
* with token counts and cost, so "why was this answer bad" and "why did this
|
|
22
|
+
* cost that" are answerable from the same record.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { TransformerBridge } from '../bridges/TransformerBridge.js';
|
|
26
|
+
import { maximalMarginalRelevance, reciprocalRankFusion } from '../retrieval/fusion.js';
|
|
27
|
+
import type { Span, Tracer } from '../telemetry/Tracer.js';
|
|
28
|
+
import {
|
|
29
|
+
SOURCE_ID_FIELD,
|
|
30
|
+
TITLE_FIELD,
|
|
31
|
+
URI_FIELD,
|
|
32
|
+
} from '../ingest/types.js';
|
|
33
|
+
import type {
|
|
34
|
+
AccessScope,
|
|
35
|
+
MetadataFilter,
|
|
36
|
+
VectorMatch,
|
|
37
|
+
VectorStore,
|
|
38
|
+
} from '../vectorstore/types.js';
|
|
39
|
+
|
|
40
|
+
/** Reranker port — a cross-encoder, an LLM judge, or a hosted rerank endpoint. */
|
|
41
|
+
export type Reranker = (
|
|
42
|
+
query: string,
|
|
43
|
+
passages: readonly RetrievedPassage[],
|
|
44
|
+
) => Promise<readonly RetrievedPassage[]>;
|
|
45
|
+
|
|
46
|
+
export interface RetrievedPassage {
|
|
47
|
+
id: string;
|
|
48
|
+
text: string;
|
|
49
|
+
/** Fused relevance score. */
|
|
50
|
+
score: number;
|
|
51
|
+
sourceId: string;
|
|
52
|
+
title?: string;
|
|
53
|
+
uri?: string;
|
|
54
|
+
metadata: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type RetrievalMode = 'hybrid' | 'dense-only' | 'keyword-only' | 'empty';
|
|
58
|
+
|
|
59
|
+
export interface RetrievalResult {
|
|
60
|
+
passages: RetrievedPassage[];
|
|
61
|
+
/** What actually ran — `dense-only` means the store had no lexical index. */
|
|
62
|
+
mode: RetrievalMode;
|
|
63
|
+
/** Candidates considered before fusion and truncation. */
|
|
64
|
+
candidatesConsidered: number;
|
|
65
|
+
traceId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RetrieveOptions {
|
|
69
|
+
/** Tenant/ACL scope. Required unless the retriever was built with a default. */
|
|
70
|
+
scope?: AccessScope;
|
|
71
|
+
/** Extra metadata predicate, AND-ed with the scope. */
|
|
72
|
+
filter?: MetadataFilter;
|
|
73
|
+
/** Passages returned. Default 5. */
|
|
74
|
+
topK?: number;
|
|
75
|
+
/** Candidates pulled from each retrieval arm before fusion. Default `topK * 4`. */
|
|
76
|
+
candidateK?: number;
|
|
77
|
+
/** Drop passages below this fused score after normalisation. Default 0. */
|
|
78
|
+
minScore?: number;
|
|
79
|
+
/** Parent span, so retrieval nests under the agent turn that asked for it. */
|
|
80
|
+
parent?: Span;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EnterpriseRetrieverOptions {
|
|
84
|
+
store: VectorStore;
|
|
85
|
+
/** Embeds the query. Omit for keyword-only retrieval. */
|
|
86
|
+
embed?: (text: string) => Promise<Float32Array>;
|
|
87
|
+
/** Applied to the fused list before truncation. */
|
|
88
|
+
reranker?: Reranker;
|
|
89
|
+
/** Default scope, when a deployment is single-tenant or scope comes from context. */
|
|
90
|
+
defaultScope?: AccessScope;
|
|
91
|
+
/** RRF weight for the dense arm. Default 1. */
|
|
92
|
+
denseWeight?: number;
|
|
93
|
+
/** RRF weight for the lexical arm. Default 1. */
|
|
94
|
+
keywordWeight?: number;
|
|
95
|
+
/** MMR relevance/diversity trade-off; 1 is pure relevance. Default 0.7. */
|
|
96
|
+
mmrLambda?: number;
|
|
97
|
+
/** Disable the MMR diversity pass. Default false. */
|
|
98
|
+
disableDiversity?: boolean;
|
|
99
|
+
tracer?: Tracer;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class EnterpriseRetriever {
|
|
103
|
+
private readonly _store: VectorStore;
|
|
104
|
+
private readonly _embed: ((text: string) => Promise<Float32Array>) | undefined;
|
|
105
|
+
private readonly _reranker: Reranker | undefined;
|
|
106
|
+
private readonly _defaultScope: AccessScope | undefined;
|
|
107
|
+
private readonly _denseWeight: number;
|
|
108
|
+
private readonly _keywordWeight: number;
|
|
109
|
+
private readonly _mmrLambda: number;
|
|
110
|
+
private readonly _diversity: boolean;
|
|
111
|
+
private readonly _tracer: Tracer | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(opts: EnterpriseRetrieverOptions) {
|
|
114
|
+
this._store = opts.store;
|
|
115
|
+
this._embed = opts.embed;
|
|
116
|
+
this._reranker = opts.reranker;
|
|
117
|
+
this._defaultScope = opts.defaultScope;
|
|
118
|
+
this._denseWeight = opts.denseWeight ?? 1;
|
|
119
|
+
this._keywordWeight = opts.keywordWeight ?? 1;
|
|
120
|
+
this._mmrLambda = opts.mmrLambda ?? 0.7;
|
|
121
|
+
this._diversity = !opts.disableDiversity;
|
|
122
|
+
this._tracer = opts.tracer;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async retrieve(query: string, opts: RetrieveOptions = {}): Promise<RetrievalResult> {
|
|
126
|
+
const topK = Math.max(1, opts.topK ?? 5);
|
|
127
|
+
const candidateK = Math.max(topK, opts.candidateK ?? topK * 4);
|
|
128
|
+
const scope = opts.scope ?? this._defaultScope;
|
|
129
|
+
|
|
130
|
+
const span = this._tracer?.startSpan('retrieval.hybrid', {
|
|
131
|
+
kind: 'retrieval',
|
|
132
|
+
...(opts.parent ? { parent: opts.parent } : {}),
|
|
133
|
+
attributes: {
|
|
134
|
+
'retrieval.store': this._store.name,
|
|
135
|
+
'retrieval.top_k': topK,
|
|
136
|
+
'retrieval.tenant': scope?.tenantId ?? 'unscoped',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const queryVector = this._embed ? await this._embed(query) : undefined;
|
|
142
|
+
|
|
143
|
+
const base = {
|
|
144
|
+
text: query,
|
|
145
|
+
topK: candidateK,
|
|
146
|
+
...(opts.filter ? { filter: opts.filter } : {}),
|
|
147
|
+
...(scope ? { scope } : {}),
|
|
148
|
+
includeVectors: this._diversity,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Both arms run concurrently: they hit different indexes and the
|
|
152
|
+
// sequential version doubles p95 latency for nothing.
|
|
153
|
+
const [dense, keyword] = await Promise.all([
|
|
154
|
+
queryVector
|
|
155
|
+
? this._store.query({ ...base, vector: queryVector })
|
|
156
|
+
: Promise.resolve<VectorMatch[]>([]),
|
|
157
|
+
this._store.keywordSearch
|
|
158
|
+
? this._store.keywordSearch(base)
|
|
159
|
+
: Promise.resolve<VectorMatch[]>([]),
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const mode = pickMode(dense.length > 0, keyword.length > 0, Boolean(this._store.keywordSearch));
|
|
163
|
+
const byId = new Map<string, VectorMatch>();
|
|
164
|
+
for (const match of [...dense, ...keyword]) {
|
|
165
|
+
if (!byId.has(match.id)) byId.set(match.id, match);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let ordered = this._fuse(dense, keyword, byId);
|
|
169
|
+
|
|
170
|
+
if (this._diversity && queryVector && ordered.length > topK) {
|
|
171
|
+
ordered = this._diversify(queryVector, ordered, byId, topK);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let passages = ordered.slice(0, Math.max(topK, this._reranker ? candidateK : topK))
|
|
175
|
+
.map((hit) => toPassage(byId.get(hit.id) as VectorMatch, hit.score));
|
|
176
|
+
|
|
177
|
+
if (this._reranker && passages.length > 0) {
|
|
178
|
+
passages = [...await this._reranker(query, passages)];
|
|
179
|
+
}
|
|
180
|
+
if (opts.minScore !== undefined) {
|
|
181
|
+
passages = passages.filter((p) => p.score >= (opts.minScore as number));
|
|
182
|
+
}
|
|
183
|
+
passages = passages.slice(0, topK);
|
|
184
|
+
|
|
185
|
+
span?.setAttributes({
|
|
186
|
+
'retrieval.mode': mode,
|
|
187
|
+
'retrieval.candidates': byId.size,
|
|
188
|
+
'retrieval.returned': passages.length,
|
|
189
|
+
'retrieval.dense_hits': dense.length,
|
|
190
|
+
'retrieval.keyword_hits': keyword.length,
|
|
191
|
+
'retrieval.reranked': Boolean(this._reranker),
|
|
192
|
+
});
|
|
193
|
+
span?.end('ok');
|
|
194
|
+
|
|
195
|
+
const result: RetrievalResult = {
|
|
196
|
+
passages,
|
|
197
|
+
mode,
|
|
198
|
+
candidatesConsidered: byId.size,
|
|
199
|
+
};
|
|
200
|
+
if (span) result.traceId = span.traceId;
|
|
201
|
+
return result;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
span?.fail(err);
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Retrieve, then answer strictly from what was retrieved.
|
|
210
|
+
*
|
|
211
|
+
* The system prompt forbids answering beyond the passages and requires
|
|
212
|
+
* `[n]` citations. That is not decoration: an enterprise deployment is judged
|
|
213
|
+
* on whether a wrong answer is *detectable*, and an uncited claim is not.
|
|
214
|
+
*/
|
|
215
|
+
async answer(
|
|
216
|
+
query: string,
|
|
217
|
+
bridge: TransformerBridge,
|
|
218
|
+
opts: RetrieveOptions & { systemPrompt?: string; maxTokens?: number; model?: string } = {},
|
|
219
|
+
): Promise<{ answer: string; result: RetrievalResult }> {
|
|
220
|
+
const span = this._tracer?.startSpan('rag.answer', {
|
|
221
|
+
kind: 'agent',
|
|
222
|
+
...(opts.parent ? { parent: opts.parent } : {}),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const result = await this.retrieve(query, { ...opts, ...(span ? { parent: span } : {}) });
|
|
227
|
+
|
|
228
|
+
if (result.passages.length === 0) {
|
|
229
|
+
// Refusing beats hallucinating: with no evidence there is nothing to
|
|
230
|
+
// ground an answer in, and a confident answer here is the exact
|
|
231
|
+
// failure enterprises cite when a pilot is killed.
|
|
232
|
+
span?.setAttribute('rag.refused', true);
|
|
233
|
+
span?.end('ok');
|
|
234
|
+
return { answer: NO_EVIDENCE_ANSWER, result };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const prompt = buildGroundedPrompt(query, result.passages);
|
|
238
|
+
const answer = await bridge.generate(prompt, {
|
|
239
|
+
systemPrompt: opts.systemPrompt ?? GROUNDED_SYSTEM_PROMPT,
|
|
240
|
+
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
241
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
span?.setAttributes({ 'rag.passages': result.passages.length, 'rag.mode': result.mode });
|
|
245
|
+
span?.end('ok');
|
|
246
|
+
return { answer, result };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
span?.fail(err);
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private _fuse(
|
|
254
|
+
dense: readonly VectorMatch[],
|
|
255
|
+
keyword: readonly VectorMatch[],
|
|
256
|
+
byId: Map<string, VectorMatch>,
|
|
257
|
+
): Array<{ id: string; score: number }> {
|
|
258
|
+
const lists = [];
|
|
259
|
+
if (dense.length) lists.push({ ids: dense.map((m) => m.id), weight: this._denseWeight });
|
|
260
|
+
if (keyword.length) lists.push({ ids: keyword.map((m) => m.id), weight: this._keywordWeight });
|
|
261
|
+
|
|
262
|
+
if (lists.length === 0) return [];
|
|
263
|
+
if (lists.length === 1) {
|
|
264
|
+
// One arm: keep the store's own similarity rather than replacing it with
|
|
265
|
+
// an RRF score, which carries no meaning for a single list.
|
|
266
|
+
const only = dense.length ? dense : keyword;
|
|
267
|
+
return only.map((m) => ({ id: m.id, score: m.score }));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const fused = reciprocalRankFusion(lists);
|
|
271
|
+
const best = fused[0]?.score ?? 1;
|
|
272
|
+
// RRF scores live on a tiny arbitrary scale (~1/60); normalising to [0,1]
|
|
273
|
+
// keeps `minScore` meaning the same thing on one arm or two.
|
|
274
|
+
return fused
|
|
275
|
+
.filter((hit) => byId.has(hit.id))
|
|
276
|
+
.map((hit) => ({ id: hit.id, score: best > 0 ? hit.score / best : 0 }));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private _diversify(
|
|
280
|
+
queryVector: Float32Array,
|
|
281
|
+
ordered: Array<{ id: string; score: number }>,
|
|
282
|
+
byId: Map<string, VectorMatch>,
|
|
283
|
+
topK: number,
|
|
284
|
+
): Array<{ id: string; score: number }> {
|
|
285
|
+
const candidates = ordered
|
|
286
|
+
.map((hit) => ({ id: hit.id, vector: byId.get(hit.id)?.vector }))
|
|
287
|
+
.filter((c): c is { id: string; vector: Float32Array } => Boolean(c.vector));
|
|
288
|
+
|
|
289
|
+
// Without stored vectors MMR cannot run; keeping the fused order is the
|
|
290
|
+
// correct degradation, not an error.
|
|
291
|
+
if (candidates.length < 2) return ordered;
|
|
292
|
+
|
|
293
|
+
const scores = new Map(ordered.map((hit) => [hit.id, hit.score]));
|
|
294
|
+
const picked = maximalMarginalRelevance(queryVector, candidates, topK, this._mmrLambda);
|
|
295
|
+
const pickedSet = new Set(picked);
|
|
296
|
+
|
|
297
|
+
return [
|
|
298
|
+
...picked.map((id) => ({ id, score: scores.get(id) ?? 0 })),
|
|
299
|
+
...ordered.filter((hit) => !pickedSet.has(hit.id)),
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function pickMode(hasDense: boolean, hasKeyword: boolean, storeHasKeyword: boolean): RetrievalMode {
|
|
305
|
+
if (hasDense && hasKeyword) return 'hybrid';
|
|
306
|
+
if (hasDense) return 'dense-only';
|
|
307
|
+
if (hasKeyword) return 'keyword-only';
|
|
308
|
+
return storeHasKeyword ? 'empty' : 'dense-only';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function toPassage(match: VectorMatch, score: number): RetrievedPassage {
|
|
312
|
+
const metadata = match.metadata as Record<string, unknown>;
|
|
313
|
+
const passage: RetrievedPassage = {
|
|
314
|
+
id: match.id,
|
|
315
|
+
text: match.text,
|
|
316
|
+
score,
|
|
317
|
+
sourceId: String(metadata[SOURCE_ID_FIELD] ?? match.id),
|
|
318
|
+
metadata,
|
|
319
|
+
};
|
|
320
|
+
const title = metadata[TITLE_FIELD];
|
|
321
|
+
if (typeof title === 'string') passage.title = title;
|
|
322
|
+
const uri = metadata[URI_FIELD];
|
|
323
|
+
if (typeof uri === 'string') passage.uri = uri;
|
|
324
|
+
return passage;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const GROUNDED_SYSTEM_PROMPT =
|
|
328
|
+
'You answer strictly from the numbered passages provided. ' +
|
|
329
|
+
'Cite every claim with the passage number in square brackets, e.g. [2]. ' +
|
|
330
|
+
'If the passages do not contain the answer, say so plainly and name what is missing. ' +
|
|
331
|
+
'Never rely on knowledge that is not in the passages.';
|
|
332
|
+
|
|
333
|
+
export const NO_EVIDENCE_ANSWER =
|
|
334
|
+
'No indexed passage matched this question within the caller\'s access scope, ' +
|
|
335
|
+
'so there is nothing to ground an answer in.';
|
|
336
|
+
|
|
337
|
+
/** Renders passages as a numbered, citable evidence block. */
|
|
338
|
+
export function buildGroundedPrompt(query: string, passages: readonly RetrievedPassage[]): string {
|
|
339
|
+
const evidence = passages
|
|
340
|
+
.map((p, i) => {
|
|
341
|
+
const label = p.title ?? p.sourceId;
|
|
342
|
+
const uri = p.uri ? ` (${p.uri})` : '';
|
|
343
|
+
return `[${i + 1}] ${label}${uri}\n${p.text}`;
|
|
344
|
+
})
|
|
345
|
+
.join('\n\n');
|
|
346
|
+
|
|
347
|
+
return `Passages:\n\n${evidence}\n\nQuestion: ${query}\n\nAnswer, citing passages by number:`;
|
|
348
|
+
}
|
package/src/rag/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rag — enterprise retrieval: hybrid, tenant/ACL scoped, cited, traced.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
EnterpriseRetriever,
|
|
7
|
+
buildGroundedPrompt,
|
|
8
|
+
GROUNDED_SYSTEM_PROMPT,
|
|
9
|
+
NO_EVIDENCE_ANSWER,
|
|
10
|
+
} from './EnterpriseRetriever.js';
|
|
11
|
+
export type {
|
|
12
|
+
EnterpriseRetrieverOptions,
|
|
13
|
+
RetrieveOptions,
|
|
14
|
+
RetrievalResult,
|
|
15
|
+
RetrievalMode,
|
|
16
|
+
RetrievedPassage,
|
|
17
|
+
Reranker,
|
|
18
|
+
} from './EnterpriseRetriever.js';
|
package/src/retrieval/bm25.ts
CHANGED
|
@@ -6,16 +6,39 @@
|
|
|
6
6
|
* Fusing the two (see {@link ./fusion}) is what lifts the memory layer from
|
|
7
7
|
* "cosine only" to a hybrid retriever on par with Weaviate-style search.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Two things are separable here and are kept separate on purpose:
|
|
10
|
+
*
|
|
11
|
+
* • the SCORING CORE ({@link bm25Idf}, {@link bm25LengthNorm}, {@link bm25TermScore})
|
|
12
|
+
* — the Okapi formula itself, which is the same wherever lexical relevance is
|
|
13
|
+
* scored, whether the index is built inline from raw text or read out of a
|
|
14
|
+
* precomputed per-field term table (BM25F). Exported so a consumer with its own
|
|
15
|
+
* index shape reuses the maths instead of restating it and drifting from it;
|
|
16
|
+
* • the TOKENIZER — which is genuinely corpus-specific. Prose wants stopword
|
|
17
|
+
* removal and stemming; identifiers and error codes want neither. So the
|
|
18
|
+
* tokenizer is an injectable {@link Bm25Options.tokenize} hook defaulting to the
|
|
19
|
+
* package's plain `tokenize`, rather than a second copy of BM25 growing around
|
|
20
|
+
* each specialisation.
|
|
21
|
+
*
|
|
22
|
+
* Pure and zero-dependency.
|
|
10
23
|
*/
|
|
11
24
|
|
|
12
25
|
import { tokenize } from '../similarity/index.js';
|
|
13
26
|
|
|
27
|
+
/** Splits text into the terms BM25 scores over. */
|
|
28
|
+
export type Bm25Tokenizer = (text: string) => string[];
|
|
29
|
+
|
|
14
30
|
export interface Bm25Options {
|
|
15
31
|
/** Term-frequency saturation. Higher = TF matters more. Default 1.5. */
|
|
16
32
|
k1?: number;
|
|
17
33
|
/** Length normalisation, 0..1. Higher = penalise long docs more. Default 0.75. */
|
|
18
34
|
b?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Tokenizer for both the query and the documents. Default is the package's plain
|
|
37
|
+
* `tokenize` (lowercase word split), which preserves identifiers verbatim. Supply
|
|
38
|
+
* a stemming/stopword tokenizer when the corpus is prose — the SAME function must
|
|
39
|
+
* be used for query and documents or nothing will match.
|
|
40
|
+
*/
|
|
41
|
+
tokenize?: Bm25Tokenizer;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
export interface Bm25Doc {
|
|
@@ -28,6 +51,45 @@ export interface Bm25Hit {
|
|
|
28
51
|
score: number;
|
|
29
52
|
}
|
|
30
53
|
|
|
54
|
+
/** Term-frequency saturation used when {@link Bm25Options.k1} is omitted. */
|
|
55
|
+
export const BM25_DEFAULT_K1 = 1.5;
|
|
56
|
+
/** Length normalisation used when {@link Bm25Options.b} is omitted. */
|
|
57
|
+
export const BM25_DEFAULT_B = 0.75;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Inverse document frequency with the +1 smoothing variant, which is always
|
|
61
|
+
* non-negative — the unsmoothed form goes negative for a term present in more than
|
|
62
|
+
* half the corpus, which subtracts score for a MATCH.
|
|
63
|
+
*/
|
|
64
|
+
export function bm25Idf(documentCount: number, documentFrequency: number): number {
|
|
65
|
+
const n = Math.max(documentCount, 1);
|
|
66
|
+
const df = Math.max(0, Math.min(documentFrequency, n));
|
|
67
|
+
return Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The document-length penalty `1 - b + b·(len/avgdl)`, factored out because it is
|
|
72
|
+
* per-DOCUMENT: computing it once and reusing it across the query's terms is what
|
|
73
|
+
* keeps scoring O(terms) rather than O(terms) divisions of the same quotient.
|
|
74
|
+
*/
|
|
75
|
+
export function bm25LengthNorm(length: number, averageLength: number, b: number = BM25_DEFAULT_B): number {
|
|
76
|
+
const avg = averageLength > 0 ? averageLength : 1;
|
|
77
|
+
return 1 - b + b * (Math.max(0, length) / avg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One term's contribution: `idf · f(k1+1) / (f + k1·lengthNorm)`.
|
|
82
|
+
*
|
|
83
|
+
* `frequency` is whatever the caller counts as the term's occurrence weight — a raw
|
|
84
|
+
* count for plain BM25, or a field-weighted sum for BM25F. Combining fields BEFORE
|
|
85
|
+
* saturation (rather than saturating each field and summing) is what makes BM25F
|
|
86
|
+
* behave like BM25 over a weighted document.
|
|
87
|
+
*/
|
|
88
|
+
export function bm25TermScore(frequency: number, idf: number, lengthNorm: number, k1: number = BM25_DEFAULT_K1): number {
|
|
89
|
+
if (frequency <= 0) return 0;
|
|
90
|
+
return (idf * (frequency * (k1 + 1))) / (frequency + k1 * lengthNorm);
|
|
91
|
+
}
|
|
92
|
+
|
|
31
93
|
/**
|
|
32
94
|
* Scores every document against `query` with Okapi BM25, returning hits sorted by
|
|
33
95
|
* descending score (documents with no query-term overlap score 0 and are dropped).
|
|
@@ -35,12 +97,13 @@ export interface Bm25Hit {
|
|
|
35
97
|
* store / a vector pre-filter) this is O(N·terms) and needs no persistence.
|
|
36
98
|
*/
|
|
37
99
|
export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {}): Bm25Hit[] {
|
|
38
|
-
const k1 = opts.k1 ??
|
|
39
|
-
const b = opts.b ??
|
|
100
|
+
const k1 = opts.k1 ?? BM25_DEFAULT_K1;
|
|
101
|
+
const b = opts.b ?? BM25_DEFAULT_B;
|
|
102
|
+
const split = opts.tokenize ?? tokenize;
|
|
40
103
|
const N = docs.length;
|
|
41
104
|
if (N === 0) return [];
|
|
42
105
|
|
|
43
|
-
const queryTerms = new Set(
|
|
106
|
+
const queryTerms = new Set(split(query));
|
|
44
107
|
if (queryTerms.size === 0) return [];
|
|
45
108
|
|
|
46
109
|
// Per-doc term frequencies + document lengths.
|
|
@@ -49,7 +112,7 @@ export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {
|
|
|
49
112
|
let totalLen = 0;
|
|
50
113
|
|
|
51
114
|
for (const doc of docs) {
|
|
52
|
-
const tokens =
|
|
115
|
+
const tokens = split(doc.text);
|
|
53
116
|
const tf = new Map<string, number>();
|
|
54
117
|
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
55
118
|
for (const t of tf.keys()) if (queryTerms.has(t)) df.set(t, (df.get(t) ?? 0) + 1);
|
|
@@ -58,22 +121,17 @@ export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {
|
|
|
58
121
|
}
|
|
59
122
|
const avgdl = totalLen / N || 1;
|
|
60
123
|
|
|
61
|
-
// idf with the +1 smoothing variant (always non-negative).
|
|
62
124
|
const idf = new Map<string, number>();
|
|
63
|
-
for (const term of queryTerms)
|
|
64
|
-
const n = df.get(term) ?? 0;
|
|
65
|
-
idf.set(term, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
|
|
66
|
-
}
|
|
125
|
+
for (const term of queryTerms) idf.set(term, bm25Idf(N, df.get(term) ?? 0));
|
|
67
126
|
|
|
68
127
|
const hits: Bm25Hit[] = [];
|
|
69
128
|
for (const d of docTerms) {
|
|
129
|
+
const lengthNorm = bm25LengthNorm(d.len, avgdl, b);
|
|
70
130
|
let score = 0;
|
|
71
131
|
for (const term of queryTerms) {
|
|
72
132
|
const f = d.tf.get(term);
|
|
73
133
|
if (!f) continue;
|
|
74
|
-
|
|
75
|
-
const denom = f + k1 * (1 - b + b * (d.len / avgdl));
|
|
76
|
-
score += idf.get(term)! * (numer / denom);
|
|
134
|
+
score += bm25TermScore(f, idf.get(term)!, lengthNorm, k1);
|
|
77
135
|
}
|
|
78
136
|
if (score > 0) hits.push({ id: d.id, score });
|
|
79
137
|
}
|
package/src/retrieval/index.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
export { chunkText } from './chunk.js';
|
|
10
10
|
export type { Chunk, ChunkOptions } from './chunk.js';
|
|
11
11
|
|
|
12
|
-
export { bm25Search } from './bm25.js';
|
|
13
|
-
export type { Bm25Doc, Bm25Hit, Bm25Options } from './bm25.js';
|
|
12
|
+
export { bm25Search, bm25Idf, bm25LengthNorm, bm25TermScore, BM25_DEFAULT_K1, BM25_DEFAULT_B } from './bm25.js';
|
|
13
|
+
export type { Bm25Doc, Bm25Hit, Bm25Options, Bm25Tokenizer } from './bm25.js';
|
|
14
14
|
|
|
15
15
|
export { reciprocalRankFusion, maximalMarginalRelevance } from './fusion.js';
|
|
16
16
|
export type { RankedList, FusedHit, MmrCandidate } from './fusion.js';
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/InstrumentedBridge.ts — one decorator that makes ANY bridge observable.
|
|
3
|
+
*
|
|
4
|
+
* The alternative — instrumenting Anthropic, OpenAI, Vertex, Fetch, Caching and
|
|
5
|
+
* SemanticCaching bridges each in their own way — guarantees six subtly different
|
|
6
|
+
* definitions of "a call". This is a single decorator over the
|
|
7
|
+
* {@link TransformerBridge} port, so a bridge added tomorrow is observable the
|
|
8
|
+
* moment it satisfies the interface, and the caching bridges are measured at the
|
|
9
|
+
* same seam as the provider bridges (which is how the cache-hit rate becomes a
|
|
10
|
+
* real number rather than an anecdote).
|
|
11
|
+
*
|
|
12
|
+
* Token counts come from the provider when the inner bridge reports them via
|
|
13
|
+
* `lastCall`, and are estimated from characters otherwise — with `estimated: true`
|
|
14
|
+
* carried through to the metric snapshot so a dashboard never mixes measured and
|
|
15
|
+
* inferred spend without saying so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { TransformerBridge, BridgeGenerateOptions } from '../bridges/TransformerBridge.js';
|
|
19
|
+
import { estimateTokens } from './pricing.js';
|
|
20
|
+
import { Tracer, type Span } from './Tracer.js';
|
|
21
|
+
import type { LlmUsage } from './types.js';
|
|
22
|
+
|
|
23
|
+
export interface InstrumentedBridgeOptions {
|
|
24
|
+
/** Tracer that receives the spans. */
|
|
25
|
+
tracer: Tracer;
|
|
26
|
+
/** Model label used when the inner bridge reports none. Default `'unknown'`. */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Span name. Default `'llm.generate'`. */
|
|
29
|
+
spanName?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Parent span, so a model call nests under the agent/graph node that made it.
|
|
32
|
+
* A function is accepted for the common case where the parent changes per call.
|
|
33
|
+
*/
|
|
34
|
+
parent?: Span | (() => Span | undefined);
|
|
35
|
+
/** Static attributes stamped on every span (tenant, environment, route). */
|
|
36
|
+
attributes?: Record<string, string | number | boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Token counter, when an exact one is available (e.g. the provider's
|
|
39
|
+
* count_tokens endpoint). Default: ~4 chars per token.
|
|
40
|
+
*/
|
|
41
|
+
countTokens?: (text: string) => number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class InstrumentedBridge implements TransformerBridge {
|
|
45
|
+
readonly supportsStreaming: boolean;
|
|
46
|
+
|
|
47
|
+
private readonly _inner: TransformerBridge;
|
|
48
|
+
private readonly _tracer: Tracer;
|
|
49
|
+
private readonly _model: string;
|
|
50
|
+
private readonly _spanName: string;
|
|
51
|
+
private readonly _parent?: Span | (() => Span | undefined);
|
|
52
|
+
private readonly _attributes: Record<string, string | number | boolean>;
|
|
53
|
+
private readonly _count: (text: string) => number;
|
|
54
|
+
|
|
55
|
+
private _lastCall: { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } | undefined;
|
|
56
|
+
|
|
57
|
+
constructor(inner: TransformerBridge, opts: InstrumentedBridgeOptions) {
|
|
58
|
+
this._inner = inner;
|
|
59
|
+
this._tracer = opts.tracer;
|
|
60
|
+
this._model = opts.model ?? 'unknown';
|
|
61
|
+
this._spanName = opts.spanName ?? 'llm.generate';
|
|
62
|
+
if (opts.parent) this._parent = opts.parent;
|
|
63
|
+
this._attributes = opts.attributes ?? {};
|
|
64
|
+
this._count = opts.countTokens ?? estimateTokens;
|
|
65
|
+
this.supportsStreaming = inner.supportsStreaming;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Usage/cache info for the most recent call — makes the decorator chainable. */
|
|
69
|
+
get lastCall(): { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } | undefined {
|
|
70
|
+
return this._lastCall;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async generate(prompt: string, opts: BridgeGenerateOptions = {}): Promise<string> {
|
|
74
|
+
const span = this._startSpan(prompt, opts, false);
|
|
75
|
+
try {
|
|
76
|
+
const reply = await this._inner.generate(prompt, opts);
|
|
77
|
+
this._finish(span, prompt, reply, opts);
|
|
78
|
+
span.end('ok');
|
|
79
|
+
return reply;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
span.fail(err);
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async *stream(prompt: string, opts: BridgeGenerateOptions = {}): AsyncIterable<string> {
|
|
87
|
+
if (!this._inner.stream) {
|
|
88
|
+
throw new Error('InstrumentedBridge: the wrapped bridge does not support streaming.');
|
|
89
|
+
}
|
|
90
|
+
const span = this._startSpan(prompt, opts, true);
|
|
91
|
+
let reply = '';
|
|
92
|
+
let first = true;
|
|
93
|
+
try {
|
|
94
|
+
for await (const chunk of this._inner.stream(prompt, opts)) {
|
|
95
|
+
if (first) {
|
|
96
|
+
span.recordFirstToken();
|
|
97
|
+
first = false;
|
|
98
|
+
}
|
|
99
|
+
reply += chunk;
|
|
100
|
+
yield chunk;
|
|
101
|
+
}
|
|
102
|
+
this._finish(span, prompt, reply, opts);
|
|
103
|
+
span.end('ok');
|
|
104
|
+
} catch (err) {
|
|
105
|
+
span.fail(err);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private _startSpan(prompt: string, opts: BridgeGenerateOptions, streaming: boolean): Span {
|
|
111
|
+
const parent = typeof this._parent === 'function' ? this._parent() : this._parent;
|
|
112
|
+
const span = this._tracer.startSpan(this._spanName, {
|
|
113
|
+
kind: 'llm',
|
|
114
|
+
...(parent ? { parent } : {}),
|
|
115
|
+
});
|
|
116
|
+
span.setAttributes({
|
|
117
|
+
...this._attributes,
|
|
118
|
+
'llm.model': opts.model ?? this._model,
|
|
119
|
+
'llm.streaming': streaming,
|
|
120
|
+
'llm.prompt_chars': prompt.length,
|
|
121
|
+
'llm.max_tokens': opts.maxTokens,
|
|
122
|
+
'llm.temperature': opts.temperature,
|
|
123
|
+
});
|
|
124
|
+
return span;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private _finish(span: Span, prompt: string, reply: string, opts: BridgeGenerateOptions): void {
|
|
128
|
+
const reported = (this._inner as { lastCall?: { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } }).lastCall;
|
|
129
|
+
const model = reported?.usage?.model ?? opts.model ?? this._model;
|
|
130
|
+
|
|
131
|
+
const usage: LlmUsage = reported?.usage ?? {
|
|
132
|
+
model,
|
|
133
|
+
inputTokens: this._count(prompt),
|
|
134
|
+
outputTokens: this._count(reply),
|
|
135
|
+
estimated: true,
|
|
136
|
+
};
|
|
137
|
+
if (reported?.cacheHit) usage.localCacheHit = true;
|
|
138
|
+
|
|
139
|
+
span.recordUsage(usage);
|
|
140
|
+
span.setAttributes({
|
|
141
|
+
'llm.response_chars': reply.length,
|
|
142
|
+
'llm.cache_hit': reported?.cacheHit ?? false,
|
|
143
|
+
'llm.cache_tier': reported?.cacheTier,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this._lastCall = {
|
|
147
|
+
usage,
|
|
148
|
+
...(reported?.cacheHit !== undefined ? { cacheHit: reported.cacheHit } : {}),
|
|
149
|
+
...(reported?.cacheTier !== undefined ? { cacheTier: reported.cacheTier } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|